Skip to content
Star on GitHub
Review and approveDraft review
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

Draft review

An email the agent drafted, to check and edit before it sends. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add draft-review
Draft from the agentAfter the pricing review with Maya
Not sent
ToMKMaya Kim
Hi Maya,

Here's what changed on the pricing page: one button per plan, the yearly price first, and the FAQ under the comparison table. The full list is in the attached summary.

Ready for your sign-off when you are.

You mention an attachment, but nothing is attached.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add draft-review

React

$npx designme add draft-review --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.

Draft review lays out an email the agent wrote the way the email will go: who it is to, the subject, the message and what is attached. People can edit it, then send it now, schedule it from a dropdown beside Send, or discard it. A warning can stop it before it goes, like a message that mentions an attachment when nothing is attached, and after sending there is a short window to undo.

  • When the agent writes on someone's behalf and nothing should leave without them seeing it.
  • When people want to change a word or two before it goes, not write it again.
  • When some messages are better sent later, like at the start of the reader's day.
  • When a check can catch a mistake before sending, and a sent message can still be taken back for a few seconds.
03

Usage

Import it and drive it from your own state.

Import

import { DraftReview } from '@/components/designme/draft-review/DraftReview';

Example

The demo above as a React component, 64 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 { DraftReview, type DraftAttachment, type DraftPhase, type SendOption } from './DraftReview';
import { useStory, useWait } from '../lib/useScript';

/* ---------- Draft review: send, get stopped by the missing attachment, attach it, send, and let the undo window pass ---------- */
const BODY = "Hi Maya,\n\nHere's what changed on the pricing page: one button per plan, the yearly price first, and the FAQ under the comparison table. The full list is in the attached summary.\n\nReady for your sign-off when you are.";
const WHEN: SendOption[] = [
  { key: 'now', label: 'Send now' },
  { key: 'tomorrow', label: 'Tomorrow at 09:00', detail: "The start of Maya's day" },
  { key: 'monday', label: 'Monday at 09:00', detail: 'The start of next week' }
];

export default function DraftReviewDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const run = useRef(0);
  const [subject, setSubject] = useState('Pricing v4: what changed');
  const [body, setBody] = useState(BODY);
  const [files, setFiles] = useState<DraftAttachment[]>([]);
  const [warn, setWarn] = useState(false);
  const [phase, setPhase] = useState<DraftPhase>('draft');
  const [left, setLeft] = useState(5);
  const [option, setOption] = useState('now');
  const attached = files.some(f => !f.loading);

  // the message talks about an attachment that isn't there: say so before it goes
  const send = async (o: SendOption, force = false) => {
    if (!force && !attached && /attach/i.test(body)) { setWarn(true); return; }
    setWarn(false); const id = ++run.current;
    setPhase('sending'); await wait(900); if (id !== run.current) return;
    if (o.key !== 'now') { setPhase('scheduled'); return; }
    // a few seconds to take it back before it really goes
    setPhase('undo');
    for (let k = 5; k > 0; k--) { setLeft(k); await wait(1000); if (id !== run.current) return; }
    setPhase('sent');
  };
  const attach = async () => {
    setWarn(false); if (files.length) return;
    setFiles([{ name: 'Pricing v4 summary.pdf', loading: true }]);
    await wait(1200); setFiles([{ name: 'Pricing v4 summary.pdf', size: '240 KB' }]);
  };

  // the preview's story, on the same beats as the site's
  useStory(ref, async ({ wait, press }) => {
    await wait(4200); press('.dm-drf-go');
    await wait(3100); press('.dm-drf-w .dm-primary');
    await wait(2800); press('.dm-drf-go');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <DraftReview subtitle="After the pricing review with Maya" to={[{ name: 'Maya Kim', initials: 'MK' }]} subject={subject} onSubjectChange={setSubject}
        body={BODY} onBodyChange={setBody} attachments={files} phase={phase} undoLeft={left}
        sendOptions={WHEN} sendOption={option} onSendOptionChange={setOption} onSend={o => void send(o)}
        warning={warn ? { text: 'You mention an attachment, but nothing is attached.', actions: [
          { label: 'Send anyway', onClick: () => void send(WHEN.find(w => w.key === option)!, true) },
          { label: 'Attach the summary', onClick: () => void attach(), primary: true }] } : undefined}
        onDiscard={() => { setWarn(false); setPhase('discarded'); }} onUndo={() => { run.current++; setPhase('draft'); }} />
    </div>
  );
}
04

