# Round 4 — flows-canvas, reworked

One screen, one hand, against a direction agreed with the operator **before**
the first file was written (DECISIONS 2026-07-30, and the Spawning law in
`KICKOFF-PROMPT.md`). This is the rework of the screen held at round 3:

> *"still misses a ton, weird, lets stick maybe with inspiring from manychat and
> it hardly did that"*

**Agreed direction (operator, this session):** rebuild on ManyChat's canvas
ergonomics, taken from the live walk in `design/research/manychat-walk/` rather
than from memory. Accepting it costs the steps rail and the boxed stage, which
reverses round-3 fork 2. He took that trade explicitly.

**Two files, one stylesheet.**

- **`flows-canvas.html`** — the working screen. One flow, interactive, with the
  six states behind the dark strip at its top. That strip is review
  scaffolding, not screen furniture.
- **`specimens.html`** — every state the screen can be in, laid out as a
  catalogue: **80 specimens** (counted off the live DOM, 2026-08-01; the "72"
  this line used to claim was never right) across screen states, all five
  trigger kinds, all nine node kinds plus the stub, ten message cases, ten node
  states, five outlet states, eight connection cases, the panels and overlays,
  the trigger wizard, the step pill, picking an existing step, and the known
  gaps drawn honestly rather than omitted.
  Added because one scene in one state is a half-omitted picture.
- **`canvas.css`** — both files link it. The specimens are built from the same
  stylesheet the screen uses, so a specimen **cannot** drift from the thing it
  documents. This is the mechanism, not a promise.

---

## What was actually wrong with round 3

Named from the two files side by side, not from the prose. Round 3's BINDING
section claimed it had copied ManyChat; the pixels had not.

| Round 3 | ManyChat (evidence) | Round 4 |
|---|---|---|
| The node showed **one truncated summary line** and a grey `Quick replies: Yes please · Now not` metadata string | The node **is the message** — full text, rendered as the contact will read it (`05`, `14`) | Full message body, every paragraph, no truncation |
| Buttons were invisible; every port was a **full-width row with a large hollow circle**, below the body | A button is a **row inside the message** with its own small outlet dot at its right edge (`05`) | Buttons are message rows, each owning its port dot |
| Branch label was a **small outlined pill floating above** the card, clipped by the frame | A **filled strip across the node's top edge** carrying the branch word (`14`, `19`) | Filled `--cm-info-soft` strip, the word alone |
| An **8px coloured square** identified the node | **Channel glyph + channel name** over the node type, two lines (`05`) | 26px type-tinted icon, channel eyebrow, type title |
| Canvas was a **bordered stage** inside the page, with a horizontal scrollbar; two nodes **clipped on open** | Canvas **fills the window** and auto-fits its steps on entry (`05`) | Canvas is the page; `fit()` runs on load and on resize |
| A **190px steps rail** repeated the whole flow as text | No rail. FAB + zoom cluster only (`13`) | Rail deleted (fork 2 reversed, operator-agreed) |
| The zoom readout rendered **sideways** | — | Horizontal, tabular, `writing-mode: horizontal-tb` pinned |

## Binding

- **The node is the message.** A message step renders every text block in full,
  as prose, in the node. You read the flow off the canvas without opening
  anything, which is the single thing round 3 did not do. Buttons are rows
  inside the message (centre-aligned, `accent-ink`, hairline-separated) and each
  one carries **its own outlet dot on the node's right edge** — which is not a
  design invention, it is what `nodePorts()` already returns: one port per
  button, keyed by the button id Meta hands back as `interactiveId`
  (`routes/automation/flow-graph.ts:189-199`).
- ~~**The branch word rides the downstream node**~~ **DEAD — see "The branch
  strip never existed" below (2026-08-01).** The filled bar is the step's OWN
  header: its text is the step's NAME and its colour is the step's KIND. Round 3
  appended "from the budget message" after the word; that
  clipped at 110px and said nothing the wire does not already say, so it is
  gone. ManyChat carries the word alone (`14`, `19`).
- **Connector hover is the three-part combo, and none of it is hover-only.**
  The wire turns `--cm-info`, the destination node takes a 2px `--cm-info`
  outline, and a `--cm-bad` delete button fades in at the curve's midpoint. The
  delete control is a real `<button>` in the tab sequence, and focusing it
  paints all three the same way (WCAG 2.1.1 / 2.4.7). Removing a link is
  undoable, so it offers **Undo in a toast, never a confirm** (CONVENTIONS 1.4).
- **The floating caret toolbar** appears only while the text box has focus, is
  anchored to the caret (measured with a hidden mirror of the field), and
  carries emoji, `{ }` contact detail and a live count. ⚠ **The count is wrong
  in this sheet**: the 2026-08-01 walk (frame 35) shows a single REMAINING
  number, and Instagram's cap is **1000**, not the 4096 we drew — 4096 is
  WhatsApp's limit, applied to an Instagram node. Both controls insert at the caret. This is evidence `07`, the best
  micro-interaction in the walk. ManyChat's own toolbar also carries an AI
  rewrite; ours does not, and that is a ruling, not a gap (DECISIONS, 2026-08-01).
- **Add-step is ONE surface.** A 44px accent FAB pinned to the **viewport**, so
  panning never loses it; every port dot and the `/` key open the same panel,
  titled to the port it was opened from. Categories in ManyChat's order:
  Starting step → Content → The AI agent → Logic → Extras (`16`, `17`, `18`).
- **A live flow opens on its numbers, not its canvas** (`04`, `11`): four
  hairline-joined tiles, a per-step Reached/Errors table with right-aligned
  tabular numerals, and the published banner with Edit one click away. It
  carries the real `STATS_NOTE`, so no delivery, open or click figure is
  invented — `FlowRunEvent` stores no message id and cannot join to one.
- **The step panel pushes the canvas; it is not a scrim takeover.** The selected
  node keeps its 2px accent ring and stays visible while it is edited (`07`).
  See fork 1 — this is a deliberate departure from the side-panel line in
  `tokens.md`, surfaced rather than reconciled.
- ~~**The private-reply constraint is stated on the node itself**~~ **The note
  survives; its CLAIM does not.** "Meta allows text only" was an unsourced
  inference and is withdrawn (`6905542`; DECISIONS 2026-07-31). The node still
  names the four limits Meta DOES document — one message, within 7 days, Live
  only during the broadcast, follow-ups within 24h — because those are the ones
  that silently break a comment opener.
- **All six states ship**, each verified in the browser: canvas; first-run empty
  ("No steps yet", action label echoes the title); loading as row-shaped
  skeletons with the FAB visibly disabled, never a spinner; the refused-save
  banner naming the cause and the fix; offline stating the age of what is shown
  and that changes are held. Filtered-to-zero is unreachable — a canvas has no
  filter, so none is drawn.

