Skip to content

Saving and unsaved changes

When changes save instantly, when they wait for the save bar, and how leaving with unsaved work is handled.

The problem#

People need to know, without thinking about it, whether a change is already live, waiting to be saved, or lost when they leave.

A settings page where some switches apply at once and others wait for a button teaches nobody anything. A long form that autosaves silently leaves people unsure whether a half-typed threshold just changed tomorrow's payment run. A procedure that Nora follows on live calls can't change the moment someone fixes a typo. And a reload or a stray click on the sidebar can throw away ten minutes of work.

The solution in Canon#

Each surface picks one save model and shows it. Small reversible changes apply instantly with Undo; related fields wait for the save bar; anything live goes through a draft and a publish.

One save model per surface

A settings group either applies each change instantly or collects them for the save bar. Never mix the two in one group.

Instant changes offer Undo

A change that applies on click answers with a toast and an Undo, and the control itself already shows the new state.

Live things are published

What customers or agents act on (procedures, prompts, flows) is edited as a draft and published as a version, with a confirmation.

Decision guide#

Choose by what the change touches and how easily it is reversed.

Which save model
The changeModelCedarline example
One on or off setting, easy to reverseInstant: switch plus a toast with UndoTake early-pay discounts
A field on a record: status, owner, tagInstant, inlineAssign INV-20417 to Priya Raman
Several related fields that only make sense togetherExplicit: useDirtyForm and the Save barPayment run defaults
Creating a recordA form with a submit buttonNew supplier
Text that something live followsDraft autosaves, publish creates a versionNora's Where is my payment procedure
An action on a row in a listOptimistic: update now, roll back on failureApprove an invoice

Instant apply#

Switches and inline record fields save on change. The control moves, a toast confirms, and Undo puts it back.

Three-way match against the PO and receipt as invoices arrive.
Pay ahead of the due date when the discount beats 6% a year.
Remittance advice goes to each supplier's remit-to email.
import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function InstantApply() {    type Key = "autoMatch" | "earlyPay" | "notify";    const id = React.useId();    const settings: {        key: Key;        label: string;        description: string;        on: string;        off: string;    }[] = [        {            key: "autoMatch",            label: "Match invoices automatically",            description:                "Three-way match against the PO and receipt as invoices arrive.",            on: "Automatic matching turned on",            off: "Automatic matching turned off",        },        {            key: "earlyPay",            label: "Take early-pay discounts",            description:                "Pay ahead of the due date when the discount beats 6% a year.",            on: "Early-pay discounts turned on",            off: "Early-pay discounts turned off",        },        {            key: "notify",            label: "Email suppliers when a payment is sent",            description:                "Remittance advice goes to each supplier's remit-to email.",            on: "Payment emails turned on",            off: "Payment emails turned off",        },    ];    const [values, setValues] = React.useState<Record<Key, boolean>>({        autoMatch: true,        earlyPay: false,        notify: true,    });    const change = (key: Key, checked: boolean) => {        setValues((current) => ({ ...current, [key]: checked }));        const setting = settings.find((item) => item.key === key);        toast.add({            title: checked ? setting?.on : setting?.off,            actionProps: {                children: "Undo",                onClick: () =>                    setValues((current) => ({ ...current, [key]: !checked })),            },        });    };    return (        <div className="w-full max-w-2xl text-left">            <SettingsGroup>                {settings.map((setting) => (                    <SettingsRow                        key={setting.key}                        inline                        label={setting.label}                        htmlFor={`${id}-${setting.key}`}                        description={setting.description}                    >                        <Switch                            id={`${id}-${setting.key}`}                            checked={values[setting.key]}                            onCheckedChange={(checked) =>                                change(setting.key, checked)                            }                            aria-describedby={descriptionId(                                `${id}-${setting.key}`,                            )}                        />                    </SettingsRow>                ))}            </SettingsGroup>        </div>    );}
  • Use it when each control stands alone and its effect is easy to reverse.
  • The toast says what is now true (Early-pay discounts turned on), not that something was saved.
  • Undo restores the previous value without a second toast; the switch moving back is the feedback.
  • If a change can't be undone, or takes effect on money already in motion, it isn't an instant change. Use a confirmation. See Destructive actions.

