Form — Autosave + saved indicator + restore
A Google Docs style note editor that quietly saves itself as you write. Edits are debounced, then persisted to localStorage while a status pill cycles from Unsaved changes to Saving to Saved a moment ago, announced through an aria-live region. A Save now button forces an immediate write, a live word and character count tracks length, and reopening the page offers to restore any unfinished draft. Validation gates Publish, which clears the saved draft from the device.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ---------- Layout ---------- */
.page {
max-width: 760px;
margin: 0 auto;
padding: 40px 20px 64px;
}
.editor-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
overflow: hidden;
}
/* ---------- Restore banner ---------- */
.restore {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 16px 20px;
background: var(--brand-50);
border-bottom: 1px solid var(--line);
animation: slideDown 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
.restore[hidden] {
display: none;
}
.restore__icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
border-radius: 50%;
background: var(--white);
color: var(--brand-d);
box-shadow: var(--sh-1);
}
.restore__body {
flex: 1 1 auto;
min-width: 0;
}
.restore__title {
font-weight: 700;
font-size: 0.95rem;
color: var(--ink);
}
.restore__desc {
margin-top: 2px;
font-size: 0.85rem;
color: var(--ink-2);
}
.restore__actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
/* ---------- Header ---------- */
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid var(--line);
}
.editor-head__meta {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex-wrap: wrap;
}
.editor-head__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.editor-head__actions {
flex: none;
}
/* ---------- Status pill ---------- */
.status {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid var(--line);
background: var(--bg);
color: var(--muted);
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
white-space: nowrap;
}
.status__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
flex: none;
}
.status[data-state="saving"] {
background: var(--brand-50);
border-color: rgba(91, 91, 240, 0.28);
color: var(--brand-d);
}
.status[data-state="saving"] .status__dot {
animation: pulse 1s ease-in-out infinite;
}
.status[data-state="saved"] {
background: rgba(47, 158, 111, 0.1);
border-color: rgba(47, 158, 111, 0.26);
color: var(--ok);
}
.status[data-state="dirty"] {
background: rgba(217, 138, 43, 0.1);
border-color: rgba(217, 138, 43, 0.28);
color: var(--warn);
}
.status[data-state="error"] {
background: rgba(212, 80, 62, 0.1);
border-color: rgba(212, 80, 62, 0.28);
color: var(--danger);
}
/* ---------- Form ---------- */
.editor-form {
padding: 24px;
}
.field + .field {
margin-top: 22px;
}
.field__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.field__label {
display: inline-block;
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-2);
margin-bottom: 7px;
}
.field__req {
color: var(--danger);
font-weight: 700;
}
.counter {
font-size: 0.76rem;
font-weight: 500;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.input {
width: 100%;
font: inherit;
color: var(--ink);
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 12px 14px;
transition: border-color 0.16s ease, box-shadow 0.16s ease,
background 0.16s ease;
}
.input::placeholder {
color: var(--muted);
}
.input:hover:not(:disabled) {
border-color: rgba(16, 19, 34, 0.28);
}
.input:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.16);
}
.input--area {
resize: vertical;
min-height: 220px;
line-height: 1.6;
}
.input:disabled {
background: var(--bg);
color: var(--muted);
cursor: not-allowed;
border-color: var(--line);
}
.input[aria-invalid="true"] {
border-color: var(--danger);
box-shadow: 0 0 0 4px rgba(212, 80, 62, 0.14);
}
.input.is-valid {
border-color: var(--ok);
}
.field__help {
margin-top: 7px;
font-size: 0.78rem;
color: var(--muted);
}
.field__help.is-error {
color: var(--danger);
font-weight: 500;
}
/* ---------- Footer ---------- */
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 26px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.editor-foot__hint {
font-size: 0.8rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---------- Buttons ---------- */
.btn {
font: inherit;
font-weight: 600;
font-size: 0.875rem;
border-radius: var(--r-md);
border: 1px solid transparent;
padding: 10px 16px;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease,
box-shadow 0.16s ease, transform 0.08s ease, color 0.16s ease;
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.28);
}
.btn:active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--solid {
background: var(--brand);
color: var(--white);
box-shadow: var(--sh-1);
}
.btn--solid:hover:not(:disabled) {
background: var(--brand-d);
}
.btn--solid:active:not(:disabled) {
background: var(--brand-700);
}
.btn--ghost {
background: var(--white);
color: var(--ink-2);
border-color: var(--line-2);
}
.btn--ghost:hover:not(:disabled) {
background: var(--bg);
border-color: rgba(16, 19, 34, 0.28);
}
.btn--icon svg {
flex: none;
}
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 50;
width: max-content;
max-width: calc(100vw - 32px);
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
background: var(--ink);
color: var(--white);
font-size: 0.85rem;
font-weight: 500;
padding: 11px 16px;
border-radius: var(--r-md);
box-shadow: var(--sh-2);
animation: toastIn 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast.is-leaving {
animation: toastOut 0.24s ease forwards;
}
.toast__icon {
display: grid;
place-items: center;
flex: none;
color: var(--accent);
}
.toast--ok .toast__icon {
color: #6ee7c4;
}
.toast--warn .toast__icon {
color: #f5c97a;
}
/* ---------- Animations ---------- */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateY(12px);
}
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.page {
padding: 20px 14px 48px;
}
.editor-card {
border-radius: var(--r-md);
}
.editor-head {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 16px;
}
.editor-head__actions {
width: 100%;
}
.editor-head__actions .btn {
width: 100%;
justify-content: center;
}
.editor-form {
padding: 16px;
}
.restore {
flex-direction: column;
padding: 14px 16px;
}
.restore__actions {
width: 100%;
}
.restore__actions .btn {
flex: 1 1 0;
justify-content: center;
}
.editor-foot {
flex-direction: column-reverse;
align-items: stretch;
text-align: center;
}
.editor-foot .btn {
width: 100%;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ---------------- Config & elements ---------------- */
var STORAGE_KEY = "stealthis:autosave-draft:v1";
var DEBOUNCE_MS = 900;
var form = document.getElementById("editorForm");
var titleEl = document.getElementById("title");
var bodyEl = document.getElementById("body");
var titleHelp = document.getElementById("titleHelp");
var bodyHelp = document.getElementById("bodyHelp");
var counterEl = document.getElementById("counter");
var statusPill = document.getElementById("statusPill");
var statusText = document.getElementById("statusText");
var saveNowBtn = document.getElementById("saveNowBtn");
var publishBtn = document.getElementById("publishBtn");
var lastSavedHint = document.getElementById("lastSavedHint");
var restoreBanner = document.getElementById("restoreBanner");
var restoreBtn = document.getElementById("restoreBtn");
var discardBtn = document.getElementById("discardBtn");
var restoreAgo = document.getElementById("restoreAgo");
var toastWrap = document.getElementById("toastWrap");
var titleDefaultHelp = titleHelp.textContent;
var bodyDefaultHelp = bodyHelp.textContent;
/* ---------------- State ---------------- */
var debounceTimer = null;
var agoTimer = null;
var lastSavedAt = null; // epoch ms of last successful save
var pendingDraft = null; // draft found on load, awaiting restore decision
/* Feature-detect localStorage so the demo degrades gracefully. */
var storageOK = (function () {
try {
var k = "__as_test__";
window.localStorage.setItem(k, "1");
window.localStorage.removeItem(k);
return true;
} catch (e) {
return false;
}
})();
/* ---------------- Helpers ---------------- */
function timeAgo(ms) {
var diff = Math.max(0, Date.now() - ms);
var s = Math.round(diff / 1000);
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
var m = Math.round(s / 60);
if (m < 60) return m + (m === 1 ? " minute ago" : " minutes ago");
var h = Math.round(m / 60);
if (h < 24) return h + (h === 1 ? " hour ago" : " hours ago");
var d = Math.round(h / 24);
return d + (d === 1 ? " day ago" : " days ago");
}
function countWords(text) {
var trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
}
function updateCounter() {
var text = bodyEl.value;
var words = countWords(text);
var chars = text.length;
counterEl.textContent =
words +
(words === 1 ? " word · " : " words · ") +
chars +
(chars === 1 ? " character" : " characters");
}
function setStatus(state, text) {
statusPill.setAttribute("data-state", state);
statusText.textContent = text;
}
function refreshSavedLabel() {
if (lastSavedAt == null) return;
var label = "Saved " + timeAgo(lastSavedAt);
setStatus("saved", label);
lastSavedHint.textContent =
"Last saved " +
timeAgo(lastSavedAt) +
" · drafts stay on this device.";
}
function startAgoTicker() {
if (agoTimer) window.clearInterval(agoTimer);
agoTimer = window.setInterval(function () {
if (statusPill.getAttribute("data-state") === "saved") {
refreshSavedLabel();
}
}, 15000);
}
/* ---------------- Toast ---------------- */
function toast(msg, variant) {
var el = document.createElement("div");
el.className = "toast" + (variant ? " toast--" + variant : "");
el.setAttribute("role", "status");
var icon = document.createElement("span");
icon.className = "toast__icon";
icon.setAttribute("aria-hidden", "true");
icon.innerHTML =
variant === "warn"
? '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
var label = document.createElement("span");
label.textContent = msg;
el.appendChild(icon);
el.appendChild(label);
toastWrap.appendChild(el);
window.setTimeout(function () {
el.classList.add("is-leaving");
el.addEventListener("animationend", function () {
if (el.parentNode) el.parentNode.removeChild(el);
});
}, 2600);
}
/* ---------------- Persistence ---------------- */
function readDraft() {
if (!storageOK) return null;
try {
var raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
var data = JSON.parse(raw);
if (data && typeof data === "object") return data;
return null;
} catch (e) {
return null;
}
}
function writeDraft() {
var payload = {
title: titleEl.value,
body: bodyEl.value,
savedAt: Date.now(),
};
if (!storageOK) {
setStatus("error", "Can't save — storage unavailable");
return false;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
lastSavedAt = payload.savedAt;
return true;
} catch (e) {
setStatus("error", "Couldn't save draft");
toast("Couldn't save your draft.", "warn");
return false;
}
}
function clearDraft() {
if (!storageOK) return;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch (e) {
/* ignore */
}
}
function hasContent() {
return titleEl.value.trim() !== "" || bodyEl.value.trim() !== "";
}
/* ---------------- Save flow ---------------- */
function performSave(announce) {
// Nothing meaningful to save yet — keep it quiet.
if (!hasContent()) {
setStatus("idle", "All changes saved");
lastSavedHint.textContent = "Not saved yet.";
clearDraft();
return;
}
setStatus("saving", "Saving…");
// Simulate a brief write so the "Saving…" state is perceptible,
// mirroring how a real network/IO save would feel.
window.setTimeout(function () {
var ok = writeDraft();
if (ok) {
refreshSavedLabel();
if (announce) toast("Draft saved.", "ok");
}
}, 350);
}
function scheduleSave() {
setStatus("dirty", "Unsaved changes");
if (debounceTimer) window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(function () {
performSave(false);
}, DEBOUNCE_MS);
}
function saveNow() {
if (debounceTimer) window.clearTimeout(debounceTimer);
if (!hasContent()) {
toast("Nothing to save yet — start writing.", "warn");
return;
}
performSave(true);
}
/* ---------------- Validation ---------------- */
function setError(input, helpEl, defaultHelp, message) {
if (message) {
input.setAttribute("aria-invalid", "true");
input.classList.remove("is-valid");
helpEl.textContent = message;
helpEl.classList.add("is-error");
helpEl.setAttribute("role", "alert");
} else {
input.removeAttribute("aria-invalid");
helpEl.textContent = defaultHelp;
helpEl.classList.remove("is-error");
helpEl.removeAttribute("role");
}
}
function validate(focusFirst) {
var firstInvalid = null;
var titleVal = titleEl.value.trim();
var bodyVal = bodyEl.value.trim();
if (!titleVal) {
setError(titleEl, titleHelp, titleDefaultHelp, "Add a title before publishing.");
firstInvalid = firstInvalid || titleEl;
} else {
setError(titleEl, titleHelp, titleDefaultHelp, null);
titleEl.classList.add("is-valid");
}
if (bodyVal.length < 10) {
setError(
bodyEl,
bodyHelp,
bodyDefaultHelp,
"Write at least 10 characters before publishing."
);
firstInvalid = firstInvalid || bodyEl;
} else {
setError(bodyEl, bodyHelp, bodyDefaultHelp, null);
bodyEl.classList.add("is-valid");
}
if (firstInvalid && focusFirst) firstInvalid.focus();
return !firstInvalid;
}
/* ---------------- Restore banner ---------------- */
function showRestore(draft) {
pendingDraft = draft;
restoreAgo.textContent = draft.savedAt ? timeAgo(draft.savedAt) : "earlier";
restoreBanner.hidden = false;
// Move focus into the dialog for keyboard users.
restoreBtn.focus();
}
function hideRestore() {
restoreBanner.hidden = true;
pendingDraft = null;
}
function doRestore() {
if (!pendingDraft) return;
titleEl.value = pendingDraft.title || "";
bodyEl.value = pendingDraft.body || "";
lastSavedAt = pendingDraft.savedAt || Date.now();
updateCounter();
refreshSavedLabel();
hideRestore();
toast("Draft restored.", "ok");
titleEl.focus();
}
function doDiscard() {
clearDraft();
lastSavedAt = null;
hideRestore();
setStatus("idle", "All changes saved");
lastSavedHint.textContent = "Not saved yet.";
toast("Draft discarded.", "warn");
titleEl.focus();
}
/* ---------------- Publish ---------------- */
function handlePublish(e) {
e.preventDefault();
if (!validate(true)) {
toast("Fix the highlighted fields first.", "warn");
return;
}
if (debounceTimer) window.clearTimeout(debounceTimer);
publishBtn.disabled = true;
publishBtn.textContent = "Publishing…";
window.setTimeout(function () {
clearDraft();
lastSavedAt = null;
titleEl.value = "";
bodyEl.value = "";
titleEl.classList.remove("is-valid");
bodyEl.classList.remove("is-valid");
setError(titleEl, titleHelp, titleDefaultHelp, null);
setError(bodyEl, bodyHelp, bodyDefaultHelp, null);
updateCounter();
setStatus("idle", "All changes saved");
lastSavedHint.textContent = "Published. Draft cleared from this device.";
publishBtn.disabled = false;
publishBtn.textContent = "Publish note";
toast("Note published — draft cleared.", "ok");
titleEl.focus();
}, 650);
}
/* ---------------- Wire up ---------------- */
function onInput() {
updateCounter();
scheduleSave();
}
titleEl.addEventListener("input", onInput);
bodyEl.addEventListener("input", onInput);
saveNowBtn.addEventListener("click", saveNow);
form.addEventListener("submit", handlePublish);
restoreBtn.addEventListener("click", doRestore);
discardBtn.addEventListener("click", doDiscard);
// Esc inside the restore dialog keeps the draft but dismisses the prompt.
restoreBanner.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
hideRestore();
titleEl.focus();
}
});
// Flush a pending debounced save before the page unloads.
window.addEventListener("beforeunload", function () {
if (debounceTimer && hasContent()) {
window.clearTimeout(debounceTimer);
writeDraft();
}
});
/* ---------------- Boot ---------------- */
function init() {
updateCounter();
startAgoTicker();
var existing = readDraft();
if (existing && (existing.title || existing.body)) {
showRestore(existing);
} else {
setStatus("idle", "All changes saved");
}
}
init();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Form — Autosave + saved indicator + restore</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<div class="editor-card" role="region" aria-label="Note editor with autosave">
<!-- Restore banner (hidden until a draft is detected on load) -->
<div
class="restore"
id="restoreBanner"
role="alertdialog"
aria-labelledby="restoreTitle"
aria-describedby="restoreDesc"
hidden
>
<span class="restore__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 4v4h4" />
</svg>
</span>
<div class="restore__body">
<p class="restore__title" id="restoreTitle">Restore unsaved draft?</p>
<p class="restore__desc" id="restoreDesc">
We found a draft you didn't finish <span id="restoreAgo">a while ago</span>.
</p>
</div>
<div class="restore__actions">
<button type="button" class="btn btn--ghost" id="discardBtn">Discard</button>
<button type="button" class="btn btn--solid" id="restoreBtn">Restore draft</button>
</div>
</div>
<header class="editor-head">
<div class="editor-head__meta">
<h1 class="editor-head__title">Untitled note</h1>
<!-- Save status pill — politely announced -->
<div
class="status"
id="statusPill"
data-state="idle"
aria-live="polite"
>
<span class="status__dot" aria-hidden="true"></span>
<span class="status__text" id="statusText">All changes saved</span>
</div>
</div>
<div class="editor-head__actions">
<button type="button" class="btn btn--ghost btn--icon" id="saveNowBtn">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<path d="M17 21v-8H7v8" />
<path d="M7 3v5h8" />
</svg>
Save now
</button>
</div>
</header>
<form class="editor-form" id="editorForm" novalidate>
<div class="field">
<label class="field__label" for="title">
Title <span class="field__req" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input
class="input"
type="text"
id="title"
name="title"
placeholder="Give your note a title"
autocomplete="off"
aria-describedby="titleHelp"
required
/>
<p class="field__help" id="titleHelp">A short title helps you find it later.</p>
</div>
<div class="field">
<div class="field__row">
<label class="field__label" for="body">
Note <span class="field__req" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<span class="counter" id="counter" aria-live="off">0 words · 0 characters</span>
</div>
<textarea
class="input input--area"
id="body"
name="body"
rows="12"
placeholder="Start writing… your work is saved automatically as you type."
aria-describedby="bodyHelp"
required
></textarea>
<p class="field__help" id="bodyHelp">
Drafts are kept on this device until you publish.
</p>
</div>
<footer class="editor-foot">
<p class="editor-foot__hint" id="lastSavedHint">Not saved yet.</p>
<button type="submit" class="btn btn--solid" id="publishBtn">
Publish note
</button>
</footer>
</form>
</div>
</main>
<!-- Toast region -->
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Autosave + saved indicator + restore
A long-form note editor with a title field and a roomy textarea that saves your work without you ever pressing a button. As you type, a status pill in the header moves through three states — an amber “Unsaved changes” the moment you edit, a pulsing “Saving…” once the debounce timer fires, and a calm green “Saved just now” when the write lands. The pill is wrapped in an aria-live="polite" region, so screen readers hear each transition without it stealing focus, and the relative timestamp keeps refreshing so “Saved a moment ago” ages into “Saved 2 minutes ago” on its own.
Drafts are written to localStorage on a debounced cadence, with a manual “Save now” button for impatient moments and a beforeunload flush so an in-flight edit is never lost if you close the tab mid-keystroke. Reopen the editor with an unfinished draft on the device and a focus-trapped restore banner appears — “Restore unsaved draft?” — letting you bring the content back or discard it, with Escape to dismiss. A live word and character count sits beside the note label, and publishing runs real validation (a title is required, the body needs a few characters) before clearing the stored draft and confirming with a toast.
Every state change is reflected both visually and for assistive tech: invalid fields get aria-invalid, error helper text switches to role="alert", focus moves to the first problem on a blocked publish, and a small toast helper announces saves, restores, and discards. The layout is a single centered card that stacks cleanly down to 360px, with visible focus rings throughout and a prefers-reduced-motion fallback.