## Demo

- **Interactive, not a still.** Hover or tab to a connector for the three-part
  highlight; delete a link and undo it from the toast; click the message step to
  open the panel and put the caret in the text to raise the toolbar; press the
  blue plus or `/` for the palette; drag a node, drag the canvas, `Ctrl`+scroll
  or the zoom cluster to zoom, Fit to re-frame.
- **Every kind, port label and limit is read off the real code**, not invented:
  node kinds and palette copy from `flow-graph.ts` (`PALETTE`, `nodePorts`,
  `swatchFor`), and buttons ≤3 at ≤20 chars, quick replies ≤13 at ≤20 chars,
  `sendMode: window | private_reply` from
  `packages/shared/src/flows/types.ts:95-131`. The node-type colours are the
  ones already shipping in `components/canvas/canvas.css:131-139`.
- **Figures are representative, not live records:** the flow name, the keyword
  chips, 342/268/68/6 and the per-step Reached/Errors. Only the message step's
  panel is wired; other steps open and say so. Adding a step, publish and the
  trigger wizard are not wired.
- **What was verified, and how.** The canvas, the pushed panel with its caret
  toolbar, and the lit connector were seen in a 1920px browser. The other five
  states were verified by asserting against the live DOM (`readView` shown with
  the stage and edit bar hidden; `world` hidden with 7 skeletons and the FAB
  disabled; the empty title and its echoing action label; both banner tones and
  their full copy) after the screenshot bridge began serving stale frames.
  **Not verified visually: the metrics, loading, first-run, refused-save and
  offline screens as full screens, and every width below 1920.** Each of those
  five is drawn at specimen size in `specimens.html` § 1 and was seen there.
- **`specimens.html` was checked section by section in a 1920px browser** —
  72 specimen cells, none empty, no section rendering blank. Sections 1, 3 and
  7 were read closely; 2, 4, 5, 6, 8 and 9 were confirmed populated and
  correctly laid out but not each read line by line.

## Forks

- [ ] **The step panel pushes the canvas instead of covering it under a scrim.**
  `tokens.md` § Side panel says a panel "enters from the right over a `scrim`";
  CONVENTIONS § Detail panels says "default to side-by-side context, not a
  takeover", and ManyChat's builder pushes (`07`). Dimming the canvas you are
  editing hides the node you are editing and its wires. Surfacing rather than
  reconciling: **recommend the pushing panel, and a one-line amendment to
  `tokens.md` exempting a canvas editor** — or say the word and it takes the
  scrim like every other panel.
- [ ] **Round-3 fork 2 is reversed by this round.** That fork was ticked
  accepted ("keep the steps rail, drop the minimap") but the screen itself was
  held; the rework deletes the rail as agreed this session. Recording it so the
  reversal is ledgered rather than silent. **Recommend: rail stays deleted.**
  Nothing replaces it — Fit plus the pinned FAB is ManyChat's whole answer, and
  a keyboard user reaches every node by `Tab` through the canvas.
- [x] **Both halves of the toolbar are settled.** `{{field}}` interpolation at
  send time is BUILT (`6b1b420`). **Improve is cancelled** — the operator priced
  it at ~$0.0001 a press and still declined: *"I really don't know if I want to
  spend it or no. better just don't add it it is ok since this feature can be
  easily delegated to a off-api ai"* (2026-08-01). The button is deleted from
  both sheets rather than drawn dead.

## Compliance checklist (CONVENTIONS §3)

- [x] Every colour, size, radius and spacing from a `--cm-*` token. The only
      raw hexes are the token definitions and the four node-type colours
      transcribed from `canvas.css`, which is where they already live.
- [x] No navy, no gold outside the brand mark. No serif outside the wordmark.
- [x] Light theme only, no `prefers-color-scheme` block.
- [x] Status is a word plus a colour everywhere (Live pill, banner tones).
- [x] No em dashes in prose. No question-phrased headings. Plain words: the
      palette says "Do something", "Check something", "Wait", not node kinds.
- [x] All six states shipped; filtered-to-zero stated unreachable with reason.
- [x] Empty distinguishes first-run, and its action echoes its title.
- [x] Loading is skeletons with disabled controls, not a spinner.
- [x] Errors name the cause and the fix; neither banner is a dead end.
- [x] Link removal offers Undo instead of a confirm.
- [x] 2px `accent-ink` focus ring at 2px offset; the connector delete control is
      focusable and no sticky bar covers it.
- [x] Every hover-revealed control (the connector delete) is keyboard-reachable.
- [x] Hit targets: FAB 44px, buttons and zoom controls 36px. **The port dots are
      11px and are the one exception** — they sit on a 30px row that is the real
      pointer target, but the dot itself is under 24px. Flagged, not hidden.
- [x] Table: numbers right-aligned and tabular, first column human-readable.
- [x] Panel: focus moves in on open, `Escape` closes it, the page header and the
      screen's own bar stay on screen.
- [x] Shortcuts match §2: `/` adds a step, `Escape` closes, `Shift+1` fits, and
      single keys never fire while the caret is in a field.
- [x] Nothing introduces a second way to do a job that already has one — the
      round-1 drag-from palette rail and the round-3 steps rail are both gone.

## ManyChat 1:1, colour included (operator order, 2026-07-31)

> *"just copy manychat 1:1. golden standard for such"* → *"even color, I said
> all which means all"*

I raised the token-sheet collision once; he reaffirmed. That is his decision, so
the canvas is now ManyChat's, **including its palette**.

**✅ Ledgered — this is no longer owed.** `docs/DECISIONS.md` § Design & UI
now carries it: *"The flow canvas renders in the sampled ManyChat palette, and
it is the ONE screen exempt from `design/tokens.md`"*, with the sampled values
and their frames. Without that entry the next session would have read the
canvas as a violation and reverted it, which is exactly how `--cm-bp-rail`
nearly went.

~~**⚠ One thing the exemption did NOT reach: `.btn--primary`.**~~ **SWEPT
2026-08-05.** Publish, Edit and the dialog's confirm rendered `--cm-accent`
indigo `#4F46E5` while everything sampled from ManyChat was `#016BE3`, so one
screen showed two primaries. They now carry the measured blue, per the ruling's
own words (*"even color, I said all which means all"*). It rides a
`.cm-fc__blue` class on the button, not a wrapper, because the publish dialog
portals to `document.body` and cannot see `.cm-fc` through the tree — which is
why `flow-canvas-tokens.css` scopes its palette to `.cm-fc, .cm-fc__blue`.
Measured after: every primary on the screen is `rgb(1, 107, 227)` and zero
elements inside `.cm-fc` still resolve an indigo token.