Explicit save#

When fields depend on each other, collect the changes and let the person commit them together. useDirtyForm tracks what changed against a saved baseline; SaveBar shows it.

Payment run defaults

Change any setting. The save bar rises with the count, Discard returns to the baseline, and Save or ⌘S commits.

Payment run defaults

Used for every scheduled run unless you change it on the run.

Invoices approved by noon go out that day.
Runs with an invoice over this amount, in USD, need a second approver.
Held invoices skip the run until the supplier uploads one.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { SelectField } from "@oration/canon/components/select-field";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function ExplicitSave() {    const id = React.useId();    const form = useDirtyForm(        {            runDay: "thursday",            threshold: "25000",            approver: "maya",            holdWithoutW9: true,        },        {            onSave: async () => {                await new Promise((resolve) => setTimeout(resolve, 800));                toast.add({                    type: "success",                    title: "Payment run defaults saved",                    description:                        "They apply from the run on Thursday, October 1.",                });            },        },    );    return (        <div className="w-full max-w-2xl text-left">            <SettingsSection                title="Payment run defaults"                description="Used for every scheduled run unless you change it on the run."            >                <SettingsGroup>                    <SettingsRow                        label="Run day"                        htmlFor={`${id}-day`}                        description="Invoices approved by noon go out that day."                    >                        <SelectField                            id={`${id}-day`}                            value={form.values.runDay}                            onValueChange={(value) => form.set("runDay", value)}                            options={[                                { value: "tuesday", label: "Tuesday" },                                { value: "thursday", label: "Thursday" },                                {                                    value: "both",                                    label: "Tuesday and Thursday",                                },                            ]}                            describedBy={descriptionId(`${id}-day`)}                            className="w-full sm:w-56"                        />                    </SettingsRow>                    <SettingsRow                        label="Second approval above"                        htmlFor={`${id}-threshold`}                        description="Runs with an invoice over this amount, in USD, need a second approver."                    >                        <Input                            id={`${id}-threshold`}                            inputMode="decimal"                            value={form.values.threshold}                            onChange={(event) =>                                form.set("threshold", event.target.value)                            }                            aria-describedby={descriptionId(`${id}-threshold`)}                            className="tabular-nums sm:w-56"                        />                    </SettingsRow>                    <SettingsRow                        label="Second approver"                        htmlFor={`${id}-approver`}                    >                        <SelectField                            id={`${id}-approver`}                            value={form.values.approver}                            onValueChange={(value) =>                                form.set("approver", value)                            }                            options={[                                { value: "maya", label: "Maya Okafor" },                                { value: "priya", label: "Priya Raman" },                                { value: "wen", label: "Wen Zhou" },                            ]}                            className="w-full sm:w-56"                        />                    </SettingsRow>                    <SettingsRow                        inline                        label="Hold invoices without a W-9"                        htmlFor={`${id}-hold`}                        description="Held invoices skip the run until the supplier uploads one."                    >                        <Switch                            id={`${id}-hold`}                            checked={form.values.holdWithoutW9}                            onCheckedChange={(checked) =>                                form.set("holdWithoutW9", checked)                            }                            aria-describedby={descriptionId(`${id}-hold`)}                        />                    </SettingsRow>                </SettingsGroup>            </SettingsSection>            <SaveBar                dirty={form.isDirty}                changes={form.dirtyCount}                saving={form.saving}                onDiscard={form.reset}                onSave={() => void form.save()}            />        </div>    );}
The save bar
PartBehavior
AppearanceSprings up from 12px below at 0.98 scale on spring.slow when the form turns dirty, and leaves on exit.slow (160ms). Sticky 16px above the bottom of the page column.
MessageAn indigo unsaved dot and the count: 1 unsaved change, 3 unsaved changes. Undo a change by hand and the count drops; reach zero and the bar leaves.
ActionsDiscard is ghost. Save changes is the view's one filled button, with the ⌘S hint.
SavingBoth buttons disable and a spinner shows until onSave settles. Throw from onSave to keep the form dirty, and toast the failure yourself.
LeavingwarnOnLeave asks before the tab closes or reloads while dirty. In-app navigation is yours to guard; see below.
Keyboard interactions
KeysAction
⌘SSaves while the bar is showing. Ctrl S on Windows and Linux.

