* feat(web): pure reducer for Critique Theater states (Phase 7.1)
Pure CritiqueState reducer driven by the contracts-level PanelEvent
(the same shape both the live SSE stream and the recorded transcript
emit), so a single reducer powers both the in-flight panel and the
rerun replay. Lifecycle covers run_started → running → (shipped /
degraded / interrupted / failed), with panelist_open / dim /
must_fix / close / round_end events building per-round
CritiquePanelistView entries as they arrive.
Defensive behaviour that surfaced while writing the spec tests:
- Terminal phases (shipped / degraded / interrupted / failed) are
sticky against further lifecycle events for the same run, except
for parser_warning which can land late and is recorded in a side
channel without changing phase.
- A new run_started for a different runId at any time discards the
prior state and reboots, so the UI can launch consecutive runs
without an explicit reset action.
- Events whose runId does not match the active run return the same
state reference, so React's useReducer doesn't re-render
subscribers on stray traffic.
- Round bookkeeping keys by round number rather than "always last",
so an out-of-order panelist_dim for round 1 arriving after a
round 2 dim does not corrupt the round 2 bucket.
Test coverage: 18 cases covering each transition, the runId guard,
sticky-terminal behaviour, the out-of-order round invariant, and
the stable-identity guarantee. Sets up Phase 7.2 and 7.3 to wire
SSE + replay into the same reducer.
* feat(web): useCritiqueStream hook subscribes to SSE and feeds reducer (Phase 7.2)
createCritiqueEventsConnection is a pure connection manager that
mirrors apps/web/src/providers/project-events.ts: opens an
EventSource at /api/projects/:id/events, listens for every name in
CRITIQUE_SSE_EVENT_NAMES, decodes each frame back into a PanelEvent
(stripping the critique. prefix and merging the data payload), and
hands it to the caller's onEvent. Reconnect uses exponential
backoff (1s → 30s) and resets on `ready`; malformed payloads drop
with a dev-mode warning rather than tearing the stream.
useCritiqueStream wraps the manager in a useReducer that owns the
CritiqueState. enabled=false or a null projectId tears down the
connection cleanly; switching projectId closes the old connection
and opens a fresh one. The returned dispatch lets local UI
synthesise actions (e.g. an Esc keypress firing a synthetic
interrupted while a kill request is in flight); production traffic
comes from the SSE stream.
Test coverage:
- sse.test.ts (10 cases, node env): subscription set covers every
CRITIQUE_SSE_EVENT_NAMES channel; payload decoding lifts the wire
shape back to PanelEvent; malformed JSON is swallowed and does
not stop the stream; exponential backoff schedule and ready-reset
semantics are pinned with a setTimeout seam; close() cancels
pending reconnects and shuts the live source; no-op fallback
when EventSource is unavailable.
- useCritiqueStream.test.tsx (6 cases, jsdom env): idle pre-event,
reducer driven by synthetic actions, no connection when disabled
or projectId is null, clean close on unmount, projectId change
reopens cleanly.
* feat(web): useCritiqueReplay hook drives reducer from transcript file (Phase 7.3)
Fetches the per-run NDJSON transcript (one PanelEvent per line),
parses every line via the shared isPanelEvent predicate, and
dispatches into the same CritiqueState reducer the live SSE stream
uses. A single reducer means the UI rendering a replay can be
identical to the live panel, and a UI mounting both
useCritiqueStream and useCritiqueReplay in parallel does not have
to reconcile two state shapes.
speed knob is `paused | instant | live | { intervalMs: N }`.
- instant flushes every event synchronously, useful for opening a
finished run already at its terminal state.
- intervalMs paces dispatches at a fixed cadence so the reviewer
can watch the run unfold.
- paused parses the transcript but holds events back until the
caller advances speed (consumers can drive a scrubber later).
- live is reserved for the future "playback at original cadence"
feature, currently treated as instant; replay timestamps are not
yet persisted with each event so honest pacing requires a
follow-up Phase 7+ task.
gunzip seam handles `.ndjson.gz` transcripts via
DecompressionStream when present; the production fetch path picks
between text and arrayBuffer based on the URL extension. Both seams
are injectable so the unit tests don't need to spin up a real
network or a real gzip pipeline.
Test coverage (8 cases, jsdom env):
- Idle status before any URL is provided.
- speed=instant flushes the full transcript synchronously to
shipped state.
- speed={intervalMs:N} paces with the setTimeout seam, reaching
done after the last tick.
- speed=paused leaves status=playing with no dispatches.
- Empty transcript reports done with state still idle.
- Fetch rejection surfaces an error status with the message.
- Malformed NDJSON lines are skipped; valid events around them
still land.
- .gz transcripts route through the gunzip seam.
Closes the Phase 7 plan tasks 7.1 / 7.2 / 7.3 (reducer + stream +
replay), all on one branch ready for review. Phases 8+ (Theater
components) consume these from this PR.
* fix(web): close payload-override gap + paused-resume bug in Critique Theater hooks (Phase 7 review)
Two P1 fixes from lefarcen's review on PR #1307:
SSE payload override
`sseToPanelEvent` previously spread `data` after the channel-derived
`type`, so a payload-provided `type` could override the channel and
route a `critique.run_started` frame into the reducer as a `ship`
action. Reversed the spread so the channel-derived `type` is
authoritative, and revalidated the resulting object through the
contracts-level `isPanelEvent` predicate before returning. Frames
that fail validation (missing runId, empty runId, unknown type) are
dropped, so a malformed or compromised SSE frame can no longer
dispatch a wrong-shape action into the reducer.
Three new sse.test.ts cases pin the regression: hostile `type:'ship'`
in the payload still resolves to `run_started`, missing runId is
dropped, empty runId is dropped.
Replay pause/resume
`useCritiqueReplay` had one big effect keyed on `transcriptUrl`
only, so flipping `speed` from `paused` to `instant` never re-fired
and the held events sat undispatched. Split into a parse effect
(depends on URL, fetches and stores events in state) and a pace
effect (depends on parsed-events + speed, owns the cursor + timers).
The playback cursor lives in a ref that survives pause/resume
cycles, so flipping `paused` -> `instant` flushes from the current
position rather than restarting (which would double-dispatch
`run_started` and reset the reducer).
Two new useCritiqueReplay.test.tsx cases:
- paused-then-instant transitions from `playing` to `done` and
reaches the shipped terminal phase
- intervalMs paced playback dispatches one event, pauses to drain
the next scheduled timer, flips to instant, and confirms the
remaining transcript drains exactly once (cursor was preserved)
Doc consistency
The earlier source comment in useCritiqueReplay.ts claimed `live`
"paces by recorded timestamps" while the impl used zero-delay
timers and the PR body said it behaves like `instant`. Aligned to
reality: `live` currently behaves like `{ intervalMs: 0 }` (events
drain on successive microtasks via setTimeoutFn) because transcripts
do not yet carry per-event timestamps. Honest timestamp-driven
pacing is queued as a Phase 7+ follow-up.
Validated: pnpm guard, pnpm --filter @open-design/web typecheck,
Theater suite 47/47 (up from 42, +3 sse + 2 replay), full web suite
96 files / 888 tests.
* feat(i18n): seed Critique Theater key block (en + zh-CN; other locales fall back via spread)
* feat(web): Theater PanelistLane component (Phase 8.1)
* feat(web): Theater ScoreTicker component (Phase 8.2)
* feat(web): Theater RoundDivider component (Phase 8.3)
* feat(web): Theater InterruptButton component with Escape keybind (Phase 8.4)
* feat(web): Theater TheaterDegraded chip (Phase 8.5)
* feat(web): Theater TheaterCollapsed post-run summary (Phase 8.6)
* feat(web): Theater TheaterTranscript replay surface (Phase 8.7)
* feat(web): Theater TheaterStage top-level container (Phase 8.8)
* feat(web): Theater CSS using existing semantic tokens (no hex literals)
* feat(web): Theater public exports barrel
* fix(web): resolve P2 + P3 review feedback on Phase 8 (PR #1314)
Addresses all 4 P2 + 3 P3 items from codex, Siri-Ray, and lefarcen.
State-lifecycle fixes (3 x P2)
1. Reducer learns a synthetic `__reset__` action (`CritiqueResetAction`).
Host hooks dispatch it when their gating prop changes so a stale
run from a prior project / transcript cannot bleed into the next
context. Reset is idempotent on idle (returns the same reference).
2. `useCritiqueStream` dispatches `__reset__` at the top of its
connection effect, so a workspace switch from project A (which
streamed a critique) to project B clears the reducer before the
new EventSource opens. enabled=false also clears.
3. `useCritiqueReplay` dispatches `__reset__` at the top of its
parse effect, so transcriptUrl swaps (including swap-to-null after
a replay reached `shipped`) lift the reducer back to idle before
the new fetch starts.
SSE validation (1 x P2)
4. `sseToPanelEvent` now runs a per-variant `hasValidVariantShape`
check after the cheap `isPanelEvent` predicate. A
`critique.ship` frame missing `composite` / `round` / `status` /
`artifactRef` is rejected before reaching the reducer, so
TheaterCollapsed can no longer crash on `undefined.toFixed(1)`.
Every variant's required fields are validated: run_started
(protocolVersion, non-empty cast, maxRounds, threshold, scale),
panelist_* (round, role, plus variant-specific shape), round_end
(round, composite, mustFix, decision in {continue,ship}, reason),
ship (round, composite, status, artifactRef.{projectId,artifactId},
summary), degraded (reason, adapter), interrupted (bestRound,
composite), failed (cause), parser_warning (kind, position).
Reducer correctness (1 x P2)
5. `panelist_open` now materializes the round + an empty panelist
view (`{dims: [], mustFixes: []}`) so TheaterStage can highlight
the in-progress lane the instant the tag opens. Before this, a
stream that emitted only `panelist_open` after `run_started` left
`rounds = []` and the UI rendered no current round until a later
`panelist_dim` arrived.
Polish (3 x P3)
6. Brand role tint swaps from `var(--magenta, var(--accent))` to
`var(--purple, var(--accent))`. `--purple` is actually defined
across the design systems; `--magenta` is not, so Brand was
silently falling through to `--accent` and looking identical to
Designer.
7. New i18n key `critiqueTheater.interruptedSummary` for the
interrupted-collapse copy ("Interrupted at round N, best
composite X.X"). Previously the interrupted branch reused
`shippedSummary` and the UI read "Shipped at round..." for a run
that specifically did not ship. Native value in en + zh-CN; other
locales fall back via `...en` spread.
8. `TheaterDegraded` heading id comes from `useId()` instead of a
hardcoded `theater-degraded-heading`, so two chips rendered on
the same page (chat history with multiple completed runs) keep
their aria-labelledby references unambiguous.
Tests (15 new cases)
- reducer.test.ts (+5): __reset__ on running/terminal/idle, panelist_open materializes round, panelist_open does not stomp prior panelist data.
- sse.test.ts (+6): variant-level rejection for ship without required fields, degraded without adapter, run_started with empty cast, panelist_dim with non-numeric score, round_end with unknown decision, plus a positive fully-formed ship.
- useCritiqueStream.test.tsx (+2): state reset on projectId change, state reset on enabled flip false.
- useCritiqueReplay.test.tsx (+1): state reset on transcriptUrl swap to null after a replay reached shipped.
- TheaterCollapsed.test.tsx (text-pinning update): asserts the interrupted branch reads "Interrupted at round 1" + "best composite 7.9", and explicitly NOT "Shipped at round...".
- TheaterDegraded.test.tsx (+1): two chips on the same page get unique aria-labelledby ids that each resolve to an `<h3>`.
Validated
- pnpm guard clean
- pnpm --filter @open-design/web typecheck clean
- Theater suite: 13 files, 101 tests (was 86 on the first Phase 8 push, +15 new)
- tests/i18n/locales.test.ts 5 of 5 across 18 locales
* feat(web): CritiqueTheaterMount wires SSE + reducer into a single drop-in (Phase 9.1)
* feat(i18n): Critique Theater strings for de + ja + ko + zh-TW (Phase 9.2)
* fix(web): resolve P1 + P2 review feedback on Phase 9 (PR #1315)
Addresses every blocker from codex, Siri-Ray, and lefarcen. The
three state-lifecycle and SSE-validation issues they also flagged
inherit fixes from PR #1314's review pass that this branch now sits
on top of after rebase.
Real daemon kill on Interrupt (P1)
- CritiqueTheaterMount now POSTs to
/api/projects/:id/critique/:runId/interrupt alongside the
optimistic local dispatch. Before this fix, clicking Interrupt
only flipped the React state to interrupted while the daemon job
kept running. The fetch is best-effort: a 404 (endpoint not wired
yet, lands in Phase 15) is swallowed with a dev-mode console.warn
so the UI still moves to the collapsed badge.
- New fetchInterrupt test seam lets RTL assert on the URL / method
and simulate the "daemon not ready yet" path. Two tests pin both:
the happy URL proj-42/critique/run-abc/interrupt POSTs, and a
rejected fetch still flips the UI.
interruptPending reset on new run (P2)
- A ref-backed effect compares the current runId against the last
one we saw; when it changes, interruptPending is cleared. A user
who interrupts run-1 and then triggers run-2 from the same mount
now gets a fresh, enabled kill button instead of one stuck in
"Interrupting…". Pinned by a new mount test.
Escape keybind scope (P2)
- InterruptButton now checks the keydown target. Escape inside an
input, textarea, select, or contenteditable element is ignored
(and any ancestor of those via closest() is treated the same
way). Body-level focus still fires the keybind so the Theater
area's affordance keeps working. Four new tests cover textarea,
input, contenteditable, and the body-focus positive case.
userFacingName i18n key (P2)
- The spec at specs/current/critique-theater.md:6 mandates a single
critiqueTheater.userFacingName key so the "Design Jury" label can
be renamed without touching code. Phase 8 introduced
critiqueTheater.title by mistake; renamed across types.ts, en.ts,
zh-CN.ts, de.ts, ja.ts, ko.ts, zh-TW.ts, and the lone consumer
TheaterStage.tsx. The locale alignment test stays green.
Validated
- pnpm guard clean
- pnpm --filter @open-design/web typecheck clean
- Theater suite: 14 files, 112 tests (was 101 before, +11 new for
the Phase 9 review pass: 3 mount + 4 InterruptButton focus scope;
the rest were already in #1314's review fix).
- tests/i18n/locales.test.ts 5 of 5 across 18 locales.
* feat(daemon): adapter-degraded registry with TTL (Phase 10.1)
In-memory registry recording adapters that produced malformed or
oversize transcripts so the orchestrator can skip them for a TTL
window (default 24h) instead of cycling through known-bad providers
on every run.
Records carry reason (malformed_block | oversize_block |
missing_artifact), source label, and expiresAt. The test-only
clock seam lets the suite advance time deterministically and prove
that an expired entry stops counting as degraded without anyone
calling clearDegraded.
7/7 vitest cases green.
* feat(daemon): synthetic good + bad adapter fixtures (Phase 10.2)
Two test-only adapters that read the existing v1 transcript
fixtures (happy-3-rounds and malformed-unbalanced) and replay them
as either a full string or a 512-byte chunked stream. The chunked
form is what the conformance harness uses to prove the parser
holds together when the transcript arrives in arbitrary network
slices, not as one buffered blob.
* feat(daemon): adapter conformance harness (Phase 10.3)
runAdapterConformance pulls a transcript through the same
parseCritiqueStream pipeline the orchestrator uses and classifies
the outcome as shipped, degraded, or failed. On a degraded
outcome it forwards the matched reason to the adapter-degraded
registry, so a single nightly conformance run is what populates
the skip list rather than the orchestrator learning each adapter
is broken at request time.
5/5 vitest cases green covering shipped, malformed degraded,
oversize degraded, no-ship failure, and the harness-thrown
failure path.
* test(e2e): Critique Theater Playwright suite (Phase 11)
Six tests, one viewport per visual case, deterministic SSE
fixtures stubbed via page.route(). Adds the suite to
test:ui:extended so the existing extended-UI lane picks it up.
Coverage:
1. Happy path: a single mounted theater plays the full
fixture (1 run_started, 5 panelists open / dim / must_fix /
close, 1 round_end, 1 ship) and ends on the score badge.
2. Interrupt mid-run: the panelist that is open at the time
the interrupt button is clicked closes with an interrupted
marker and the transcript freezes there.
3. Visual regression at 375x720 mobile.
4. Visual regression at 768x1024 tablet.
5. Visual regression at 1280x800 desktop.
6. A11y role tree: the theater region exposes a labelled
landmark, each panelist lane is a group with an accessible
name, the score is a status live region.
All SSE traffic is stubbed by page.route so the suite runs in CI
without a daemon. The toggle is seeded via localStorage by
bootAppWithCritiqueEnabled so the gate behaves as if Settings
flipped it on. typecheck clean; playwright --list reports 6.
* test(web): reducer p99 bench at 10k iterations (Phase 13.1)
Locks the documented 2ms budget for the Critique Theater reducer
on a representative SSE script (27 actions, one full happy run)
behind a regression gate. Asserts p99 stays under 4ms (2x the
documented budget) so CI runners with a noisy neighbour do not
flake while a real regression to 20ms or 200ms still trips.
The bench is a vitest case rather than a bare microbenchmark so
it runs in the same CI lane as every other web test and does not
need a parallel runner.
* test(web): critique surface coverage walker (Phase 13.2)
Walks the public critique surface (11 SSE event names, 5 panelist
roles, 6 lifecycle phases, 9 named i18n keys) and asserts each
named symbol appears in both the src corpus and the test corpus.
The walker is the gate that catches a rename in one half of the
codebase without a matching update in the other half: a future
PR that drops 'panelist_must_fix' from the reducer without also
removing its test reference fails this suite.
62 assertions, one per symbol per corpus.
* docs: Critique Theater user guide (Phase 14.1)
Seven sections aimed at end users (not contributors):
1. What is Design Jury
2. How it works (the five panelists, auto-converging rounds,
the composite formula)
3. Settings (the M1 toggle and what it does)
4. Reading the score badge
5. Replay surface
6. Troubleshooting (degraded, interrupted, failed)
7. FAQ
The composite formula is documented as
designer * 0 + critic * 0.4 + brand * 0.2 + a11y * 0.2 + copy * 0.2
because anyone trying to reverse-engineer the score is going to
search for those weights and the docs are the place they should
land first.
* docs(daemon): critique module AGENTS map (Phase 14.2)
Daemon-side wayfinder for the apps/daemon/src/critique directory.
Tables every file, what owns what invariant, and the 'when you
change anything here' guide so a future contributor does not
have to reverse-engineer the rollout resolver before adding a
new SSE event.
* docs(web): Theater module AGENTS map (Phase 14.3)
Web-side mirror of the daemon AGENTS map. Same file table, same
invariants section, same change-impact guide, sized to the
Theater component package.
* feat(daemon): rollout flag resolver (Phase 15.1)
Single decision point every caller consults to know whether the
orchestrator should wire the critique pipeline for a given run.
Priority:
1. Skill-level policy (required wins, opt-out wins inversely)
2. Per-project override from the Settings toggle
3. OD_CRITIQUE_ENABLED env override
4. Rollout phase default
M0 dark-launch false
M1 settings only false (toggle is off until the user flips it)
M2 per-skill true if skill opted in
M3 global default true
OD_CRITIQUE_ROLLOUT_PHASE parser defaults to M0 on unknown input
so a fresh install never surprises a user with the feature on.
10/10 vitest cases green covering every cell of the matrix.
* feat(web): Settings toggle hook for Critique Theater (Phase 15.2)
React hook that reads critiqueTheaterEnabled from the existing
open-design:config localStorage blob and stays in sync via:
- the platform storage event (cross-tab)
- a open-design:critique-theater-toggle CustomEvent (same-tab)
Same-tab event is the one that fires when the Settings panel saves
in the current window: the toggle and every mounted theater update
without a page reload.
setCritiqueTheaterEnabled(next) is the imperative setter the Settings
panel calls. It preserves the rest of the stored config (mode, apiKey,
etc.) and dispatches the same-tab event after the localStorage write.
The web hook reflects what the user toggled; the daemon-side
isCritiqueEnabled is the final routing authority (project override,
env, rollout phase). When they disagree, the daemon wins for backend
gating and the web reflects the toggle state.
6/6 vitest cases green covering first read, stored read, same-tab
event flip, config preservation, corrupted JSON tolerance, and
cross-tab storage event.
* test(web): Phase 15 toggle hook failure-mode coverage (PR #1320)
lefarcen P2 on PR #1320 flagged that the PR body claimed safe
behavior for disabled localStorage, non-object JSON, and missing
CustomEvent shim, but the suite only covered corrupt JSON plus
happy-path storage events. Added four failure-mode tests so the
swallowed errors are not silently traded for a throw in a future
refactor:
1. Returns false on a stored JSON value that parses to an array
(non-object). Catches a regression where the guard treats
anything truthy as a config blob.
2. Returns false on a stored JSON value of literal 'null'.
typeof null === 'object' in JS, so the guard has to check null
explicitly; this test pins that check.
3. Returns false when localStorage.getItem throws (private mode /
disabled storage / SecurityError). The hook must swallow and
return false so the rest of the app keeps rendering.
4. setCritiqueTheaterEnabled still dispatches the same-tab
CustomEvent when localStorage.setItem throws (quota exceeded /
disabled storage). The dispatch path is the in-session
broadcast that keeps every mounted hook coherent even when
persistence is unavailable; verified by mounting two probes
and asserting both flip after the setter is called with a
throwing setItem.
10/10 vitest cases green (6 existing + 4 new).
* fix(web): honor CustomEvent payload in toggle hook listener (PR #1320)
Both Siri-Ray (blocking) and lefarcen (P2 new) caught the same
real bug in the failure-mode test I added in affcdd27: the test
asserts the in-session UI flips when localStorage.setItem throws,
but the CustomEvent listener was ignoring the event's typed
detail and just calling readToggle(). Under a throwing setItem
the localStorage value is stale (or absent), so the listener
would see the OLD value and the test would fail (or worse, the
production claim 'in-session event keeps mounts coherent' was
hollow).
Fixed the hook, not the test: the listener now reads
event.detail.enabled when it is a boolean, falling back to
readToggle() only for malformed events or for cross-tab storage
events (which do not carry a typed payload). The setter already
dispatched the detail; the listener just was not consuming it.
Test changes:
- The existing 'setItem throws' test now asserts the right
behavior for the right reason. Updated the inline comment to
say the listener reads from detail, not localStorage.
- New test 'falls back to readToggle when the CustomEvent
carries no usable detail' pins the fallback path: a
malformed dispatcher (no detail, or detail.enabled not a
boolean) degrades cleanly instead of throwing or being
silently ignored.
11 / 11 vitest cases green (10 prior + 1 new fallback).
* feat(daemon): route critique spawn-path eligibility through the rollout resolver
The wireup edit Phase 10 and Phase 15 carved out: today server.ts gates
the critique pipeline on critiqueCfg.enabled, which is just the
OD_CRITIQUE_ENABLED env var. After this commit it gates on
isCritiqueEnabled(...) from the Phase 15 resolver, so the full
priority matrix is live:
1. Per-skill od.critique.policy veto (opt-out / required)
2. Per-project override (M1 Settings toggle, written through the
existing Phase 6 settings endpoint)
3. OD_CRITIQUE_ENABLED env override (power-user lane / CI fixtures)
4. OD_CRITIQUE_ROLLOUT_PHASE default
M0 dark-launch false
M1 settings only false
M2 per-skill only when skillPolicy === 'opt-in'
M3 global default true
Default behaviour on a fresh install is unchanged: the resolver
returns false at M0 without an env override or a project override,
so prod traffic falls through to the legacy single-pass path
exactly the way it did before.
Inputs threaded today: phase from OD_CRITIQUE_ROLLOUT_PHASE,
envOverride from OD_CRITIQUE_ENABLED. skillPolicy and projectOverride
are passed as null for the v1 cutover; the daemon-side handler that
round-trips critiqueTheaterEnabled on the project settings row and
the od.critique.policy frontmatter resolver land as the next two
commits in this branch.
The three call sites that used critiqueCfg.enabled (the brand-thread
guard, the skill-thread guard, the top-line critiqueShouldRun
compound) now read from a single locally-scoped critiqueEnabledForRun
boolean, so the eligibility check is computed exactly once per spawn
and the prompt composer + orchestrator stay in lockstep the way
the existing comment already promised.
Tests still green: daemon vitest 22 / 22 across rollout +
conformance + adapter-degraded. Daemon typecheck clean.
* feat(web): mount CritiqueTheaterMount in ProjectView
The web counterpart of the daemon wireup. ProjectView now renders
<CritiqueTheaterMount projectId={project.id} enabled={...} /> as a
sibling of <AppChromeHeader> inside the top-level <div className="app">.
The mount is the drop-in from the Phase 9 stack: it owns the SSE
subscription, the kill-request handshake, and the phase-aware swap
from the live <TheaterStage> to the collapsed badge once a run
settles. The mount returns null until the daemon emits a
critique.run_started for the active project, so the visual surface
is byte-for-byte unchanged for users who have not opted in.
Enabled wiring: useCritiqueTheaterEnabled() reads the M1 Settings
toggle from the existing open-design:config localStorage blob and
stays in sync with both the platform storage event (cross-tab) and
the same-tab open-design:critique-theater-toggle CustomEvent the
Phase 15 setter dispatches. The hook honors the event payload
directly so a private-mode browser that cannot persist the toggle
still updates the in-session UI correctly.
The daemon-side gate (isCritiqueEnabled in apps/daemon/src/server.ts)
remains the authority for whether a run is actually wired through
the critique pipeline. This hook only governs whether the web layer
renders the resulting SSE stream when the daemon emits one. The
two-layer gate is intentional: an integrator embedding the Theater
in a custom UI can flip the web visibility independent of the
daemon's routing decision, and a daemon-side env override flips
backend gating without touching the web's localStorage.
Tests still green: web Theater suite 181 / 181 across 16 files.
Web typecheck clean.
* feat(daemon): resolve od.critique.policy frontmatter at the spawn site
The next step in the wireup branch's ladder: replace the placeholder
`skillPolicy: null` with the actual value parsed from the active
skill's SKILL.md frontmatter.
Three small edits, one new field on a public type:
1. SkillInfo gains a `critiquePolicy: SkillCritiquePolicy` field
carrying the parsed `od.critique.policy` token (required /
opt-in / opt-out / null). The field is null when the skill has
no opinion, which lets the lower-priority resolver tiers
(projectOverride, envOverride, phase default) decide.
2. listSkills() populates the new field via a small
`normalizeCritiquePolicy` helper that tolerates the YAML
scalar's casing and trims whitespace. Unknown tokens collapse
to null so a typo in SKILL.md cannot accidentally force the
panel on or off; it just falls through. Derived example cards
inherit the parent's policy.
3. server.ts captures `skill.critiquePolicy` into a hoisted
`skillCritiquePolicy` variable inside the existing skill-load
block, then threads it into the isCritiqueEnabled call as the
skillPolicy input. The hoisting keeps the variable in scope at
the resolver call site without restructuring the spawn handler.
After this commit, the priority matrix the rollout resolver was
designed for is live for its top tier. The previous commit wired
env + phase; this one wires skill. The projectOverride input
remains null pending the next commit that extends the Phase 6
settings endpoint.
Daemon vitest: 10 / 10 rollout cases pass against the new wiring.
Daemon typecheck: clean.
* feat(daemon): feed projectOverride into the rollout resolver from project metadata
Replaces the placeholder `projectOverride: null` in the spawn
handler with the actual value the Settings panel writes onto the
project's metadata blob: `critiqueTheaterEnabled?: boolean`.
The read is defensive at the boundary: the metadata object is
typed loosely (it round-trips through SQLite as a free-form JSON
blob), so the spawn handler narrows to `boolean` and falls
through to `null` for any other shape. A missing key, a malformed
value, or a project that has never visited Settings collapses to
`null`, which is exactly the resolver's "no opinion, fall
through to env / phase" signal.
The `critique` frontmatter slot also gets typed on the
SkillFrontmatter shape so the `od.critique.policy` chain the
previous commit introduced no longer needs a bracket-access
cast. Same pattern as the existing `craft`, `preview`, and
`design_system` nested-record slots.
After this commit, every tier of the rollout resolver's priority
matrix is wired:
1. skillPolicy (from SKILL.md od.critique.policy)
2. projectOverride (from project metadata critiqueTheaterEnabled)
3. envOverride (from OD_CRITIQUE_ENABLED)
4. rollout phase (from OD_CRITIQUE_ROLLOUT_PHASE)
The write path for projectOverride still flows through the
existing project-update handler the Settings panel already uses
to persist project metadata; no new endpoint is needed. The
Settings UI button that calls setCritiqueTheaterEnabled and
posts the new field is the next commit on this branch.
Daemon typecheck: clean. Daemon vitest: 10 / 10 rollout cases
still green against the new wiring.
* fix(daemon): forward critique events to project sinks + align composer gate (PR #1338)
Two codex review items addressed in one commit since they share the
same root cause (resolver-enabled run hits a transport / prompt
contract that was still env-gated):
P1 (transport mismatch). The daemon emits critique.* SSE frames
through critiqueBus -> design.runs.emit, which fans out on
/api/runs/:runId/events. The web CritiqueTheaterMount subscribes to
/api/projects/:projectId/events (it's project-scoped, not run-
scoped, because the mount lives at the project workspace and
follows the user across runs). Result: in production the mount
never sees a real frame and the e2e tests' stubbed routes hide the
mismatch.
Fixed by extending critiqueBus.emit to fan out to BOTH sinks: the
existing runs.emit transport, AND the per-project event-sinks map.
The project-events route emits via sse.send(payload.type, payload),
so we pack the SSE channel name onto payload.type and let the sink
push the right channel. The web sseToPanelEvent overwrites type
from the channel name on the way back into a PanelEvent, so the
round-trip stays correct.
P2 (prompt gate misalignment). composeSystemPrompt reads
cfg.enabled to decide whether to append the panel addendum, but
critiqueCfg.enabled is loaded from OD_CRITIQUE_ENABLED only. A run
the resolver enabled via phase / project / skill (env unset) would
have critiqueShouldRun = true while critiqueCfg.enabled remained
false, dropping the panel prompt while still routing through
runOrchestrator -> parser waits for tags that never arrive -> run
degrades.
Fixed by passing a derived config { ...critiqueCfg, enabled: true }
to the composer when critiqueShouldRun is true. The composer's own
gate now agrees with the resolver decision on every input the
spec defines.
Daemon typecheck: clean. Daemon vitest: 10 / 10 rollout cases
still green against the new wiring.
* fix: address PerishCode P1 + P2 follow-ups on PR #1338
Two follow-up items PerishCode flagged on the activation PR.
Non-blocking but both are real:
1. Phase 11 e2e suite was wired into test:ui:extended but lands
the user on '/' (home route) where ProjectView (and therefore
CritiqueTheaterMount) is never rendered. With the suite as
written, every assertion would time out the first time the
lane runs in CI, contradicting the PR body's claim that the
suite stays parked behind test.describe.fixme.
The state diverged from my earlier Phase 11 work because the
merge from main on commit 4ab719c6 brought in #1307's
squash-merged version of the e2e file (the pre-fixme shape).
Re-applied test.describe.fixme to the describe block plus
removed ui/critique-theater.test.ts from the test:ui:extended
script in e2e/package.json. Added a file-header docblock
explaining what the follow-up commit needs to do: replace
goto('/') with /projects/:id navigation similar to
app-design-files.test.ts, split the SSE fixture into a live
prefix and terminal suffix (Codex P2 on PR #1320), and commit
the first PNG baselines.
2. bestRoundOf in CritiqueTheaterMount returned the LAST round
with a numeric composite, not the round with the HIGHEST
composite, while bestCompositeOf correctly returned the max.
A run that closed round 1 at 8.5 and round 2 at 6.0 would
dispatch interrupted { bestRound: 2, composite: 8.5 } on a
user-clicked interrupt.
Folded the two helpers into a single bestRoundAndComposite
that walks state.rounds once and returns the matching pair so
the two values cannot drift. The onInterrupt callback now
destructures from one helper instead of two independent reads.
Falls back to (state.activeRound, 0) when no round has closed
with a composite yet.
Web typecheck: clean. CritiqueTheaterMount.test.tsx: 7 / 7 cases
still green against the new helper.
* fix: wire M1 project override end-to-end + correct deferred-surface doc claims (PR #1338)
Three lefarcen P2s on the latest review pass, all real:
1. M1 project override was half-wired: the daemon read
metadata.critiqueTheaterEnabled but the web setter only
wrote localStorage. A user opt-in would render the Theater
on the web (localStorage was set) while the daemon resolved
projectOverride=null and skipped critique unless env / phase
already permitted. Two halves talking past each other.
Extended setCritiqueTheaterEnabled to accept an optional
{ projectId, fetchProjectSettings } options bag. When a
projectId is supplied, the setter ALSO sends a
PATCH /api/projects/:id with { metadata: { critiqueTheaterEnabled
} } so the daemon's spawn-time resolver picks the same value up
on the next generation. The existing project-routes endpoint
already accepts arbitrary metadata patches, so no new endpoint
is needed. The local write + the CustomEvent dispatch still
fire before the PATCH, so a network failure does not unwind
the in-session UI flip. Three new vitest cases pin the new
path: PATCHes when projectId is provided, skips when it is
not, swallows a rejected PATCH so the in-session UI still
flips.
2. Rollout docs (docs/critique-theater.md section 3) claimed the
Settings toggle persists into the daemon settings store, but
the previous implementation only had a localStorage reader /
writer plus a daemon read of project metadata, with no
round-trip. Rewrote the section to lead with the four-tier
resolver (skill policy / project override / env / phase),
document that the setter now round-trips via the existing
PATCH endpoint when given a projectId, and call out the
Settings panel UI control as a deliberate follow-up.
3. Troubleshooting table pointed users at /api/metrics/critique
(Phase 12, deferred) and 'od adapters clear-degraded <id>'
(CLI wrapper that does not exist). Replaced the metrics
reference with the local conformance harness command
(pnpm --filter @open-design/daemon vitest run
tests/critique-conformance.test.ts) that ships today, with a
note that the Phase 12 dashboard surfaces this status as a
series once that PR lands. Replaced the CLI command with the
programmatic clearDegraded() helper that exists today and
flagged the CLI wrapper as planned follow-up.
Web typecheck: clean. Toggle hook tests: 14 / 14 green (11
existing + 3 new for the round-trip path).
* test(web): multi-round interrupt regression for bestRoundAndComposite (PR #1338)
lefarcen P3 follow-up to the previous bestRoundAndComposite fix:
the existing CritiqueTheaterMount.test.tsx interrupt cases only
exercised a single-round state, so a future refactor back to two
independent helpers wouldn't be caught by the test suite even
though it'd reintroduce the round / composite drift bug.
Added a regression case that:
1. Drives the reducer through two complete rounds with the
full 5-role cast closing at distinct composites: round 1
at 8.5, round 2 at 6.0 (the high-composite round is NOT the
most recent one).
2. Clicks Interrupt + waits for the daemon ack via the test
seam fetcher returning 204.
3. Asserts the collapsed badge displays "round 1" (the
correct best-composite round), and queryByText for
"round 2 ... 8.5" returns null (the buggy pairing
would have produced that string).
The bestRoundAndComposite helper walks state.rounds in one pass
and returns the matching pair, so the round number and the
composite cannot drift apart. This test locks the fix in: a
refactor that splits the helpers back into independent walks
will be caught here.
8 / 8 vitest cases green on the file.
* fix(web): read-merge-write the project metadata in setCritiqueTheaterEnabled (PerishCode P2 on PR #1338)
The previous round-trip sent { metadata: { critiqueTheaterEnabled: next } }
as the entire PATCH body. The daemon's project-routes handler only
re-stamps three immutable fields (baseDir, importedFrom,
fromTrustedPicker) before calling updateProject(db, id, patch),
which then does a shallow { ...existing, ...patch } in apps/daemon/
src/db.ts. So patch.metadata replaces the row's metadata wholesale,
dropping kind, templateId, linkedDirs, and every other field the rest
of the app reads.
No in-tree caller passes projectId today (only vitest cases), so the
bug had not surfaced yet. But the surface is documented in
docs/critique-theater.md section 3 and the function's own JSDoc as
the M1 round-trip path, so it would have shipped as a latent footgun
for the next integrator: a Settings UI follow-up, or any third party
that wires the setter into a project-aware surface.
Fix: read-merge-write rather than a bare patch.
- GET /api/projects/:id to read the row's current metadata.
- Spread that metadata into the PATCH body and overlay
critiqueTheaterEnabled: next on top, mirroring the partial-metadata
pattern already used in ChatComposer.tsx for linkedDirs.
- PATCH the merged object.
Failure handling:
- GET fails: skip the PATCH entirely. We cannot construct a safe
merged body without the current state, and a bare patch would
wipe other metadata. The in-session CustomEvent fired earlier in
the setter still keeps every mounted hook consistent; the next
save retries the round-trip.
- PATCH fails: log in dev. The in-session UI is already correct via
the CustomEvent.
Tests (TDD, red-first):
- 'GETs the project then PATCHes with merged metadata when a
projectId is supplied': stubs a GET that returns
{ kind: 'template', templateId: 'modern-blog', linkedDirs: [...] }
and asserts the PATCH body equals the merge plus the toggle.
- 'PATCHes with just the toggle when the project has no prior
metadata': stubs a GET that returns no metadata block.
- 'skips the PATCH (does not stomp metadata) when the prefetch GET
fails': stubs a rejecting GET and asserts only the GET fires.
- 'swallows a rejected PATCH after a successful prefetch': stubs a
successful GET and a rejecting PATCH; asserts the in-session UI
still flips via the CustomEvent.
Doc updated on the setter's JSDoc to describe the new three-step
flow (localStorage, CustomEvent, read-merge-write PATCH) and the
two failure modes.
Verified:
- pnpm --filter @open-design/web typecheck clean.
- pnpm --filter @open-design/web test: 111 files / 1055 tests green
(was 1052, +3 from the new merge-flow cases).
* fix(web): restore wait-for-daemon-ack pattern on Theater interrupt
Same regression as flagged on PR #1316 post-main-merge: the
optimistic local dispatch fired before the POST resolved, so a
daemon 404 / 409 still terminalized the UI and the real SSE
terminal event got ignored by the sticky interrupted phase.
Snapshot runId / bestRound / composite at click time, dispatch
interrupted only on res.ok, clear interruptPending on rejection or
non-2xx so the user can retry. Tests cover rejection + 404 leaving
the run on the live stage; the 204 path waits for the ack.
* fix(test): add projectKind prop to FileViewer deck render after v0.7.0 merge
* fix(daemon): address PerishCode P3 trio on PR #1338 (emit helper reuse + spawn-input coverage + restored docs)
---------
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
10 KiB
Design Jury (Critique Theater)
A built-in design panel that reviews every generation before it ships. Five specialists (Designer, Critic, Brand, Accessibility, Copy) score the artifact across each round, and the run keeps iterating until the composite clears the threshold or the orchestrator gives up.
The product label is Design Jury. The internal feature name (code paths,
metrics, env vars) stays Critique Theater: apps/daemon/src/critique/,
apps/web/src/components/Theater/, SSE channels critique.*, env vars
OD_CRITIQUE_*. The user-facing label is sourced from a single i18n key,
critiqueTheater.userFacingName, so the product name can be renamed without
touching code.
1. What is Design Jury
When an agent emits an <artifact> block, the Critique Theater wraps the
artifact in a structured review cycle. Five panelists vote on independent
dimensions, the orchestrator composites their scores, and the run either
ships (composite ≥ threshold) or runs another round. The user sees:
- A live panel while the run is in flight: one lane per panelist with the current round's score, the cumulative must-fix count, and a sparkline of per-dim scores.
- A score ticker showing the composite trajectory across rounds, with a horizontal threshold marker so it is obvious whether the latest round cleared the bar.
- A collapsed badge once the run settles: "Shipped at round 2, composite
8.6" for the happy path, with status variants for
below_threshold,timed_out, andinterrupted.
The Theater runs inside the same CLI session the agent already uses (no second process, no second transport). All five panelists are turns in the same conversation, which keeps the model context coherent and prevents the "panelist disagrees with itself across processes" failure mode.
2. How it works
The five panelists
| Role | What it scores |
|---|---|
| Designer | Layout, composition, hierarchy. The "is this beautiful and balanced" lens. |
| Critic | Whether the artifact actually meets the brief. Contrast, weight, readability. |
| Brand | Token compliance, voice, on-brand color use. |
| Accessibility | WCAG conformance, focus rings, semantic structure, alt text. |
| Copy | Voice, tone, terseness, error message quality. |
The cast is fixed in v1. Per-skill cast configuration is reserved for v2 (see roadmap).
Auto-converging rounds
The orchestrator runs up to 3 rounds by default. After each round the composite is computed from the per-panelist weights:
composite = designer × 0.0
+ critic × 0.4
+ brand × 0.2
+ a11y × 0.2
+ copy × 0.2
(Designer is weighted at zero in v1 because their dimensions are aesthetic preferences rather than ship gates. The slot exists so the Designer's qualitative notes still travel into the transcript, and a future config release can bump the weight without changing the schema.)
If the composite meets the threshold (8.0 / 10) the run ships. Otherwise
the orchestrator emits the round summary, the agent revises, and the next
round begins. After 3 rounds without convergence the run takes the
fallbackPolicy path (ship_best by default, falling back to whichever
round had the highest composite).
One CLI session, one transport
The orchestrator does not spawn extra processes per panelist. Each panelist
is a turn in the same agent session, separated by <PANELIST role="...">
tags in the protocol stream. The parser converts those into panelist_*
events that the web reducer consumes via SSE. This keeps the operational
contract identical to a normal generation: same auth, same env, same logs.
3. Settings reference
Enable / disable
The feature is gated by a four-tier resolver on the daemon side:
- Per-skill
od.critique.policy(highest priority). A skill that setspolicy: requiredforces the panel on for every generation that uses it;policy: opt-outforces it off;policy: opt-inlets the panel run only at M2 and above. - Per-project override. The web's
setCritiqueTheaterEnabledsetter (Phase 15.2) writes the toggle tolocalStoragefor the in-session UI and, when called with aprojectId, performs a read-merge-write through the existingPATCH /api/projects/:idsettings endpoint: GET the current project, mergecritiqueTheaterEnabledinto the existing metadata blob, PATCH the merged object so other metadata fields (kind,templateId,linkedDirs, etc.) survive. If the prefetch GET fails the setter skips the PATCH entirely instead of stomping the row. The daemon readsmetadata.critiqueTheaterEnabledon the next spawn. A dedicated Settings panel control that wires theprojectId-aware call lands in a follow-up PR; integrators embedding the Theater can already call the setter directly today. OD_CRITIQUE_ENABLEDenv override. Power-user lane / CI fixtures.- Rollout phase default (lowest priority). M0 / M1 =
false, M2 = true forpolicy: opt-inskills, M3 =trueeverywhere.
The web hook (useCritiqueTheaterEnabled) reads localStorage and
governs whether the Theater UI renders for the active session; the
daemon-side resolver makes the actual routing decision per
generation. The two layers share the same toggle but answer
different questions.
Defaults: disabled during M0 dark-launch and M1 settings-toggle phases.
Enabled by default per skill during M2, then globally during M3 after
≥ 90% of production adapters maintain conformance for 14 consecutive days
(see docs/agent-adapters.md).
Per-skill override
A skill can opt in or out via od.critique.policy in its SKILL.md
frontmatter:
od:
critique:
policy: required # or 'opt-in', 'opt-out'
Skills that publish a deterministic artifact (e.g. od-export-pdf) usually
set opt-out; skills that generate net-new design output (magazine-poster,
saas-landing) set required.
Score threshold and weights
The threshold (8.0) and per-role weights (designer 0 / critic 0.4 / brand 0.2 / a11y 0.2 / copy 0.2) are read-only in v1. v2 will expose them in the Settings panel.
4. Reading the score badge
After a run settles the Theater collapses to a single chip. The chip displays:
- composite — the final score on the 0 / 10 scale.
- round — which round closed the run.
- status badge — colored token explaining how the run ended:
Shipped: composite ≥ threshold (the happy path, accent-tinted).Below threshold: composite < threshold after all rounds (amber-tinted).Timed out: total run exceededtotalTimeoutMs(amber-tinted).Interrupted: user pressed Esc or clicked Interrupt mid-run.Degraded: the panel was offline this run (adapter could not produce a valid critique). See troubleshooting below.
The interrupted chip uses a distinct copy ("Interrupted at round N, best
composite X.X") so the user is not told the run shipped when it did not.
5. Replay
Every run writes a structured transcript (.ndjson, optionally .gz).
Opening the chip's Replay button mounts a read-only Theater that plays
the transcript at the chosen speed:
Instant: flushes every event synchronously. Useful when reopening a finished run.Live: paces events at the original cadence (Phase 7+ follow-up; v1 treats this the same as a 0-msintervalMs).{ intervalMs: N }: fixed N-ms delay between events.Paused: holds the cursor in place. Resuming picks up exactly where the pause landed (no re-flush of prior events).
The replay surface is keyboard-scrubbable: J / K jump round-by-round,
Esc exits the replay.
6. Troubleshooting
"Panel offline this run"
The orchestrator emits a degraded event when one of these happens:
| Reason | Cause | Remediation |
|---|---|---|
malformed_block |
The adapter emitted a <CRITIQUE> block the parser rejects. |
Re-run the conformance harness locally (pnpm --filter @open-design/daemon vitest run tests/critique-conformance.test.ts) to confirm the adapter's transcript shape. The Phase 12 dashboard surfaces this status as a Prometheus series once that PR lands; until then the harness is the authoritative source. |
oversize_block |
The block exceeded parserMaxBlockBytes. |
Usually a runaway model; retry once or raise the budget. |
adapter_unsupported |
The adapter is marked critique:degraded for the 24h TTL window. |
Wait for the TTL to elapse. The adapter-degraded registry exposes clearDegraded(adapterId) from apps/daemon/src/critique/adapter-degraded.ts for programmatic resets; a od adapters clear-degraded <id> CLI wrapper is planned in a follow-up. |
protocol_version_mismatch |
The adapter is on an older protocol. | Update the adapter or pin protocol negotiation. |
missing_artifact |
The run finished but no <artifact> body landed. |
Almost always a prompt bug; check the skill template. |
"Below threshold after 3 rounds"
The run shipped under the bar with fallbackPolicy: ship_best. Tune the
brief (more specific guidance, tighter token constraints), switch to a
skill whose DESIGN.md aligns better with the brief, or raise maxRounds.
"Interrupted at round N"
The user pressed Esc or clicked Interrupt. The chip surfaces the best composite seen so far. You can resume by re-issuing the brief.
7. FAQ
Why five panelists, why fixed? The five-role cast is the smallest set that covers the lenses every artifact reviewer uses in practice (composition, intent, brand, accessibility, voice). Locking the set keeps the SSE wire shape, the SQLite schema, and the metrics dashboard stable. Per-skill cast configuration is reserved for v2.
Why is my adapter marked degraded for 24h? The conformance harness
runs every adapter nightly against 10 brief templates. If an adapter
drops under the 90% shipped or 95% clean-parse thresholds for two
consecutive cycles, it gets marked critique:degraded for 24h. The mark
auto-clears on the next clean cycle.
Can I add my own panelist? Not in v1. v2 plans a panelist plug-in
contract; see docs/roadmap.md for the timeline.
Related
docs/spec.md— protocol v1 wire format.docs/architecture.md— orchestrator + parser layout.docs/skills-protocol.md—od.critique.policyfrontmatter contract.docs/agent-adapters.md— conformance contract and adapter responsibilities.docs/roadmap.md— v2 panelist extensions.apps/daemon/src/critique/AGENTS.md— daemon-side module map.apps/web/src/components/Theater/AGENTS.md— web-side module map.