**Every value was sampled with Pillow out of `design/research/manychat-walk`,
never eyeballed and never remembered** — the ledgered law is "1:1, never from
memory", and a colour guessed off a screenshot is memory. Each carries the frame
it came from:

| role | hex | frame |
|---|---|---|
| canvas | `#F5F5F5` | every frame, 68 to 78% of all pixels |
| node card | `#FFFFFF` | 06 |
| message panel inside the card | `#F5F5F5` | 06 |
| ink (title, message) | `#1A191E` | 06 |
| muted (eyebrow, outlet labels, hint) | `#6E6E6E` | 06 |
| wire stroke | `#787878` | 06 |
| outlet dot | `#858585` | 06 |
| button hairline | `#E6E6E8` | 06 |
| blue (FAB, links, lit connector) | `#016BE3` | 13, 14, 09 |
| selection green | `#3FB17E` | 15 |
| Actions kind bar amber | `#FFECA7` | 14, 19, 28, 41 (was mislabelled “branch strip”) |
| Start Automation lime | `#E3FB9B` on `#152900` | 06 |
| trigger condition row green | `#E0FEE6` | 05, 09 |
| LIVE pill red | `#D42E00` | 06 |
| hint pill fill | `#EBECEE` | 06 |

### ⚠ A product claim was wrong, not just the mockup

*"private reply doesn't allow for buttons — that is not true. in my use of
manychat I had to use button to progress the conversation, literally. check"*

**Checked, and the operator is right that our claim is unfounded.** Meta's
private-reply documentation states exactly four limitations, and **message type
is not one of them**:

1. only one message may be sent to the commenter,
2. it must be sent within 7 days of the comment,
3. for Instagram Live, only during the broadcast,
4. follow-ups need a reply and land inside 24 hours of it.

Nothing says text-only. Every example simply happens to be text, and
message-type support is **undocumented**, on both the Instagram Platform and the
Messenger Platform pages, and in Sinch's third-party integration notes.

So this repo carries an **inference presented as fact**. `scope-board.md` says
*"a private reply is text-only BY META'S SCHEMA"* and *"buttons are unreachable
on the opener BY DESIGN and no code can fix that"*, and `HANDOFF.md` repeats it.
Direct operating experience beats our inference, and there is no documented
schema behind the claim. **These lines need correcting, and the comment-to-DM
design that was built around them is worth re-checking** — it deliberately puts
the first tappable button on the follow-up DM because of this.

Honest limit: I have **not proved buttons DO work** on a private reply either.
Only that our "impossible" was never sourced. It cannot be tested live until App
Review lands, because `instagram:comment_to_dm` is off.

The mockup now shows the opener carrying its three buttons, and its note states
the limits Meta actually documents.

### Two more corrections from the same message

- **A WhatsApp trigger on an Instagram flow makes no sense.** The example is one
  channel throughout now: post comment, direct message, story reply, all
  Instagram.
- ~~**The branch strip was over-applied.**~~ **Superseded 2026-08-01: it was
  the right correction to the wrong model.** See below.

### The branch strip never existed (2026-08-01 walk, frames 27, 28, 41)

The bar is the **step's own header**. Its text is the step's **name**; its
colour is the step's **kind**. Proof: a Condition dropped straight from the
palette, wired to nothing at all, already carries it. The `yes` / `no` /
`budget` bars in frame 19 are Actions steps the **operator renamed** — which is
also why the branch's meaning survives the change, since naming a step after
the branch that reaches it is exactly what he does.

Six kind colours, sampled with Pillow off the frames named, never eyeballed:

| Kind | Node bar | Panel header |
|---|---|---|
| Actions | `#FFECA7` (28, 41) | `#FDF0D0` (28) |
| Condition | `#B4FBF5` (27) | `#9DEFE1` (27) |
| Randomizer | `#DED7FF` (29, 41) | — |
| Smart Delay | `#FEDBD5` (29) | — |
| Message | none — the card is white (35) | `#F2D4F6` (40) |
| Trigger | — | `#E2FAEC` (32) |
| Comment | `#FEF5E4`, and that IS the card (29) | none |

⚠ **The panel header takes the kind's HUE, not its value.** Condition is
`#B4FBF5` on the node and `#9DEFE1` in the panel. Assuming they matched would
have been wrong, so both were measured.

**And the bodies do not share one renderer**, which is the deeper correction:
Actions stacks label-over-value pairs with one outlet however many actions it
holds; Condition prints a row per condition group plus a fixed fallback row and
never says "Yes"/"No"; Smart Delay writes a sentence with the value bolded
inline; Randomizer prints a row per variation with the **port tinted per
variation**; Comment is a sticky note with no header and no ports at all.

Also corrected here: the Actions glyph is a **bolt** and the Condition glyph a
**funnel** — the arrow and hamburger shipped in round 4 were inferred, and
inference is the one thing the 1:1 ruling forbids.

### Second pass — what the first 1:1 still got wrong

*"still seems off. not completely like manychat. you forgot to replicate some
stuff like icons or such."* Correct. The first pass worked off the frames at
thumbnail size and inferred the rest. I cropped and magnified them 3 to 6 times
and found five real misses:

1. **The avatar is the CHANNEL's own logo**, not a node-type colour tile. Frame
   06 has the actual Instagram gradient mark in the circle. This was the single
   biggest reason the node "looked off": a solid indigo circle where a brand
   logo belongs.
2. **The step-kind glyph, top right, is BLUE** (`--mc-blue`), not grey.
3. **The whole type scale was two steps small.** Title 17px regular, eyebrow
   12px, body 14px at 1.6 line height — I had 13/10/13.
4. **A channel-less step is a different shape entirely** (frame 14): no avatar,
   no blue glyph, and **no grey panel** — a grey kind eyebrow over a dark
   summary line, straight on the card. I was drawing every step the same.
5. **The trigger card's header is a plain black lightning glyph and a word**,
   with no coloured tile; its rows carry a channel logo plus a title AND a grey
   sub-line; and "+ New Trigger" is a tall dashed rounded rect with centred blue
   text. Card radius is 16px, not 12.

Also corrected: the branch strip's bolt is amber, not ink; a reply option is a
white control **inside** the grey panel with its dot at the panel's edge; and
`Start Automation` is a lime full-bleed bar over a centred name and a bordered
blue link control.

### The example flow is now comprehensive

Twelve steps on one canvas, covering **every node kind the engine executes**
plus the message cases that carry their own ports: three trigger kinds in one
card (post comment, WhatsApp message, story reply), a private-reply message with
three buttons, Set User Field, Condition, a card set, Ask a Question, Smart
Delay, Randomizer with three weighted ports, a WhatsApp message with quick
replies, AI Step, fetched text with its error outlet, and Start Automation.
Wired and unwired outlets both appear, so the filled-versus-hollow distinction is
visible without hovering.