Drafts and publishing#

For what Nora and the workflows run on, editing never touches the live version. The draft autosaves as you type; publishing makes it the next version.

A versioned procedure

Edit the step. The draft saves itself and the status changes to Draft, not live. Publish asks first, then adds version 5 to the history.

Where is my payment

Procedure Nora follows on supplier calls

Version 4 is live
  • v4Sep 21, Priya RamanLive
  • v3Sep 9, Priya Raman
  • v2Aug 30, Tomás Ferreira
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { Label } from "@oration/canon/components/label";import { Spinner } from "@oration/canon/components/spinner";import { StatusLabel } from "@oration/canon/components/status-dot";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { CheckIcon } from "lucide-react";import * as React from "react";export function DraftAndPublish() {    type Version = { n: number; at: string; by: string };    const id = React.useId();    const published =        "Ask for the invoice number or PO. Look up the payment, then read back the amount, the payment method and the date it was sent or is scheduled.";    const [live, setLive] = React.useState(published);    const [draft, setDraft] = React.useState(published);    const [saving, setSaving] = React.useState(false);    const [savedAt, setSavedAt] = React.useState<string | null>(null);    const [confirm, setConfirm] = React.useState(false);    const [versions, setVersions] = React.useState<Version[]>([        { n: 4, at: "Sep 21", by: "Priya Raman" },        { n: 3, at: "Sep 9", by: "Priya Raman" },        { n: 2, at: "Aug 30", by: "Tomás Ferreira" },    ]);    const hasDraft = draft !== live;    const current = versions[0]?.n ?? 1;    React.useEffect(() => {        if (draft === live) return;        setSaving(true);        const timer = window.setTimeout(() => {            setSaving(false);            setSavedAt("just now");        }, 700);        return () => window.clearTimeout(timer);    }, [draft, live]);    return (        <div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border px-4 py-3">                <div className="min-w-0 flex-1">                    <h3 className="truncate text-sm font-semibold">                        Where is my payment                    </h3>                    <p className="text-xs text-muted-foreground">                        Procedure Nora follows on supplier calls                    </p>                </div>                {hasDraft ? (                    <StatusLabel                        tone="warning"                        className="text-muted-foreground"                    >                        Draft, not live                    </StatusLabel>                ) : (                    <StatusLabel                        tone="success"                        className="text-muted-foreground"                    >                        Version {current} is live                    </StatusLabel>                )}            </div>            <div className="flex flex-col gap-2 px-4 py-4">                <div className="flex items-center justify-between gap-3">                    <Label htmlFor={id}>Step 2: Look up the payment</Label>                    <span                        aria-live="polite"                        className="flex items-center gap-1.5 text-xs text-muted-foreground"                    >                        {hasDraft ? (                            saving ? (                                <>                                    <Spinner className="size-3" />                                    Saving draft                                </>                            ) : savedAt ? (                                <>                                    <CheckIcon                                        aria-hidden="true"                                        className="size-3"                                    />                                    Draft saved {savedAt}                                </>                            ) : null                        ) : null}                    </span>                </div>                <Textarea                    id={id}                    value={draft}                    onChange={(event) => setDraft(event.target.value)}                    className="min-h-20"                />            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    variant="ghost"                    disabled={!hasDraft}                    onClick={() => {                        setDraft(live);                        toast.add({                            title: "Draft discarded",                            description: `Version ${current} is unchanged.`,                        });                    }}                >                    Discard draft                </Button>                <Button                    disabled={!hasDraft || saving}                    onClick={() => setConfirm(true)}                >                    Publish version {current + 1}                </Button>            </div>            <ul className="border-t border-border px-2 py-1">                {versions.map((version, index) => (                    <li                        key={version.n}                        className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5"                    >                        <span className="w-8 text-13 font-medium tabular-nums">                            v{version.n}                        </span>                        <span className="min-w-0 flex-1 truncate text-13 text-muted-foreground">                            {version.at}, {version.by}                        </span>                        {index === 0 ? (                            <span className="text-xs text-muted-foreground">                                Live                            </span>                        ) : (                            <Button                                variant="ghost"                                size="sm"                                onClick={() =>                                    toast.add({                                        title: `Version ${version.n} opened as a draft`,                                        description:                                            "Publish it to make it live again.",                                    })                                }                            >                                Restore                            </Button>                        )}                    </li>                ))}            </ul>            <ConfirmDialog                open={confirm}                onOpenChange={setConfirm}                destructive={false}                title={`Publish version ${current + 1}?`}                description={`Nora follows it from the next supplier call. Version ${current} stays in the history and can be restored.`}                confirmLabel={`Publish version ${current + 1}`}                onConfirm={async () => {                    await new Promise((resolve) => setTimeout(resolve, 600));                    setLive(draft);                    setSavedAt(null);                    setVersions((list) => [                        { n: current + 1, at: "Sep 28", by: "Maya Okafor" },                        ...list,                    ]);                    toast.add({                        type: "success",                        title: `Version ${current + 1} published`,                        description: "Nora uses it from the next call.",                    });                }}            />        </div>    );}
  • Show which version is live and whether a draft exists, as a status label beside the title.
  • Autosave the draft quietly: Saving draft with a small spinner, then Draft saved just now. No toast for autosave.
  • Publishing is the view's one filled button, labelled with the version it creates. Confirm it with a plain (not red) Confirm dialog that says who is affected and when.
  • Keep every published version. Restoring an old one opens it as a draft; it goes live only when published again.
  • Discard draft returns to the live version and says so in a toast.

