Skip to content
Star on GitHub
ConversationPrompt library
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Prompt library

Saved prompts with blanks to fill in before they go to the agent. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add prompt-library
Prompt libraryShared with DesignMe Studio
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add prompt-library

React

$npx designme add prompt-library --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Prompt library keeps a team's saved prompts, each with blanks written like {page}. Opening one lays its blanks out as fields, a text field to type in or a dropdown to pick from, and the prompt above fills in as they change. It can't be used with a blank left empty: the empty blank turns red and says what it needs.

  • When a team asks the agent the same kinds of things and wants them worded the same way each time.
  • When a prompt only changes in a few places, like the page or the tone.
  • When some blanks have a short list of right answers, and a dropdown keeps them consistent.
  • When people should see the exact prompt the agent will get before it goes.
03

Usage

Import it and drive it from your own state.

Import

import { PromptLibrary } from '@/components/designme/prompt-library/PromptLibrary';

Example

The demo above as a React component, 43 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { PromptLibrary, type PromptBlank, type SavedPrompt } from './PromptLibrary';
import { useStory } from '../lib/useScript';

/* ---------- Prompt library: open a prompt, try to use it with a blank empty, fill it in, pick a tone, then use it ---------- */
const PAGE: PromptBlank = { label: 'Page', options: ['Pricing v4', 'Home', 'Checkout'] };
const PROMPTS: SavedPrompt[] = [
  { id: 'rewrite', title: 'Rewrite a section', template: 'Rewrite {section} on {page} so it reads {tone}.', blanks: {
    section: { label: 'Section', placeholder: 'Like the plan cards', error: 'Say which section to rewrite' }, page: PAGE, tone: { label: 'Tone', options: ['clearer', 'friendlier', 'shorter'] } } },
  { id: 'feedback', title: 'Summarize feedback', template: 'Summarize the comments on {page} into three fixes.', blanks: { page: PAGE } },
  { id: 'tokens', title: 'Check the tokens', template: 'Check {page} against the {library} tokens and list what drifts.', blanks: { page: PAGE, library: { label: 'Library', options: ['Studio UI', 'Marketing'] } } },
  { id: 'notes', title: 'Write release notes', template: "Write release notes for {version} in the studio's voice.", blanks: { version: { label: 'Version', placeholder: 'Like Pricing v4', error: 'Say which version they are for' } } }
];

export default function PromptLibraryDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useState<string | null>(null);
  const [values, setValues] = useState<Record<string, string>>({});

  // the preview's story, on the same beats as the site's
  useStory(ref, async ({ wait, press, taken }) => {
    await wait(2100); press('[data-prompt="rewrite"]');
    await wait(2100); press('.dm-plb-use');
    await wait(2500);
    // taking over while it types takes the letters back
    let typing = true;
    taken(() => { if (typing) setValues(v => ({ ...v, section: '' })); });
    for (let k = 1; k <= 14; k++) { setValues(v => ({ ...v, section: 'the plan cards'.slice(0, k) })); await wait(70); }
    typing = false;
    await wait(1600); press('.dm-plb-fd:nth-child(3) .dm-plb-sel');
    await wait(1600); press('.dm-plb-pop.dm-open .dm-mi:nth-child(2)');
    await wait(2100); press('.dm-plb-use');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <PromptLibrary prompts={PROMPTS} subtitle="Shared with DesignMe Studio" open={open} onOpenChange={setOpen} values={values} onValuesChange={setValues} />
    </div>
  );
}
04

Props

Everything PromptLibrary takes.

PropTypeWhat it does
promptsrequiredSavedPrompt[]
titlestring
subtitlestringThe line under the title, like "Shared with DesignMe Studio"
openstring | nullThe open prompt's id, or null for the list
defaultOpenstring | null
onOpenChange(id: string | null) => void
valuesRecord<string, string>The open prompt's blanks, by key. A picked blank left out starts on its first option
defaultValuesRecord<string, string>
onValuesChange(values: Record<string, string>) => void
onUse(text: string, prompt: SavedPrompt, values: Record<string, string>) => voidGets the filled prompt once every blank has a value
onUndo() => void
labels{ back?: string; note?: string; use?: string; usedTitle?: string; usedDetail?: string; undo?: string; blank?: string; blanks?: string }
classNamestring
05

Code

The full source, as the CLI installs it.