Auto-fit now clamps at 0.8. Below that the message text stops being readable and
the node's whole point goes with it; a flow larger than the frame is panned,
which is what ManyChat does with its own large flows (frame 13).

**What changed structurally, each against a frame.** The node loses its border
entirely and separates on a real shadow at 12px radius, 278px wide (06) — that
was the contrast complaint. The channel avatar becomes a circle and the step
kind's own glyph goes quiet in the header's top right (06). The message moves
into an inset panel at reading size (06). A button becomes a white rounded
control rather than a table row (06). **A filled dot means wired and a hollow
ring means not** — ManyChat's own distinction, `I'm interested ●` against
`Next Step ○` in 06, which I had guessed right and have now confirmed. Wires
land with an **arrowhead** (05, 06). The **dot grid is gone**: ManyChat has none
at any zoom, it was ours, and it was competing with the node edge. The zoom
**percentage readout and the Fit button are gone** too — 13 shows three separate
rounded buttons and nothing else; `Shift+1` still fits, so no capability is lost.

Verified by reading computed style off the running page: canvas `#F5F5F5`, node
`#FFFFFF` with `0px` border at `12px` radius and `278px` wide, message panel
`#F5F5F5` at `13px` in `#1A191E`, branch `#FFECA7`, trigger row `#E0FEE6`, FAB
`#016BE3`, LIVE `#D42E00`, wired dot `#858585`, three zoom buttons, no readout,
and the arrowhead marker resolving on all nine wires.

**One deviation, deliberate, because copying it would have been wrong.** The
private-reply note was amber. On this canvas amber now means *this is a branch*,
so an amber note read as a branch strip. It is a plain muted line instead.
ManyChat has no equivalent element to copy.

Two defects fixed while looking: `layout()` cleared the wire layer with
`innerHTML = ''`, which took `<defs>` with it, so the arrowhead marker vanished
on the first redraw; and `--n-message: var(--n-message)` was self-referencing, so
CSS dropped it as cyclic and every node icon rendered with no fill.

**`node-options.html` is superseded by this ruling** — its whole job was to offer
node looks, and the look is now settled. Its § 8 (the alert comparison) is still
live.

## The tinted alert box, removed (operator order, 2026-07-31)

> *"I don't like the transparent && border with border matching font colors pop
> up for errors, cautions and alerts… I want it removed throughout the website."*

**The reference agrees.** shadcn/ui, which 21st.dev components are built on,
gives its destructive Alert `bg-card text-destructive` over the shared
`rounded-lg border` base (`registry/new-york-v4/ui/alert.tsx`). The ordinary and
the error alert are the **same box**: same surface, same neutral border.
Severity is carried by text and icon colour only. ManyChat is consistent in the
one place the walk captured it — system events render as plain centred grey text
in the message stream, not tinted boxes (EVIDENCE 21). Drawn side by side in
`node-options.html` § 8.

**Done, at the primitive** (`apps/web/src/components/ui/ui.css`):
`.cm-banner--bad/--warn/--info` and `.cm-toast--success/--error/--warning/--info`
lose their tint and their hue-matched border; the title takes the status colour,
the icon already had it. `QueryState.tsx` is the error state of **every screen in
the app** and uses the banner, so this lands app-wide from one edit.
**Verified** by loading the real `tokens.css` + `ui.css` into a shadow root and
reading computed style: all three variants render `rgb(255,255,255)` on
`rgb(212,212,216)`, titles `#B91C1C` / `#B45309` / `#1D4ED8`. Contrast on
surface: bad 6.4:1, warn 4.9:1, info 7.5:1, ok 4.9:1.

Also done: `.cm-thread__paused` (`components/inbox/inbox.css`) and the mockups'
own `.banner` in `canvas.css`, so the sheets and the code agree.

**⚠ One left deliberately, because it collides with a binding rule.**
`.cm-note` (`inbox.css`) is the internal-note fill. `design/CONVENTIONS.md` § Inbox
and threads says internal notes are *"`warn-soft` fill plus an explicit 'Internal
note' label, never colour alone"* — a sourced line, not a preference. A note is
also not an alert: it is a message type inside a stream, and the fill is what
stops it being mistaken for something the customer received. **Surfacing rather
than reconciling: say the word and it goes too, and CONVENTIONS gets amended in
the same commit.**

Not touched, and worth a word: the status **pill** (Live / Shadow / Off) is a
soft fill with **no border**, and it is a label rather than a box competing with
the page. Three smaller tinted fills also survive because none of them is an
alert: `.cm-takeover` (accent-soft, no border), `.cm-insp__warn` (warn-soft, no
border), and the two `.cm-trace__val--*` left-stripe blocks. Say if those go too.

## The trigger wizard, drawn (2026-08-01, frames 32-34)

Item 1 of the three the sheet still owed. **Trigger settings are not a form.**
They are a stepped wizard: a labelled progress bar, one question a step, option
cards where the chosen one grows a green border and opens its sub-form, a back
chevron in the header and a second one bottom left, and a pinned primary that
reads `Continue` until the last step and `Save` on it.

**Sampled with Pillow, never eyeballed**, each off the frame named:

| role | hex | frame | how |
|---|---|---|---|
| progress bar, filled run | `#096A37` | 34 | 3px tall, read at its core row |
| progress bar, unfilled run | `#D5D5D5` | 32 | same 3px |
| chosen option card's border | `#2E6D50` | 32 | 2px, read on its top AND bottom edge |
| form hairline | `#D5D5D5` | 32-34 | option card, inset box, reply row, back button |
| `Continue` while unanswered | `#C3DBFF` | 32 | |
| `Save` when live | `#016BE3` | 34 | the same `--mc-blue` |

⚠ **The bar's green and the card border's green are NOT one value.** `#096A37`
against `#2E6D50`. A 2px border through JPEG is the noisier read of the two, so
it was taken twice on opposite edges of the card and both came back the same.
Collapsing them into one token would have been the identical mistake the panel
header tints already taught, so both are kept.

**Three things the wizard reproduces that a screenshot alone does not show.**

1. **An option chosen is not an answer given.** In frame 32 "Specific Post or
   Reel" IS selected and `Continue` is STILL dead, because no post has been
   picked inside it. Modelled as `needs` on an option, and drawn as its own
   specimen because it is the detail a rebuild would silently drop.
2. **The step count is the trigger KIND's.** It is read off
   `FlowTriggerSchema`: a post comment holds three questions, a DM holds one, a
   contact event holds two. A one-question kind drops the counter and the bar
   entirely — "Step 1 of 1" over a full bar is a form in a wizard's clothes.