Leaving with unsaved changes#

Unsaved work is never thrown away without asking. The save bar covers closing the tab; moving elsewhere inside the app needs a guard you add.

Switching sections while dirty

Change a setting in Approvals, then pick another section. The dialog offers to keep editing or discard.

import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { SideTabs } from "@oration/canon/components/side-tabs";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function LeaveGuard() {    type Section = "approvals" | "remittance" | "notifications";    const id = React.useId();    const panelId = `${id}-panel`;    const [section, setSection] = React.useState<Section>("approvals");    const [pending, setPending] = React.useState<Section | null>(null);    const form = useDirtyForm(        {            threshold: "25000",            dualApproval: true,            replyTo: "remittance@cedarline.com",            digest: false,        },        {            onSave: async () => {                await new Promise((resolve) => setTimeout(resolve, 600));                toast.add({ type: "success", title: "Settings saved" });            },        },    );    const go = (next: string) => {        const target = next as Section;        if (target === section) return;        if (form.isDirty) setPending(target);        else setSection(target);    };    return (        <div className="flex w-full max-w-3xl flex-col gap-4 text-left md:flex-row">            <SideTabs                label="Payables settings"                orientation="vertical"                panelId={panelId}                value={section}                onValueChange={go}                className="md:w-48"                items={[                    { id: "approvals", label: "Approvals" },                    { id: "remittance", label: "Remittance" },                    { id: "notifications", label: "Notifications" },                ]}            />            <div                id={panelId}                role="tabpanel"                aria-labelledby={`side-tab-${section}`}                className="min-w-0 flex-1"            >                <SettingsGroup>                    {section === "approvals" ? (                        <>                            <SettingsRow                                label="Second approval above"                                htmlFor={`${id}-t`}                            >                                <Input                                    id={`${id}-t`}                                    inputMode="decimal"                                    value={form.values.threshold}                                    onChange={(event) =>                                        form.set(                                            "threshold",                                            event.target.value,                                        )                                    }                                    className="tabular-nums sm:w-40"                                />                            </SettingsRow>                            <SettingsRow                                inline                                label="Require two approvers on new suppliers"                                htmlFor={`${id}-d`}                            >                                <Switch                                    id={`${id}-d`}                                    checked={form.values.dualApproval}                                    onCheckedChange={(checked) =>                                        form.set("dualApproval", checked)                                    }                                />                            </SettingsRow>                        </>                    ) : section === "remittance" ? (                        <SettingsRow                            label="Reply-to address"                            htmlFor={`${id}-r`}                        >                            <Input                                id={`${id}-r`}                                type="email"                                value={form.values.replyTo}                                onChange={(event) =>                                    form.set("replyTo", event.target.value)                                }                                className="sm:w-60"                            />                        </SettingsRow>                    ) : (                        <SettingsRow                            inline                            label="Daily digest of held invoices"                            htmlFor={`${id}-g`}                        >                            <Switch                                id={`${id}-g`}                                checked={form.values.digest}                                onCheckedChange={(checked) =>                                    form.set("digest", checked)                                }                            />                        </SettingsRow>                    )}                </SettingsGroup>                <SaveBar                    dirty={form.isDirty}                    changes={form.dirtyCount}                    saving={form.saving}                    shortcut={false}                    onDiscard={form.reset}                    onSave={() => void form.save()}                />            </div>            <ConfirmDialog                open={pending !== null}                onOpenChange={(open) => {                    if (!open) setPending(null);                }}                title="Discard unsaved changes?"                description={`You changed ${form.dirtyCount === 1 ? "1 setting" : `${form.dirtyCount} settings`}. Leaving this section discards ${form.dirtyCount === 1 ? "it" : "them"}.`}                cancelLabel="Keep editing"                confirmLabel="Discard changes"                onConfirm={() => {                    form.reset();                    if (pending) setSection(pending);                }}            />        </div>    );}
Ways to leave
Way outWhat happens
Close or reload the tabThe browser's own prompt, from SaveBar warnOnLeave.
Another section, tab or link in the appA Confirm dialog: Discard unsaved changes?, with Keep editing and a red-tint Discard changes.
Close a dialog or sheet with editsThe same confirmation. A pristine dialog closes without asking.
Press DiscardReturns to the baseline at once, no confirmation. The person asked for it.

