From 3dd2d6a112ec8d55e9c3b9e6df35e579e9fe7962 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 12 Jul 2026 10:31:18 -0700 Subject: [PATCH] feat(dashboard): move all body HTML to the client (root-only server shell); fix the dead poll interval (JEF-408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server now emits a ROOT-ONLY document shell — `` + `
` + the deferred bundle. ALL body HTML (the status strip, the tab nav, and every view body) renders in the Preact client, reconciling from `/api/{tab}.json`. This supersedes ADR-0025's server-rendered strip/nav; recorded as ADR-0027. Two production bug fixes: - Dead recurring poll + blank tab-swaps + a CSP eval violation, all one root cause: `poll.js` called its interval as `(ms, fn)` but defaulted to native `setInterval` (`(fn, ms)`), so it passed the number 5000 as the handler — the recurring poll never fired, and the browser coerced the number to the string "5000" and took the legacy string-handler eval path, which the strict CSP correctly blocked. The default is now `(ms, fn) => setInterval(fn, ms)`; the CSP stays strict. A client tab-swap also restarts the poll so the new tab refetches immediately. - The status strip + tab nav moved to the client (`strip.jsx`), a 1:1 port of the retired maud `status_strip.rs`, reusing the existing `dashboard.css` classes verbatim. The judging axis is chosen by a single server-derived `judging-state` token so the honesty DERIVATION stays server-side (ADR-0025 contract); the client performs zero honesty derivation. Honesty preserved: green renders ONLY when `judging-state === "all-clear"`; watching/warming/ no-model/blind and any awaiting/uncertain counts are non-green. A blank before the first fetch is honest (absent ≠ green). SSR/hydration of the strip is a noted follow-up. Retired `dashboard/components/` (status_strip.rs, nav.rs). Tests: Rust page_tests (body root-only), serialize_tests (judging-state token across states), dashboard_guards (root-only body); JS strip.test.jsx, poll.test.js regression (ticks >1 + function handler), app-refetch.test.jsx. Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-Authored-By: Claude Opus 4.8 --- ...-dashboard-root-only-shell-client-strip.md | 70 ++++++ docs/adr/README.md | 3 +- engine/examples/dashboard_preview.rs | 10 +- engine/src/engine/dashboard/components/mod.rs | 17 -- engine/src/engine/dashboard/components/nav.rs | 35 --- .../dashboard/components/status_strip.rs | 183 -------------- engine/src/engine/dashboard/mod.rs | 30 +-- engine/src/engine/dashboard/page.rs | 50 ++-- engine/src/engine/dashboard/page_tests.rs | 166 +++++-------- .../view_model/props/serialize_tests.rs | 125 ++++++++++ .../dashboard/view_model/props/status.rs | 53 ++++- engine/tests/dashboard_guards.rs | 171 +++----------- engine/web/src/app.jsx | 13 +- engine/web/src/poll.js | 8 +- engine/web/src/strip.jsx | 181 ++++++++++++++ engine/web/test/app-refetch.test.jsx | 58 +++++ engine/web/test/fixtures.js | 3 + engine/web/test/poll.test.js | 54 ++++- engine/web/test/strip.test.jsx | 223 ++++++++++++++++++ 19 files changed, 909 insertions(+), 544 deletions(-) create mode 100644 docs/adr/0027-dashboard-root-only-shell-client-strip.md delete mode 100644 engine/src/engine/dashboard/components/mod.rs delete mode 100644 engine/src/engine/dashboard/components/nav.rs delete mode 100644 engine/src/engine/dashboard/components/status_strip.rs create mode 100644 engine/web/src/strip.jsx create mode 100644 engine/web/test/app-refetch.test.jsx create mode 100644 engine/web/test/strip.test.jsx diff --git a/docs/adr/0027-dashboard-root-only-shell-client-strip.md b/docs/adr/0027-dashboard-root-only-shell-client-strip.md new file mode 100644 index 0000000..8964c0e --- /dev/null +++ b/docs/adr/0027-dashboard-root-only-shell-client-strip.md @@ -0,0 +1,70 @@ +# 0027. Dashboard: the server emits a ROOT-ONLY shell; the status strip + nav render in the client + +- Status: Accepted +- Date: 2026-07-12 +- Supersedes (in part): [0025](0025-dashboard-v4-preact-client-render.md) — its decision that the + persistent **status strip** and the **tab nav** stay SERVER-RENDERED for a calm-when-blind first + paint. Everything else in 0025 (Preact reconciler, view_model/props as the serde JSON contract, + built-from-source bundle, zero-egress, server-derived honesty tokens) stands. +- Reaffirms: [0016](0016-severity-vs-urgency.md) — presentation is a view, never a gate; and the + honesty axes of [0019](0019-dashboard-v3-presentation-architecture.md)/0025 (blind ≠ green). + +## Context + +Under ADR-0025 the engine went Preact-only for every view *body*, but kept TWO parts server-rendered +in maud — the status strip and the tab nav — so the honest calm-when-blind banner would paint before +any JS ran. That split had two concrete costs that surfaced in production (JEF-408): + +1. **A dead recurring poll masqueraded as a working one.** `poll.js` called its injected interval as + `(ms, fn)`, but the default was native `setInterval` (`(fn, ms)`), so `setInterval(POLL_MS, tick)` + handed the *number* 5000 as the handler. The recurring poll never fired — only the initial tick — + so a client tab-swap blanked the view forever ("clicking does nothing"). Worse, the browser + coerced the numeric handler to the string `"5000"` and took the legacy string-handler path, which + compiles the string as code (an eval); the strict CSP (`script-src 'self'`, no `unsafe-eval`) + correctly blocked it. One reversed-args bug, three symptoms. + +2. **Two rendering stacks for the same honesty derivation.** The maud `status_strip.rs` and the + (client) view bodies both had to agree on the judging axis, kept in sync by hand. Any maud-only + strip meant the strip and the body could drift, and the shell carried body HTML the client would + have to reconcile around. + +The calm-when-blind justification for a server-rendered strip is weaker than it first appears: a +blank document before the first fetch is *honest* (absent is not a green all-clear), and the strip's +honesty is a **derivation**, not a render — as long as that derivation stays server-side, WHERE the +strip DOM is produced is immaterial to the honesty contract. + +## Decision + +We will move ALL body HTML — the status strip and the tab nav included — to the Preact client, and +have the server emit a **ROOT-ONLY** document shell. + +- **Server shell (`page.rs`):** the body is just `
` + the deferred + bundle `