Props

Everything DraftReview takes.

PropTypeWhat it does
titlestring
subtitlestringThe line under the title, like "After the pricing review with Maya"
torequiredDraftRecipient[]
subjectrequiredstring
onSubjectChange(subject: string) => void
bodyrequiredstringThe message as the agent wrote it. Paragraphs are split by blank lines. It stays editable; read edits from onBodyChange
onBodyChange(body: string) => void
attachmentsDraftAttachment[]
warning{ text: ReactNode; actions: { label: string; onClick: () => void; primary?: boolean }[] }A problem to settle before it sends, like an attachment the message mentions but nothing attached
phaseDraftPhase
sendOptionsSendOption[]When to send, picked from the menu beside the button
sendOptionstring
defaultSendOptionstring
onSendOptionChange(key: string) => void
onSend(option: SendOption) => void
onDiscard() => void
onUndo() => voidTakes it back: undoes a send in its undo window, cancels a scheduled one, or brings back a discarded draft
undoLeftnumberSeconds left to undo, shown while the phase is undo
labels{ send?: string; schedule?: string; sending?: string; scheduling?: string; discard?: string; notSent?: string; sent?: string; scheduled?: string; delivered?: string; discarded?: string; nothingSent?: string; undo?: string; cancel?: string }
classNamestring
05

Code

The full source, as the CLI installs it.

DraftReview.tsxReact · 154 lines
'use client';
import { useRef, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, Fold, Spin, Swap } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './draft-review.css';

export interface DraftRecipient {
  name: string;
  initials?: string;
  /** The avatar's colour, like 'var(--dm-av-l)' */
  color?: string;
}

export interface DraftAttachment {
  name: string;
  /** Shown beside the name, like "240 KB" */
  size?: string;
  /** Still uploading: a spinner in place of the size */
  loading?: boolean;
}

export interface SendOption {
  /** `now` sends straight away; any other key schedules it */
  key: string;
  /** Like "Tomorrow at 09:00" */
  label: string;
  /** The quiet line under it in the menu */
  detail?: string;
}

/** draft (being checked), sending, undo (sent, with a window to take it back), sent, scheduled or discarded */
export type DraftPhase = 'draft' | 'sending' | 'undo' | 'sent' | 'scheduled' | 'discarded';

export interface DraftReviewProps {
  title?: string;
  /** The line under the title, like "After the pricing review with Maya" */
  subtitle?: string;
  to: DraftRecipient[];
  subject: string;
  onSubjectChange?: (subject: string) => void;
  /** The message as the agent wrote it. Paragraphs are split by blank lines. It stays editable; read edits from onBodyChange */
  body: string;
  onBodyChange?: (body: string) => void;
  attachments?: DraftAttachment[];
  /** A problem to settle before it sends, like an attachment the message mentions but nothing attached */
  warning?: { text: ReactNode; actions: { label: string; onClick: () => void; primary?: boolean }[] };
  phase?: DraftPhase;
  /** When to send, picked from the menu beside the button */
  sendOptions?: SendOption[];
  sendOption?: string;
  defaultSendOption?: string;
  onSendOptionChange?: (key: string) => void;
  onSend?: (option: SendOption) => void;
  onDiscard?: () => void;
  /** Takes it back: undoes a send in its undo window, cancels a scheduled one, or brings back a discarded draft */
  onUndo?: () => void;
  /** Seconds left to undo, shown while the phase is undo */
  undoLeft?: number;
  labels?: { send?: string; schedule?: string; sending?: string; scheduling?: string; discard?: string; notSent?: string; sent?: string; scheduled?: string; delivered?: string; discarded?: string; nothingSent?: string; undo?: string; cancel?: string };
  className?: string;
}