Known gap

warnOnLeave only covers closing or reloading the tab. Following an in-app link with unsaved edits loses them without asking, so guard section switches and links yourself, as this example does. Recorded on Save bar.

Optimistic updates#

For row actions that almost always succeed, show the result at once and reconcile when the request returns.

Approve, then reconcile

Approve an invoice and the row changes immediately. Turn on Fail the next request to see it roll back with an error toast and Try again.

Waiting for approval

  • Northwind Freight

    INV-20417

    $18,240.00
  • Orchard Street Foods

    INV-20438

    $7,305.18
  • Pinecrest Supply

    INV-20442

    $1,264.00
import { Button } from "@oration/canon/components/button";import { StatusLabel } from "@oration/canon/components/status-dot";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function OptimisticUpdate() {    type Row = {        id: string;        supplier: string;        amount: string;        approved: boolean;    };    const failId = React.useId();    const [failNext, setFailNext] = React.useState(false);    const failRef = React.useRef(false);    const [busy, setBusy] = React.useState<string[]>([]);    const [rows, setRows] = React.useState<Row[]>([        {            id: "INV-20417",            supplier: "Northwind Freight",            amount: "$18,240.00",            approved: false,        },        {            id: "INV-20438",            supplier: "Orchard Street Foods",            amount: "$7,305.18",            approved: false,        },        {            id: "INV-20442",            supplier: "Pinecrest Supply",            amount: "$1,264.00",            approved: false,        },    ]);    const setApproved = (rowId: string, approved: boolean) =>        setRows((list) =>            list.map((row) => (row.id === rowId ? { ...row, approved } : row)),        );    const approve = (row: Row) => {        const shouldFail = failRef.current;        failRef.current = false;        setFailNext(false);        setApproved(row.id, true);        setBusy((list) => [...list, row.id]);        window.setTimeout(() => {            setBusy((list) => list.filter((item) => item !== row.id));            if (!shouldFail) return;            setApproved(row.id, false);            toast.add({                type: "error",                title: `Couldn't approve ${row.id}`,                description: `${row.supplier} is back in the queue. Nothing was sent.`,                actionProps: {                    children: "Try again",                    onClick: () => approve(row),                },            });        }, 1000);    };    return (        <div className="flex w-full max-w-xl flex-col gap-3 text-left">            <label                htmlFor={failId}                className="flex items-center gap-2 text-13 text-muted-foreground"            >                <Switch                    id={failId}                    checked={failNext}                    onCheckedChange={(checked) => {                        failRef.current = checked;                        setFailNext(checked);                    }}                />                Fail the next request            </label>            <section className="overflow-hidden rounded-xl bg-card shadow-border">                <h3 className="border-b border-border px-4 py-3 text-sm font-semibold">                    Waiting for approval                </h3>                <ul>                    {rows.map((row) => (                        <li                            key={row.id}                            className={cn(                                "flex min-h-13 items-center gap-3 border-b border-border px-4 py-2 transition-colors duration-150 last:border-b-0",                                row.approved && "bg-muted/40",                            )}                        >                            <div className="min-w-0 flex-1">                                <p className="truncate text-13 font-medium">                                    {row.supplier}                                </p>                                <p className="font-mono text-xs text-muted-foreground">                                    {row.id}                                </p>                            </div>                            <span className="w-24 text-right text-13 font-medium tabular-nums">                                {row.amount}                            </span>                            <div className="flex w-32 justify-end">                                {row.approved ? (                                    <StatusLabel                                        tone="success"                                        className="text-muted-foreground"                                    >                                        Approved                                        {busy.includes(row.id) ? (                                            <span className="sr-only">                                                , saving                                            </span>                                        ) : null}                                    </StatusLabel>                                ) : (                                    <Button                                        variant="outline"                                        size="sm"                                        onClick={() => approve(row)}                                    >                                        Approve                                    </Button>                                )}                            </div>                        </li>                    ))}                </ul>            </section>        </div>    );}
  • Change the row, the count and any total in the same frame as the click.
  • On success, say nothing more. The row already changed.
  • On failure, put the row back exactly as it was and raise an error toast that names the record and offers Try again.
  • Don't be optimistic about money leaving the account, publishing, or anything a person outside Cedarline will see. Wait for the answer with a Pending button.