3. **Leaving mid-wizard is guarded.** The one guarded exit on this screen;
   everything else offers Undo in a toast. An abandoned trigger has nothing
   left to undo, which is the whole test CONVENTIONS applies.

**The markup lives in `node.js`, not in the screen.** `wizardHead/Body/Foot` are
called by `flows-canvas.html` and by `specimens.html` alike, so the six wizard
specimens cannot drift from the screen — the same mechanism the shared
stylesheet already gives the rest of the sheet. A specimen that is a copy
drifts; one that is a call cannot.

**Verified in a 1920px browser, not asserted.** Every step walked with the
panel open: bar 33.3 / 66.7 / 100%, `Continue` dead until answered, back
appearing from step 2, primary turning `Save` on step 3, the guard catching
Close, `Keep editing` returning to the same step with the answer intact,
`Leave` closing it — and then a message step opened cleanly afterwards, which
is the regression that killed `openPanel` last session and is now covered.
Console clean on load and through the walk.

### Two defects found while drawing it

- **Every toast offered an Undo.** `showToast` rendered the Undo button
  unconditionally, so "Steps arranged." and "Adding a step is not wired" both
  offered an undo that did nothing. `undo` is opt-in now, and only the link
  removal passes it.
- **`id="ig"` appears 18 times in the specimen sheet** — `channelLogo()` emits
  the Instagram gradient with a fixed id per logo. Every `url(#ig)` resolves to
  the first, and they are identical, so it renders correctly by luck.
  Pre-existing, not touched, named here so the rebuild does not inherit it.

### Forks this raises

- [ ] **Step headings are STATEMENTS, not questions.** ManyChat asks *"Which
  Post or Reel do you want to use in automation?"*; the compliance checklist in
  this file forbids a question-phrased heading and has never been overruled. So
  it reads "Pick the posts this watches". This is the one place the 1:1 ruling
  and the copy rules genuinely collide. **Recommend: keep the statements** —
  1:1 was ruled about the look and the colour, and every other line of copy on
  this canvas is already ours. Say the word and they become questions.
- [ ] **Include and exclude are drawn as two boxes, though our schema needs
  one.** `not_contains` is one of the five `FLOW_KEYWORD_MATCH_MODES`, so an
  exclusion is a row like any other and one list would carry both.
  **Recommend: keep the two boxes.** ManyChat's shape is what you already read,
  and the schema is unaffected either way. It also closes the "exclude-keyword
  list" that the walk listed as something we had no answer for: we do, and it
  is the workaround you carry by hand for `Comment "INVEST"`.
- [ ] **Three trigger kinds were never walked.** Live comments, story replies
  and contact events have their questions from `FlowTriggerSchema`, which is
  real, but their sub-forms are ours and copied from nothing. **Recommend:
  walk them before the build rather than after**, since the account is open and
  it is the cheap half of the cost.

## The pill, and pointing a port at a step already here (2026-08-01)

Items 2 and 3, the last the sheet owed.

**The pill above a selected step (frame 41).** Duplicate and delete in red, and
nothing else. It is NOT a right-click menu — which is exactly why `contextmenu`
and the `Delete` key both did nothing during the walk and it beat the walker
twice. Renaming is the pencil in the panel header, not here.

Measured off frame 41 rather than guessed: **71×36 at 8px radius, white, no
border**, a soft shadow fading over ~13px, **centred on the step and 22px above
its top edge**. Two 30×28 controls with 4px padding and a 2px gap reproduce that
box to within a pixel. The duplicate glyph is `--mc-muted`, read cleanly. The
trash is `--mc-red` and **was not measured independently** — it is a 1.5px
stroke and its pixels run from `#914140` to `#E46D73`, so the token sampled
from frame 06's LIVE pill is reused and the honest limit is stated here.

Deleting takes the links that reached the step with it, names how many, and
offers **Undo in a toast rather than a confirm**. ManyChat asks for no
confirmation either — frame 42 shows the step simply gone. Duplicating keeps
everything the step says and none of its wiring, so the copy's outlets start
hollow, which is the filled-versus-hollow distinction doing its job.

**⚠ `--mc-green` is corrected, `#3FB17E` → `#3CB268`.** Both claim to be the
selected step's outline. The old value came from frame 15, a **1254px** capture
whose outline pixels scatter from `#5AAE8A` to `#92CCA6` — it cannot have been
read reliably. Frame 41 is 1920px and its top edge modes at `#3CB268` across 91
pixels of a single row. Same element, better measurement, so the better one
wins. This is a correction to a sampled value, not a preference.

**Pointing a port at a step already here (EVIDENCE 10).** ManyChat's
`Select Existing Step`, the one affordance the walk found that we had **no
equivalent for anywhere**. A port can point at a step already on the canvas
instead of minting a new one, which is how a flow loops back and how several
branches land on one ending.

Theirs dims the whole canvas and banners *"Select the next step by clicking on
it"*. **Ours dims only the steps you cannot pick**, so what IS pickable stays
legible — a uniformly dimmed canvas you have to hunt through is a worse version
of the same idea. Two rules decide it: a port cannot point at its own step, and
**nothing wires INTO a trigger**, because a trigger is where a flow starts and
never where one arrives. The banner takes the hint pill's slot rather than
opening a second surface competing for the same strip.

One port, one wire: pointing a port somewhere new **moves** it rather than
forking it, which is what `pruneEdges()` guarantees in the engine. Cancel and
`Escape` leave **zero side effects**, verified by comparing the edge list before
and after — that is the part of ManyChat's behaviour worth copying exactly.

### Three defects found while drawing them

- **Five of the twenty port dots were dead.** A Condition's rows and a
  Randomizer's variations draw their OWN outlet dots from the per-kind body
  renderer, so they are not in `n.ports`; the palette handler looked them up
  there, got `undefined`, and threw on the next line. Every branch outlet on
  the canvas was unclickable and nothing said so. Fixed by falling back to the
  written label the dot already carries. **All twenty now open the palette and
  name their port**, checked one by one.
- **The "Pinned chrome" specimen was laid out wrong.** Its wrapper holds only
  absolutely positioned children, so it collapsed to zero width, `left:50%`
  resolved to `0`, and the hint pill wrapped one word per line. This section of
  the sheet was claimed as "read closely". Fixed.
- **Toasts, again**: `showToast`'s Undo was wired to one hard-coded edge
  restore, so a second undoable action could not have its own. Undo now carries
  its action, which is what let delete and re-pointing both offer a real one.

## The screen is built (2026-08-01)

`apps/web/src/routes/automation/FlowCanvasScreen.tsx` is rebuilt from these
sheets, from the LAYOUT out. The behaviour that was already right was kept; the
render tree and the stylesheet are new, because grafting the sheet's features
onto round 3's shape is the broken promise the standing law names.