const WHEN: SendOption[] = [
  { key: 'now', label: 'Send now' },
  { key: 'tomorrow', label: 'Tomorrow at 09:00', detail: 'The start of their day' },
  { key: 'monday', label: 'Monday at 09:00', detail: 'The start of next week' }
];

/** An email the agent drafted, laid out like the email it is, to check and edit before it goes: send now or later, or
    discard it. A warning can stop it before it sends, and after sending there is a short window to undo. */
export function DraftReview({
  title = 'Draft from the agent', subtitle, to, subject, onSubjectChange, body, onBodyChange, attachments = [], warning, phase = 'draft',
  sendOptions = WHEN, sendOption, defaultSendOption, onSendOptionChange, onSend, onDiscard, onUndo, undoLeft = 5, labels = {}, className
}: DraftReviewProps) {
  const [pick, setPick] = useControllable(sendOption, defaultSendOption ?? sendOptions[0].key, onSendOptionChange);
  const menu = usePopover({ align: 'end' });
  const chosen = sendOptions.find(o => o.key === pick) ?? sendOptions[0];
  const later = chosen.key !== 'now';
  const L = { send: 'Send', schedule: 'Schedule', sending: 'Sending', scheduling: 'Scheduling', discard: 'Discard', notSent: 'Not sent', sent: 'Sent', scheduled: 'Scheduled', delivered: 'Delivered just now', discarded: 'Draft discarded', nothingSent: 'Nothing was sent', undo: 'Undo', cancel: 'Cancel', ...labels };
  const names = to.map(p => p.name).join(', ');
  // the body is written once and left to the browser while it is edited, so the caret stays where it is
  const first = useRef(body);
  const busy = phase === 'sending', done = phase !== 'draft' && phase !== 'sending';
  const tag = phase === 'sent' ? ['dm-ok', L.sent] : phase === 'scheduled' ? ['dm-b', L.scheduled] : ['', L.notSent];
  const row = phase === 'undo' ? { icon: 'ok' as const, cls: '', t: `${L.sent} to ${names}`, sub: `You can undo for ${undoLeft}s`, btn: L.undo }
    : phase === 'sent' ? { icon: 'ok' as const, cls: '', t: `${L.sent} to ${names}`, sub: L.delivered }
    : phase === 'scheduled' ? { icon: 'clock' as const, cls: 'dm-later', t: `${L.scheduled} for ${chosen.label.replace(/^\w/, c => c.toLowerCase())}`, sub: `To ${names}`, btn: L.cancel }
    : phase === 'discarded' ? { icon: 'trash' as const, cls: 'dm-bin', t: L.discarded, sub: L.nothingSent, btn: L.undo } : null;

  return (
    <div data-dm="" className={className} style={{ width: '100%' }}>
      <div className={cx('dm-drf dm-panel', done && 'dm-sent')}>
        <div className="dm-drf-h">
          <span className="dm-drf-ag"><Icon name="spark" /></span>
          <div className="dm-drf-ht"><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
          <span className={cx('dm-tag dm-drf-tag', tag[0])}><Swap>{tag[1]}</Swap></span>
        </div>
        <div className="dm-drf-fs" inert={done}>
          <div className="dm-drf-r"><span className="dm-drf-l">To</span>
            {to.map(p => <span key={p.name} className="dm-drf-p"><Avatar initials={p.initials ?? p.name.split(' ').map(w => w[0]).join('').slice(0, 2)} color={p.color ?? 'var(--dm-av-l)'} />{p.name}</span>)}
          </div>
          <label className="dm-drf-r"><span className="dm-drf-l">Subject</span><input className="dm-drf-s" value={subject} onChange={e => onSubjectChange?.(e.target.value)} readOnly={!onSubjectChange} aria-label="Subject" /></label>
        </div>
        <div className="dm-drf-b" contentEditable={!done} suppressContentEditableWarning role="textbox" aria-multiline="true" aria-label="Message" spellCheck={false}
          style={{ whiteSpace: 'pre-wrap' }} inert={done} onInput={e => onBodyChange?.((e.target as HTMLElement).innerText)}>{first.current}</div>
        <div className="dm-drf-att" inert={done}>
          {attachments.map(a => (
            <span key={a.name} className="dm-drf-file dm-enter"><Icon name="file" size="xs" /><span>{a.name}</span>
              <span className="dm-muted dm-drf-fz">{a.loading ? <Spin /> : a.size}</span></span>
          ))}
        </div>
        <Fold open={!!warning && phase === 'draft'} className="dm-drf-wf">
          <div className="dm-drf-w">
            <p><Icon name="alert" size="xs" /><span>{warning?.text}</span></p>
            <div className="dm-drf-wa">
              {warning?.actions.map(a => <button key={a.label} type="button" className={cx('dm-pill dm-sm', a.primary ? 'dm-primary' : 'dm-ghost')} onClick={a.onClick}>{a.label}</button>)}
            </div>
          </div>
        </Fold>
        {row ? (
          <div className={cx('dm-drf-done dm-enter', row.cls)}>
            <Icon name={row.icon} size={row.icon === 'trash' ? 'xs' : undefined} />
            <span className="dm-t">{row.t}<span className="dm-muted dm-drf-sub">{row.sub}</span></span>
            {row.btn && <button type="button" className="dm-pill dm-sm" onClick={onUndo}>{row.btn}</button>}
          </div>
        ) : (
          <div className="dm-drf-f">
            <button type="button" className="dm-pill dm-ghost dm-sm dm-drf-x" disabled={busy} onClick={onDiscard}><Icon name="trash" size="xs" />{L.discard}</button>
            <span className="dm-sp" />
            <Swap className="dm-muted dm-drf-when">{later ? chosen.label : ''}</Swap>
            <div className="dm-drf-mg">
              <button type="button" className="dm-pill dm-primary dm-sm dm-drf-go" disabled={busy} onClick={() => onSend?.(chosen)}>
                {busy ? <><Spin />{later ? L.scheduling : L.sending}</> : <><Icon name={later ? 'clock' : 'send'} size="xs" />{later ? L.schedule : L.send}</>}
              </button>
              <button type="button" className="dm-pill dm-primary dm-sm dm-drf-dd" aria-label="When to send" disabled={busy} {...menu.trigger}><Icon name="chev-d" size="xs" /></button>
            </div>
            <div {...menu.pop} className={cx(menu.pop.className, 'dm-drf-pop')} role="menu" aria-label="When to send">
              {sendOptions.map(o => (
                <button type="button" key={o.key} className="dm-mi" role="menuitemradio" aria-checked={o.key === chosen.key} onClick={() => { setPick(o.key); menu.closeSoon(); }}>
                  <span>{o.label}{o.detail && <span className="dm-d">{o.detail}</span>}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
                </button>
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}
draft-review.cssReact · 53 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Draft review ---------- */
/* what the agent wrote, laid out like the email it is: who it goes to, the subject, the message and what is attached,
   then send now or later, or discard. Before sending it checks for an attachment the message mentions, and after
   sending there is a short window to undo */
.dm-drf{position:relative;width:100%;max-width:440px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-drf-h{display:flex;align-items:center;gap:var(--dm-s-3)}
.dm-drf-ag{width:32px;height:32px;border-radius:9px;background:var(--dm-chip);color:var(--dm-fg2);display:grid;place-items:center;flex:none}
.dm-drf-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-drf-ht b{font-weight:500;font-size:14px}
.dm-drf-ht span{font-size:13px}
.dm-drf-fs{display:flex;flex-direction:column;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-drf-r{display:flex;align-items:center;gap:10px;min-height:40px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13.5px}
.dm-drf-l{width:52px;flex:none;color:var(--dm-muted);font-size:13px}
.dm-drf-p{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px 0 3px;border-radius:var(--dm-r-pill);background:var(--dm-chip);font-size:13px}
.dm-drf-p .dm-av{width:20px;height:20px;font-size:9px}
.dm-drf-s{flex:1;min-width:0;height:38px;padding:0;border:0;background:none;outline:0;font:inherit;font-size:13.5px;color:var(--dm-fg)}
.dm-drf-r:has(.dm-drf-s:focus){border-bottom-color:color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-drf-b{font-size:14px;line-height:1.6;color:var(--dm-fg);outline:0;border-radius:6px}
.dm-drf-b:focus{box-shadow:0 0 0 6px var(--dm-surface),0 0 0 7px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-drf-att{display:flex;flex-wrap:wrap;gap:6px}
.dm-drf-att:empty{display:none}
.dm-drf-file{display:inline-flex;align-items:center;gap:var(--dm-s-2);height:30px;padding:0 10px;border-radius:var(--dm-r-sm);background:var(--dm-chip);font-size:13px}
.dm-drf-file>svg{color:var(--dm-fg2)}
.dm-drf-file .dm-muted{font-size:12px;display:inline-flex;align-items:center}
.dm-drf-file .dm-spin{width:10px;height:10px}
/* the shut warning is hidden once it has folded away, so nothing measures the clipped part as if it were drawn */
.dm-drf-wf.dm-shut>div{visibility:hidden;transition:visibility 0s var(--dm-t)}
.dm-drf-w{padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-warn-bg);display:flex;flex-direction:column;gap:10px}
.dm-drf-w p{margin:0;display:flex;gap:var(--dm-s-2);font-size:13px;line-height:1.45;color:var(--dm-fg)}
.dm-drf-w p svg{color:var(--dm-warn);flex:none;margin-top:3px}
.dm-drf-wa{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}
.dm-drf-w .dm-pill.dm-ghost{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
/* a hairline sets the buttons apart from the message, as in a mail composer */
.dm-drf-f{display:flex;align-items:center;gap:var(--dm-s-2);padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-drf-f .dm-sp{flex:1}
.dm-drf-when{font-size:13px}
.dm-drf-mg{display:flex;gap:1px;flex:none}
.dm-drf-go{border-radius:var(--dm-r-pill) 0 0 var(--dm-r-pill);padding-right:var(--dm-s-3)}
.dm-drf-dd{border-radius:0 var(--dm-r-pill) var(--dm-r-pill) 0;padding:0 9px}
.dm-drf-pop{min-width:220px}
.dm-drf.dm-sent .dm-drf-fs,.dm-drf.dm-sent .dm-drf-b,.dm-drf.dm-sent .dm-drf-att{opacity:.55;transition:opacity .32s}
.dm-drf-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-drf-done.dm-bin{background:var(--dm-chip)}
.dm-drf-done.dm-later{background:var(--dm-pb-bg)}
.dm-drf-done>svg{color:var(--dm-ok);flex:none}
.dm-drf-done.dm-bin>svg,.dm-drf-done.dm-later>svg{color:var(--dm-fg2)}
.dm-drf-done .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-drf-done .dm-t span{font-size:13px}
.dm-drf-done .dm-t .dm-muted{color:var(--dm-fg2)}
.dm-drf-done .dm-pill{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
@media (hover:hover) and (pointer:fine){.dm-drf-done .dm-pill:hover{box-shadow:var(--dm-shadow-pop)}}
draft-review.htmlHTML · 24 lines
<div class="card" data-c="draft-review">
  <div class="stage-in">
    <div class="drf panel">
      <div class="drf-h"><span class="drf-ag"><svg class="i s"><use href="#i-spark"/></svg></span><div class="drf-ht"><b>Draft from the agent</b><span class="muted">After the pricing review with Maya</span></div><span class="tag drf-tag">Not sent</span></div>
      <div class="drf-fs">
        <div class="drf-r"><span class="drf-l">To</span><span class="drf-p"><span class="av" style="background:var(--av-l);color:var(--av-l-ink)">MK</span>Maya Kim</span></div>
        <label class="drf-r"><span class="drf-l">Subject</span><input class="drf-s" value="Pricing v4: what changed" aria-label="Subject"></label>
      </div>
      <div class="drf-b" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message" spellcheck="false">Hi Maya,<br><br>Here's what changed on the pricing page: one button per plan, the yearly price first, and the FAQ under the comparison table. The full list is in the attached summary.<br><br>Ready for your sign-off when you are.</div>
      <div class="drf-att"></div>
      <div class="fold shut drf-wf"><div><div class="drf-w"><p><svg class="i xs"><use href="#i-alert"/></svg><span>You mention an attachment, but nothing is attached.</span></p><div class="drf-wa"><button class="pill ghost sm drf-any">Send anyway</button><button class="pill primary sm drf-at"><svg class="i xs"><use href="#i-clip"/></svg>Attach the summary</button></div></div></div></div>
      <div class="drf-f">
        <button class="pill ghost sm drf-x"><svg class="i xs"><use href="#i-trash"/></svg>Discard</button><span class="sp"></span><span class="muted drf-when"></span>
        <div class="drf-mg"><button class="pill primary sm drf-go"><svg class="i xs"><use href="#i-send"/></svg>Send</button><button class="pill primary sm drf-dd" aria-label="When to send" aria-haspopup="menu" aria-expanded="false" data-pop=".drf-pop" data-pop-align="end"><svg class="i xs"><use href="#i-chev-d"/></svg></button></div>
      </div>
      <div class="pop drf-pop" role="menu" aria-label="When to send">
        <button class="mi" role="menuitemradio" aria-checked="true" data-close data-w="now"><span>Send now</span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <button class="mi" role="menuitemradio" aria-checked="false" data-close data-w="Tomorrow at 09:00"><span>Tomorrow at 09:00<span class="d">The start of Maya's day</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <button class="mi" role="menuitemradio" aria-checked="false" data-close data-w="Monday at 09:00"><span>Monday at 09:00<span class="d">The start of next week</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
      </div>
    </div>
  </div>
</div>
draft-review.cssHTML · 52 lines
/* ---------- Draft review ---------- */
/* what the agent wrote, laid out like the email it is: who it goes to, the subject, the message and what is attached,
   then send now or later, or discard. Before sending it checks for an attachment the message mentions, and after
   sending there is a short window to undo */
.drf{position:relative;width:100%;max-width:440px;margin:0 auto;padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3)}
.drf-h{display:flex;align-items:center;gap:var(--s-3)}
.drf-ag{width:32px;height:32px;border-radius:9px;background:var(--chip);color:var(--fg2);display:grid;place-items:center;flex:none}
.drf-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.drf-ht b{font-weight:500;font-size:14px}
.drf-ht span{font-size:13px}
.drf-fs{display:flex;flex-direction:column;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.drf-r{display:flex;align-items:center;gap:10px;min-height:40px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:13.5px}
.drf-l{width:52px;flex:none;color:var(--muted);font-size:13px}
.drf-p{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px 0 3px;border-radius:var(--r-pill);background:var(--chip);font-size:13px}
.drf-p .av{width:20px;height:20px;font-size:9px}
.drf-s{flex:1;min-width:0;height:38px;padding:0;border:0;background:none;outline:0;font:inherit;font-size:13.5px;color:var(--fg)}
.drf-r:has(.drf-s:focus){border-bottom-color:color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.drf-b{font-size:14px;line-height:1.6;color:var(--fg);outline:0;border-radius:6px}
.drf-b:focus{box-shadow:0 0 0 6px var(--surface),0 0 0 7px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.drf-att{display:flex;flex-wrap:wrap;gap:6px}
.drf-att:empty{display:none}
.drf-file{display:inline-flex;align-items:center;gap:var(--s-2);height:30px;padding:0 10px;border-radius:var(--r-sm);background:var(--chip);font-size:13px}
.drf-file>svg{color:var(--fg2)}
.drf-file .muted{font-size:12px;display:inline-flex;align-items:center}
.drf-file .spin{width:10px;height:10px}
/* the shut warning is hidden once it has folded away, so nothing measures the clipped part as if it were drawn */
.drf-wf.shut>div{visibility:hidden;transition:visibility 0s var(--t)}
.drf-w{padding:10px var(--s-3);border-radius:var(--r-in);background:var(--warn-bg);display:flex;flex-direction:column;gap:10px}
.drf-w p{margin:0;display:flex;gap:var(--s-2);font-size:13px;line-height:1.45;color:var(--fg)}
.drf-w p svg{color:var(--warn);flex:none;margin-top:3px}
.drf-wa{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}
.drf-w .pill.ghost{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
/* a hairline sets the buttons apart from the message, as in a mail composer */
.drf-f{display:flex;align-items:center;gap:var(--s-2);padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.drf-f .sp{flex:1}
.drf-when{font-size:13px}
.drf-mg{display:flex;gap:1px;flex:none}
.drf-go{border-radius:var(--r-pill) 0 0 var(--r-pill);padding-right:var(--s-3)}
.drf-dd{border-radius:0 var(--r-pill) var(--r-pill) 0;padding:0 9px}
.drf-pop{min-width:220px}
.drf.sent .drf-fs,.drf.sent .drf-b,.drf.sent .drf-att{opacity:.55;transition:opacity .32s}
.drf-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}
.drf-done.bin{background:var(--chip)}
.drf-done.later{background:var(--pb-bg)}
.drf-done>svg{color:var(--ok);flex:none}
.drf-done.bin>svg,.drf-done.later>svg{color:var(--fg2)}
.drf-done .t{flex:1;min-width:0;display:flex;flex-direction:column}
.drf-done .t span{font-size:13px}
.drf-done .t .muted{color:var(--fg2)}
.drf-done .pill{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
@media (hover:hover) and (pointer:fine){.drf-done .pill:hover{box-shadow:var(--shadow-pop)}}
draft-review.jsHTML · 59 lines
DM.def('draft-review', (r, c) => {
  const IC = { spark:I('spark'), warn:I('alert','xs'), clip:I('clip','xs'), send:I('send','xs'), chev:I('chev-d','xs'), chk:I('check','xs'), trash:I('trash','xs'), av:AV('MK','var(--av-l)') };
  r.innerHTML = r.innerHTML.replace(/\{(\w+)\}/g, (m, k) => IC[k] || m);
  const root = $('.drf', r), go = $('.drf-go', r), dd = $('.drf-dd', r), att = $('.drf-att', r), wf = $('.drf-wf', r), when = $('.drf-when', r), f = $('.drf-f', r), tag = $('.drf-tag', r), body = $('.drf-b', r), fields = [$('.drf-fs', r), body, att];
  const items = $$('.drf-pop .mi', r);
  let at = 'now', file = false, done = null, run = 0;
  const label = () => (at === 'now' ? I('send','xs') + 'Send' : I('clock','xs') + 'Schedule');
  // when to send: now, or a set time from the menu beside the button
  items.forEach(m => m.addEventListener('click', () => { at = m.dataset.w; items.forEach(x => x.setAttribute('aria-checked', x === m)); go.innerHTML = label(); swap(when, at === 'now' ? '' : at); }));
  const warn = on => { wf.classList.toggle('shut', !on); wf.inert = !on; };
  warn(false);
  // the result of sending, scheduling or discarding takes the footer's place, with a white button to take it back
  function row(ic, t, sub, btn, fn, cls){
    f.hidden = true; root.classList.add('sent'); fields.forEach(e => e.inert = true); body.contentEditable = 'false';
    done = html(`<div class="drf-done ${cls || ''}">${ic}<span class="t">${t}<span class="muted drf-sub">${sub}</span></span>${btn ? `<button class="pill sm">${btn}</button>` : ''}</div>`);
    root.appendChild(done); enter(done); if (btn) $('button', done).onclick = fn; return done;
  }
  function back(){
    run++; if (done){ done.remove(); done = null; }
    f.hidden = false; root.classList.remove('sent'); fields.forEach(e => e.inert = false); body.contentEditable = 'true';
    go.disabled = dd.disabled = false; go.innerHTML = label(); swap(tag, 'Not sent'); tag.className = 'tag drf-tag';
  }
  function send(force){
    // the message talks about an attachment that isn't there: say so before it goes
    if (!force && !file && /attach/i.test(body.textContent)){ warn(true); return; }
    warn(false);
    const id = ++run;
    c.run(async () => {
      go.disabled = dd.disabled = true; go.innerHTML = `<span class="spin"></span>${at === 'now' ? 'Sending' : 'Scheduling'}`;
      await c.wait(900); if (id !== run) return;
      if (at !== 'now'){ row(I('clock'), `Scheduled for ${at.replace(/^\w/, ch => ch.toLowerCase())}`, 'To Maya Kim', 'Cancel', back, 'later'); swap(tag, 'Scheduled'); tag.className = 'tag b drf-tag'; return; }
      // a few seconds to take it back before it really goes
      const d = row(I('ok'), 'Sent to Maya Kim', 'You can undo for 5s', 'Undo', back), sub = $('.drf-sub', d);
      for (let k = 5; k > 0; k--){ sub.textContent = `You can undo for ${k}s`; await c.wait(1000); if (id !== run) return; }
      $('button', d).remove(); swap(sub, 'Delivered just now'); swap(tag, 'Sent'); tag.className = 'tag ok drf-tag';
    });
  }
  go.onclick = () => send(false);
  $('.drf-any', r).onclick = () => send(true);
  $('.drf-at', r).onclick = () => c.run(async () => {
    if (file) return; warn(false);
    const chip = html(`<span class="drf-file">${I('file','xs')}<span>Pricing v4 summary.pdf</span><span class="muted drf-fz"><span class="spin"></span></span></span>`);
    att.appendChild(chip); enter(chip);
    await c.wait(1200); $('.drf-fz', chip).textContent = '240 KB'; file = true;
  });
  $('.drf-x', r).onclick = () => { warn(false); row(I('trash','xs'), 'Draft discarded', 'Nothing was sent', 'Undo', back, 'bin'); };
  // the preview's story: send, get stopped by the missing attachment, attach it, send, and let the undo window pass
  c.story(async s => {
    await s.beat(3);
    await s.point(body, false); await s.beat(3);
    await s.tap(go); s.away();
    await s.until(() => !wf.classList.contains('shut')); await s.beat(5);
    await s.tap($('.drf-at', r)); s.away();
    await s.until(() => file); await s.beat(2);
    await s.tap(go); s.away();
    await s.until(() => done && !$('button', done));
  });
});
06

Questions

What people ask about it.

Does it send the email?
No. It calls onSend with the picked option and shows the phase you pass. Your code sends or schedules the message, runs the undo window and moves the phase along.
Does it check for missing attachments by itself?
No. The preview checks whether the message mentions an attachment when nothing is attached, and passes a warning. Pass your own warning for any check you run before sending.
Can I use it for messages that aren't email?
Yes. Change the title and labels, and pass the recipients and send options you need. With no attachments and no warning, neither shows.
07

More in Review and approve

Moments where a person checks, steers or signs off on the agent's work.

Change reviewAccept or reject each change the agent made, file by file.
Plan reviewCheck the agent's plan, edit steps, then let it run.
Permission gateThe agent asks before it touches something new.
Hand-off to a personThe agent hands a conversation to a person, with what they need to pick it up.
Decision promptThe agent asks a few quick questions before it starts.
Proposal cardA suggested change with confidence, alternatives and undo.
Design tunerTweak a component by hand or let the agent tune it.
Rewrite toolbarSelect a sentence and let the agent rewrite it in place.
All 14 in Review and approve →