Do and don't#

2 unsaved changes
Do. Let the save bar carry the one filled button while the form is dirty.
Approvals
Remittance
Don't. Add a permanent Save button at the bottom of every settings section. It sits there enabled with nothing to save.
Do. Apply a switch at once and offer Undo in the toast.
Don't. Ask Are you sure? before turning on early-pay discounts. Confirming a reversible change slows everyone down to protect nobody.
Do. Edit live procedures as drafts and publish them as versions.
Don't. Autosave straight into what Nora runs on. A typo goes live mid-call.

Accessibility#

Save state has to be heard as well as seen.

  • The save bar is a labelled region (Unsaved changes) and its count is a polite live region, so changes to the count are announced.
  • Save carries aria-keyshortcuts for ⌘S or Ctrl S.
  • Autosave status (Saving draft, Draft saved just now) sits in a polite live region beside the field it describes.
  • Toasts for instant changes are announced politely; their Undo is a real button, reachable from the keyboard.
  • The leave confirmation is an alert dialog with focus on Keep editing, the safe choice.
  • An optimistic row that rolls back is announced through the error toast, which names the record.

The save bar's live region mounts with its text already inside, so most screen readers announce later count changes but not the bar's arrival. The gap is recorded on Save bar.

Components involved#

The parts this pattern is built from.

Components involved
ComponentRole here
Save barThe sticky unsaved-changes bar with Discard, Save and ⌘S.
Dirty formuseDirtyForm(initial, { onSave }): values, baseline, dirtyCount, reset, save.
Settings sectionSections, groups and label-left rows.
SwitchInstant on and off settings.
ToastConfirms instant changes with Undo, reports failures.
Confirm dialogPublishing, and discarding unsaved changes.
Side tabsSettings sections that guard against leaving dirty.
Pending buttonWaits for the answer when optimism isn't safe.
Status labelLive version against draft.