PromptLibrary.tsxReact · 156 lines
'use client';
import { useId, useState } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './prompt-library.css';

export interface PromptBlank {
  /** The field's label, like "Section". Lowercased, it also stands in the prompt while the blank is empty */
  label: string;
  /** For a typed blank: the hint in the empty field */
  placeholder?: string;
  /** For a picked blank: the choices, in a dropdown that starts on the first */
  options?: string[];
  /** What the field says when the prompt is used with it empty. Defaults to "Fill in the <label>" */
  error?: string;
}

export interface SavedPrompt {
  id: string;
  title: string;
  /** The prompt, with each blank written as {key}, like "Rewrite {section} on {page}" */
  template: string;
  blanks: Record<string, PromptBlank>;
}

export interface PromptLibraryProps {
  prompts: SavedPrompt[];
  title?: string;
  /** The line under the title, like "Shared with DesignMe Studio" */
  subtitle?: string;
  /** The open prompt's id, or null for the list */
  open?: string | null;
  defaultOpen?: string | null;
  onOpenChange?: (id: string | null) => void;
  /** The open prompt's blanks, by key. A picked blank left out starts on its first option */
  values?: Record<string, string>;
  defaultValues?: Record<string, string>;
  onValuesChange?: (values: Record<string, string>) => void;
  /** Gets the filled prompt once every blank has a value */
  onUse?: (text: string, prompt: SavedPrompt, values: Record<string, string>) => void;
  onUndo?: () => void;
  labels?: { back?: string; note?: string; use?: string; usedTitle?: string; usedDetail?: string; undo?: string; blank?: string; blanks?: string };
  className?: string;
}

const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const parts = (t: string) => t.split(/(\{\w+\})/).map(p => /^\{\w+\}$/.test(p) ? { key: p.slice(1, -1) } : p);
/** Every blank's value: what was typed or picked, or a picked blank's first option */
const filled = (p: SavedPrompt, values: Record<string, string>) => Object.fromEntries(Object.entries(p.blanks).map(([k, b]) => [k, values[k] ?? b.options?.[0] ?? '']));

function Pick({ id, blank, value, wide, onChange }: { id: string; blank: PromptBlank; value: string; wide?: boolean; onChange: (v: string) => void }) {
  // the blanks sit at the foot of the card, so their lists open upward over the prompt instead of past the footer
  const menu = usePopover({ side: 'up' });
  return (
    <div className={cx('dm-plb-fd', wide && 'dm-wide')}>
      <span id={id + '-l'}>{blank.label}</span>
      <button type="button" className="dm-plb-sel" aria-labelledby={`${id}-l ${id}-v`} {...menu.trigger}><Swap className="dm-v">{cap(value)}</Swap><Icon name="chev-d" size="xs" /></button>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-plb-pop')} role="menu" aria-label={blank.label}>
        {blank.options!.map(o => (
          <button type="button" key={o} className="dm-mi" role="menuitemradio" aria-checked={o === value} onClick={() => { onChange(o); menu.closeSoon(); }}>
            <span>{cap(o)}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
          </button>
        ))}
      </div>
      <span id={id + '-v'} hidden>{cap(value)}</span>
    </div>
  );
}

/** The studio's saved prompts, each with blanks like {page}. Opening one lays its blanks out as fields, the prompt fills
    in as they change, and it can't be used until every blank has a value. */