**Mockup and build were screenshotted at 1920 and compared side by side.** They
read as the same screen: the message in full inside its grey inset panel,
buttons as rows owning their dots, the kind-coloured header bars, the trigger
card with its keyword chips, filled-versus-hollow outlets, wires curving with
arrowheads, the pinned FAB and the three zoom buttons, the hint pill.

**Where the build deliberately differs, and why:**

| Sheet | Build | Why |
|---|---|---|
| Bar reads `AED 500K to 1M` | Bar reads `Actions` | That is a step the operator RENAMED. Our schema stores no per-step name. Named as a gap, not faked with a derived number that renumbers on every delete. |
| `Send Message`, `Start Automation` | `Message`, `Start flow` | Our words, from `flowNodeTitle`. ManyChat's product vocabulary is not ours. |
| The Instagram glyph in the avatar | The ledgered channel swatch in a circle | See below. |

**The avatar fork is closed by a ruling that already existed.** *"maybe use the
logo batch with its colors its of a circular avatar"* is `docs/DECISIONS.md`
§ Design & UI, **"Channel chip colours (2026-07-24, operator pick C·D·D)"** —
WhatsApp `#12824A`, the real Instagram gradient, Facebook `#3B5998`, already
tokenized as `--cm-ch-*`. So the avatar reuses that swatch rather than sampling
a second one. ⚠ It collides with a 2026-07-22 order recorded in
`ChannelMark.tsx` — *a plain word, never a glyph* — which governs the Contacts
and Inbox CHIPS, not this canvas. Surfaced, not reconciled quietly.

**The exemption is now a place, not a habit.** The sampled palette lives in
`flow-canvas-tokens.css` alone; the conformance test names that file as a RULED
exemption pinned to an exact count, so a stray colour still fails the build, and
`flow-canvas-screen.css` is held to zero like every other swept sheet.

### Three defects the rebuild uncovered

- **The conformance suite was already RED on clean `HEAD`.** `flow-graph.ts`
  seeds a comment node with a `fontSize` field — the sticky note's own stored
  size — and the scanner read it as an off-scale CSS declaration. The lane's
  "web 137/137" was a scoped run that never included it.
- **The app shell's content area is `display: block`**, so a screen that sizes
  itself with `flex: 1` gets nothing and its canvas collapses to zero height.
  The stage measures its own top and takes the rest of the viewport instead;
  the shell hosts every page and was not touched for one screen.
- **`COL_W` was 300 against a 290px card.** Ten pixels for a wire to curve
  through, so every connection on the canvas was drawn and invisible. 420 now,
  and each column re-stacks from the heights actually rendered, because the node
  is the message and no single row constant survives that.

## How to react

Tick a fork to accept the recommendation as written. Leave it unticked and write
beside it to override. A ticked fork is **not** screen sign-off — say plainly
whether this screen is build-ready, held, or dead.

---

# Round 4 — the held screens, one hand, one direction

Round 3 put eleven sheets in front of the operator from eleven parallel seats and
nine were rejected: *"there is no direction and the output is all over the place."*
Round 4 is the same screens authored by one hand, one at a time, against the
direction below. The direction is stated once here and every sheet obeys it, so
no screen re-decides a question another screen already answered.

⚠ **The 22 round-3 forks do NOT bind this round.** They were demoted to
PROVISIONAL on 2026-07-31 — the bulk tick was not informed consent. Their status
lives in exactly one place, `docs/DECISIONS.md` § Design & UI; the round-3
`ANNOTATIONS.md` is reasoning only and rules nothing. Fork #1 (channels as rows)
is already struck on Dashboard. **Charts were never banned** — only bars, meters,
split bars and traffic lights are.

`design/tokens.md` and `design/CONVENTIONS.md` sit above this file. Where they
collide with a line here, they win and the collision gets surfaced, not
reconciled.

---

## The direction

**The Inbox is the direction.** Two screens survived round 3 — `login` and
`inbox-per-channel`. The other eight get made to look like they belong to the
same product as those two, not to eight products. Six laws, each naming the
round-3 failure it exists to prevent.

1. **The vocabulary is frozen.** Container, row, pill, chip, segmented control,
   table, stat tile, side panel, banner, empty, skeleton, popover, toast. That is
   the list. A screen needing a thirteenth primitive stops and asks instead of
   inventing one. *Round 3 invented a coverage map, a numbered wizard, a section
   nav with counts and a verdict sentence — four navigation metaphors, four
   authors.*
2. **One computed sentence under the title, then the evidence.** No stat row as a
   header, no uppercase eyebrow, nothing decorative above the fold. *Login's
   discipline: the tagline was cut because it said nothing the button did not.*
3. **At most two bordered containers above the fold**, rows inside them,
   hairline-separated. *Settings drew ten section pages plus seven rows plus a
   panel and read as clutter.*
4. **A screen shows state; it never teaches.** Names, facts, counts. No preview
   chips of what a page contains, no wizard numbering, no completeness tally.
   Navigation is name, click, page. *That is what "went off track and direction"
   was.*
5. **Words where a person would use words; figures only in columns.** No bar,
   meter, split bar or traffic light anywhere — already ruled.
6. **A mockup file is the screen and nothing else.** No six-state gallery under
   it, no dark review-harness strip beside it. The six states are named in prose
   per screen here, and get drawn once for the round in a separate `states.html`
   after the vocabulary settles. *Round 3's files ran 1,000 to 2,200 lines
   because every one carried its own gallery.*

### What the direction changed about the scope

- **`activity-filter-bar` and `activity-detail-panel` merge into ONE sheet.**
  They are one screen — list, filters, panel. Two seats authoring them apart is
  exactly why they disagreed about the column set.
- **`analytics` is not authored this round.** It is blocked on the operator's own
  definition round (`HANDOFF.md` OPEN 9, "what to show and how"). Round 3 failed
  there because a seat invented the definition itself. Not repeating that.
- So eight held screens become **six sheets**, in this order, each reacted before
  the next is started: **dashboard** → agent-overview → contacts → activity →
  settings → knowledge-doc. Dashboard leads because it sets the row, the stat
  tile and the status line every later sheet reuses.

---

## dashboard

`dashboard.html` · replaces `home` · operator's round-3 verdict: *"rename to
dashboard, literally might need a full rework"*

### Binding

- **The screen is ordered by what you do, not by what the system stores.** That
  is the rework. Round 3's home kept the API's own order (channels, then
  performance, then today) with attention wherever it landed. Here the ONLY
  container holding actions comes first and owns the fold; channels are evidence
  under it; today and performance drop below the fold because nobody acts on
  them. Three containers, two above the fold.
