AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Braces, CornerDownLeft, Library, Plus, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"
import {
  initialPromptValues,
  missingRequiredPromptVariables,
  parsePromptTemplate,
  renderPromptTemplate,
  resolvePromptVariables,
} from "./prompt-library.contract"
import type { PromptEntry, PromptLibraryStatus, ResolvedPromptVariable } from "./prompt-library.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prompt-library.json

Prompt

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 variables array 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-disabled plus a description that names the missing variables; the click handler is what refuses. A native disabled would 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.

On This Page