export function PromptLibrary({
  prompts, title = 'Prompt library', subtitle, open, defaultOpen = null, onOpenChange, values, defaultValues = {}, onValuesChange, onUse, onUndo, labels = {}, className
}: PromptLibraryProps) {
  const [cur, setCur] = useControllable(open, defaultOpen, onOpenChange);
  const [vals, setVals] = useControllable(values, defaultValues, onValuesChange);
  const [tried, setTried] = useState(false);
  const [used, setUsed] = useState(false);
  const uid = useId();
  const L = { back: 'All prompts', note: 'Fills in as you go', use: 'Use prompt', usedTitle: 'Added to your message', usedDetail: 'Ready to send to the agent', undo: 'Undo', blank: 'blank', blanks: 'blanks', ...labels };
  const count = (p: SavedPrompt) => { const n = Object.keys(p.blanks).length; return `${n} ${n === 1 ? L.blank : L.blanks}`; };
  const p = prompts.find(x => x.id === cur);
  const go = (id: string | null) => { setCur(id); setVals({}); setTried(false); setUsed(false); };

  if (!p) return (
    <div data-dm="" className={className} style={{ width: '100%' }}>
      <div className="dm-plb dm-panel"><div className="dm-plb-list dm-enter">
        <div className="dm-plb-h"><div className="dm-plb-ht"><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div></div>
        <div className="dm-plb-rows">
          {prompts.map(x => (
            <button type="button" key={x.id} className="dm-plb-row" data-prompt={x.id} onClick={() => go(x.id)}>
              <span className="dm-plb-ic"><Icon name="file-text" /></span>
              <span className="dm-plb-rt"><b>{x.title}</b><span>{x.template}</span></span>
              <span className="dm-plb-n">{count(x)}</span><Icon name="chev-r" size="xs" />
            </button>
          ))}
        </div>
      </div></div>
    </div>
  );

  const all = filled(p, vals);
  const empty = Object.keys(p.blanks).filter(k => !all[k].trim());
  const keys = Object.keys(p.blanks), typed = keys.filter(k => !p.blanks[k].options), picked = keys.filter(k => p.blanks[k].options);
  const set = (k: string, v: string) => setVals({ ...vals, [k]: v });
  const use = () => {
    if (empty.length) { setTried(true); return; }
    setUsed(true); onUse?.(p.template.replace(/\{(\w+)\}/g, (m, k: string) => all[k] ?? m), p, all);
  };

  return (
    <div data-dm="" className={className} style={{ width: '100%' }}>
      <div className="dm-plb dm-panel">
        <div className={cx('dm-plb-one dm-enter', used && 'dm-used')}>
          <div className="dm-plb-h">
            <button type="button" className="dm-ib dm-plb-back" aria-label={L.back} onClick={() => go(null)}><Icon name="chev-l" /></button>
            <div className="dm-plb-ht"><b>{p.title}</b><span className="dm-muted">{count(p)}</span></div>
          </div>
          <p className="dm-plb-tp" aria-live="polite">
            {/* the prompt as it reads now: filled blanks in the accent colour, empty ones by name, and red once they were needed */}
            {parts(p.template).map((q, i) => typeof q === 'string' ? q
              : <span key={i} className={cx('dm-plb-s', !all[q.key]?.trim() && 'dm-empty', tried && !all[q.key]?.trim() && 'dm-bad')}>{all[q.key]?.trim() || p.blanks[q.key].label.toLowerCase()}</span>)}
          </p>
          <div className="dm-plb-vars" inert={used}>
            {typed.map(k => {
              const b = p.blanks[k], bad = tried && !all[k].trim(), id = `${uid}-${k}`;
              return (
                <label key={k} className={cx('dm-plb-fd dm-wide', bad && 'dm-bad')} data-k={k}>
                  <span>{b.label}</span>
                  <input className="dm-plb-in" value={vals[k] ?? ''} placeholder={b.placeholder} aria-invalid={bad} aria-describedby={id + '-e'} onChange={e => set(k, e.target.value)} />
                  <span className="dm-plb-er" id={id + '-e'} hidden={!bad}>{bad ? b.error ?? `Fill in the ${b.label.toLowerCase()}` : ''}</span>
                </label>
              );
            })}
            {/* picked blanks sit two to a row, and an odd one out takes the row */}
            {picked.map((k, i) => <Pick key={k} id={`${uid}-${k}`} blank={p.blanks[k]} value={all[k]} wide={picked.length % 2 === 1 && i === picked.length - 1} onChange={v => set(k, v)} />)}
          </div>
          {used ? (
            <div className="dm-plb-done dm-enter">
              <Icon name="ok" />
              <span className="dm-t">{L.usedTitle}<span className="dm-muted">{L.usedDetail}</span></span>
              <button type="button" className="dm-pill dm-sm" onClick={() => { setUsed(false); onUndo?.(); }}>{L.undo}</button>
            </div>
          ) : (
            <div className="dm-plb-f"><span className="dm-muted dm-plb-note">{L.note}</span><span className="dm-sp" /><button type="button" className="dm-pill dm-primary dm-sm dm-plb-use" onClick={use}>{L.use}</button></div>
          )}
        </div>
      </div>
    </div>
  );
}
prompt-library.cssReact · 52 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Prompt library ---------- */
/* the studio's saved prompts, each with blanks like {page}. Opening one lays its blanks out as fields, the prompt
   fills in as they change, and it can't be used until every blank has a value */