- **The head is a title plus one computed sentence** (law 2): "1 channel
  receiving · 1 disabled by Meta · 6 things need you · checked 30 seconds ago".
  Every clause is derived from the payload, none is written. The item count is an
  in-page link to the container it counts. "Check again" states what it is doing
  while it works and then updates both "checked" readouts so they cannot
  disagree.
- **Needs you is rows in one bordered container**, severity as a word plus a
  colour ("Fix now" / "Waiting") in a fixed-width leading cell so the two classes
  align down one edge and the red ones read as a block. Count is right-aligned
  and tabular; a system row with no count renders the em dash, which is the only
  place that glyph appears. Sentences are the real `home.service.ts` labels, not
  invented ones, with the engineering word "LLM" replaced by "Model provider".
- **The row is not a whole-row link, because the row holds a control.** The
  sentence is the link and Dismiss sits in a trailing 36px cell, visible on hover
  *and* on keyboard focus. This is the same resolution round-3 agent-overview
  used for its gate rows — one rule for the whole product rather than two.
  System-derived rows carry no dismiss at all, so nothing can be hidden that only
  fixing will clear.
- **Channels are one row per channel in one bordered container** with a small-caps
  column header and three right-aligned tabular columns on a shared numeric spine
  (last inbound / last outbound / errors 24h). Gate states are read-only chips on
  a labelled sub-line — this screen reports, the Agent Overview tunes. The
  WhatsApp standing status reads in info tone, not bad: the row is known and
  parked, not on fire.
- **The quota meter is deleted, not recoloured.** `home.css`'s `home-meter` is a
  bar, and bars are banned by the round-3 rulings. It becomes the sentence
  "Quality high · Tier 1,000 a day · 0 of 1,000 business-initiated today" in
  tabular figures.
- **Worker, database and Redis are a foot line inside the channels container**,
  never a stat tile (round-3 home fork 2, ticked). A dead worker is visible
  before it becomes an alert. When it dies the foot line goes bad *and* an
  attention row appears: the row is what you act on, the line is what you glance
  at.
- **Today and the agent is one container of hairline-joined stat tiles**, in two
  labelled bands. That replaces six separately bordered stat boxes and the
  3px left-border accent in the shipped `home.css`, which is the banned side
  stripe. Tiles use `auto-fill` so the three-tile band keeps the same tile width
  as the seven-tile band above it. Agent performance stays ONE group, per
  DECISIONS 2026-07-16.
- **Token-pure, light only.** No `prefers-color-scheme`, no hex outside the
  inlined token block, nothing above the 24px size ceiling, 36px minimum targets,
  2px `accent-ink` focus ring at 2px offset, tabular numerals on every figure,
  motion suppressed under `prefers-reduced-motion`. Both media queries cite
  `--cm-bp-shell` / `--cm-bp-rail` in a comment rather than inventing a number.

### The six states, named (drawn in the round's `states.html`, per law 6)

- **Loading** — row-shaped skeletons inside all three containers, the head
  sentence held as a skeleton line so nothing jumps. Never a spinner.
- **Error** — full-width persistent banner naming the timeout, stating nothing
  was changed, carrying "Try again". The last good reading stays underneath with
  its age.
- **Empty (all clear)** — Needs-you collapses to one row, "Nothing needs you".
  Channels and the tiles still render, because they are not empty, they are calm.
- **Partial** — a channel with no events yet renders em dashes in its three
  cells and the sentence "No messages yet"; the head sentence says so once rather
  than per row.
- **Offline** — figures freeze, the head sentence states their age, "Check again"
  disables and says why.
- **First run** — no channel connected: Needs-you is replaced by one row, "No
  channel is connected", action "Connect a channel" (label echoes the title).
  Filtered-to-zero is unreachable — this screen has no filter.

### Demo

- Every figure is invented but shaped like the real `GET /api/home` return
  (`apps/web/src/lib/home-api.ts`) and internally consistent: WhatsApp is silent
  so its message counts are zero and all 128 in / 96 out are Instagram; 74 bot
  replies today against 74 of 104 over 24 hours is two windows, not a
  contradiction.
- Attention sentences are transcribed from `apps/api/src/home/home.service.ts`
  (`reviewLabels`, the campaign-stall and failure-spike strings, the fail-safe
  row). The gate values are the live ones from `HANDOFF.md`. The nav is the real
  `AppShell.tsx` IA with "Home" renamed to "Dashboard".
- Nothing is wired. Hover, focus and anchors are native; no script in the file.
- **Verified in Chrome at 1568px only.** The shared browser would not resize
  (same failure round 3 reported), so the `--cm-bp-rail` and `--cm-bp-shell`
  media queries are written but **not visually verified**. Saying so rather than
  claiming it.

### Forks

- [ ] **"Home" becomes "Dashboard" in the nav, the route and the crumb.** The
  operator ordered the rename; the mock draws it. The route `/` is unchanged
  because moving it would break every bookmark and the post-login redirect.
  **Recommend: rename the label only, keep the route at `/`.**
- [ ] **Today and the agent sits below the fold and stays there.** Ten figures
  nobody acts on were competing with the two containers that carry actions.
  Nothing is deleted, it is demoted. **Recommend: keep it below.** Or does a
  specific figure need to be at the top, in which case name it and it joins the
  head sentence rather than coming back as a tile row?
- [ ] **The quota meter is gone** and quota is a sentence. Bars are banned by the
  round-3 rulings, and this was the last one on the screen.
  **Recommend: the sentence.**

### How to react

Tick a fork to accept it as written. Leave it unticked and write underneath to
override. Anything about the screen that is not a fork, say in your own words —
it becomes a law in the direction above and binds the five sheets after this one.

---

## dashboard — four variations (2026-07-31)

Four different **organizing principles**, not four skins. Same facts in all four
so the comparison is structure, not content. One hand, per the standing law.

| | file | organizing principle | costs you |
|---|---|---|---|
| **A** | `dash-a-queue.html` | **By urgency.** One list, worst first, no zones and no containers. A channel appears only when something is wrong with it. Everything calm is one quiet paragraph at the foot. | You cannot see a healthy channel at a glance. Numbers are demoted to prose. |
| **B** | `dash-b-briefing.html` | **By narrative.** The screen is written in sentences at 19px with the figures bold inline, then a row of verbs. Closest thing to being told what happened. | Prose does not scan. At twenty alerts it stops working, and it is the least conventional thing here. |
| **C** | `dash-c-console.html` | **By density.** A single-line strip of eight figures, a three-channel status strip, then one table of everything needing action with Where / Waiting / Count. Keyboard hints on the head. | It is a tool, not a welcome. Nothing is emphasised over anything else. |
| **D** | `dash-d-channels.html` | **By channel.** One panel per connected channel, each carrying its own state, its own figures and its own waiting work. Cross-channel breakage sits above in its own bar. A dashed panel of the same footprint invites the next channel. | Repeats the frame three times. Work that spans channels needs a home outside the grid, which is what the top bar is. |

