ConversationAnswer action bar
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callAnswer action bar
Copy, regenerate, rate and listen to an answer from one row under it. 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 answer-action-barReact
npx designme add answer-action-bar --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.
Answer action bar is the row of actions under an agent's answer: copy, regenerate with a pager through the versions, thumbs up or down, and read aloud. A thumbs down asks why with a few reasons, a new version highlights what changed, and reading lights each word as it goes.
- Under every answer in a chat, when people need to copy, retry, rate or listen to it without leaving the thread.
- When a regenerated answer should keep the earlier versions a click away, with what changed easy to spot.
- When a thumbs down should capture a reason the agent can act on, not just a count.
- When people listen to answers, and you want them to see where the voice is and pause on a word.
- Copy writes the answer's plain text to the clipboard, and its icon turns into a check with a "Copied to clipboard" tooltip for about 1.6 seconds.
- Regenerate calls
onRegenerateand spins. With a second version, a pager slides in with previous and next and a count like "2 / 2". Whilestreaming, new words fade in, and paging and reading wait. - A version's
changedphrase is highlighted in green, so a new version shows what it fixed. Itsmetasits after the bar, like "Sonnet 5.5 · 4.8s". - Thumbs up and down are toggles, one rating per version. A thumbs down opens "What was off?" with
reasons, which default to Wrong numbers, Too long, Not our tone and Missed the brief. Picking one callsonReasonand closes it. - Read aloud widens into a progress bar and a clock, like "0:04 / 0:07", and lights each word in turn, 0.28 seconds a word by default. Pressing it again pauses and keeps the place; it doesn't speak, so connect your own text to speech through
readingandonReadingChange. statusadds a quiet line under the bar.statusToneturns it amber for a problem like a dropped connection, or gives it a green check once something worked.
- The buttons sit in a toolbar labelled "Answer actions", and each has its own label, like "Copy" or "Previous version". Left and Right move between them, skipping any that are switched off.
- The thumbs use
aria-pressed, and so does read aloud while it plays. Its label changes between Read aloud, Pause reading and Resume reading. - The answer, the version count and the status line are live regions, so a new version or a retry is announced.
- The reasons after a thumbs down are plain buttons in the tab order.
Usage
Import it and drive it from your own state.
Import
import { AnswerActionBar } from '@/components/designme/answer-action-bar/AnswerActionBar';Example
The demo above as a React component, 68 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 { AnswerActionBar, type AnswerVersion } from './AnswerActionBar';
import { useScript, useStory, useWait } from '../lib/useScript';
/* ---------- Answer action bar: listen, hear a wrong number, rate it down, say why, ride out a dropped connection, copy the fix ---------- */
// the versions a regenerate writes, in turn; $24 to $19 a month saves 21%, so the first one is wrong
const POOL: AnswerVersion[] = [
{ text: 'Pro is $24 a month, or $19 a month when billed yearly. That saves 15%, and yearly is the option the new pricing page shows first.', meta: 'Sonnet 5.5 · 6.2s' },
{ text: 'Pro is $24 a month, or $19 a month billed yearly, which saves 21%, or $60 a year. The new pricing page shows the yearly price first.', changed: '21%, or $60 a year.', meta: 'Sonnet 5.5 · 4.8s' },
{ text: 'Billed yearly, Pro drops from $24 to $19 a month: $60 back each year, about 21%. That is why the pricing page leads with yearly.', changed: '$60 back each year,', meta: 'Sonnet 5.5 · 5.1s' }
];
export default function AnswerActionBarDemo() {
const ref = useRef<HTMLDivElement>(null);
const wait = useWait();
const [versions, setVersions] = useState<AnswerVersion[]>([{ ...POOL[0], text: '' }]);
const [version, setVersion] = useState(0);
const [streaming, setStreaming] = useState(true);
const [status, setStatus] = useState('');
const [tone, setTone] = useState<'warn' | 'ok'>();
const failed = useRef(false);
// a version streams in word by word
const stream = async (i: number, v: AnswerVersion) => {
setStreaming(true);
const ws = v.text.split(' ');
for (let k = 1; k <= ws.length; k++) { setVersions(vs => vs.map((x, j) => j === i ? { ...v, text: ws.slice(0, k).join(' ') } : x)); await wait(38); }
setStreaming(false);
};
useScript(async ({ wait: w }) => { await w(300); await stream(0, POOL[0]); });
// regenerate writes the next version; the first time after a thumbs down the connection drops once and is retried
const regenerate = async (note?: string) => {
if (streaming) return;
const label = 'Writing a new version' + (note ? ' with your note' : '');
setTone(undefined); setStatus(label); setStreaming(true);
if (note && !failed.current) {
failed.current = true; await wait(1200);
for (let k = 2; k > 0; k--) { setTone('warn'); setStatus(`Couldn't reach Sonnet 5.5. Retrying in ${k}s`); await wait(1000); }
setTone(undefined); setStatus(label);
}
const i = versions.length, v = POOL[Math.min(i, POOL.length - 1)];
setVersions(vs => [...vs, { ...v, text: '' }]); setVersion(i);
await stream(i, v);
setTone('ok'); setStatus(i === 1 ? 'Version 2 fixes the saving: 21%, not 15%' : `Version ${i + 1} of ${i + 1}`);
};
// the preview's story: listen until the wrong number, pause, rate it down, pick why, then copy the fix
useStory(ref, async ({ wait, press }) => {
await wait(2900); press('.dm-aab-rd');
await wait(4100); press('.dm-aab-rd');
await wait(500); press('.dm-aab-dn');
await wait(1500); press('.dm-aab-ch .dm-chip');
await wait(400 + 1200 + 2000 + 1200 + 1500); press('.dm-aab-cp');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<AnswerActionBar question="How much do people save on the yearly Pro plan?" versions={versions} version={version} onVersionChange={setVersion}
streaming={streaming} status={status} statusTone={tone} onRegenerate={() => void regenerate()}
onReason={(_, reason) => { setTimeout(() => void regenerate(reason), 400); }}
onCopy={() => { setTone('ok'); setStatus('Copied without the highlights'); }} />
</div>
);
}
Props
Everything AnswerActionBar takes.
| Prop | Type | What it does |
|---|---|---|
question | ReactNode | The person's message the answer replies to, shown as a bubble above it |
versionsrequired | AnswerVersion[] | Every version of the answer so far, oldest first |
version | number | Which version is showing. Leave it out to let the arrows page through them |
defaultVersion | number | |
onVersionChange | (index: number) => void | |
streaming | boolean | The newest version is still being written: new words fade in as they arrive, and paging and reading wait |
onRegenerate | () => void | Called by the regenerate button. Add a version and show it to answer it |
onCopy | (text: string) => void | Called with the plain text after it is copied |
onRate | (index: number, rating: AnswerBarRating) => void | Called when a thumb is pressed or pressed again to clear it |
reasons | string[] | The reasons a thumbs down offers. Defaults to Wrong numbers, Too long, Not our tone and Missed the brief |
onReason | (index: number, reason: string) => void | Called with the reason picked after a thumbs down |
status | string | The quiet line under the bar, like "Writing a new version". It shimmers while streaming |
statusTone | 'warn' | 'ok' | warn turns the status amber; ok gives it a green check |
reading | ReadingState | Read aloud: playing lights each word in turn, paused keeps the place. Leave it out to let the button play and pause |
defaultReading | ReadingState | |
onReadingChange | (state: ReadingState) => void | |
secondsPerWord | number | How long the built-in reading clock gives each word. Defaults to 0.28s |
bare | boolean | Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own |
className | string |
Code
The full source, as the CLI installs it.
AnswerActionBar.tsxReact · 154 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useCopied, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './answer-action-bar.css';
export type AnswerBarRating = 'up' | 'down' | null;
export type ReadingState = 'idle' | 'playing' | 'paused';
/** One version of the answer */
export interface AnswerVersion {
text: string;
/** A phrase in the text that changed from the version before, like "21%, or $60 a year". It's highlighted */
changed?: string;
/** Quiet text after the bar, like "Sonnet 5.5 · 6.2s" */
meta?: string;
/** Its rating. Leave it out to let the thumbs hold their own, one per version */
rating?: AnswerBarRating;
}
export interface AnswerActionBarProps {
/** The person's message the answer replies to, shown as a bubble above it */
question?: ReactNode;
/** Every version of the answer so far, oldest first */
versions: AnswerVersion[];
/** Which version is showing. Leave it out to let the arrows page through them */
version?: number;
defaultVersion?: number;
onVersionChange?: (index: number) => void;
/** The newest version is still being written: new words fade in as they arrive, and paging and reading wait */
streaming?: boolean;
/** Called by the regenerate button. Add a version and show it to answer it */
onRegenerate?: () => void;
/** Called with the plain text after it is copied */
onCopy?: (text: string) => void;
/** Called when a thumb is pressed or pressed again to clear it */
onRate?: (index: number, rating: AnswerBarRating) => void;
/** The reasons a thumbs down offers. Defaults to Wrong numbers, Too long, Not our tone and Missed the brief */
reasons?: string[];
/** Called with the reason picked after a thumbs down */
onReason?: (index: number, reason: string) => void;
/** The quiet line under the bar, like "Writing a new version". It shimmers while streaming */
status?: string;
/** warn turns the status amber; ok gives it a green check */
statusTone?: 'warn' | 'ok';
/** Read aloud: playing lights each word in turn, paused keeps the place. Leave it out to let the button play and pause */
reading?: ReadingState;
defaultReading?: ReadingState;
onReadingChange?: (state: ReadingState) => void;
/** How long the built-in reading clock gives each word. Defaults to 0.28s */
secondsPerWord?: number;
/** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
bare?: boolean;
className?: string;
}
const REASONS = ['Wrong numbers', 'Too long', 'Not our tone', 'Missed the brief'];
const clock = (s: number) => Math.floor(s / 60) + ':' + String(Math.floor(s) % 60).padStart(2, '0');
/** Every action on an answer in one row under it: copy, regenerate with versions, thumbs with a reason, and read aloud. */
export function AnswerActionBar({
question, versions, version, defaultVersion = 0, onVersionChange, streaming = false, onRegenerate, onCopy, onRate, reasons = REASONS, onReason,
status = '', statusTone, reading, defaultReading = 'idle', onReadingChange, secondsPerWord = .28, bare = true, className
}: AnswerActionBarProps) {
const [cur, setCur] = useControllable(version, defaultVersion, onVersionChange);
const [read, setRead] = useControllable(reading, defaultReading, onReadingChange);
const reduce = useReducedMotion();
const last = Math.max(0, versions.length - 1), at = Math.min(Math.max(cur, 0), last), v = versions[at];
const [own, setOwn] = useState<Record<number, AnswerBarRating>>({});
const rating = v?.rating !== undefined ? v.rating : own[at] ?? null;
const [asked, setAsked] = useState<Record<number, string>>({});
const [copied, copyText] = useCopied();
const [spin, setSpin] = useState(false);
// the words, with the changed phrase marked
const text = v?.text ?? '';
const from = v?.changed ? text.indexOf(v.changed) : -1, to = from < 0 ? -1 : from + (v?.changed?.length ?? 0);
let p = 0;
const words = text.split(' ').filter(Boolean).map(w => { const a = text.indexOf(w, p); p = a + w.length; return { w, nw: from >= 0 && a < to && a + w.length > from }; });
// reading: a place in words, lit one word at a time
const [pos, setPos] = useState(0);
const timer = useRef<ReturnType<typeof setInterval>>(undefined);
useEffect(() => { setPos(0); if (read !== 'idle') setRead('idle'); }, [at]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (read !== 'playing') return;
timer.current = setInterval(() => setPos(x => x + .1 / secondsPerWord), 100);
return () => clearInterval(timer.current);
}, [read, secondsPerWord]);
useEffect(() => { if (read === 'playing' && pos >= words.length) { setRead('idle'); setPos(0); } }, [pos, read, words.length]); // eslint-disable-line react-hooks/exhaustive-deps
const lit = read === 'idle' ? -1 : Math.min(words.length - 1, Math.floor(pos));
const rate = (r: AnswerBarRating) => { const next = rating === r ? null : r; setOwn(o => ({ ...o, [at]: next })); onRate?.(at, next); if (next !== 'down') setAsked(a => ({ ...a, [at]: '' })); };
const regenerate = () => { if (streaming) return; setRead('idle'); setPos(0); setSpin(true); setTimeout(() => setSpin(false), 700); onRegenerate?.(); };
const copy = () => { copyText(text); onCopy?.(text); };
const why = rating === 'down' && !asked[at];
return (
<div data-dm="" className={cx('dm-aab', !bare && 'dm-panel', className)} style={bare ? undefined : { padding: 16 }}>
{question && <div className="dm-bub">{question}</div>}
<p className="dm-msg dm-aab-t" aria-live="polite">
{words.map(({ w, nw }, k) => (
// inside the changed phrase the space is highlighted too, so the phrase reads as one
<span key={at + ':' + k}>{k ? <span className={nw && words[k - 1].nw ? 'dm-nw' : undefined}> </span> : null}<span className={cx('dm-w dm-in', nw && 'dm-nw', k === lit && 'dm-rd')} style={!streaming && !reduce ? { animationDelay: k * 10 + 'ms' } : undefined}>{w}</span></span>
))}
</p>
<div className="dm-aab-row">
<div className="dm-aab-bar" role="toolbar" aria-label="Answer actions" onKeyDown={e => {
// Left and Right move between the toolbar's buttons, skipping any that are switched off
const k = ({ ArrowRight: 1, ArrowLeft: -1 } as Record<string, number>)[e.key]; if (!k) return;
const bs = [...e.currentTarget.querySelectorAll('button')].filter(b => !b.disabled && !b.closest('[inert]')), i = bs.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return; e.preventDefault(); bs[(i + k + bs.length) % bs.length].focus();
}}>
<Tip label={copied ? 'Copied to clipboard' : 'Copy'} open={copied || undefined}><button type="button" className={cx('dm-ib dm-aab-cp', copied && 'dm-copied')} aria-label="Copy" onClick={copy}><Icon name={copied ? 'check' : 'copy'} /></button></Tip>
{onRegenerate && <Tip label="Regenerate"><button type="button" className={cx('dm-ib dm-aab-re', spin && 'dm-go')} aria-label="Regenerate" disabled={streaming} onClick={regenerate}><Icon name="refresh" /></button></Tip>}
<span className={cx('dm-aab-pg', versions.length > 1 && 'dm-on')} inert={versions.length < 2}>
<Tip label="Previous version"><button type="button" className="dm-ib" aria-label="Previous version" disabled={at === 0 || streaming} onClick={() => setCur(at - 1)}><Icon name="chev-l" size="xs" /></button></Tip>
<span className="dm-aab-n dm-num" aria-live="polite">{at + 1} / {Math.max(versions.length, 1)}</span>
<Tip label="Next version"><button type="button" className="dm-ib" aria-label="Next version" disabled={at === last || streaming} onClick={() => setCur(at + 1)}><Icon name="chev-r" size="xs" /></button></Tip>
</span>
<span className="dm-aab-sep" aria-hidden="true" />
<Tip label="Good answer"><button type="button" className="dm-ib dm-aab-up" aria-label="Good answer" aria-pressed={rating === 'up'} onClick={() => rate('up')}><Icon name="up-thumb" /></button></Tip>
<Tip label="Bad answer"><button type="button" className="dm-ib dm-aab-dn" aria-label="Bad answer" aria-pressed={rating === 'down'} onClick={() => rate('down')}><Icon name="down-thumb" /></button></Tip>
<span className="dm-aab-sep" aria-hidden="true" />
<Tip label={read === 'playing' ? 'Pause' : read === 'paused' ? 'Resume' : 'Read aloud'}>
<button type="button" className={cx('dm-aab-rd', read !== 'idle' && 'dm-on', read === 'paused' && 'dm-paused')} aria-pressed={read === 'playing'}
aria-label={read === 'playing' ? 'Pause reading' : read === 'paused' ? 'Resume reading' : 'Read aloud'} disabled={streaming || !words.length}
onClick={() => setRead(read === 'playing' ? 'paused' : 'playing')}>
<span className="dm-ic"><Icon name={read === 'playing' ? 'pause' : read === 'paused' ? 'play' : 'vol'} size="xs" /></span>
<span className="dm-aab-pr" aria-hidden="true"><i style={{ width: Math.min(100, pos / Math.max(words.length, 1) * 100) + '%' }} /></span>
<span className="dm-aab-tm dm-num">{clock(pos * secondsPerWord)} / {clock(words.length * secondsPerWord)}</span>
</button>
</Tip>
</div>
<Swap className="dm-aab-meta dm-muted dm-num">{v?.meta ?? ''}</Swap>
</div>
<Fold open={why} className="dm-aab-why">
<div className="dm-aab-wy">
<span className="dm-aab-wq">What was off?</span>
<div className="dm-aab-ch">
{reasons.map(r => <button key={r} type="button" className="dm-chip dm-sm" onClick={() => { setAsked(a => ({ ...a, [at]: r })); onReason?.(at, r); }}>{r}</button>)}
</div>
</div>
</Fold>
<p className={cx('dm-aab-st dm-muted', statusTone && 'dm-' + statusTone)} aria-live="polite">
{statusTone === 'ok' && status ? <><Icon name="ok" size="xs" /><span key={status} {...enter()}>{status}</span></> : <Swap className={streaming && status ? 'dm-shim' : undefined}>{status}</Swap>}
</p>
</div>
);
}
answer-action-bar.cssReact · 48 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Answer action bar ---------- */
/* every action on an answer in one raised rail under it: copy, regenerate with its versions, thumbs, and read aloud, which
widens into a progress bar and a clock and lights each word as it reads it */
.dm-aab{display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-aab-t{min-height:78px}
.dm-aab-t .dm-w{transition:background .16s,color .16s;border-radius:var(--dm-r-xs)}
.dm-aab-t .dm-w.dm-in{animation:dm-aabw .42s var(--dm-ease) both}
@keyframes dm-aabw{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:none}}
.dm-aab-t.dm-out{animation:dm-aabo .32s var(--dm-ease) both}
@keyframes dm-aabo{to{opacity:0;filter:blur(4px);transform:translateY(-2px)}}
.dm-aab-t .dm-nw{background:var(--dm-hl-ok);border-radius:0}
.dm-aab-t .dm-w.dm-rd{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-aab-row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--dm-s-2) var(--dm-s-3)}
.dm-aab-bar{display:inline-flex;align-items:center;gap:1px;padding:3px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:var(--dm-shadow)}
.dm-aab-bar .dm-ib{width:28px;height:28px}
.dm-aab-bar .dm-ib[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-aab-bar .dm-ib svg{transition:transform .42s var(--dm-ease)}
.dm-aab-re.dm-go svg{transform:rotate(360deg)}
.dm-aab-up[aria-pressed="true"]{color:var(--dm-ok);background:var(--dm-ok-bg)}
.dm-aab-dn[aria-pressed="true"]{color:var(--dm-bad);background:var(--dm-bad-bg)}
.dm-aab-up[aria-pressed="true"] svg,.dm-aab-dn[aria-pressed="true"] svg{transform:scale(1.1) rotate(-8deg)}
.dm-aab-sep{width:1px;height:16px;background:var(--dm-line);margin:0 var(--dm-s-1)}
/* the version pager only takes room once there is a second version */
.dm-aab-pg{display:inline-flex;align-items:center;max-width:0;overflow:hidden;opacity:0;transition:max-width .42s var(--dm-ease),opacity .32s}
.dm-aab-pg.dm-on{max-width:96px;opacity:1}
.dm-aab-pg .dm-ib{width:24px;height:24px}
.dm-aab-n{font-size:12px;color:var(--dm-muted);min-width:32px;text-align:center}
.dm-aab-rd{display:inline-flex;align-items:center;height:28px;padding:0 7px;border-radius:var(--dm-r-pill);color:var(--dm-fg2);transition:background .16s,color .16s}
@media (hover:hover) and (pointer:fine){.dm-aab-rd:hover{background:var(--dm-hover)}}
.dm-aab-rd .dm-ic{display:grid;place-items:center;width:14px}
.dm-aab-rd[aria-pressed="true"]{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-aab-rd.dm-paused{background:var(--dm-chip);color:var(--dm-fg2)}
.dm-aab-pr{width:0;height:3px;border-radius:2px;background:color-mix(in srgb,currentColor 22%,transparent);overflow:hidden;margin-left:0;transition:width .42s var(--dm-ease),margin .42s var(--dm-ease)}
.dm-aab-pr i{display:block;height:100%;width:0;background:currentColor;border-radius:inherit}
.dm-aab-tm{font-size:11.5px;max-width:0;overflow:hidden;white-space:nowrap;transition:max-width .42s var(--dm-ease),margin .42s var(--dm-ease)}
.dm-aab-rd.dm-on .dm-aab-pr{width:56px;margin-left:var(--dm-s-2)}
.dm-aab-rd.dm-on .dm-aab-tm{max-width:80px;margin-left:var(--dm-s-2)}
.dm-aab-meta{font-size:12px}
.dm-aab-wy{display:flex;flex-direction:column;gap:var(--dm-s-2);padding:2px 0 var(--dm-s-1)}
.dm-aab-wq{font-size:13px;font-weight:500}
.dm-aab-ch{display:flex;flex-wrap:wrap;gap:6px}
.dm-aab-ch .dm-chip.dm-on{background:var(--dm-sel);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
.dm-aab-st{margin:0;min-height:20px;font-size:12.5px;display:flex;align-items:center;gap:6px}
.dm-aab-st.dm-warn{color:var(--dm-warn)}
.dm-aab-st.dm-ok svg{color:var(--dm-ok);flex:none}
@media (max-width:480px){.dm-aab-rd.dm-on .dm-aab-tm{display:none}.dm-aab-rd.dm-on .dm-aab-pr{width:40px}}
answer-action-bar.htmlHTML · 27 lines
<div class="card bare" data-c="answer-action-bar">
<div class="stage-in">
<div class="aab">
<div class="bub">How much do people save on the yearly Pro plan?</div>
<p class="msg aab-t" aria-live="polite"></p>
<div class="aab-row">
<div class="aab-bar" role="toolbar" aria-label="Answer actions">
<button class="ib aab-cp" aria-label="Copy" data-tip="Copy"><svg class="i s"><use href="#i-copy"/></svg></button>
<button class="ib aab-re" aria-label="Regenerate" data-tip="Regenerate"><svg class="i s"><use href="#i-refresh"/></svg></button>
<span class="aab-pg"><button class="ib aab-prev" aria-label="Previous version" data-tip="Previous version"><svg class="i xs"><use href="#i-chev-l"/></svg></button><span class="aab-n num" aria-live="polite">1 / 1</span><button class="ib aab-next" aria-label="Next version" data-tip="Next version"><svg class="i xs"><use href="#i-chev-r"/></svg></button></span>
<span class="aab-sep" aria-hidden="true"></span>
<button class="ib aab-up" aria-label="Good answer" aria-pressed="false" data-tip="Good answer"><svg class="i s"><use href="#i-up-thumb"/></svg></button>
<button class="ib aab-dn" aria-label="Bad answer" aria-pressed="false" data-tip="Bad answer"><svg class="i s"><use href="#i-down-thumb"/></svg></button>
<span class="aab-sep" aria-hidden="true"></span>
<button class="aab-rd" aria-label="Read aloud" aria-pressed="false" data-tip="Read aloud"><span class="ic"><svg class="i xs"><use href="#i-vol"/></svg></span><span class="aab-pr" aria-hidden="true"><i></i></span><span class="aab-tm num"></span></button>
</div>
<span class="aab-meta muted num"></span>
</div>
<div class="fold shut aab-why"><div><div class="aab-wy">
<span class="aab-wq">What was off?</span>
<div class="aab-ch"><button class="chip sm">Wrong numbers</button><button class="chip sm">Too long</button><button class="chip sm">Not our tone</button><button class="chip sm">Missed the brief</button></div>
</div></div></div>
<p class="aab-st muted" aria-live="polite"></p>
</div>
</div>
</div>
answer-action-bar.cssHTML · 47 lines
/* ---------- Answer action bar ---------- */
/* every action on an answer in one raised rail under it: copy, regenerate with its versions, thumbs, and read aloud, which
widens into a progress bar and a clock and lights each word as it reads it */
.aab{display:flex;flex-direction:column;gap:var(--s-3)}
.aab-t{min-height:78px}
.aab-t .w{transition:background .16s,color .16s;border-radius:var(--r-xs)}
.aab-t .w.in{animation:aabw .42s var(--ease) both}
@keyframes aabw{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:none}}
.aab-t.out{animation:aabo .32s var(--ease) both}
@keyframes aabo{to{opacity:0;filter:blur(4px);transform:translateY(-2px)}}
.aab-t .nw{background:var(--hl-ok);border-radius:0}
.aab-t .w.rd{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
.aab-row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--s-2) var(--s-3)}
.aab-bar{display:inline-flex;align-items:center;gap:1px;padding:3px;border-radius:var(--r-pill);background:var(--surface);box-shadow:var(--shadow)}
.aab-bar .ib{width:28px;height:28px}
.aab-bar .ib[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.aab-bar .ib svg{transition:transform .42s var(--ease)}
.aab-re.go svg{transform:rotate(360deg)}
.aab-up[aria-pressed="true"]{color:var(--ok);background:var(--ok-bg)}
.aab-dn[aria-pressed="true"]{color:var(--bad);background:var(--bad-bg)}
.aab-up[aria-pressed="true"] svg,.aab-dn[aria-pressed="true"] svg{transform:scale(1.1) rotate(-8deg)}
.aab-sep{width:1px;height:16px;background:var(--line);margin:0 var(--s-1)}
/* the version pager only takes room once there is a second version */
.aab-pg{display:inline-flex;align-items:center;max-width:0;overflow:hidden;opacity:0;transition:max-width .42s var(--ease),opacity .32s}
.aab-pg.on{max-width:96px;opacity:1}
.aab-pg .ib{width:24px;height:24px}
.aab-n{font-size:12px;color:var(--muted);min-width:32px;text-align:center}
.aab-rd{display:inline-flex;align-items:center;height:28px;padding:0 7px;border-radius:var(--r-pill);color:var(--fg2);transition:background .16s,color .16s}
@media (hover:hover) and (pointer:fine){.aab-rd:hover{background:var(--hover)}}
.aab-rd .ic{display:grid;place-items:center;width:14px}
.aab-rd[aria-pressed="true"]{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
.aab-rd.paused{background:var(--chip);color:var(--fg2)}
.aab-pr{width:0;height:3px;border-radius:2px;background:color-mix(in srgb,currentColor 22%,transparent);overflow:hidden;margin-left:0;transition:width .42s var(--ease),margin .42s var(--ease)}
.aab-pr i{display:block;height:100%;width:0;background:currentColor;border-radius:inherit}
.aab-tm{font-size:11.5px;max-width:0;overflow:hidden;white-space:nowrap;transition:max-width .42s var(--ease),margin .42s var(--ease)}
.aab-rd.on .aab-pr{width:56px;margin-left:var(--s-2)}
.aab-rd.on .aab-tm{max-width:80px;margin-left:var(--s-2)}
.aab-meta{font-size:12px}
.aab-wy{display:flex;flex-direction:column;gap:var(--s-2);padding:2px 0 var(--s-1)}
.aab-wq{font-size:13px;font-weight:500}
.aab-ch{display:flex;flex-wrap:wrap;gap:6px}
.aab-ch .chip.on{background:var(--sel);color:var(--accent-fg,var(--accent));box-shadow:none}
.aab-st{margin:0;min-height:20px;font-size:12.5px;display:flex;align-items:center;gap:6px}
.aab-st.warn{color:var(--warn)}
.aab-st.ok svg{color:var(--ok);flex:none}
@media (max-width:480px){.aab-rd.on .aab-tm{display:none}.aab-rd.on .aab-pr{width:40px}}
answer-action-bar.jsHTML · 93 lines
DM.def('answer-action-bar', (r, c) => {
r.innerHTML = r.innerHTML.replace('{copy}', I('copy')).replace('{re}', I('refresh')).replace('{l}', I('chev-l','xs')).replace('{rr}', I('chev-r','xs'))
.replace('{up}', I('up-thumb')).replace('{dn}', I('down-thumb')).replace('{vol}', I('vol','xs'));
// the versions a regenerate writes, in turn. [[…]] marks what changed from the version before; $24 to $19 a month saves 21%
const POOL = [
{ t:'Pro is $24 a month, or $19 a month when billed yearly. That saves 15%, and yearly is the option the new pricing page shows first.', meta:'Sonnet 5.5 · 6.2s' },
{ t:'Pro is $24 a month, or $19 a month billed yearly, which saves [[21%, or $60 a year]]. The new pricing page shows the yearly price first.', meta:'Sonnet 5.5 · 4.8s' },
{ t:'Billed yearly, Pro drops from $24 to $19 a month: [[$60 back each year]], about 21%. That is why the pricing page leads with yearly.', meta:'Sonnet 5.5 · 5.1s' }
];
const t = $('.aab-t', r), pg = $('.aab-pg', r), n = $('.aab-n', r), pv = $('.aab-prev', r), nx = $('.aab-next', r), re = $('.aab-re', r), cp = $('.aab-cp', r);
const up = $('.aab-up', r), dn = $('.aab-dn', r), rd = $('.aab-rd', r), bar = $('.aab-pr i', r), tm = $('.aab-tm', r), meta = $('.aab-meta', r), why = $('.aab-why', r), st = $('.aab-st', r), chips = $$('.aab-ch .chip', r);
// every version keeps its own rating
const vers = [{ ...POOL[0], rate:'' }]; let cur = 0, busy = false, failOnce = false;
const plain = v => v.t.replace(/\[\[|\]\]/g, '');
// each word's HTML, with the space before it; inside the changed phrase the space is highlighted too, so the phrase reads as one
const words = v => { let on = false; return v.t.split(' ').map((w, i) => { const a = w.startsWith('[['), b = w.includes(']]'), inner = on && !a; if (a) on = true;
const s = (i ? `<span${inner ? ' class="nw"' : ''}> </span>` : '') + `<span class="w${on ? ' nw' : ''}">${w.replace(/\[\[|\]\]/g, '')}</span>`; if (b) on = false; return s; }); };
const status = (text, tone) => { st.className = 'aab-st muted' + (tone ? ' ' + tone : ''); if (tone === 'ok') { st.innerHTML = `${I('ok','xs')}<span>${text}</span>`; enter(st); } else swap(st, text); };
function nav(){
n.textContent = (cur + 1) + ' / ' + vers.length; pg.classList.toggle('on', vers.length > 1); pg.inert = vers.length < 2;
pv.disabled = cur === 0 || busy; nx.disabled = cur === vers.length - 1 || busy; re.disabled = busy;
up.setAttribute('aria-pressed', vers[cur].rate === 'up'); dn.setAttribute('aria-pressed', vers[cur].rate === 'down');
swap(meta, vers[cur].meta);
}
// a version streams in word by word; paging between versions shows one at once
async function stream(v){
t.classList.add('out'); await c.wait(260); t.classList.remove('out'); t.innerHTML = '';
const ws = words(v);
for (let i = 0; i < ws.length; i++){ t.insertAdjacentHTML('beforeend', ws[i]); t.lastElementChild.classList.add('in'); await c.wait(38); }
}
async function show(i){ stop(); cur = i; nav(); t.classList.add('out'); await c.wait(240); t.classList.remove('out'); t.innerHTML = words(vers[i]).join(''); $$('.w', t).forEach((w, k) => { w.classList.add('in'); w.style.animationDelay = k * 10 + 'ms'; }); }
pv.onclick = () => c.run(() => show(cur - 1));
nx.onclick = () => c.run(() => show(cur + 1));
// regenerate writes the next version; a dropped connection is retried once with a countdown
function regenerate(note){
if (busy) return; stop(); busy = true; re.classList.add('go'); c.later(700, () => re.classList.remove('go')); nav();
c.run(async () => {
if (failOnce){ failOnce = false;
status('Writing a new version' + (note ? ' with your note' : '')); st.classList.add('shim'); await c.wait(1200); st.classList.remove('shim');
for (let k = 2; k > 0; k--){ status(`Couldn't reach Sonnet 5.5. Retrying in ${k}s`, 'warn'); await c.wait(1000); }
}
status('Writing a new version' + (note ? ' with your note' : '')); st.classList.add('shim');
const v = { ...POOL[Math.min(vers.length, POOL.length - 1)], rate:'' }; vers.push(v); cur = vers.length - 1; nav();
await stream(v); busy = false; nav(); st.classList.remove('shim');
status(cur === 1 ? 'Version 2 fixes the saving: 21%, not 15%' : `Version ${cur + 1} of ${vers.length}`, 'ok');
});
}
re.onclick = () => regenerate();
cp.onclick = () => { copied(cp, plain(vers[cur])); status('Copied without the highlights', 'ok'); };
// thumbs: up is a quiet thanks; down asks why, and a reason writes a version that answers it
up.onclick = () => { const v = vers[cur]; v.rate = v.rate === 'up' ? '' : 'up'; why.classList.add('shut'); nav(); status(v.rate ? 'Thanks, noted for this project' : ''); };
dn.onclick = () => { const v = vers[cur]; v.rate = v.rate === 'down' ? '' : 'down'; why.classList.toggle('shut', v.rate !== 'down'); why.inert = v.rate !== 'down'; nav(); status(''); chips.forEach(x => x.classList.remove('on')); };
chips.forEach(ch => ch.onclick = () => { chips.forEach(x => x.classList.toggle('on', x === ch)); c.later(400, () => { why.classList.add('shut'); why.inert = true; failOnce = vers.length === 1; regenerate(ch.textContent); }); });
why.inert = true;
// read aloud: about 0.28s a word, each word lit as it's read, with a progress bar and a clock
let reading = 0, pos = 0;
const clock = s => Math.floor(s / 60) + ':' + String(Math.floor(s) % 60).padStart(2, '0');
const icon = k => { $('.ic', rd).innerHTML = I(k, 'xs'); };
// stop drops the place (a new version, another page); pause keeps it, so the bar, the clock and the lit word stay
function stop(){ if (reading) clearInterval(reading); reading = 0; pos = 0; rd.classList.remove('on', 'paused'); rd.setAttribute('aria-pressed', 'false'); rd.setAttribute('aria-label', 'Read aloud'); rd.dataset.tip = 'Read aloud'; icon('vol'); bar.style.width = '0%'; $$('.w.rd', t).forEach(w => w.classList.remove('rd')); }
function pause(){ clearInterval(reading); reading = 0; rd.classList.add('paused'); rd.setAttribute('aria-pressed', 'false'); rd.setAttribute('aria-label', 'Resume reading'); rd.dataset.tip = 'Resume'; icon('play'); }
rd.onclick = () => {
if (reading) return pause();
const ws = $$('.w', t); if (!ws.length || busy) return;
const total = ws.length * .28;
rd.classList.add('on'); rd.classList.remove('paused'); rd.setAttribute('aria-pressed', 'true'); rd.setAttribute('aria-label', 'Pause reading'); rd.dataset.tip = 'Pause'; icon('pause');
reading = c.every(100, () => {
pos += 100 / 280; const i = Math.min(ws.length - 1, Math.floor(pos));
ws.forEach((w, k) => w.classList.toggle('rd', k === i));
bar.style.width = Math.min(100, pos / ws.length * 100) + '%'; tm.textContent = clock(pos * .28) + ' / ' + clock(total);
if (pos >= ws.length) stop();
});
};
// Left and Right move between the toolbar's buttons, skipping any that are switched off
c.on($('.aab-bar', r), 'keydown', e => {
const k = { ArrowRight:1, ArrowLeft:-1 }[e.key]; if (!k) return;
const bs = $$('.aab-bar button', r).filter(b => !b.disabled && !b.closest('[inert]')), i = bs.indexOf(document.activeElement);
if (i < 0) return; e.preventDefault(); bs[(i + k + bs.length) % bs.length].focus({ preventScroll:true });
});
nav();
c.run(async () => { busy = true; nav(); await c.wait(300); await stream(vers[0]); busy = false; nav(); });
// the preview's story: listen, hear a wrong number, rate it down, say why, ride out a dropped connection, copy the fix
c.story(async s => {
await s.until(() => !busy); await s.beat(2);
await s.tap(rd); s.away(); await s.beat(7);
await s.tap(rd); await s.beat(1);
await s.tap(dn); s.away(); await s.beat(3);
await s.tap(chips[0]); s.away();
await s.until(() => vers.length > 1 && !busy); await s.beat(3);
await s.tap(cp); s.away(); await s.beat(1);
});
});
Questions
What people ask about it.
- Does read aloud speak the answer?
- No. It runs a clock that lights each word in turn and shows the progress. Drive
readingfrom your own text to speech, and setsecondsPerWordto match its pace. - How do I show what a new version changed?
- Pass the changed phrase as the version's
changed. It must appear in the text exactly as written, and the words it covers are highlighted. - Where do the ratings go?
onRateis called with the version's index and'up','down'ornullwhen a thumb is pressed again. Passratingon each version to control them, or leave it out and the bar keeps one rating per version itself.- Can I hide regenerate?
- Yes. Leave out
onRegenerateand the button isn't shown. The pager still appears whenever there are two or more versions.
More in Conversation
Everything for asking, answering and replying, by text or by voice.