Cadora · design spec
Everything needed to rebuild or extend the walk-away review surface — the exact tokens, the parked pill, the triage callout's anatomy, its states, the mobile rules, and the terminal lines on either side of the park. The rendered components are live HTML; edit them here or lift the specs into Figma.
Amber family, two voices — the flag asks, the pill waits
#e8a13aPill text · on rgba(240,180,41,.15)--amber · #ffd08a⏸ Parked flag · callout border--green · #16c784Approve · stored · decided ✓--red · #fb7185Abort outline--blue · #3b82f6Document links--bg · #0f172aPage ground · comments field--canvas · #111c2fGate card · reviewer input--panel-2 · #162237Callout surface--line · #334158Borders--muted · #9aa8bcCost · kind chip · status msgThree depths carry the hierarchy: the callout sits on --panel-2, each gate card insets to --canvas, and the comments field insets again to --bg — the darker the surface, the closer to the reviewer's own words.
System sans for triage · monospace where the run speaks
| Role | Family | Size / weight | Notes |
|---|---|---|---|
| Node id | system-ui | inherit / 700 | <strong> in .park-head, left cell |
| Cost so far | system-ui | inherit | $1.2874 so far — four decimals, right cell; omitted when unknown |
| Doc link | system-ui | 13px | --blue, word-break: break-all for long paths |
| Kind chip | system-ui | 11px | .park-kind — new / modified, muted, 6px left margin |
| Decided line · status msg | system-ui | 13px | .park-done green · .park-msg muted |
| Run-state pill | system-ui | 12px / line-height 1 | Lowercase status word, e.g. parked |
| Metric | Value | Metric | Value |
|---|---|---|---|
| Callout radius · padding | 12px · 14px 16px | Gate card radius · padding | 10px · 10px 12px |
| Pill radius · padding | 999px · 5px 8px | Comments padding | 6px 8px · 2 rows |
| Button padding | 7px 16px | Button padding ≤480px | 10px 18px · 14px |
| Decided opacity | 0.75 | Reviewer maxlength | 80 |
| Poll interval | 5s | Re-render after store | 900ms |
One state where silence is healthy
| Pill | Fill / text | Meaning |
|---|---|---|
| running · created | #173765 / #3b82f6 | A conductor process is alive and working. |
| completed | #c9ffd8 / #08783d | Finalized, ok: true. |
| failed · error | #4b1520 / #fb7185 | Finalized, something broke. |
| review_waiting | #3b2f15 / #ffd08a | Live gate — a process is blocked on a human right now. |
| stale? | #3b2f15 / #ffd08a | Badge on an "active" run whose status.json went quiet — the conductor may be dead. |
| parked | rgba(240,180,41,.15) / #e8a13a | No process, on purpose. Gates await review; cadora resume continues. |
parked shares the amber family with review_waiting and stale? but is the only translucent fill — a run that is deliberately not running, not one that went quiet. The staleness check exempts it: parked is the one state where untouched-for-days is healthy. While parked, status.json reads "status": "parked" with the pending node list, and the manifest stays "ok": null — in flight is the truth.
One card per pending gate · decisions stored now, applied at resume
Every condition a gate card must render
Open · undecided
Storing
Stored · confirms, then re-renders
Decided · reduced opacity
Refused · the server's words
No changed documents
A decided gate collapses to its head line at opacity 0.75: ✓ <decision> by <name> — applies at resume (the by clause appears only when a reviewer was named). One decision per gate — a second attempt is refused with 409 and the refusal is printed in place. The callout renders as long as gates are pending; once every gate is decided the run simply waits, decided cards intact, for cadora resume.
≤480px · the card stacks, the tap targets grow
| Rule at ≤480px | Value |
|---|---|
| Actions wrap | .review-actions gains flex-wrap: wrap — three buttons never shrink to fit one line. |
| Tap targets grow | .review-btn padding 10px 18px, font 14px. |
| Head stacks | .park-head goes column, aligned to the start — node id above cost. |
| Workspace single-column | .run-workspace drops its two-column grid to display: block. |
The product rule: mobile is for triage and deciding — on a phone you decide gates you already understand. The full reading and annotation experience deliberately stays on desktop; the phone gets the verdicts, the desk gets the documents.
What each control does, and the contract that protects the reviewer
| Control | Action | Result |
|---|---|---|
| Approve | POST approve | Stored in the archive; the node passes when the run resumes. |
| Request changes | POST request_changes + comments | Stored; at resume the stage re-runs with the comments in its prompt. |
| Abort | POST abort | Stored; the resumed run stops at this gate, recorded in the evidence. |
| Document link | Open the rendered file | Serves the doc's current bytes from the run workspace, new tab. |
| Reviewer input | Saved on submit | Name recorded on the decision and remembered as cadora-reviewer. |
| Rule | Why |
|---|---|
| Polling pauses while any gate is undecided | The 5s auto-poll skips its refresh until every pending gate has a decision — a re-render must never wipe a half-typed comment. After a store, the panel re-renders once (900ms) to show the decided card. |
| cadora-reviewer persists | Typed once, the name prefills every later gate on every run from that browser — the live review panel reads the same key, so the identity follows the reviewer between surfaces. Each device's browser keeps its own copy. |
| JSON-only POST | POST /api/runs/<id>/park/decision with Content-Type: application/json — body {node_id, decision, comments, reviewer}. Any other content type is refused (415). |
| Decisions bind to bytes | The store records the SHA-256 of each document's bytes at decision time — what the reviewer actually saw. A resume refuses to honor the decision if those bytes changed. |
| Same validity rules as every surface | Only approve / request_changes / abort; request_changes requires comments. The recorded method is dashboard. |
| Refusal | Server response | The panel shows |
|---|---|---|
| 409 already decided | node 'design' already has a parked decision | The server's error, verbatim, in .park-msg. |
| 400 not pending | node 'design' is not pending in this park | Verbatim. |
| 400 invalid decision | request_changes requires reviewer comments · invalid review decision: '…' | Verbatim — the gate stays open to retry. |
| 404 not parked | run is not parked - use the live review endpoint | No JSON body, so the fallback: failed to store the decision. |
The park banner · the resume line — the surface's bookends
The process then exits with code 75 — EX_TEMPFAIL, the sysexits convention for a temporary condition where the caller is invited to retry. Wrappers and schedulers must never read a waiting human as a failure. Parking is opted into per run with --on-review park; the default (wait) blocks in place.
At resume, each stored decision announces itself before it acts: <decision> by <reviewer> via <method> (an unnamed reviewer prints as (unattributed)). A stored decision that fails validation is discarded with the red line — one shot, then the gate falls back to live review. Nothing re-runs and nothing is re-paid: completed nodes reload verbatim from the archive.
Words are part of the design
| Do | Not |
|---|---|
| Name the state and the consequence — “the run is not running; decisions apply at cadora resume” | “Paused” |
| Name when it lands — “✓ stored: approve — applies at resume” | “Saved!” |
| Relay the refusal verbatim — “node 'design' already has a parked decision” | “Error 409” |
| Price the decision — $1.2874 so far | Hiding spend until the run ends |
| Sentence case — “Request changes” | Title Case — “Request Changes” |