Prompt Library
A saved-prompt library with fill-in-the-blank templates — search across title, body and tags, faceted categories, a variable form ordered by first appearance, and a preview that is character-for-character what gets inserted.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/prompt-library.jsonPrompt
Build a React + TypeScript + Tailwind "PromptLibrary" component with zod and
lucide-react. The hard part is the template engine, not the browsing shell.
Contract
- A zod schema (`promptEntrySchema` in a sibling contract file) is the single
source of truth for one saved prompt: { id, title, body, category, tags?,
variables?: { name, label?, hint?, placeholder?, defaultValue?, multiline?,
required? }[] }. `promptLibraryStatusSchema` — "loading" | "empty" | "error"
| "ready" — is the panel's own render state.
- `variables` is metadata ABOUT the placeholders, never the list of them. Which
variables exist, and in which order they are asked for, is derived from `body`
by the parser, so a template can never disagree with its form. A declared
entry whose name never appears in the body is ignored.
- Props: items; status; onInsert?(text, entry); onRetry?; onCreate?;
insertLabel? (default "Insert"); label? (default "Prompt library");
defaultSelectedId?; skeletonRows? (clamped 1-24); emptyState?; className.
Affordances are handler-gated: no onInsert means no Insert button (a
read-only browser), no onRetry means the error branch has no button, no
onCreate means the empty branch has no call to action.
- The template functions live in the CONTRACT file, not in the component: they
are pure and React-free, so a server route can render exactly the same string
the UI previewed. Export parsePromptTemplate(body) -> segments,
promptTemplateVariables(segments), resolvePromptVariables(entry),
initialPromptValues(vars), missingRequiredPromptVariables(vars, values) and
renderPromptTemplate(segments, values).
Behavior - the template grammar (define it, document it, do not improvise)
- A placeholder is a double-brace token. The name is trimmed, so a padded token
and a tight one are the same variable, and the name charset is letters (any
script, so CJK names work), digits, underscore, dot and hyphen. Anything else
- a space, a quote, a colon, a nested brace - means "not a placeholder", which
is what stops a pasted JSON body from being swallowed as a variable named
`"a": 1`. Unclosed and empty tokens stay literal too: the parser never errors,
it just declines.
- ESCAPE BY DOUBLING THE OPENING BRACE: four opening braces render two literal
ones. A template system with no escape hatch is broken - prompts about
templating (mail-merge tokens, Jinja/Handlebars snippets) have to be able to
emit the syntax verbatim. Doubling is chosen over a backslash escape because
prompt bodies are full of regexes, Windows paths and LaTeX that would
otherwise all need escaping too. The closing pair never needs an escape: it
is only meaningful after an unescaped opener.
- PARSE ONCE, OVER THE TEMPLATE ONLY. The parser is a single left-to-right pass
producing a segment list of literal runs and named holes. Rendering walks that
list and concatenates; values are dropped into the holes and never re-scanned.
That is the whole injection story: a value containing a placeholder token for
another variable stays inert text. Do NOT implement this as
"replace-until-nothing-changes" over the body string - that engine substitutes
the user's own input, and one value can then rewrite another.
- One field per unique name, ordered by FIRST APPEARANCE in the body, never
alphabetically: the reader fills them in reading order, and a name used three
times is typed once and lands in all three spots (show the occurrence count on
the label so that is obvious).
- Required by default. An unfilled required variable (blank or whitespace-only)
BLOCKS insertion - the button gets aria-disabled (not the native disabled
attribute, which would make it unfocusable and undescribable) and an
aria-describedby hint NAMES the missing variables and shrinks as they are
filled. An optional variable left blank simply contributes nothing to the
output; placeholders are never left behind in the emitted text.
- THE PREVIEW IS THE PAYLOAD. The preview pane is rendered from the same segment
list and the same values as the emitted string, with nothing decorative added
to its text, so its textContent equals what onInsert receives character for
character. Assert that in a test. While insertion is blocked the pane shows
the leftover placeholders instead, and the character counter is hidden rather
than describing a string nobody can see.
- Values are scoped to one entry and re-synced during render (not in an effect)
when the open entry changes, so the first paint of a new prompt already shows
its own defaults instead of flashing a stranger's answers.
- Search matches title, body and tags, case-insensitively. Because rows show a
title and not the body, a hit that landed only inside the template says so on
the row. Category chips are FACETED against the search (their counts sum to
the visible row count), and filtering down to zero renders a no-match block
that names the query, states how many prompts the library still holds, and
offers Clear filters - never the same screen as an empty library.
- Four first-class branches on `status`: loading -> skeletons in the real
two-pane geometry; empty -> "no saved prompts yet" plus a one-line
explanation of the placeholder syntax; error -> message + optional retry;
ready -> browser + detail. `status === "ready"` with zero items falls back to
the empty block rather than to a bare no-match screen.
Rendering & styling
- Semantic tokens only: bg-card (panel), bg-muted / text-muted-foreground
(secondary text, skeletons, preview surface), bg-primary +
text-primary-foreground (active category chip, Insert), bg-primary/8 (open
row), bg-primary/10 (a filled value inside the preview), bg-destructive/10 +
text-destructive (an unfilled placeholder inside the preview),
focus-visible:ring-2 ring-ring on every control. cn() merges the consumer
className into the root.
- The open row is marked by tint AND font weight, never by tint alone - a few
percent of primary is not a reliable channel.
- Layout keys off @container, not viewport breakpoints: one column below @2xl,
list + detail side by side above it, because this panel is a full-width page
in one app and a 360px sidebar in another. Long prompts are never trapped in a
fixed-height scroller: the panel grows and the host decides where scrolling
happens.
- Long unbroken tokens (a URL inside a template) need wrap-anywhere together
with min-w-0 - either one alone still lets the token push the layout out.
- Every variable field is a real label + control pair wired with htmlFor, hints
are attached with aria-describedby, and the result count lives in a persistent
sr-only aria-live region (created empty, filled later - a live region that
arrives together with its text is usually missed).
- Only the loading pulse and colour transitions animate, each carrying
motion-reduce:*-none.
Customization levers
- Delimiters: the parser is fifteen lines around two constants (opener/closer)
and one name charset. Swap them for dollar-brace or percent-name by changing
those three values plus the escape rule; keep "escape by doubling the opener"
so the system stays closed.
- Required policy: flip the default in resolvePromptVariables if your library
prefers "insert with the holes left in" - but then render the holes into the
emitted string too, so the preview stays the payload.
- Field kinds: `multiline` picks a textarea; add `options` to the variable
schema and render a native select for enumerated values (the engine does not
care where the string came from, it only reads the values record).
- Browsing shell: swap the category chips for a tree, drop them entirely for a
small library, or wrap the list in your own max-height + overflow-y-auto if
the surface is height-constrained.
- Actions: onInsert is the only outbound action by design. Add copy / favourite
/ edit as extra buttons beside it - they take the same rendered string, so
they can never disagree with the preview.
- Density: p-3 / p-4 panes read as comfortable; the detail grid is one column
below @xl and two above it, which is the knob for form-heavy prompts.Concepts
- The body is the source of truth — the form is generated by parsing the template, so a prompt can never ask for a variable it does not use, or silently drop one it does. The declared
variablesarray only decorates what the parser found: label, hint, default, multiline, required. - Escape by doubling — writing the opening brace twice over (
{{{{) emits one literal{{, the way%%works in printf. Without an escape hatch a prompt library cannot hold prompts about templating; a backslash escape would have forced every regex, path and LaTeX fragment in the corpus to be escaped as well. - Single-pass substitution — parsing happens over the template only; values are dropped into the holes and never re-read. A value that happens to contain
{{another_variable}}therefore stays literal text. The naive "loop replace until nothing changes" engine fails exactly here: the user's own input becomes template source. - Reading order, not alphabetical — fields follow first appearance in the body, because that is the order a human reads the prompt in. A name used three times is one field labelled fills 3 spots: typed once, replaced everywhere.
- Preview is the payload — the preview pane and the inserted string come from the same segment list and the same values, and nothing decorative is added to the pane's text, so the two are equal character for character. Anything that would break that equality (the character counter, a marker for an empty value) is suppressed rather than allowed to diverge.
- Blocked, not hidden — an unfinished prompt keeps a visible, focusable Insert button carrying
aria-disabledplus a description that names the missing variables; the click handler is what refuses. A nativedisabledwould drop the button out of the tab order and take its explanation with it. - Filtered-empty is not empty — "nothing matches that search, your library still has 10 prompts" and "you have not saved anything yet" are different screens with different exits, because the fix for one is clear the filter and the fix for the other is write a prompt.
Context Attachments
The context list hanging off a conversation — files, links, snippets and selections with their token cost, under a budget meter that names exactly which entries get dropped when the window overflows.
Message Branch
A compact pager for the sibling variants of one reply — an id-stable selection that survives late arrivals, follow-the-tip on regenerate, ends that never steal focus, and one announcement per move.