.dm-plb{position:relative;width:100%;max-width:440px;margin:0 auto;padding:var(--dm-s-4)}
.dm-plb-list,.dm-plb-one{display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-plb-list[hidden],.dm-plb-one[hidden]{display:none}
.dm-plb-h{display:flex;align-items:center;gap:var(--dm-s-2);min-height:32px}
.dm-plb-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-plb-ht b{font-weight:500;font-size:14px}
.dm-plb-ht span{font-size:13px}
.dm-plb-back{margin-left:-6px;flex:none}
.dm-plb-rows{display:flex;flex-direction:column;margin:0 -8px}
.dm-plb-row{display:flex;align-items:center;gap:var(--dm-s-3);width:100%;padding:9px var(--dm-s-2);border-radius:var(--dm-r-in);text-align:left;color:var(--dm-fg);transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-plb-row:hover{background:var(--dm-row)}}
.dm-plb-ic{width:32px;height:32px;border-radius:9px;background:var(--dm-chip);color:var(--dm-fg2);display:grid;place-items:center;flex:none}
.dm-plb-rt{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.dm-plb-rt b{font-weight:500;font-size:13.5px}
.dm-plb-rt>span{font-size:13px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-plb-n{font-size:12px;color:var(--dm-muted);flex:none}
.dm-plb-row>svg:last-child{color:var(--dm-muted);flex:none}
.dm-plb-tp{margin:0;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-chip);font-size:14px;line-height:1.8;color:var(--dm-fg)}
.dm-plb-s{padding:2px 5px;border-radius:6px;background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));-webkit-box-decoration-break:clone;box-decoration-break:clone;transition:background .25s,color .25s,box-shadow .25s}
.dm-plb-s.dm-empty{background:var(--dm-surface);color:var(--dm-muted);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}
.dm-plb-s.dm-bad{color:var(--dm-bad);box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-plb-vars{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--dm-s-3)}
.dm-plb-fd{display:flex;flex-direction:column;gap:5px;font-size:12.5px;font-weight:500;min-width:0}
.dm-plb-fd.dm-wide{grid-column:1/-1}
.dm-plb-in{width:100%;border:0;outline:0;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);height:var(--dm-field-h);padding:0 var(--dm-s-3);font:inherit;font-size:13.5px;font-weight:400;color:var(--dm-fg);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-plb-in{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-plb-in::placeholder{color:var(--dm-muted)}
.dm-plb-in:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-plb-fd.dm-bad .dm-plb-in{box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-plb-er{font-size:12px;color:var(--dm-bad);font-weight:400}
.dm-plb-er[hidden]{display:none}
.dm-plb-sel{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);width:100%;height:38px;padding:0 10px 0 var(--dm-s-3);border-radius:11px;background:var(--dm-chip);font-size:13.5px;font-weight:400;color:var(--dm-fg);text-align:left;transition:background .2s,box-shadow .2s}
@media (hover:hover) and (pointer:fine){.dm-plb-sel:hover{background:var(--dm-row)}}
.dm-plb-sel[aria-expanded="true"]{background:var(--dm-surface);box-shadow:inset 0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent)),0 0 0 4px var(--dm-accent-bg)}
.dm-plb-sel>svg{color:var(--dm-muted);flex:none;transition:transform .32s var(--dm-ease)}
.dm-plb-sel[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-plb-pop{min-width:180px}
.dm-plb-f{display:flex;align-items:center;gap:var(--dm-s-2);min-height:32px}
.dm-plb-f .dm-sp{flex:1}
.dm-plb-note{font-size:13px}
.dm-plb-done{display:flex;align-items:center;gap:var(--dm-s-3);padding:10px 10px 10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-ok-bg);font-size:13.5px}
.dm-plb-done>svg{color:var(--dm-ok);flex:none}
.dm-plb-done .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-plb-done .dm-t span{font-size:13px}
.dm-plb-done .dm-t .dm-muted{color:var(--dm-fg2)}
.dm-plb-done .dm-pill{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
.dm-plb-one.dm-used .dm-plb-vars{opacity:.55;transition:opacity .32s}
prompt-library.htmlHTML · 17 lines
<div class="card" data-c="prompt-library">
  <div class="stage-in">
    <div class="plb panel">
      <div class="plb-list">
        <div class="plb-h"><div class="plb-ht"><b>Prompt library</b><span class="muted">Shared with DesignMe Studio</span></div></div>
        <div class="plb-rows"></div>
      </div>
      <div class="plb-one" hidden>
        <div class="plb-h"><button class="ib plb-back" aria-label="All prompts" data-tip="All prompts"><svg class="i s"><use href="#i-chev-l"/></svg></button><div class="plb-ht"><b class="plb-tt"></b><span class="muted plb-vn"></span></div></div>
        <p class="plb-tp" aria-live="polite"></p>
        <div class="plb-vars"></div>
        <div class="plb-f"><span class="muted plb-note">Fills in as you go</span><span class="sp"></span><button class="pill primary sm plb-use">Use prompt</button></div>
      </div>
    </div>
  </div>
</div>
prompt-library.cssHTML · 51 lines
/* ---------- Prompt library ---------- */
/* the studio's saved prompts, each with blanks like {page}. Opening one lays its blanks out as fields, the prompt
   fills in as they change, and it can't be used until every blank has a value */
.plb{position:relative;width:100%;max-width:440px;margin:0 auto;padding:var(--s-4)}
.plb-list,.plb-one{display:flex;flex-direction:column;gap:var(--s-3)}
.plb-list[hidden],.plb-one[hidden]{display:none}
.plb-h{display:flex;align-items:center;gap:var(--s-2);min-height:32px}
.plb-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.plb-ht b{font-weight:500;font-size:14px}
.plb-ht span{font-size:13px}
.plb-back{margin-left:-6px;flex:none}
.plb-rows{display:flex;flex-direction:column;margin:0 -8px}
.plb-row{display:flex;align-items:center;gap:var(--s-3);width:100%;padding:9px var(--s-2);border-radius:var(--r-in);text-align:left;color:var(--fg);transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.plb-row:hover{background:var(--row)}}
.plb-ic{width:32px;height:32px;border-radius:9px;background:var(--chip);color:var(--fg2);display:grid;place-items:center;flex:none}
.plb-rt{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
.plb-rt b{font-weight:500;font-size:13.5px}
.plb-rt>span{font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.plb-n{font-size:12px;color:var(--muted);flex:none}
.plb-row>svg:last-child{color:var(--muted);flex:none}
.plb-tp{margin:0;padding:10px var(--s-3);border-radius:var(--r-in);background:var(--chip);font-size:14px;line-height:1.8;color:var(--fg)}
.plb-s{padding:2px 5px;border-radius:6px;background:var(--accent-bg);color:var(--accent-fg,var(--accent));-webkit-box-decoration-break:clone;box-decoration-break:clone;transition:background .25s,color .25s,box-shadow .25s}
.plb-s.empty{background:var(--surface);color:var(--muted);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}
.plb-s.bad{color:var(--bad);box-shadow:inset 0 0 0 1px var(--bad)}
.plb-vars{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--s-3)}
.plb-fd{display:flex;flex-direction:column;gap:5px;font-size:12.5px;font-weight:500;min-width:0}
.plb-fd.wide{grid-column:1/-1}
.plb-in{width:100%;border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);height:var(--field-h);padding:0 var(--s-3);font:inherit;font-size:13.5px;font-weight:400;color:var(--fg);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.plb-in{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.plb-in::placeholder{color:var(--muted)}
.plb-in:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.plb-fd.bad .plb-in{box-shadow:inset 0 0 0 1px var(--bad)}
.plb-er{font-size:12px;color:var(--bad);font-weight:400}
.plb-er[hidden]{display:none}
.plb-sel{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);width:100%;height:38px;padding:0 10px 0 var(--s-3);border-radius:11px;background:var(--chip);font-size:13.5px;font-weight:400;color:var(--fg);text-align:left;transition:background .2s,box-shadow .2s}
@media (hover:hover) and (pointer:fine){.plb-sel:hover{background:var(--row)}}
.plb-sel[aria-expanded="true"]{background:var(--surface);box-shadow:inset 0 0 0 1.5px var(--accent-mark,var(--accent)),0 0 0 4px var(--accent-bg)}
.plb-sel>svg{color:var(--muted);flex:none;transition:transform .32s var(--ease)}
.plb-sel[aria-expanded="true"]>svg{transform:rotate(180deg)}
.plb-pop{min-width:180px}
.plb-f{display:flex;align-items:center;gap:var(--s-2);min-height:32px}
.plb-f .sp{flex:1}
.plb-note{font-size:13px}
.plb-done{display:flex;align-items:center;gap:var(--s-3);padding:10px 10px 10px var(--s-3);border-radius:var(--r-in);background:var(--ok-bg);font-size:13.5px}
.plb-done>svg{color:var(--ok);flex:none}
.plb-done .t{flex:1;min-width:0;display:flex;flex-direction:column}
.plb-done .t span{font-size:13px}
.plb-done .t .muted{color:var(--fg2)}
.plb-done .pill{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
.plb-one.used .plb-vars{opacity:.55;transition:opacity .32s}
prompt-library.jsHTML · 61 lines
DM.def('prompt-library', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{chevl}', I('chev-l'));
  const PAGE = { l:'Page', o:['Pricing v4', 'Home', 'Checkout'] };
  // the studio's saved prompts. Each blank is typed in, or picked from a short list that starts on its first option
  const P = [
    { t:'Rewrite a section', p:'Rewrite {section} on {page} so it reads {tone}.', v:{ section:{ l:'Section', ph:'Like the plan cards', er:'Say which section to rewrite' }, page:PAGE, tone:{ l:'Tone', o:['clearer', 'friendlier', 'shorter'] } } },
    { t:'Summarize feedback', p:'Summarize the comments on {page} into three fixes.', v:{ page:PAGE } },
    { t:'Check the tokens', p:'Check {page} against the {library} tokens and list what drifts.', v:{ page:PAGE, library:{ l:'Library', o:['Studio UI', 'Marketing'] } } },
    { t:'Write release notes', p:"Write release notes for {version} in the studio's voice.", v:{ version:{ l:'Version', ph:'Like Pricing v4', er:'Say which version they are for' } } }
  ];
  const cap = s => s[0].toUpperCase() + s.slice(1), uid = Math.random().toString(36).slice(2, 7);
  const list = $('.plb-list', r), one = $('.plb-one', r), rows = $('.plb-rows', r), tp = $('.plb-tp', r), vars = $('.plb-vars', r), f = $('.plb-f', r), root = $('.plb', r);
  rows.innerHTML = P.map((x, i) => `<button class="plb-row" data-i="${i}"><span class="plb-ic">${I('file-text')}</span><span class="plb-rt"><b>${x.t}</b><span>${x.p}</span></span><span class="plb-n">${Object.keys(x.v).length} ${Object.keys(x.v).length === 1 ? 'blank' : 'blanks'}</span>${I('chev-r','xs')}</button>`).join('');
  let cur = null, vals = {}, bad = new Set(), done = null;
  // the prompt as it reads now: filled blanks in the accent colour, empty ones by name, and red once they were needed
  const fill = () => { tp.innerHTML = cur.p.replace(/\{(\w+)\}/g, (m, k) => `<span class="plb-s${vals[k] ? '' : ' empty'}${bad.has(k) ? ' bad' : ''}">${vals[k] || cur.v[k].l.toLowerCase()}</span>`); };
  const err = (k, on) => { const fd = $(`.plb-fd[data-k="${k}"]`, vars), e = $('.plb-er', fd); fd.classList.toggle('bad', on); e.hidden = !on; e.textContent = on ? cur.v[k].er : ''; $('.plb-in', fd).setAttribute('aria-invalid', on); if (on) bad.add(k); else bad.delete(k); };
  function open(i){
    cur = P[i]; vals = {}; bad = new Set(); undo();
    $('.plb-tt', r).textContent = cur.t; $('.plb-vn', r).textContent = `${Object.keys(cur.v).length} ${Object.keys(cur.v).length === 1 ? 'blank' : 'blanks'}`;
    $$('.plb-pop', r).forEach(p => p.remove());
    const keys = Object.keys(cur.v), typed = keys.filter(k => !cur.v[k].o), picked = keys.filter(k => cur.v[k].o);
    // typed blanks take the full width; picked ones sit two to a row, and an odd one out takes the row. The blanks sit at
    // the foot of the card, so their lists open upward over the prompt instead of past the footer
    vars.innerHTML = [...typed, ...picked].map(k => { const v = cur.v[k], wide = !v.o || (picked.length % 2 && k === picked[picked.length - 1]);
      if (!v.o) return `<label class="plb-fd wide" data-k="${k}"><span>${v.l}</span><input class="plb-in" placeholder="${v.ph}" aria-describedby="plb-e-${k}-${uid}"><span class="plb-er" id="plb-e-${k}-${uid}" hidden></span></label>`;
      vals[k] = v.o[0];
      return `<div class="plb-fd${wide ? ' wide' : ''}" data-k="${k}"><span id="plb-l-${k}-${uid}">${v.l}</span><button class="plb-sel" aria-haspopup="menu" aria-expanded="false" aria-labelledby="plb-l-${k}-${uid} plb-v-${k}-${uid}" data-pop=".plb-pop-${k}" data-pop-pos="up"><span class="v" id="plb-v-${k}-${uid}">${cap(v.o[0])}</span>${I('chev-d','xs')}</button></div>`; }).join('');
    picked.forEach(k => {
      const pop = html(`<div class="pop plb-pop plb-pop-${k}" role="menu" aria-label="${cur.v[k].l}">${cur.v[k].o.map((o, n) => `<button class="mi" role="menuitemradio" aria-checked="${!n}" data-close data-o="${o}"><span>${cap(o)}</span><span class="ck">${I('check','xs')}</span></button>`).join('')}</div>`);
      root.appendChild(pop);
      $$('.mi', pop).forEach(m => m.addEventListener('click', () => { vals[k] = m.dataset.o; $$('.mi', pop).forEach(x => x.setAttribute('aria-checked', x === m)); swap($(`#plb-v-${k}-${uid}`, vars), cap(m.dataset.o)); fill(); }));
    });
    $$('.plb-in', vars).forEach(inp => inp.addEventListener('input', () => { const k = inp.closest('.plb-fd').dataset.k; vals[k] = inp.value.trim(); if (vals[k] && bad.has(k)) err(k, false); fill(); }));
    fill(); list.hidden = true; one.hidden = false; enter(one);
  }
  function undo(){ if (done){ done.remove(); done = null; } f.hidden = false; one.classList.remove('used'); vars.inert = false; }
  $$('.plb-row', rows).forEach(b => b.onclick = () => open(+b.dataset.i));
  $('.plb-back', r).onclick = () => { closePops(); one.hidden = true; list.hidden = false; enter(list); };
  // a prompt can't be used with a blank left empty: the blank says what it needs
  $('.plb-use', r).onclick = () => {
    const empty = Object.keys(cur.v).filter(k => !vals[k]);
    if (empty.length){ empty.forEach(k => err(k, true)); fill(); return; }
    f.hidden = true; one.classList.add('used'); vars.inert = true;
    done = html(`<div class="plb-done">${I('ok')}<span class="t">Added to your message<span class="muted">Ready to send to the agent</span></span><button class="pill sm">Undo</button></div>`);
    one.appendChild(done); enter(done); $('button', done).onclick = undo;
  };
  // the preview's story: open a prompt, try to use it with a blank empty, fill it in, pick a tone, then use it
  c.story(async s => {
    await s.beat(3); await s.tap($('.plb-row', rows)); s.away();
    await s.beat(3); await s.tap($('.plb-use', r)); s.away();
    await s.beat(4);
    const inp = $('.plb-in', vars); await s.point(inp, false);
    await s.type(inp, 'the plan cards', { n:.14, each:() => inp.dispatchEvent(new Event('input')) });
    await s.beat(1); s.away(); await s.beat(1);
    await s.tap($('.plb-sel', $('.plb-fd[data-k="tone"]', vars))); await s.beat(2);
    await s.tap($('.plb-pop-tone .mi[data-o="friendlier"]', r)); s.away(); await s.beat(3);
    await s.tap($('.plb-use', r)); s.away(); await s.beat(3);
  });
});
06

Questions

What people ask about it.

Does it send the prompt to the agent?
No. onUse gets the filled text. Put it in your composer, send it, or both.
How is it different from Prompt starters?
Prompt starters are one-click suggestions that fill the composer as they are. Prompt library is for saved prompts with blanks that change each time, filled in before they are used.
Can a blank appear more than once in a prompt?
Yes. Write the same {key} in each place. It has one field, and every place it appears fills in from it.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
All 15 in Conversation →