ConversationPrompt library
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callPrompt 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add prompt-libraryReact
npx designme add prompt-library --reactThe 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.
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.
promptseach have atitleand atemplatewith blanks written as{key}. The list shows each template, blanks in braces, and how many blanks it has.blanksdescribe each one: alabel, and either aplaceholderfor a text field oroptionsfor a dropdown that starts on the first.- The prompt at the top fills in as the fields change: filled blanks in the accent colour, empty ones by name.
- Use prompt checks every blank first. An empty one is outlined in red in the prompt and in its field, with its
errorunderneath. onUsegets the filled prompt, the prompt it came from and every value. The footer then says it was added, with Undo.open/onOpenChangeandvalues/onValuesChangelet you drive which prompt is open and what is filled in.
- Each prompt in the list is a button, and the back button is labelled "All prompts".
- Text blanks are labelled inputs. An empty one marked by Use prompt sets
aria-invalidand is described by its error. - Dropdown blanks are buttons named by their label and current value. Each choice is a menu item that marks the picked one with
aria-checked, and Escape closes the menu. - From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
- The filled prompt is an
aria-live="polite"region, so changes to it are read out.
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>
);
}
Props
Everything PromptLibrary takes.
| Prop | Type | What it does |
|---|---|---|
promptsrequired | SavedPrompt[] | |
title | string | |
subtitle | string | The line under the title, like "Shared with DesignMe Studio" |
open | string | null | The open prompt's id, or null for the list |
defaultOpen | string | null | |
onOpenChange | (id: string | null) => void | |
values | Record<string, string> | The open prompt's blanks, by key. A picked blank left out starts on its first option |
defaultValues | Record<string, string> | |
onValuesChange | (values: Record<string, string>) => void | |
onUse | (text: string, prompt: SavedPrompt, values: Record<string, string>) => void | Gets 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 } | |
className | string |
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);
});
});
Questions
What people ask about it.
- Does it send the prompt to the agent?
- No.
onUsegets 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.
More in Conversation
Everything for asking, answering and replying, by text or by voice.