Custom Instructions
An end-user preference form for assistant instructions: autosizing questions with example popovers, per-field character budgets, one switch that gates every field without erasing it, and a Save / Cancel bar whose Cancel stays undoable.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/custom-instructions.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CustomInstructions" component: the
end-user preference form an assistant reads before every new chat. Deps:
lucide-react, Radix Popover (the `radix-ui` package), the shadcn `button`
primitive and cn(). No form library — the answers are plain controlled state.
Contract
- forwardRef<HTMLFormElement>; the root IS a <form> (Save is type="submit",
onSubmit is preventDefault'd), extending React.FormHTMLAttributes minus
onSubmit / onChange / defaultValue / children / title.
- Field descriptor: { id, label, hint?, placeholder?, maxLength?, examples? }.
Export a DEFAULT_FIELDS pair — "What should the assistant know about you?"
and "How should it respond?", 1500 characters each, four examples each.
- Value: { enabled: boolean; answers: Record<string, string> }.
- value? — the SAVED baseline (controlled); defaultValue? — the uncontrolled
baseline; defaultDraft? — the initial editing copy, so a restored draft can
mount already dirty. fields? defaults to DEFAULT_FIELDS.
- onDraftChange?(draft), onSave?(next), onCancel?(discarded).
- status?: "idle" | "saving" | "error", errorMessage?.
- title?, description?, toggleLabel?, minRows? (4), maxRows? (12), disabled?,
readOnly?, className.
- Answers are keyed by field id. Keys no current field claims are carried
through every edit and handed back on save untouched — a form must not drop a
value just for not knowing about it.
Behavior — baseline vs draft (the whole contract)
- Keep ONE draft copy in state and compare it to the baseline through a
SIGNATURE, not object identity: JSON of [enabled, ...answers of the current
fields]. Identity would loop forever against a caller that inlines
value={{…}}, and a whole-record compare would call a stale key a change.
- dirty = draftSig !== savedSig. The Save / Cancel bar exists only while dirty;
there is no permanently parked bar and no disabled Save.
- When the baseline changes underneath: if our own save was in flight, that is
the landing — clear the drift flag and flash "Saved". Else if the draft was
clean, adopt the new baseline silently. Else keep every character the user
typed and raise a one-line drift notice ("Changed elsewhere — saving replaces
that copy"). Never overwrite unsaved work to stay in sync.
- Do this in a render-phase "previous signature" comparison, not an effect, so
the form never paints one frame with the stale baseline.
Behavior — save
- requestSave is a no-op unless dirty, editable, not already saving, not over
budget, and not locked. Take a ONE-SHOT lock so a double click cannot fire
onSave twice; release it when the draft, the baseline or the status changes.
- Uncontrolled forms commit locally; controlled ones wait for the parent to hand
back a new value — which is what makes status="saving" observable at all.
- status="saving": both buttons disabled, spinner on Save, fields stay editable.
- status="error": nothing is cleared, the bar stays, and a destructive-tinted
role="alert" line explains that every character is still there.
- ⌘S / Ctrl+S and ⌘Enter save from any field: preventDefault (kill the browser
Save Page dialog) plus stopPropagation (kill an app-level ⌘S doing it twice).
- A landed save replaces the bar with a "Saved" line for ~2.6s, and only while
the form is clean — a user who kept typing during the save sees the unsaved
bar instead of a contradictory confirmation.
Behavior — cancel is undoable, not confirmable
- Cancel reverts the draft to the baseline IMMEDIATELY, snapshots what it threw
away, and replaces the bar with "Changes discarded · Undo" for ~6s. Undo puts
the snapshot back. No "are you sure?" dialog stands between the user and a
cancel they meant.
- Any keystroke or toggle inside that window drops the snapshot, so a stale Undo
can never overwrite text written after the revert.
- Move focus deliberately: the button the keyboard was on is being unmounted, so
after Cancel focus the Undo button, and after Undo focus Save. Never drop the
user on <body>.
Behavior — the gate
- The switch is part of the value, so flipping it makes the form dirty like any
other edit. Off means "not sent", never "erased": every answer stays on screen
and is saved as-is.
- Gate with readOnly + aria-disabled, NOT disabled: a disabled textarea cannot be
scrolled, selected or read by a screen reader, and the whole point is that the
text is still there. Dim it, add a muted explanation line, and point the
switch's aria-describedby at that line.
Behavior — budgets and counting
- maxLength is a SOFT budget. Over it: the counter and the field border turn
destructive, the bar reports the total overage, Save is blocked — and not one
character is truncated. Never let a paste be silently eaten.
- Count graphemes (Intl.Segmenter when available, code points otherwise), not
string length: a flag emoji is one thing on screen and four UTF-16 units, and
a budget the user can see must count what the user sees.
- Counter styling is a three-step ladder: muted, then plain foreground from 90%
of the budget, then destructive past it.
- Fields with no maxLength show a bare count and never block a save.
Behavior — autosize and examples
- Grow each textarea to its content in a LAYOUT effect (height:auto, then
scrollHeight clamped between minRows and maxRows in computed line-heights),
then switch overflowY on only past the ceiling. Re-run on width changes via a
ResizeObserver that ignores height-only entries — reacting to the height you
just wrote is an observer loop. Disconnect on unmount.
- Each field with examples gets a small "Examples" popover. Picking one does NOT
close-and-insert in the same tick: remember the choice, close the popover, and
insert in onCloseAutoFocus with preventDefault — execCommand needs the field
focused and Radix would otherwise hand focus back to the trigger.
- Insert semantics: a blank field takes the example whole, anything else keeps
its text and gets the example appended on its own line. Insert through
document.execCommand("insertText") so the browser's own undo stack survives
(⌘Z puts the field back); on refusal fall back to a state update and restore
the caret in a layout effect.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, border,
bg-muted/40 and bg-muted/60 for the gate note and the save bar, bg-popover /
text-popover-foreground for the examples panel, bg-primary + bg-input for the
switch track, text-muted-foreground for hints and counters, text-destructive +
bg-destructive/10 for over-budget and failed saves. No hex, no rgb(), no
oklch().
- One card: header (title, description, switch) → optional gate note → the
fields → exactly one of the save bar / Saved line / Undo line → the error
line. overflow-hidden on the root keeps the bar inside the rounded border;
the popover is portalled so that clipping cannot reach it.
- Accessibility: every question is a real <label htmlFor>; each textarea is
aria-describedby its hint and its counter; over-budget fields are
aria-invalid; the switch is a <button role="switch" aria-checked> named by its
visible label; the failure line is role="alert"; one sr-only
role="status" aria-live="polite" region carries saving / saved / discarded /
over-budget / unsaved in that priority order.
- Motion: the bar enters with motion-safe:animate-in fade + slide, the switch
knob uses motion-reduce:transition-none, the spinner
motion-reduce:animate-none, the popover motion-reduce:animate-none. Under
reduced motion everything still appears and works — it just does not move.
- Clean up on unmount: the flash timer, the ResizeObserver, nothing else is
allowed to outlive the component.
Customization levers
- Questions: `fields` is the whole content model. Two questions is the default;
three (name / project / avoid) is a prop change, not a fork. Drop `examples`
to remove the popover, drop `maxLength` to remove the budget.
- Budget policy: change the soft cap per field, move the 90% nudge threshold, or
make it hard by passing maxLength to the textarea too — losing pastes is the
price, which is why it is soft here.
- Undo window (6s) and Saved flash (2.6s): raise for calmer surfaces, lower for
dense settings pages. Set them to the same value if you want one rhythm.
- Cancel semantics: swap the undo window for an arm-then-confirm Cancel (like a
destructive discard) when the field is long-form rather than a preference.
- Density: minRows / maxRows control the field height; the card's px-4 / py-3
rhythm and text-sm / text-xs pairing are the only spacing knobs — override
through className.
- Copy: title, description and toggleLabel are props; the gate note, the Saved
line and the fallback error text are one string each in the source.
- Persistence: the component owns no I/O. onSave gives you the whole value —
PUT it, or write it to localStorage and feed it back as `value` to keep the
Saving → Saved cycle honest.Concepts
- Baseline vs draft — the saved copy and the copy being edited are two separate values, and every question the UI asks ("is there anything to save?", "did the server move?") is a comparison of their signatures rather than of object identities, so a caller that rebuilds
valueon every render still behaves. - Undoable cancel — Cancel reverts immediately and keeps the discarded text for a few seconds behind an Undo button, which is the cheap version of a confirmation dialog: nobody is interrogated for cancelling, and nobody loses a paragraph either. The first keystroke after the revert drops the snapshot so Undo can never resurrect stale text over new work.
- Gate without erase — the master switch changes whether the instructions are sent, never whether they exist; the fields go read-only and dim rather than disabled, because a disabled textarea cannot be scrolled, selected or reached by a screen reader and the text is the whole point.
- Soft budget — the character limit warns, colours and blocks the save, but never truncates: a limit that silently eats a paste is a bug wearing a counter. Counting is done in graphemes so an emoji costs what it looks like it costs.
- One-shot save lock — the first Save press latches until the draft, the baseline or the status changes, so a double click, an Enter plus a click, or ⌘S twice in a row still produce exactly one write.
- Insert after close — an example is remembered on click, applied only once the popover has closed and focus is back in the field, and written through the platform's own insert command so a plain ⌘Z undoes it like typing.
Memory List
The things an assistant remembers about someone, as a list they can audit — inline editing behind a one-editor lock, a two-step bin that fires exactly once, an add box that refuses duplicates, per-memory and master switches, the "used in this chat" highlight, and four data states.
Model Router
A routing policy panel — ordered first-match-wins rules with testable condition chips, per-rule switches with optimistic rollback, a health-aware fallback chain, and a prompt tester that names the rule which claims a request and the ones never asked.