Mockups
Click any image for the full-size render (1600 px wide). The design board shows the same images side by side.
D: live view (recommended, from your 25 September review)




D: review view




D: cadence sheet
A: Reference (earlier round)




B: Hero band (earlier round)




C: Transcript spine (earlier round)




States sheet
CT Copilot dashboard: design system
The spec every dashboard story (COPILOT-045 onwards) builds from. The dashboard has two screens. The live view is what a rep sees during a call: it is design G, "One list", approved on 2026-09-25, and its mockup in docs/design/shotgun/G/ is the literal layout spec. The review view is the audit ledger for replay and curation: it is variant D's review view (variant C's layout in plain English). Token values are literal, so lift them, never approximate them.
- Approved: G, "One list" (2026-09-25), for the live view (§Live view, §Call plan contract). Canonical mockup source and tokens:
docs/design/shotgun/G/(live.html?s=onboarding|cs&theme=light|dark,live.css,data.js,NOTES.md) and the four rendersG-{onboarding,cs}-{light,dark}.pngat 1600x1000. Re-render withnode docs/design/shotgun/G/render.mjs. Brief:docs/design/shotgun/BRIEF.md(round 3, the living call plan). - Kept from D: the review view (§Review view), for Stevan now and the team later. Brief: Stevan's review of A, B and C, verbatim with answers, in
docs/design/FEEDBACK-2026-09-25.md. D's live-view mockups (mockups/D-live-*,mockups/D-cadence-*) are superseded by G and kept only as history. - Board:
docs/design/board.html(A to D; the round-3 directions E, F, G and H are indocs/design/shotgun/<E|F|G|H>/). Board screenshots:docs/design/evidence/COPILOT-011-board-{light,dark}.png. - Review-view mockups:
docs/design/mockups/D-review-{onboarding,cs}-{light,dark}.png; C's panels indocs/design/mockups/C-{onboarding,cs}-{light,dark}.png; states indocs/design/mockups/states-{light,dark}.png. Source:docs/design/mockups/src/(tokens.css,components.css,render.js,variant.html?v=A|B|C&s=onboarding|cs,states.html; for D alsovariant-d.css,render-d.js,review.html?s=…). Re-render withnode scripts/design/render-mockups.mjs, or only D with--only 'D-*,board-*'. It servesdocs/design/locally and runsnpx playwright screenshot --color-scheme=light|darkat a 1600x1000 viewport, full page. - Inputs:
docs/design/README.md,reference-copilot-dashboard-{1,2}.jpg, architecture §8.1 panel table and WebSocket protocol (docs/architecture.md:341-381), PRD UI stories COPILOT-045 to 055, 067, 072, 093 and 094.
Two views, two jobs. The live view answers four questions at a glance: where are we in this call, what have we covered, what is worth saying right now, and what is still to come. It shows one list, the call plan, next to the transcript; it spends its one accent on "worth saying"; it carries no number, threshold, rule or confidence. The review view is an audit ledger: dense and quiet, warm paper in light mode and graphite in dark mode, every claim linked back to a transcript row (#i), every number monospaced with tabular figures. The live view is for the minute the rep is in; the review view is for checking the engine after the call. The same decision stream and the same call-plan object feed both.
Design constraints (non-negotiable)
- No probability, anywhere (INV-COPILOT-012). Do not build a closing-probability or activation-likelihood number, bar, gauge, chart or percentage hero. The onboarding hero is a count (
x / ymust-say items said) plus latched risk flags. The CS hero is a state (none → reported → owned → resolved) plus a health value on a 0 to 1 scale, shown as0.64, never as64%. The completeness tally is discrete: one cell per applicable item, coloured by that item's state. It is never a continuous fill. Only the talk-share bar uses%, because it is a word ratio computed in code. No DOM text matches/probability|likelihood/i. - Status is never colour-only. Must-say items render icon + text label for all four states: filled check + "said", half-filled circle + "uncertain" (with its value), empty ring + "unsaid", dashed ring with a dash + "n/a" (with the reason). Signals use lamp shape as well as colour: filled = lit, dashed = uncertain, hollow = off, dotted = unknown (
null, shown as "no answer", never0.00). Risk uses a triangle icon + the word. - Risk flags carry the offending utterance index (
#38) and the value (0.71), plus the pseudonymised quote. Flags never auto-clear. A false-positive flag stays in the list, greyed and struck through, with "flagged as false positive". - The next-best-move card shows
title,what, and ≤ 3 approved lines, exactly one of them prefixed "say:". It also showsrule: <move> hidden because <reason>forsuppressed[0], and "listening… (leaning X)" when gated. Every rep-facing string goes throughrenderRepText(approved line or verified rewrite). The UI cannot render anything else. - Telemetry shows the model id with a drift warning when it ≠
model_expected, latency mean / p95, tokens (last in / out and total), budget degradation (questions dropped), cache hits, and cumulative cost at $0.042/M input. - Keyboard: transcript rows are focusable (
tabindex=0,aria-selected).Shift+↑/↓extends the selection from the focused row,mopens the mark form,Escclears. The focus ring is always visible: 2px--focus. - No dead controls. Every button in the mockups maps to a protocol message or a route (see §Panels). Anything not yet wired renders disabled with microcopy that says why. There is no theme toggle, because the theme follows the OS (
prefers-color-scheme). - Solid colours only. No alpha in any surface token, so a panel never shows what is behind it in dark mode. The only translucency is the dialog scrim and shadow.
How the constraints apply to the live view (G) and the review view (D)
The constraints above were written for the audit ledger and hold in the review view as stated. In the live view:
- 1 (no probability): unchanged. The live view shows no number except clock times and the amounts in the facts chips. Its labels avoid "probability", "likelihood" and "confidence"; the web tests assert that no DOM text matches
/probability|likelihood/i. - 2 (never colour-only): every plan state is a word next to its mark ("Done 02:58", "Now", "Next", "To come", "Raised by the client 03:55"); the marks are a tick, a dot, a ring and a dash, never colour alone. The caution has an icon and words. Chips are words.
- 3 (risk flags): holds in the review view (index, value, quote, false alarm kept). The live view shows a flag only as the caution under the turn it refers to, for 15 s.
- 4 (next-best-move card): replaced in the live view by the plan list, in which the current item is the only open card. Rep-facing text is still only approved text or a verified rewrite through
renderRepText(INV-COPILOT-003): item titles, hints, headlines, points, not-needed reasons, caution notes, purpose labels and chip labels are all approved policy text (§Call plan contract, rep-facing text). There is no "say:" prefix in either view; the points block carries one "Worth saying" tag instead. In the review view, "What the rep saw" replaces C's card panel. Acknowledge and Dismiss are removed from both views;ack_card/dismiss_cardstay in the protocol for M3 rep feedback, with no control. - 5 (telemetry): review view only.
- 6 (keyboard): holds in the review view. In the live view every control is a native button or link (plan rows, time links, chips, "+N more", the purpose control) with a visible 2px
--accentfocus ring. - 7 (no dead controls): the Live view / Review view switch is a route (
?view=live|review). In the live view, time links jump to their transcript line, collapsed rows expand, chips jump to the turn that set them and the purpose control sendsset_purpose. In the review view, "Mark moment" renders disabled with "Select lines first" until a span is selected. - 8 (solid colours): G's tokens have no alpha in any surface;
--shadowis the only translucency in the live view, as the dialog scrim is in the review view. - Talk-share bar: review view only, labelled "Share of words".
Live view (G, "One list")
What a rep sees during a call. The same screen replays an uploaded or historical call (M1) and runs on live audio in M3 (the call audio and the rep's microphone). The literal layout spec is docs/design/shotgun/G/live.html with live.css; data.js holds the two drawn scenarios (onboarding mid-call with a concern open; customer success after the issue is fixed) and doubles as the fixture shape for the web tests; NOTES.md has the rationale (a), the data contract (b, folded into §Call plan contract) and the cadence rules (c, below). G's tokens (§Tokens, "Live view tokens (G)") replace D's tokens for the live view; the review view keeps §Tokens "Review view tokens (C and D)". Where this section and the G render differ, this section wins: the additions are the one-tag rule, the purpose control, the chips rules, the rate-pressure item and the replay top bar.
Principle: one list, one reading focus. The plan and the suggestions are one object, so nothing floats: the current item is a card that sits in its place in the list, between what is done and what is still to come. Where the call is, is answered by position, not by a stage label. The only large text on the screen after the item's title is the current item's 2 or 3 points.
Layout
- Frame:
height: 100vh, grid rows56px 1fr: the top bar, then the panes. Only the transcript and the plan list scroll; the page does not (bodyoverflow: hiddenabove 1100px). - Panes: grid
620px 1fr, gap 24px, padding 20px 24px 24px. Left: the transcript, full height. Right: the plan pane. - At 1440px wide and below the transcript column is 540px; there is no horizontal scroll at 1440 wide. Below 1100px the panes stack (transcript first,
max-height: 50vh) and the page scrolls. - Drawn at 1600x1000 (the renders) and checked at 1440x900 (Stevan's laptop). Pane heights assume a window about 1000px tall.
Top bar
Left to right with 20px gaps, 24px side padding and a 1px --line rule under it: the brand "CT Copilot" (Bricolage Grotesque 700, 16px); the state pill; the call label in --ink 600 followed by the call kind in --ink-2 ("Onboarding call", "Customer success call"); a spacer; the view link; the clock (DM Mono 15px, --ink-2).
- Live (M3): the pill reads "Live" with an 8px
--accentdot that pulses (2.4 s),--accent-tint-2fill,--accent-lineborder,--accent-inktext, 13px 600. - Replay (M1): the pill reads "Replay" in neutral ink (
--surfacefill,--lineborder,--ink-2text, no pulse), and a compact player sits before the clock: play/pause, a scrubber without ticks, speed, and the source ("call recording" or "uploaded call"). The Live pill never shows in a replay. - Call label: calls are pseudonymised, so the label is the call's display label (for example "Call 3339895706"), never a name from the transcript. G's "Helen M." and "Nadia A." are mockup pseudonyms.
- View link: "Review view", 13px
--ink-2, a route (?view=review), just before the clock.
Transcript pane
Head: "Transcript" (Bricolage Grotesque 600, 20px) and "Newest at the bottom" (13px, --ink-3). Body: a --surface box with a 1px --line border and 10px radius that scrolls; its top edge fades into the surface (a sticky 24px gradient) so the first visible line reads as a live scroll rather than a cut. Auto-scroll keeps the newest line in view unless the rep has scrolled up.
- Row: grid
44px 56px 1fr, gap 10px, padding 7px 18px, 14.5px/1.42. Time in DM Mono 12px--ink-3; the speaker label in 11px 700 uppercase at +0.06em, "You" in--ink-2and "Client" in--ink-3(both grey, the rep's a step darker); the text in--ink. Asides (backchannels and acknowledgements such as "Sure." or "Right.") are--ink-3. - The latest utterance has the
--accent-tint-2fill. - A line that ticked a plan item (a point covered, or the lock that made an item done) ends with a covered tag: a tick in
--accentand the item's title in 12.5px 500--ink-3("Fund from your own account"), never a system phrase. - The caution sits under the rep turn it refers to (§Caution).
- "Listening" sits at the foot whenever Jev has nothing new (§Listening and hold).
- A time link elsewhere (a plan row, a chip) scrolls its line to the centre and flashes it (
--surface-2for 1.6 s). - No
#i, no decision markers, no share-of-words bar, no future lines.
Plan pane
Top to bottom:
- Head row: "Plan for this call" (Bricolage Grotesque 600, 20px) and, right-aligned, the tally in words, 13px
--ink-3: "4 done, 5 to come, 1 not needed" ("to come" counts next and later items). - Purpose line: the call purpose's approved label, 14px
--ink-2("First call after sign-up"). It is a quiet button. Clicking it opens an inline list of this scenario's purposes under the heading "Change the purpose of this call"; choosing one sendsset_purpose{purpose_id}, and the label is then followed by "Set by you" in--ink-3. Before a purpose is known the line is empty, never "unknown". This is the one control G adds to the render. - Client facts chips (§Client facts chips).
- The list, which scrolls: items in plan order, 3px apart, then the "Not needed on this call" group at the foot.
Plan item rows
Every row is a button (aria-expanded) with a 22px mark, the title, and the state word right after the title (a 12px column gap plus 4px padding, so title and state read as one line; never pushed to the far edge). Row padding 5px 14px 5px 12px, radius 10px, hover fill --surface-2.
| State | Mark | Title | State word | Notes |
|---|---|---|---|---|
| done | tick in --accent |
15.5px 500 --ink-2 |
"Done 02:58"; a client item reads "Client raised 00:03, done 03:47" | the times are links |
| current | 10px --accent dot |
the open card (§The current item) | "Now"; a client item reads "Raised by the client 03:55" | the only open thing on screen |
| next | 8px hollow ring, --ink-4 |
15.5px 500 --ink |
"Next", --ink-2 600 |
one item, whenever anything is still to come |
| later | 8px hollow ring, --ink-4 |
15.5px 400 --ink-3 |
"To come"; a client item reads "Client asked 01:12, to come" | |
| not needed | 8px dash, --ink-4 |
15.5px 400 --ink-3 |
its reason as a sentence ("The amount is well above the minimum") | only in the foot group |
State words are 12.5px --ink-3; their times are DM Mono 12px with a dotted --ink-4 underline, and clicking a time jumps to that line in the transcript and flashes it. Clicking a done or later row expands it: its hint (13px --ink-3) and its points, each with "Covered 03:44" when covered (14px --ink-2, rows ruled by --line-2). Marks are CSS shapes: no icon font, no hand-drawn SVG.
The current item
The one open card, in its place in the list: --surface fill, a 1px --accent-line border, --shadow, 10px radius and 4px margin above and below.
- Head: the 10px dot, the title in Bricolage Grotesque 600 23px/1.2
--ink, and the state word in--ink-2600 ("Now", or "Raised by the client 03:55"). - Hint: one approved sentence saying why the item is on this plan, 14px
--ink-2("Answer the cost worry before moving on. Compare total cost, not the fee."). Body padding 6px 20px 16px 50px. - One tag, then the points. The points block carries exactly one small tag at its top ("Worth saying", or "Say" on a rate-pressure item; the tag style below), and then the approved lines, usually 2 or 3. No line ever starts with "say:", "Say", "Worth saying" or any other repeated prefix: the tag says it once for the block. Each point sits on
--accent-tintwith a 10px radius and 12px 16px padding, 19px/1.38--ink, max-width 620px (about 66 characters per line; not66ch, which measures about 100 characters in Instrument Sans), 8px apart. The points are the largest text after the title and the only text a rep needs to read. - Covered point: a point just covered keeps its slot for the hold but drops to one small grey line: a tick in
--accent, the text in 15px--ink-3cut with an ellipsis, and "Covered just now, 04:44" (12.5px). After the hold it folds into one "Also covered at 08:16: …" line (13px--ink-3) under the points. - Tag style: inline-block, padding 2px 7px, radius 5px, 11px 700 uppercase at +0.06em, 8px below it. "Worth saying" and "Say" are
--accent-inkon--accent-tint-2(they belong to the green points, so they spend no new meaning of the accent); "Ask first" and "Careful" are--ink-2on--surface-2. The tag names the block once: "Say" on a rate-pressure item, whose points are the playbook's verbatim lines (the playbook shows "Say" once on the block, never in a line), and "Worth saying" on every other item. - Rate-pressure item: under the title, the situation's headline in the client's words (16px 500
--ink, in quotation marks) and its hint (14px--ink-2); then, when the situation says to qualify first, a single "Ask first" tag over the four qualifying questions, shown as a compact sub-checklist (one 15px--inkrow each, with a hollow ring that becomes a tick and "Covered 04:12" when the rep asks it); then a single "Say" tag over the situation's approved lines (§Client-added items). - Tailored rewrite (stretch, off by default): a verified rewrite renders as one more point, with "tailored (verified)" in 12.5px
--ink-3under it.
Client-added items
- Concern. When the client raises a concern it is inserted as the current item at the current position, on the client turn that raised it. Its title is the concern's approved headline in the client's words ("Client is comparing our fees with their bank"); its points are the approved lines of the move the engine picked for that topic. The item it interrupted shows "Next" and reopens as current when the concern closes; the concern item then collapses into a done row ("Client raised 03:55, done 05:10").
- Rate pressure. When the client pushes on the rate (§Call plan contract, rate pressure), the inserted item is titled "Rate pressure"; its headline is the situation's headline in the client's words ("Company X gives me a better rate.") and its hint is the playbook's delivery guidance for that situation. When the situation says to qualify first (a named competitor), its body starts with the "Ask first" sub-checklist (when did you get that rate; was it a live quote on your amount or a headline off a website; what were the fees on top, including the receiving bank's cut; what actually landed against what you were quoted); then, under "Say", the playbook's approved lines for that situation, verbatim. For a named competitor these read "They probably do on the first one. What matters is what you're getting on trade twenty, when nobody's watching." and "Pull your last three confirmations from them and let's compare what landed, not what was quoted." (
docs/policy/rate-objections.md). If the situation changes (no quote in hand, then a competitor is named), the headline and points change in place; there is never a second rate item. A do-not the rep commits during the item (running the competitor down, explaining the spread) shows as the usual caution under that turn. - Client ask. When the client asks for something the plan does not have (a colleague also needs to book payments), the item is inserted after the current one as "Client asked 01:12, to come", titled with the move's approved headline.
Not needed on this call
Template items that do not apply to this call sit in a dimmed group at the foot of the list, after a 1px --line rule and the label "Not needed on this call" (12px 600 uppercase at +0.06em, --ink-3, left padding 46px), each with its plain reason ("No document was asked for"). The sequence above is never interrupted by something that will not happen. An item that starts to apply again moves back up as "To come".
Client facts chips
The chips row sits in the plan head, under the purpose line and above the list, exactly as in G. The chips are the engine's client facts: a closed set of slots from the policy (§Call plan contract, client fact values), never free text, so the row stays bounded on a long call.
- Slots (about 12): client type, country of residence, currency pair, amount, frequency, what the transfers are for, deadline or timing, funding account, who books or decides, documents status, current provider, rate expectation.
- One value per slot, updated in place. When the client corrects a fact, its chip is updated, never appended. An updated chip shows a small "updated 04:12" (12px
--ink-3, the time in DM Mono) for about 10 s of call time, then reads plainly again. - Empty slots are invisible. There is never a placeholder chip.
- Order: by group (client, transfer, how they pay, status), then by the slot's importance, then by recency.
- Cap: at most 8 chips are visible. When more slots are filled, a ninth chip reads "+N more" and expands the rest inline, with "Show fewer" to collapse them. Both are buttons.
- Links: each chip links to the turn that established it (or last updated it): a click scrolls the transcript to that line and flashes it.
- Wrapping: the row wraps to at most two lines at 1440 wide; with the cap of 8 and G's chip size it never needs a third.
- Style (G): padding 3px 9px, a 1px
--lineborder, pill radius,--surfacefill, 12.5px--ink-2, 6px gaps. - Text: approved text only: the value's approved label ("Corporate client") or an approved template filled by code with typed params only, an amount and ISO 4217 codes ("About 30,000 NZD", "SAR to NZD").
Caution
A caution appears in the transcript under the rep turn it refers to, within one decision of that turn: a --surface-2 box, radius 5px, padding 6px 10px 6px 8px, with a 16px ring icon holding "!" in --ink-2, then a single "Careful" tag, then the approved caution note in 13px 500 --ink ("That sounded like a rate prediction."). The note never repeats "Careful" or any other prefix; the tag says it once. Neutral ink, never a second accent colour, never a box in the plan.
It is timed from the turn, not from page load or message arrival: the element carries data-shown-at (the turn's time) and --elapsed (the clock minus the turn's time); it fades out between 12 s and 15 s after the turn (fade-away 3s delayed by 12s - elapsed), and under prefers-reduced-motion it hides at once at 15s - elapsed. A second caution replaces the first; they never stack. The clock is the call clock (the player in a replay, the call time in M3), so after a seek or in a replay the caution leaves at the same moment it would on a live page. The flag stays in the review view for good.
Listening and hold
"Listening" (an 8px --ink-4 dot pulsing, 13px --ink-3) shows at the transcript foot whenever Jev has nothing new: before the first decision, while no item is current, and while decisions are failing or skipped. Nothing blanks: an unknown decision (error, timeout, budget skipped, credits empty, catching up) keeps the plan and the chips as they are and never shows zeros.
Cadence rules
The engine decides what is true; the presenter in the page (web/src/presenter.ts, COPILOT-093) decides when the rep sees it. The engine's own rules run first (a challenger item must lead for two decisions in a row; at most one change of the current item per decision; §Call plan contract). These are G's rules as implemented (shotgun/G/NOTES.md (c)):
- Items change state only after a client turn ends (a client utterance's
t_end; backchannels and acknowledgements excluded). The current card never moves while the rep is talking. A plan decided on a client turn is applied as it arrives (that turn has ended); a plan decided on a rep turn waits for the next client turn to end; after the call's last turn any pending plan is applied. - The current card stays at least 20 s once opened; a challenger must lead for two decisions in a row before the card switches (the engine's hysteresis, reused). A client concern or rate-pressure item is inserted at the next change moment without waiting out the 20 s.
- A point ticks the moment the rep covers it (feedback on what the rep just did, applied as soon as that rep turn's decision arrives), turns from the tint to the grey covered line with "Covered just now, 04:44", and stays about 5 s after the next client turn ends; then it folds into the "Also covered at 08:16" line.
- At most one new point appears per decision; a new point settles in with a 0.5 s fade (
settle: opacity 0 to 1 with a 3px rise), never a flash. - A client concern is inserted as the current card at the current position on the client turn that raised it; the item it interrupted is "Next" and reopens when the concern collapses to done.
- A caution appears under its rep turn within one decision, in neutral ink, and fades between 12 s and 15 s after that turn, then is gone; a second caution never stacks on the first.
- "Listening" shows at the transcript foot whenever Jev has nothing new; nothing blanks, and the card is held on a failed decision.
- An updated chip shows "updated 04:12" for about 10 s of call time, then reads plainly.
- Under
prefers-reduced-motionthe fades collapse to instant changes and the pulses stop; the caution still leaves at 15 s.
| Constant | Value |
|---|---|
LIVE_MIN_DWELL |
20 s of call time |
LIVE_COVERED_LINGER |
5 s after the next client turn ends |
LIVE_CAUTION_FADE_START / LIVE_CAUTION_GONE |
12 s / 15 s after the turn |
LIVE_CHIP_UPDATED |
10 s |
| Point settle / pulse | 0.5 s / 2.4 s |
How it is built. The presenter is a pure function over the last presented state, the latest decision.plan with its decision index and unknown flag, the call clock and the utterances (speaker, kind, t_end from GET /api/calls/:id); the page runs it on every decision and every clock tick. It keeps the engine's latest plan as pending and applies it at the next change moment that satisfies the dwell; ticks and cautions are applied as they arrive and are never dropped. The same presenter drives a replay, where the clock is the player, and a seek resets it to the decision at the new clock. There are no timers in the engine or the Durable Object (INV-COPILOT-009).
Type, colour and motion
- Type: Bricolage Grotesque (500, 600, 700) for the brand, the pane titles and the current item's title; Instrument Sans (400, 500, 600, 700) for everything else; DM Mono (400, 500) for times and the clock. Body 15px/1.45. All three are self-hosted woff2 files with their OFL licences; the render loads Google Fonts, the product uses no font CDN.
- Colour: neutral cool grey and one green accent. The accent is spent on four things only: the tinted points worth saying now (and their "Worth saying" or "Say" tag), the current dot, the Live pill and the ticks. The "You" and "Client" labels, the state words, the covered tags, "Ask first" and "Careful" are grey, so green means "worth saying" and nothing else.
- Radius: 10px for cards, panes and point boxes; 5px for tags, small labels and the caution; pills for chips and the state pill.
- Motion only for state changes, gated by
prefers-reduced-motion:pulse(2.4 s, the Live and Listening dots),settle(0.5 s, a new point),fade-away(the caution). - Contrast:
--ink-3is the smallest text colour (5.0:1 on the page and 4.7:1 on the hover surface in light; 5.5:1 and 4.5:1 in dark).--ink-4is for glyphs only (hollow marks, dashes, the listening dot, dotted underlines), never for text.
Removed from the live view (kept in the review view)
The signals grid; the stage strip and stage history; the separate concern panel; the next-best-move card as a panel of its own; the must-say checklist panel (its items are plan items now); the risk-flag box; every confidence number, threshold and legend; telemetry; Acknowledge and Dismiss; "shown since #i"; "rule: X hidden because Y"; #i references; the share-of-words bar; "say:".
Call plan contract
One object, the call plan, replaces the card, the must-say checklist and the stage on the live view. The engine builds it in step() (pure, INV-COPILOT-001) as Snapshot.plan; the Durable Object sends it in every decision (decision.d.plan); the live view renders it through the presenter; the review view's plan panel reads the same object. Stories: COPILOT-079 to COPILOT-091 (policy, engine, wire), COPILOT-092 (rate-pressure eval cases), COPILOT-093 and COPILOT-094 (presenter, chips), COPILOT-051 (the plan list) and COPILOT-049 (the review view's plan panel).
Shape
interface CallPlan {
decision_id: string | null; // `session_id:i`, set by the Durable Object on the wire (null inside the engine); binds a rewrite to the plan it was made for
purpose: { id: string; label: RepFacingText; source: 'jev' | 'override'; at: Evidence } | null; // null until a purpose locks
facts: FactChip[]; // filled slots only, in display order (group, importance, recency)
items: PlanItem[]; // this call's plan, in order
caution: Caution | null; // the newest undismissed risk flag that has a caution note
}
interface Evidence { i: number; t: number } // utterance index and its t_end in call seconds: the time link
interface PlanItem {
id: string; // template item id, `concern:<type>:<start_i>`, `rate:<start_i>` or `ask:<move id>` (one id per concern episode)
title: RepFacingText; // template title, concern headline, "Rate pressure" or a move headline
headline: RepFacingText | null; // rate pressure only: the situation's headline, the client's words ("Company X gives me a better rate.")
hint: RepFacingText | null; // why the item is on this plan; for rate pressure, the situation's hint
state: 'done' | 'current' | 'next' | 'later' | 'not_needed';
origin: 'plan' | 'client'; // 'client' = raised by the client (concern, rate pressure, client ask)
raised_at: Evidence | null; // the client turn that added it
done_at: Evidence | null; // the turn that met the last done condition, rep or client
not_needed_reason: RepFacingText | null;
ask_first: PlanPoint[]; // rate pressure only: qualify_the_quote's points when the situation has qualify_first, under one "Ask first" tag
points: PlanPoint[]; // approved lines, under one tag: "Say" on a rate-pressure item, "Worth saying" elsewhere
}
interface PlanPoint { line: RepFacingText; covered_at: Evidence | null; fresh: boolean } // fresh = covered on this decision
interface FactChip {
slot: string;
group: 'client' | 'transfer' | 'how_they_pay' | 'status';
label: RepFacingText; // approved value label, or an approved template with code-formatted params
at: Evidence; // the turn that established the value
updated_at: Evidence | null; // the turn that last replaced it
}
interface Caution { risk_id: string; note: RepFacingText; at: Evidence }
G's mockup data calls later "upcoming" and marks client items with origin: 'client'. Raised by the client is not a state of its own, because G shows client items as current ("Raised by the client 03:55"), later ("Client asked 01:12, to come") and done ("Client raised 00:03, done 03:47"). The tally and the words "Now", "Next" and "To come" are derived by the view.
RepFacingText gains one variant, for chips only: { kind: 'approved_template'; text_id; policy_version; params: Record<string, string> }, an approved template whose params are typed and formatted by code: an amount as digits with thousands separators (/^\d{1,3}(,\d{3})*$/), a currency as an ISO 4217 code from the policy's currency list; nothing else. Each param is checked when the chip is built and again in renderRepText. Everything else stays approved or a verified rewrite (INV-COPILOT-003).
Definitions (one each)
- Template. The plan starts from the template for the call's purpose (
otheruntil a purpose locks). A purpose change re-templates: items with the same id keep their state and evidence, done items the new template lacks stay listed as done, and client items stay. - Applies. Each template item has an
appliesrule in the checklist grammar (always,never,when_fact,when_amount_below_gbp,when: 'client_type') pluswhen_slotwithinornot_inover a fact slot's value. Unknown inputs keep an item applicable. A non-applying item that is not done isnot_needed, with its approved reason. - Done. An item is done when every condition in its
done_whenholds: a must-say id held, a persisted fact, a filled fact slot, its move suppressed by a must-say lock, or its pointsdelivered.done_atpoints at the utterance that satisfied the last condition, whichever side spoke it: "Understand the transfer" is done at the client answer that fixed amount and frequency (02:41), "Check we can serve them" at the client's bank answer (02:58), "Fund from your own account" at the rep turn that lockedsaid_fund_from_own_account(03:44). The row's time link jumps to that utterance. Done never reopens. - Delivered (done for an item without a must-say): either every one of its points is covered, or at least one is covered and the client turn that follows does not reopen the topic (no concern of that item's topic opens). One covered point followed by a client concern on the same topic keeps the item current.
- Current. In order: an inserted concern or rate-pressure item; else the item whose move is the card's current move (the
card.tsranking after its hysteresis); else the first open item whosestagesinclude Jev's current stage; else the previous current if it is still open; else the first open item. A challenger must lead for two decisions in a row (thresholds.confirm_updates) before it replaces an open current; an insert, and the current item becoming done, switch at once. At most one change of the current item per decision (a switch, an insert or a close); done and not-needed marks on other rows may land on the same decision, because those rows recede. - Next. The plan item after the current item, skipping done and not-needed items. When a client item is current, the item it interrupted is next regardless of position, and it reopens as current when the client item closes. The ranking chooses the current item; it does not choose next.
- Inserted items. Concern: on the decision a concern of a known type opens (not dismissed), with one item per concern episode (a concern raised again after it closed is a new item); done when it clears or expires. Rate pressure: the rate questions are asked only while such a concern is open, so the rate episode opens one decision after the concern; the concern item then turns into the rate item in place (same position, same "raised" time), and a change of situation changes it in place. Client ask: a policy
asksrule (a client-turn fact such asclient_asked_about_access, mapped to a move) inserts that move as a later item after the current one.
Plan templates (policy data)
policy/src/plan-<scenario>.json (plan-onboarding.json, plan-customer-success.json), built into the bundle as Policy.plan and hashed under weights_hash: no Jev question depends on it, so an edit is absorbed by recompute with zero Jev calls. It holds:
purpose_question,purposes[](anidand an approvedlabeleach),purpose_min_conf,purpose_max_decisions;templates: { <purpose id>: item[] }, whereitem = { id, title, hint, applies, not_needed_reason, points: { move, lines? }, stages[], done_when[] }(title, hint and reason are approved text);asks[]({ fact, move }, each fact a client-persisted signal),rate_pressure(the approved title "Rate pressure", theask_firstsituationqualify_the_quoteand theconcessionsituationconcession_terms; everything else comes from the situations in the playbook), andfact_slots[](client fact values; thecurrenciesandcountrieslists live in the scenario bank next to the questions that use them, sobank_hashcovers them).
Template content decisions from Stevan's review (2026-09-25):
- Lists vary by call purpose, so every purpose has its own template and
otheris short. A transfer-update or rate-move call gets no onboarding items. - Quote lifetime is not an onboarding item. Payment reason stays (CT needs to know what the transfers are for). Rate transparency stays for now, without leaning on rate.
- RM contact becomes "who looks after you" or is dropped: the onboarding team manages new clients for 100 days after their first trade, then customer success takes over.
- The list has to earn the reps' trust: fewer items, the ones the transcripts show really matter.
Seed templates (drafts for the red-pen; G's data.js draws both):
- Onboarding, first call after sign-up: Set the agenda; Understand the transfer; Check we can serve them; Fund from your own account; Who holds the money; When the money arrives; Booking is binding; Documents still needed (when documents are outstanding); Minimum transfer (when the amount is below the minimum); First transfer and next step.
- Customer success, transfer issue: Confirm the caller is on the account; Resolve the issue; Status with specifics; Upcoming payments; Tools that fit (alert, forward, recurring); Explain the document request (when one was asked for); Agree the next step.
- The other purposes (document chase, first-transfer walkthrough, status update, rate or hedging, recurring or batch setup, dormant re-engagement, other) are shorter.
Call purpose
One Jev Choice per call over a closed taxonomy per scenario, asked on the early decision points until it locks:
- onboarding:
first_call_after_signup,document_chase,first_transfer_walkthrough,other; - customer success:
transfer_issue,status_update,rate_or_hedging,recurring_or_batch_setup,dormant_reengagement,other.
The Choice lives in the scenario bank, so its answers are cached under bank_hash. It locks on the first answer at or above purpose_min_conf (with its confidence and evidence), never changes from Jev after that, and stops being asked once locked or after purpose_max_decisions decisions. A manual override (set_purpose, from the purpose control or the review view) replaces it, is audited, survives reconnects and seeks, and is never replaced by Jev.
Client fact values (extraction contract)
- Slots are a closed set, defined in
plan-<scenario>.jsonfact_slots[]as{ id, group, importance, source, trigger, known_fact?, labels or template }. About 12:client_type,residency,currency_pair(two legs),amount,frequency,transfer_purpose,timing,funding_account,decision_maker,documents_status,current_provider,rate_expectation. - Values are never generated text. Either a closed-set Choice picks the value (
fact_<slot>in the scenario bank: client type, each currency leg over the bank'scurrencieslist of ISO 4217 codes, frequency, what the transfers are for, timing buckets, funding account, who books or decides, documents status, current provider, rate expectation), or code extracts candidate spans and Jev picks one:span_amountover theextractAmountscandidates (src/engine/amount.ts) andspan_residencyover country names from the bank'scountriesgazetteer (each country with an approved label). The bank holds each span question with anoneoption; the request adds the candidatesc0toc5as they appear in the post-budget window (the state that is hashed) and names the questionspan_<slot>@<8 hex of the candidate list's sha256>, so a cached answer is only ever reused for the same candidates. - When asked: only on client turns, and only when the slot's trigger matches the latest utterance (an amount, a currency, a country, a keyword list) or right after the slot's
*_knownfact persisted while the slot is empty; at most 3 value questions per request, by importance; a triggered slot left out by the cap stays pending and is asked on the next client turn while its evidence is still in the window. The token budget drops them first. - Update, never append. A slot holds one value
{ value, conf, i, t, updated_at? }. A later confident answer with a different value replaces it and setsupdated_at; the same value keeps its first evidence.client_typealso setsSessionState.client_type, so the{ when: 'client_type' }rules start to work. - Chips:
factChips()returns the filled slots in display order; the label is the value's approved label (a country's approved label for residency), or the slot's approved template filled with typed params (an amount, ISO 4217 codes). The view caps the row at 8 (§Live view, client facts chips).
Rate pressure
From Stevan's rate-objection playbook (docs/source/ct-rate-objection.txt), encoded on 2026-09-25 as described in docs/policy/rate-objections.md (the id map): never defend the rate; change what is being compared.
- Situations are the points source. The rate moves (
handle_rate_objection,handle_alternative_provider,show_the_rate_against_mid_marketin onboarding;handle_rate_complaintin customer success) hold their approved lines insituations[]:qualify_the_quote,no_quote_in_hand,competitor_named,serial_shopper,mid_market_comparison,bank_matchedandconcession_terms, each with aheadline(the client's words),hint(the playbook's delivery guidance),points,do_not,do_not_flags,qualify_firstandcore_text_id. Their old flat lines areretired. The card and the plan take points from the situation chosen for the call (by the rate episode below, falling back to the move's approved flat lines) (COPILOT-083). - Detection.
rate_objection_type(a Choice over the five situations andnone) with the client nouls (client_asked_for_better_rate,client_named_competitor_rate,client_cited_mid_market,client_says_bank_matched,client_shops_every_trade), the rep nouls (rep_qualified_the_quote,rep_closed_with_live_quote,rep_obtained_something_for_concession),rate_pressure_raised/rate_pressure_handledand the five do-not nouls below form the rate question group: 16 questions sent only while arate,feesoralternative_providerconcern is open or a rate-pressure item is current (COPILOT-081; the drop order stays the backstop). A rate episode opens on a confident answer other thannonewhile such a concern is open (so one decision after the concern opened), re-types in place when a later confident answer differs (no quote in hand, then a competitor is named), and closes with the concern (COPILOT-086). Asking the five do-not nouls only inside the group is Stevan's call to confirm (PRD §7 Q19): outside a rate concern an unprompted concession goes unflagged. - The item. Title "Rate pressure"; headline = the situation's headline in the client's words ("Company X gives me a better rate."); hint = the situation's hint; then, when the situation has
qualify_first: true(todaycompetitor_named), one "Ask first" tag overqualify_the_quote's four questions ("When did you get that rate?", "Was that a live quote on your amount, or a headline off a website?", "What were the fees on top, including the receiving bank's cut?", "What actually landed in the beneficiary account last time, against what you were quoted?"); then one "Say" tag over the situation's approved points, verbatim:no_quote_in_hand: "Better than what?", "No. That's the price.", "And it's live, so it's moving while we talk. Do you want it, or shall I requote in ten?";competitor_named: "They probably do on the first one. What matters is what you're getting on trade twenty, when nobody's watching.", "Pull your last three confirmations from them and let's compare what landed, not what was quoted.";serial_shopper: "Happy to quote you every time. If you want a better number without the ring-around, give me the flow for the quarter and I'll price it properly.";mid_market_comparison: "That's the mid-market rate. It's the midpoint between buying and selling, so it's a reference number rather than a price. Nobody trades at it, banks included.", "The number worth comparing is what actually lands from us against what actually lands from your bank, same day, same amount.";bank_matched: "Good. That's the first time they've sharpened up for you. Ask them to hold it in writing for the next twelve months and see what comes back.", "Worth looking at what they were giving you before we started quoting. That's their real price, the one that applies when nobody's pushing." When a price move is on the table (a rep turn in the episode withrep_obtained_something_for_concessionorrep_improved_rate_unaskedat or abovesignal_on), the approved points ofconcession_termsjoin the "Say" block (today only its closing line is approved; points 1 to 4 are drafts).
- The do-nots are cautions. The five risk flags (
rep_improved_rate_unasked,rep_explained_spread,rep_said_standard_for_volume,rep_rubbished_competitor,rep_went_under_bank) latch on rep turns; each has a caution note shown under its turn with the single "Careful" tag. The encoded draft notes start with "Careful:"; that prefix is removed, because the tag says it once (COPILOT-082). The concession check isrep_improved_rate_unasked(moved on price without getting something back) withrep_obtained_something_for_concessionas its counterpart. - Open rulings are listed in
docs/policy/rate-objections.md"Needs Stevan's ruling" (rate transparency, core lines,qualify_firstforbank_matched, headline length, concession terms, alerts, retirements, the reading ofrep_improved_rate_unasked, pricing authority).
Per-point said detection
One noul per visible point: covered::<text_id> ("In the rep's turns in recent_transcript, did the rep make this point, in any wording?" with the approved line), generated from the playbook (flat lines and situation points alike) like phrasing:: and cached with the move answers under playbook_hash. It is asked only on rep-turn decisions, for the uncovered points of the current item (the "Ask first" questions first) and, for the 4 decisions after a switch, of the item the view may still be showing during its dwell; at most 3 per request, least recently asked first, so a point left out on one turn is judged on the next while its turn is still in the window. A point is covered on the first decision at or above persist_fact, and stays covered.
Rep-facing text
Every string the live view shows from policy is approved text with a text_id, red-penned and loader-checked like the playbook lines: move headlines (headline.<move id>, the client's words, 8 words or fewer), concern headlines (concern.<topic>.headline), situation headlines and hints (situation.<id>.headline, situation.<id>.hint), caution notes per risk rule (caution.<risk id>, "That sounded like a rate prediction."), purpose labels, template titles, hints and not-needed reasons, the rate-pressure title, and fact labels and templates (plan.…). Lines and situation points keep their own status (draft, approved or retired; a retired line never reaches a bundle). The loader hard-rejects any of them that is not approved, exactly as it does lines (INV-COPILOT-003). No approved text starts with a prefix the view already shows as a tag ("say:", "Say", "Worth saying", "Ask first", "Careful"); the builder refuses one that does, and the encoded draft caution notes lose their leading "Careful:" (COPILOT-082).
What exists in src/engine today, and what is new
| Piece | Exists today | New (story) |
|---|---|---|
| Approved lines | CardState.lines (RepFacingText), card.ts linesOf (up to 3 per move) |
points per plan item (088); a retired line status (079) |
| Situations | encoded in policy/src/playbook-*.json (7 rate situations with headline, hint, points, do_not, do_not_flags, qualify_first, core_text_id; docs/policy/rate-objections.md), not read by the engine |
situations as the points source for the card and the plan (083) |
| Question set | the whole bank on every decision (the rate questions push the worst onboarding request to 13,596 estimated tokens) | conditional question groups (081) |
| Applicability | ChecklistItem.applies and hero.ts applies() (always, never, when_fact, when_amount_below_gbp, client_type) |
when_slot rules and not-needed reasons (087, 088) |
| Must-say locks | facts.ts persistFacts (said_* on rep turns, with i); ChecklistEntry said / uncertain / unsaid / na |
done_at from the last lock an item needs (088) |
| Current move | card.ts allowedMoves and pickCard hysteresis (switch_margin, confirm_updates, card_cooldown_utts) |
the current item from move, stage and inserts; one change per decision (088) |
| Concerns | concern.ts open, re-raise, clear, expire; ConcernState.start_i; ConcernEpisode |
the inserted concern item (088); the rate episode from rate_objection_type is rate.ts SessionState.rate and rateView (086, done) |
| Stage | stage.ts StageState.current with hysteresis |
the stage as a tie-break for the current item (088) |
| Risk flags | risk.ts latched RiskFlag { id, i, t, value, label }; the five rate do-not flags with draft live_notes (encoded) |
approved caution notes (082), plan.caution (090, done) |
| Client facts | persisted *_known facts; client_type (unknown unless obvious); amount.ts extractAmounts and gbpEquivalent |
fact slot values and chips (085) |
| Purpose | none | the purpose Choice, lock and override (084, 091) |
| Per-point coverage | none (coverage is per must-say id) | covered::<text_id> nouls and delivered (089, done: plan.ts coveredToAsk, PlanMemory.covered) |
| The plan | none (one card at a time) | templates (087, done: policy data only), the state machine (088, done: plan.ts updatePlan, SessionState.plan), Snapshot.plan (090, done: step() calls updatePlan after pickCard; recompute replays purpose events), decision.d.plan and set_purpose (091) |
| Timing | the engine has no clock | the presenter in the page times dwell, holds and cautions from the call clock (093) |
Review view (variant D)
For Stevan now and the team later: replay, audit and curation. Mockups: mockups/D-review-{onboarding,cs}-{light,dark}.png. Source: review.html?s=onboarding|cs. It is variant C's layout and panels (§Layout, §Panels, §States) with plain-English labels, the risk flags as a calm panel of their own, the mark form docked under the transcript, and one new panel: What the rep saw. Kept as approved with D; the one change with G is that the must-say checklist panel is now The plan and reads from the same call-plan object as the live view (§Call plan contract). Numbers and #i links belong here. The live view (§Live view) links here with "Review view" and this view links back with "Live view"; both are routes.
Layout
| Column | Span | Onboarding (top to bottom) | Customer success (top to bottom) |
|---|---|---|---|
| Spine | 4 | Transcript (sticky; with the mark panel it fills calc(100vh - 128px), min 640px; scrolls internally) then Mark a moment |
Same |
| Centre | 5 | What the rep saw, The plan, Risk flags | What the rep saw, The plan, Issue resolution, Risk flags |
| Rail | 3 | Call stage, Concerns, Signals, Jev usage | Same |
Top bar: brand, view switch (Review view current, Live view a route), call picker, scenario badge, Upload call, connection. The player bar is the full §Player (decision and risk ticks), with the source label "call recording". The debug drawer, alerts and upload states are unchanged (§States).
Panels (changes against C)
- Transcript: a "Share of words" label on the rep / client bar. Rows keep
#i, decision markers, risk marks, and add "✓ covered a point". The footer shows "◆ Jev checked this line" and the selection count (or "Click a line, Shift-click another to select"). - Mark a moment: docked under the transcript, so it sits next to the selected lines. Resting: one line of instructions and a disabled Mark moment with "Select lines first". Open: the meta is "#37 to #38, 2 lines". "How was it?" offers Good example / Acceptable / Avoid (it still sends
model/acceptable/avoid), then "What kind of moment" (the tag) and "Note (no names or numbers)". Samemark_momentmessage and error handling as §Mark moment. - What the rep saw: the plan's current item as the rep saw it at the clock, reconstructed by running the live view's presenter over the decision history (labelled "As shown to the rep (reconstructed from the decisions)", because browser arrival times are not recorded) and drawn by the live view's own renderer at a compact size (20px title, 14px points, the "Worth saying" tag), plus the caution if it was showing. Under it: "How sure Jev is 0.58 (a card shows from 0.35)" and "Held back:
Agree the next step, until the fees concern closes" (the plain-English form ofsuppressed[0]). Then How the plan changed, newest first: time,#i, event (item opened, point covered, item done, client item added, purpose set, caution shown, concern raised again, concern closed). This replaces C's rule line, Acknowledge / Dismiss and "shown since". - The plan (was Must-say checklist): reads
decision.plan, the same object as the live view. Meta: "4 done, 5 to come, 1 not needed". The purpose, with "set by you" when it was overridden. Every item in plan order with its state word and mark (never colour alone),#ilinks for the turn that raised it and the turn that did it, and, for items that carry a must-say, the checklist value fromdecision.checklist: said, unsure 0.52, not yet. Not-needed items give the reason in words ("Only when documents are outstanding"). Items with a must-say keep Mark as false alarm (flag_false_positive{kind:'must_say'}). - Risk flags (own panel, no red box): ▲ and the name in
--risk,#i, the quote, then "Jev's score 0.71. Shown to the rep once as a caution." and Mark as false alarm. A false alarm is greyed and struck through, with "Marked as a false alarm." Empty: "No risk flags in this call." - Issue resolution (customer success): steps no issue / issue raised / owned by rep / resolved; "call health, 0 to 1"; "smoothed" instead of EMA; the chart's x-axis reads "each check".
- Call stage: "Call stage (Jev's best guess)", meta "how sure 0.71", and the note "Reps do not see the stage: the live plan shows where the call is by position."
- Concerns (replaces Open concern): every concern in the call, newest first. Each has a name and an open / closed chip, then "Raised at #34, again at #39. Topic: money. How sure: 0.66". Open concerns add "Closes when the client accepts, is ready to book or agrees a next step. It also closes after 8 of Jev's checks without coming up again." and Mark as false alarm. Closed: "#12 to #15. Closed: the client accepted the answer." (or "Closed: it did not come up again for 8 checks." when it expired). The approved lines are on the plan item, not repeated here.
- Signals: a legend in words ("Signal strength is how sure Jev is that the signal is there, from 0 to 1. On at 0.60 and above, unsure from 0.30 to 0.70.") and keys for on / unsure / off / no answer / kept for the rest of the call. An unknown value reads "no answer" (C showed a dash).
- Jev usage (was Jev telemetry): labels in the table below.
Label changes
| Before (C) | After (review view) |
|---|---|
hero · onboarding |
onboarding call |
5 / 9 must-say items said · k uncertain · m not applicable |
The plan: 4 done, 5 to come, 1 not needed; must-say values on their items |
uncertain 0.52 / unsaid / n/a |
unsure 0.52 / not yet / not needed |
amount ≥ £5k mentioned at #41 |
Until an amount of £5,000 or more is mentioned |
▲ Risk flags · 1, value 0.71 |
Risk flags, meta 1 in this call, Jev's score 0.71 |
flag false positive |
Mark as false alarm |
Call stage, confidence 0.71 |
Call stage (Jev's best guess), how sure 0.71 |
Open concern, bucket: money, since #34 · 3 decisions ago |
Concerns, Topic: money, Raised at #34, again at #39 |
clears when: The client accepts the comparison, or asks to see a quote. |
Closes when the client accepts, is ready to book or agrees a next step. It also closes after 8 of Jev's checks without coming up again. |
Next best move, confidence 0.58 ≥ 0.35 |
What the rep saw, How sure Jev is 0.58 (a card shows from 0.35) |
say: prefix |
none (the lead point is first) |
rule: Agree the next step hidden because a concern is open (#34) |
Held back: Agree the next step, until the fees concern closes |
Acknowledge / Dismiss / shown since #41 |
removed; How the plan changed lists events with #i |
listening… (leaning X) |
live: Listening at the transcript foot; review: How the plan changed shows the leaning |
Signals, lit ≥ 0.60 · uncertain 0.30 to 0.70 |
Signals with the legend in words |
| a dash (unknown signal) | no answer |
lock icon persisted |
kept for the rest of the call |
Jev telemetry, 26 requests |
Jev usage, 26 checks so far |
latency mean / p95 |
Response time, average / slowest 1 in 20 |
tokens last in / out, tokens total |
Last check, tokens sent / received, Tokens so far |
budget: 0 questions dropped, cache hits 12 / 26, cost (cumulative) |
Questions dropped to fit the size limit, Answers reused 12 of 26, Cost so far |
last request / response |
Show the last request and answer |
talk-share rep 64% / client 36% |
Share of words rep 61% / client 39% |
| decision-point diamond (unexplained) | ◆ Jev checked this line legend |
Resolution, none → reported → owned → resolved, EMA |
Issue resolution, no issue / issue raised / owned by rep / resolved, smoothed |
Mark moment verdicts model / acceptable / avoid |
Good example / Acceptable / Avoid (same values sent) |
n of N utterances |
n of N lines |
Tokens
Live view tokens (G)
Canonical file: docs/design/shotgun/G/live.css (its :root blocks). These replace D's tokens for the live view. COPILOT-045 copies them into web/app.css unchanged, scoped to [data-view="live"], with the same three blocks: light on the root, dark under @media (prefers-color-scheme: dark) guarded by :not([data-theme="light"]), and dark again under [data-theme="dark"].
/* light */
[data-view="live"] {
--bg: #f2f3f5; --surface: #fbfbfc; --surface-2: #e9ebef;
--ink: #15181d; --ink-2: #4b5159; --ink-3: #62696f; --ink-4: #8a9099;
--line: #dcdfe4; --line-2: #eaecf0;
--accent: #1f7a56; --accent-ink: #176046; --accent-tint: #e2f1e8; --accent-tint-2: #eef6f1; --accent-line: #b9dcc9;
--shadow: 0 1px 2px rgba(21, 24, 29, .05), 0 12px 32px -16px rgba(21, 24, 29, .22);
--r: 10px; --r-s: 5px;
--font-display: "Bricolage Grotesque", "Instrument Sans", sans-serif;
--font-ui: "Instrument Sans", system-ui, sans-serif;
--font-mono: "DM Mono", ui-monospace, monospace;
}
/* dark: under @media (prefers-color-scheme: dark) for :root:not([data-theme="light"]), and under :root[data-theme="dark"] */
[data-view="live"] {
--bg: #101215; --surface: #181b20; --surface-2: #21252b;
--ink: #e8eaed; --ink-2: #aeb4bc; --ink-3: #858c96; --ink-4: #565d67;
--line: #2a2f37; --line-2: #23282f;
--accent: #5fc795; --accent-ink: #8ddbb6; --accent-tint: #16291f; --accent-tint-2: #141f1a; --accent-line: #245c43;
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 32px -16px rgba(0, 0, 0, .6);
}
Type scale (live view):
| Use | Font and size |
|---|---|
| Brand | Bricolage Grotesque 700, 16px |
| Pane titles ("Transcript", "Plan for this call") | Bricolage Grotesque 600, 20px, -0.01em |
| The current item's title | Bricolage Grotesque 600, 23px/1.2, -0.012em |
| Points worth saying | Instrument Sans 400, 19px/1.38 |
| Plan row titles | Instrument Sans 500 (done, next) or 400 (later, not needed), 15.5px |
| Body | Instrument Sans 400, 15px/1.45 |
| Transcript text | Instrument Sans 400, 14.5px/1.42 |
| Hints, purpose line | Instrument Sans 400, 14px |
| Tally, sub-heads, caution | Instrument Sans 13px |
| State words, chips, covered tags | Instrument Sans 12.5px |
| Tags ("Worth saying", "Say", "Ask first", "Careful"), speaker labels, group label | Instrument Sans 700, 11 to 12px, uppercase, +0.06em |
| Times | DM Mono 12px (rows, transcript); the clock 15px |
Review view tokens (C and D)
Canonical file: docs/design/mockups/src/tokens.css. COPILOT-045 copies it into web/app.css unchanged, scoped to [data-view="review"]. Fonts: IBM Plex Sans (400/500/600), IBM Plex Mono (400/500) and IBM Plex Sans Condensed (500/600, panel labels only). They are self-hosted woff2 (SIL OFL 1.1, mockups/src/fonts/OFL.txt), with no CDN.
Colour
:root { /* light: "paper" */
--bg: #F3F1EC; --surface: #FBFAF7; --surface-2: #EEEBE4; --surface-3: #E4E0D7;
--line: #DCD7CD; --line-strong: #BFB8AA;
--ink: #1C1B18; --ink-2: #4A4740; --ink-3: #78736A;
--accent: #2447A8; --accent-ink: #FFFFFF; --accent-bg: #E4EAF8; --focus: #2447A8;
--said: #1D7447; --said-bg: #E1F0E6;
--uncertain: #9A5800; --uncertain-bg: #F8EAD3;
--risk: #B42318; --risk-bg: #FAE5E1;
--rep: #2447A8; --client: #0E6E66; --selection-bg: #DDE5F7;
--shadow-overlay: 0 12px 32px #1C1B1829; --scrim: #1C1B1873;
}
@media (prefers-color-scheme: dark) { :root { /* dark: "instrument" */
--bg: #0E1012; --surface: #15181B; --surface-2: #1B1F23; --surface-3: #242A2F;
--line: #262B30; --line-strong: #394048;
--ink: #E8E6E0; --ink-2: #ABA89F; --ink-3: #75726B;
--accent: #88A6FF; --accent-ink: #0E1012; --accent-bg: #1B2540; --focus: #88A6FF;
--said: #52C28C; --said-bg: #13291E;
--uncertain: #E9A546; --uncertain-bg: #2E2312;
--risk: #F27562; --risk-bg: #331816;
--rep: #88A6FF; --client: #52C4B6; --selection-bg: #1F2A45;
--shadow-overlay: 0 12px 32px #000000A6; --scrim: #000000A6;
} }
Colour meanings: --said for said / lit / done / resolution progress; --uncertain for the 0.30 to 0.70 band, catching-up, drift and budget warnings; --risk for risk flags, credits-empty, failures and destructive buttons; --accent for links (#i refs), the current utterance, selection and primary buttons; --rep and --client for speaker labels and the talk-share bar. The CS chart draws health in --ink (solid, 1.8px) and the EMA in --accent (dashed 4/3, 1.5px). Chart.js reads these through getComputedStyle and re-reads them on a prefers-color-scheme change.
Type scale
| Token | Size | Use |
|---|---|---|
--fs-label |
11px, Plex Condensed 600, uppercase, +0.07em | panel labels, subheads, speaker tags |
--fs-meta |
12px (Mono for values) | timestamps, ids, values, chips, confidence |
--fs-body-sm |
13px | transcript rows, list rows, alerts |
--fs-body |
14px | card lines, body, form fields |
--fs-title |
16px / 600 | card title, concern type, current stage, dialog title |
--fs-head |
20px Mono 600 | CS health value |
--fs-hero |
32px / 600, −0.02em | completeness x / y |
Line heights: --lh-tight 1.25 (titles), --lh-body 1.45. font-feature-settings: "tnum" is set on body.
Spacing, radii, layout constants
Spacing is on a 4px base: --sp-1 2 · --sp-2 4 · --sp-3 8 · --sp-4 12 · --sp-5 16 · --sp-6 24 · --sp-7 32. Panel padding is 12px top and 16px sides / bottom. List rows are 5 to 7px vertical with a 1px --line rule between rows.
Radii: --r-chip 2px (chips, badges, tally cells) · --r-control 4px (buttons, inputs, lines, risk box) · --r-panel 6px (panels, alerts, dialog).
Grid: --grid-cols 12 · --grid-gap 12px · --page-pad 16px · --page-max 1680px.
Layout (review view)
A 12-column grid with a 12px gap and 16px page padding, max width 1680px. Designed at 1440 to 1600px wide, which is Stevan's laptop. Below 1200px the three columns collapse to two (the spine plus a single stack of centre and rail); below 800px they stack to one column. Every variant has the same two full-width rows on top:
- Top bar: brand · call picker · scenario badge · (spacer) · Upload call · connection status.
- Player bar: play/pause · clock
mm:ss / mm:ss· scrubber (decision-point ticks; risk ticks in--risk) · speed segmented control 0.5×/1×/2×/5×/10×/20× · catching-up badge (only whilepending > 0) · clock source (audio · mp3orvirtual).
Below them, variant C (Transcript spine) uses the layout below. The review view keeps it (§Review view); the live view has its own layout (§Live view).
| Column | Span | Onboarding (top → bottom) | Customer success (top → bottom) |
|---|---|---|---|
| Spine | 4 | Transcript (sticky, height: calc(100vh - 128px), min 560px, scrolls internally) |
Transcript |
| Centre | 5 | Hero-onboarding (checklist + risk flags) → Next best move → Mark moment form (when open) | Hero-CS (resolution → health → what moved it) → Next best move → Mark moment form (when open) |
| Rail | 3 | Stage strip → Open concern → Signals (1 column) → Jev telemetry | Stage strip → Open concern → Signals → Jev telemetry |
Alerts (credits-empty, model-drift, call-replaced, budget-skipped) render as a full-width row between the player bar and the grid. The debug drawer docks full-width at the bottom of the page. The upload panel opens as a full-width row under the top bar.
Panels (review view)
Each subsection gives the content, the data source, and the controls with what each one sends. The exact visuals are in mockups/C-*.png. Components live in components.css. These are variant C's panels. The review view keeps them with the label changes in §Review view; the live view uses none of them (its transcript has its own form, §Live view).
Player
<audio> (/api/calls/:id/audio, Range) or a virtual clock. The play button is a 32px round --ink button with the icon in --surface. The clock is Mono 13px. The scrubber is a 3px rail with an --ink fill, a 13px head, 6px decision-point ticks and 8px --risk ticks at flagged utterances. Speed is a segmented control where the pressed segment is --ink on --surface. Above 4× the audio is muted and the virtual clock drives, and the source label switches to virtual. The scenario badge is uppercase Condensed on --surface-2. Scrubbing sends seek{i}; the clock sends utterance{i}. The catching-up badge is in §States.
Upload
Opens from Upload call in the top bar as a full-width panel titled "Upload call", with the meta line showing accepted types from /api/health upload_types (today "mp3 only"). It shows the filename + x / y MB (Mono), a 6px progress bar in --accent, and a step list: uploading → transcribing → ingesting (● current, ✓ done in --said, ✕ failed in --risk). The final status drives the panel: ready → Open replay (selects the call in the picker); processing → the call is listed but disabled in the picker; needs_review / needs_scenario → see §States. PUT /api/calls/upload (XHR progress), POST …/transcribe, then POST …/ingest.
Transcript
Header: "Transcript" plus n of N utterances. A talk-share bar sits under the header: rep % in --rep, client % in --client, a 6px bar. The rep-monologue warning (> 70 words) is an amber strip under the bar. Each row is a grid 38px | 12px | 1fr: Mono 11px time · decision-point diamond (--ink-3, only on decision points) · REP/CLIENT Condensed tag in the speaker colour + text (13px, textContent only) + #i in Mono --ink-3. Backchannels are 12px --ink-3 with tighter padding. Future rows (after the clock) are --ink-3. The current row has an --accent-bg fill and a 3px --accent left border and follows decision.i. Risk rows get an inline ▲ rate prediction in --risk, and marked rows get a ● marked pin in --accent. Clicking any #i elsewhere calls scrollToUtterance(i).
Hero-onboarding
In the review view this panel is The plan plus the Risk flags panel (§Review view); the icon, tally and flag-row details below still apply.
Label "Must-say checklist", meta "hero · onboarding". Top: completeness 5 / 9 (32px, denominator in --ink-3) with "must-say items said", "k uncertain · m not applicable" beside it, and the discrete tally under it (said = solid --said, uncertain = dashed --uncertain on --uncertain-bg, unsaid = --surface-3; n/a items are not counted). List rows 16px | name | state label (+value) | #i, ordered said → uncertain → unsaid → n/a, with the n/a reason in 11px (amount ≥ £5k mentioned at #41). Risk flags sit below in a bordered box (--risk border, --risk-bg fill) titled ▲ Risk flags · N. Each flag row shows name · #i · value · flag false positive, with the quote on the next line. The per-item and per-flag flag false positive link sends flag_false_positive{kind, id, i}. When there are no flags, a neutral box says "No risk flags in this call."
Hero-CS
Label "Resolution", meta "hero · customer success". A four-step track none → reported → owned → resolved: done nodes are filled --said with ✓, the current node is a --said ring with a halo, pending nodes are a hollow ring with "not yet". Under each name: the time (Mono) and #i. Then the health line: value 0.64 (20px Mono), "health · EMA 0.56 · last change +0.06", legend at right, and a 120px chart (Chart.js in the build) with gridlines at 0.0 / 0.5 / 1.0, health solid, EMA dashed, and a dotted --said marker labelled at each resolution transition. Then "What moved it": ≤ 3 rows ordered by |Δ|, name | #i | ±Δ (up --said, down --risk).
Stage strip
Review view only ("Call stage", §Review view).
Label "Call stage", meta confidence 0.71. The current stage is shown large (16px/600) with "since #i", then the history as chips separated by ›. Past chips are --ink-2 on --surface-2; the current chip has an --ink border on --surface.
Open concern
Review view only ("Concerns", §Review view); in the live view a concern is an inserted plan item.
Label "Open concern", meta confidence. Type (16px/600) or the parent bucket, then bucket: money and a since #i · n decisions ago / clears when … key-value list. Next come the topic's approved lines (same style as card lines, via renderRepText) and flag false positive (flag_false_positive{kind:'concern'}). Empty: "No open concern." plus "Last cleared:
Next best move
Superseded: the live view's current plan item (§Live view) and the review view's What the rep saw replace this panel. The rewrite slot and the gated state below still apply.
Label "Next best move", meta confidence 0.58 ≥ 0.35. The body has a 3px --said left rule: title (16px/600), what (13px --ink-2), and ≤ 3 lines on --surface-2. The highlighted line has a --said-bg fill, a --said border and a bold say: prefix. A verified rewrite renders as a fourth, dashed line labelled "tailored (verified)". Only {kind:'rewrite', verified:true} renders. Below: rule: <s>Move</s> hidden because <reason> in 12px --ink-3. Footer: Acknowledge (ack_card), Dismiss (dismiss_card), and shown since #i. The gated state is in §States.
Signals
Label "Signals", meta lit ≥ 0.60 · uncertain 0.30 to 0.70. A single column in the rail (two columns when a variant gives it ≥ 5 grid columns). Each row is lamp | name | [lock] value. The lock icon marks persisted facts. Score signals show their level (high / medium / low) instead of a number. Lamps are described in the constraints above.
Jev telemetry
Label "Jev telemetry", meta N requests. A two-column key/value grid: the model on its own line (jev-1.13.0 = expected, or the drift form in §States); latency mean / p95 ms; tokens last in / out; tokens total; cost (cumulative); budget (questions dropped); cache hits hits / decisions. At the bottom, the link last request / response opens the debug drawer. It is disabled with the title "open with ?debug=1" when the session was not loaded with debug:true.
Debug drawer
Only rendered when the page has ?debug=1 (load{debug:true}). It is full-width, docked at the bottom, with a 2px --ink top rule. Tabs: Last request · Last response · Weights · Rewrites (selected tab has an --ink underline). Request and response are pretty-printed JSON in Mono 11.5px on --surface-2 (keys --accent, strings --client, numbers --uncertain). Weights holds the thresholds form → set_weights → the recomputed diff and state drift: N of M decisions changed. Rewrites lists dropped candidates struck through, with the verifier reason and value in --risk Mono.
Mark moment
Opens in the centre column under the card, or with m when a span is selected. The meta line shows the span (#35 to #38 · 4 utterances). Fields: verdict as a segmented radio model | acceptable | avoid (selected segment --ink), tag (a select of moment kinds + free text), note (textarea). Mark moment sends mark_moment{start_i, end_i, verdict, tag, note}; Cancel / Esc closes it. On success an --ink toast shows "Marked #35 to #38 as avoid · risk" and the rows get a pin. error{note_rejected} gives the note a --risk border and the inline text "Remove names or numbers from the note."
States
All drawn in mockups/states-{light,dark}.png. Only the states listed here exist. Do not invent others.
Listening / gated card
Review view (What the rep saw). The live view shows "Listening" at the transcript foot instead (§Live view).
When next_move.confidence < 0.35, the card body becomes a dashed --line-strong box with a three-bar glyph and the text "listening… (leaning Explain that a booking is binding)". The meta line shows confidence 0.22 < 0.35, and a note says "No line is shown until the move is confident. Approved lines only." No lines, rule or buttons are shown.
Uncertain band
A value in 0.30 to 0.70 is never shown as off. Checklist rows use the half-filled icon + "uncertain 0.52" in --uncertain, and the tally cell is dashed. Signals use the dashed amber lamp with the value in --uncertain. Unknown (null) uses the dotted lamp and "no answer".
Catching-up badge
At high speed, while queue.pending > 0, an amber badge ● catching up (7 queued) sits after the speed control (--uncertain-bg fill, 1px --uncertain border). It disappears at 0.
Credits-empty alert
On alert{credits_empty}, a full-width --risk alert reads: "Jev credits are empty (402 · code 2021) The session stopped. Nothing after #i was judged; the last snapshot is held. Top up the Cloudflare account, then start a new session." The play button and speed control are disabled (grey play button). The last snapshot stays rendered.
Model-drift warning
On alert{model_drift} (once per session), an amber alert reads "Model drift: stub-1 answered, jev-1.13.0 expected Answers from a different model are not comparable with calibrated thresholds." with a Got it button that dismisses it. Telemetry keeps stub-1 ▲ expected jev-1.13.0 in --uncertain for the rest of the session.
Budget skipped
alert{budget_skipped} shows an amber note: "Decision #57 skipped: request over the 12,000-token cap. The previous snapshot is held; nothing is rendered as zero." A decision marked unknown keeps the previous panel values.
Call-replaced notice
error{call_replaced} shows a neutral (--surface-2) alert: "This call's transcript was replaced (rev 2 → 3) Decisions from this session are stale and playback is off. Reload to start a new session on the current transcript." Reload call starts a new session (new nonce, load without resume). Play is disabled.
Upload progress / failure / needs-scenario / needs-review
- Progress: filename,
8.4 / 14.1 MB,--accentbar, steps● uploading 60% · ○ transcribing · ○ ingesting. - Failure: the bar turns
--risk, and the failed step shows the server's message verbatim (✕ 415: the file is not a supported audio type …; 413 "convert to mp3"; 5xx). Choose another file. - Needs scenario: steps all ✓, text "transcribed and reviewed. Jev could not tell the scenario (confidence 0.48)." Hands over to the COPILOT-072 scenario panel. Until that ships, a plain status line.
- Needs review: "has N unresolved proper nouns. Nothing is replayable until they are reviewed." The call is listed but disabled in the picker. Hands over to the COPILOT-072 review panel.
Invalidation confirmation dialog
Used when the speaker swap (or a scenario replacement) returns 409 call_has_derived_rows. A modal alertdialog over the --scrim: 460px wide, --r-panel, --shadow-overlay. Title "Swap speakers and discard derived work?". The body gives the rev change and a count table (decisions, moments, marks, labels, answers; Mono counts), then "Open sessions stop and reconnect on the new transcript. This cannot be undone." Actions: Cancel (default focus) and the destructive Delete N rows and swap (--risk fill), which re-posts with force:true and then reconnects with a new nonce.
Transcript selection (mouse and keyboard)
Mouse: shift-click or drag selects start_i..end_i. Keyboard: focus a row (visible 2px --focus ring), then Shift+↓/↑ extends from the focused row. Selected rows get the --selection-bg fill with a 1px --accent edge on the first and last rows. A hint line under the transcript reads Shift + ↑ ↓ extend · m mark · Esc clear · 4 utterances selected (#35 to #38). m opens the Mark moment form; Esc clears.
Empty / loading
- Empty (no call selected): a centred panel with "Pick a call to replay", "Four sample calls are loaded. Upload an mp3 to add your own.", the call picker and Upload call.
- Loading (connecting / waiting for
hello): "Connecting to the session…" with three skeleton bars (--surface-2, 10px). No zeros are rendered before the firstdecision.
Variants
All share the tokens of their view, the components and the data. A, B and C changed only the layout and the hero form; D split the product into two screens; round 3 (E, F, G and H, docs/design/shotgun/) redesigned the live screen around a living call plan.
G: One list (approved 2026-09-25, the live view)
From the round-3 brief (shotgun/BRIEF.md), written after Stevan's verdict on D: the card floated with no anchor, the caution outlived its moment, the progress dots had no labels, and above all the screen had no overall structure for the call at hand. G puts the transcript full height on the left and, on the right, the plan as one list: the current item open in place, done items collapsed with a tick and the time, what is to come greyed, items not needed at the foot with a reason, and a client concern inserted where the call is. Plan and suggestions are one object. For: nothing floats, reading order is call order, every state is a word, and the one accent means "worth saying". Against: it needs new engine pieces (purpose, templates, per-point detection, fact values) and new approved policy text before it can run on real calls (§Call plan contract). Full rationale: shotgun/G/NOTES.md (a).
E, F and H (round 3, not chosen)
E, "Agenda spine": the plan as a vertical stepper on the left, the transcript in the centre. F, "Plan strip and focus": a horizontal plan strip across the top and one large focus card. H, "Teleprompter": a one-line breadcrumb, one centred card and a three-line transcript ticker. Mockups in docs/design/shotgun/{E,F,H}/.
D: Live view and review view (review view kept, live view superseded by G)
From Stevan's review of A, B and C (FEEDBACK-2026-09-25.md). The rep sees one card during the call: a short situation headline, 2 or 3 green points worth mentioning now, ticks as they are covered, one caution line when a risk fires, and a quiet context line and progress strip. Everything with a number, a threshold or a rule (signals, stage history, confidence, telemetry, the checklist, risk values, #i links, the mark form) moves to the review view, which is C's layout in plain English. The cadence rules keep the card from moving faster than a rep can read while talking. For: it answers "what should a rep look at?" with one thing, and it keeps the audit ledger for the person who audits. Against: the one card floated with no structure for the call, which is what round 3 fixed. Its review view is kept; its live view is replaced by G.
A: Reference
This is the reference demo's layout, kept literally: the transcript where the video was (7 cols), the hero where the probability chart was (below it), the coaching column on the right (5 cols: stage rail, card, 2-col signals, concern), and telemetry as a header strip. The onboarding hero is split: the completeness numeral on the left, the checklist on the right, risk flags full-width under both. For: zero learning cost against the demo. Against: the hero is the last thing on the page (below the fold at 1440×900), which inverts the point of replacing the probability. The header strip has no room for drift, budget or cache.
B: Hero band
A full-width stage rail, then a full-width hero band (onboarding: numeral · 5×2 grid of item tiles · risk box; CS: resolution track · health chart · what moved it), then three columns: transcript 5 · card + concern 4 · signals + telemetry 3. For: the strongest "the headline is compliance" read, good for demos. Against: the band pushes the transcript and card below the fold. The tiles reflow whenever conditional items change applicability. The #i references in the band point at a transcript that is off screen.
C: Transcript spine
The transcript as a viewport-tall evidence column on the left (4), the verdict (hero) and the next move in the centre (5), and a context rail on the right (3: stage, concern, signals, telemetry). For: every #i in the hero, flags, concern and moved list points at a row that is always on screen. The hero is the first thing in the centre column and the card sits directly under it. Span selection and the mark form sit next to the evidence, which is Stevan's M1 job (audit the engine, mark moments). Against: signals get one narrow column, and the rail scrolls on smaller laptops.
Approved variant
- status: approved
- variant: G (One list)
- date: 2026-09-25
- approval: accepted-recommendation 2026-09-25
- scope: G is the live view (§Live view, §Call plan contract; mockup source and tokens in
docs/design/shotgun/G/). The review view is D's review view (§Review view), unchanged except that its must-say checklist panel is now The plan, read from the same call-plan object. - supersedes: D (Live view + Review view), recommended on 2026-09-25 before round 3. D's live view is replaced by G; D's review view is kept. C's layout lives on in the review view.
- rationale: Stevan's verdict on D asked for an overall structure for the call at hand, built for this call rather than a fixed checklist, with words instead of anonymous dots and a caution that leaves with its moment. G answers it with one list in which the plan and the suggestions are the same object. Stevan approved G on 2026-09-25 and added the facts-chips rules, the one-tag rule ("Worth saying" or "Say", "Ask first", "Careful") and the rate-pressure item.
- open items (none blocks COPILOT-045): the purpose control in the plan head is the one control G adds to the render (§Live view, plan pane); the seed templates, purpose labels, headlines, caution notes and chip labels are drafts for the red-pen (§Call plan contract).