All four drop what was rejected: no status dots, no box foot, no lede paragraph
(B's prose is the concept, not a lede), no uppercase small-caps, no soft pill on
every row, channels no longer a data-row list, and roughly half the figures gone.

**A, B and D use sentence-case labels, 15px row names and a 30px title, which the
approved token sheet does not currently allow** (`TOKENS-AMENDMENT.md`). Judge
the layout; if one of these is the direction, that amendment has to land with it.

### One real finding, worth ruling separately

**The Home API cannot report Facebook.** `apps/web/src/lib/home-api.ts:53` types
`HomeChannel = HomeWhatsAppChannel | HomeInstagramChannel` — there is no Facebook
member, and `apps/api/src/home/home.service.ts` builds only those two. But
`facebook:comments` and `facebook:comments:moderation` are **live on prod**.
So the operator's *"it's missing other channels, it should show channels
depending on what is connected"* is not a mockup defect: **the screen is
structurally unable to show a live channel.** All four variants draw Facebook as
if the API returned it. Whichever variant wins, the API needs a third member
before it can be built honestly.

---

## How the channel box sentence is decided

> ⛔ **UNRULED PROPOSAL.** Nothing below is implemented or approved. The live
> screen renders raw gate pills; the sentences in the mockups were typed by hand.
> Kept here as the reasoning behind a proposal, never as a spec.

Asked by the operator, 2026-07-31. In F and G I wrote those sentences by hand,
which is fine for a mockup and **not** how it may ship. `CLAUDE.md` is explicit:
*anything an operator might legitimately change is data seeded from defaults,
never a hardcoded constant*. A gate flip must rewrite the sentence with no code
change. So the sentence is **assembled**, never authored.

Three parts, in order: a **verdict**, a **capability sentence**, a **recency
clause**.

### 1 · The verdict — one line, first match wins

Read from `connection` and `webhook` (`home-api.ts:8-25`), never from the gates.

| condition | verdict |
|---|---|
| `connection.configured` is false | Not set up |
| `webhook.mutedLabel` is not null | *the muted label itself* — "Switched off by Meta" |
| `connection.active` is false | Turned off |
| every capability gate is `off` | Connected, but answering nothing |
| any gate is `shadow` or `canary`, none `live` | Practising — it writes, nothing sends |
| any gate is `live` | Answering on its own |

The order matters and is not arbitrary: a muted channel outranks its gates,
because a live gate on a dead number is a true statement that misleads.

### 2 · The capability sentence — one clause per gate, joined

Each gate contributes at most one clause. Clauses join in a fixed order so the
sentence reads the same on every channel.

| gate | `live` | `shadow` / `canary` | `off` |
|---|---|---|---|
| comments | It replies to comments | It drafts comment replies for you to check | *(no clause)* |
| moderation | and hides the bad ones | and flags the bad ones for you | *(no clause)* |
| dms | It answers DMs | It drafts DM replies for you to check | It does not answer DMs yet |
| comment_to_dm | When someone comments it opens a DM | — | *(no clause)* |

`off` is silent **except** where the absence is the interesting fact. DMs get an
explicit "not yet" because that is the thing the operator is waiting on; nobody
needs to be told comment-to-DM is off.

The clause strings are **seeded data**, not constants in code — the same
treatment `packages/shared/src/connections/presentation.ts` already gives
connector copy. Rewording is a data edit, not a deploy.

### 3 · The recency clause

From `lastInboundAt`: "Someone wrote 4 minutes ago." When the channel is muted or
off, it swaps to the past tense — "The last message was 3 days ago" — because
"someone wrote" implies a channel that could still be written to.

### ⚠ Two of these cannot be built today

- **The Home API returns the wrong gates.** `HomeInstagramChannel.gates` carries
  only `{ comments, moderation }` and `HomeWhatsAppChannel.gates` only
  `{ agent }` (`home-api.ts:42-51`). There is no `dms` and no `comment_to_dm` on
  either — but both exist live in prod `settings.channelGates`. So the DM clause,
  the one the operator most wants to read, has no data behind it.
- **Facebook has no member at all** on the `HomeChannel` union, while
  `facebook:comments` is live. Already flagged; same root.

Both are the same defect: **the Dashboard payload was built for two channels and
two gates, and the product now has three channels and five gates.** Whatever
design wins, that payload gets extended before the sentence can be honest.

### Why this matters beyond one box

A hand-written sentence would be wrong the first time a gate flips and nobody
would notice, because no test can see stale prose. An assembled one cannot go
stale — that is the whole reason for assembling it.

### dashboard G — width behaviour, measured

`auto-fit` does the work; there are no invented breakpoints. Measured in Chrome
by rendering the page at each width and reading the resolved grid, not by eye:

| width | channel boxes | top-bar figures | sidebar | h-overflow |
|---:|---:|---:|---|---|
| 1920 | 4 | 5 | shown | none |
| 1690 | 4 | 5 | shown | none |
| 1600 | 4 | 5 | shown | none |
| 1400 | 3 | 5 | shown | none |
| 1180 | 2 | 4 | shown | none |
| 1024 | 2 | 3 | shown | none |
| 900 | 1 | 2 | shown | none |
| 768 | 1 | 2 | shown | none |
| 600 | 1 | 2 | **hidden, burger** | none |
| 430 | 1 | 1 | **hidden, burger** | none |
| 360 | 1 | 1 | **hidden, burger** | none |

The only media query is `--cm-bp-shell` (768px), and it changes the sidebar,
padding and type size — **not** the column counts. A first pass did force one
column below 768 and that cost a whole column at 745px where two fit; the grids
are left intrinsic, which is also what `tokens.md` asks for.

⚠ **`--cm-content-max: 1360px` is not applied on this screen.** The operator
asked for the width to run to the edge of the display, and `tokens.md:90` says
"Content max-width 1360px". Surfaced, not reconciled: this is the third thing
`tokens.md` blocks, after the type scale and the casing rule, and it belongs in
the same amendment.

### One bug this round, and it is the one HANDOFF warns about

The "One has waited 3h 20m" line rendered as orange text on WhatsApp green. Cause:
`.wa` was defined twice in the same file — the WhatsApp chip background, and
again as an abbreviation for "warn". `HANDOFF.md` names this exact trap
(*"grep every new class name against all stylesheets"*) after `.cm-avatar` and
`.cm-select` did the same thing. I reproduced it inside a single 300-line file.

Fixed by naming: `tag--whatsapp` / `tag--instagram` / `tag--facebook` and
`trend-up` / `trend-down` / `trend-flat` / `trend-warn`. **No abbreviated class
names in a mockup from here.** An abbreviation is what makes a collision
invisible.
