diff --git a/dashboard/audit-baselines/agents__dark__1440.png b/dashboard/audit-baselines/agents__dark__1440.png index 903f78612e..1c46648efb 100644 Binary files a/dashboard/audit-baselines/agents__dark__1440.png and b/dashboard/audit-baselines/agents__dark__1440.png differ diff --git a/dashboard/audit-baselines/agents__dark__320.png b/dashboard/audit-baselines/agents__dark__320.png index 6134e7bdf6..ccc9f7a991 100644 Binary files a/dashboard/audit-baselines/agents__dark__320.png and b/dashboard/audit-baselines/agents__dark__320.png differ diff --git a/dashboard/audit-baselines/agents__dark__768.png b/dashboard/audit-baselines/agents__dark__768.png index 95bd207ab4..09d65564c4 100644 Binary files a/dashboard/audit-baselines/agents__dark__768.png and b/dashboard/audit-baselines/agents__dark__768.png differ diff --git a/dashboard/audit-baselines/agents__light__1440.png b/dashboard/audit-baselines/agents__light__1440.png index 5196fc8f6e..fa01c74115 100644 Binary files a/dashboard/audit-baselines/agents__light__1440.png and b/dashboard/audit-baselines/agents__light__1440.png differ diff --git a/dashboard/audit-baselines/agents__light__320.png b/dashboard/audit-baselines/agents__light__320.png index f7618e1b41..641a76725f 100644 Binary files a/dashboard/audit-baselines/agents__light__320.png and b/dashboard/audit-baselines/agents__light__320.png differ diff --git a/dashboard/audit-baselines/agents__light__768.png b/dashboard/audit-baselines/agents__light__768.png index 3dee298992..570e3b25be 100644 Binary files a/dashboard/audit-baselines/agents__light__768.png and b/dashboard/audit-baselines/agents__light__768.png differ diff --git a/dashboard/audit-baselines/automations__dark__1440.png b/dashboard/audit-baselines/automations__dark__1440.png index 6edc8e5e46..4fa0e861c8 100644 Binary files a/dashboard/audit-baselines/automations__dark__1440.png and b/dashboard/audit-baselines/automations__dark__1440.png differ diff --git a/dashboard/audit-baselines/automations__dark__320.png b/dashboard/audit-baselines/automations__dark__320.png index 7714019f9d..5dfa63f47a 100644 Binary files a/dashboard/audit-baselines/automations__dark__320.png and b/dashboard/audit-baselines/automations__dark__320.png differ diff --git a/dashboard/audit-baselines/automations__dark__768.png b/dashboard/audit-baselines/automations__dark__768.png index 335d1bdb81..4d4de5c334 100644 Binary files a/dashboard/audit-baselines/automations__dark__768.png and b/dashboard/audit-baselines/automations__dark__768.png differ diff --git a/dashboard/audit-baselines/automations__light__1440.png b/dashboard/audit-baselines/automations__light__1440.png index 9bbdd8244f..6ee031b05e 100644 Binary files a/dashboard/audit-baselines/automations__light__1440.png and b/dashboard/audit-baselines/automations__light__1440.png differ diff --git a/dashboard/audit-baselines/automations__light__320.png b/dashboard/audit-baselines/automations__light__320.png index 4608e8b6df..76835c4f06 100644 Binary files a/dashboard/audit-baselines/automations__light__320.png and b/dashboard/audit-baselines/automations__light__320.png differ diff --git a/dashboard/audit-baselines/automations__light__768.png b/dashboard/audit-baselines/automations__light__768.png index 9ae359c1f2..59e302cc26 100644 Binary files a/dashboard/audit-baselines/automations__light__768.png and b/dashboard/audit-baselines/automations__light__768.png differ diff --git a/dashboard/audit-baselines/brain__dark__1440.png b/dashboard/audit-baselines/brain__dark__1440.png index 483e55330e..4527179170 100644 Binary files a/dashboard/audit-baselines/brain__dark__1440.png and b/dashboard/audit-baselines/brain__dark__1440.png differ diff --git a/dashboard/audit-baselines/brain__dark__320.png b/dashboard/audit-baselines/brain__dark__320.png index afd246865e..6eacbb763d 100644 Binary files a/dashboard/audit-baselines/brain__dark__320.png and b/dashboard/audit-baselines/brain__dark__320.png differ diff --git a/dashboard/audit-baselines/brain__dark__768.png b/dashboard/audit-baselines/brain__dark__768.png index 45ac5afb05..fd2891ae5b 100644 Binary files a/dashboard/audit-baselines/brain__dark__768.png and b/dashboard/audit-baselines/brain__dark__768.png differ diff --git a/dashboard/audit-baselines/brain__light__1440.png b/dashboard/audit-baselines/brain__light__1440.png index ab692ee3b1..ead6e9f25a 100644 Binary files a/dashboard/audit-baselines/brain__light__1440.png and b/dashboard/audit-baselines/brain__light__1440.png differ diff --git a/dashboard/audit-baselines/brain__light__320.png b/dashboard/audit-baselines/brain__light__320.png index 109c351bc5..8ea8995484 100644 Binary files a/dashboard/audit-baselines/brain__light__320.png and b/dashboard/audit-baselines/brain__light__320.png differ diff --git a/dashboard/audit-baselines/brain__light__768.png b/dashboard/audit-baselines/brain__light__768.png index af8e595b8b..167f926b80 100644 Binary files a/dashboard/audit-baselines/brain__light__768.png and b/dashboard/audit-baselines/brain__light__768.png differ diff --git a/dashboard/audit-baselines/code__dark__1440.png b/dashboard/audit-baselines/code__dark__1440.png index be786a6ed7..a95ebd41b0 100644 Binary files a/dashboard/audit-baselines/code__dark__1440.png and b/dashboard/audit-baselines/code__dark__1440.png differ diff --git a/dashboard/audit-baselines/code__dark__320.png b/dashboard/audit-baselines/code__dark__320.png index e4fc6a08f6..4d9e88678e 100644 Binary files a/dashboard/audit-baselines/code__dark__320.png and b/dashboard/audit-baselines/code__dark__320.png differ diff --git a/dashboard/audit-baselines/code__dark__768.png b/dashboard/audit-baselines/code__dark__768.png index b6ac8d5da0..4796242828 100644 Binary files a/dashboard/audit-baselines/code__dark__768.png and b/dashboard/audit-baselines/code__dark__768.png differ diff --git a/dashboard/audit-baselines/code__light__1440.png b/dashboard/audit-baselines/code__light__1440.png index 2fe0dee40e..5b435597b4 100644 Binary files a/dashboard/audit-baselines/code__light__1440.png and b/dashboard/audit-baselines/code__light__1440.png differ diff --git a/dashboard/audit-baselines/code__light__320.png b/dashboard/audit-baselines/code__light__320.png index 3e099aa06f..7f21b8e58e 100644 Binary files a/dashboard/audit-baselines/code__light__320.png and b/dashboard/audit-baselines/code__light__320.png differ diff --git a/dashboard/audit-baselines/code__light__768.png b/dashboard/audit-baselines/code__light__768.png index 4acc25f5c0..b16569d04c 100644 Binary files a/dashboard/audit-baselines/code__light__768.png and b/dashboard/audit-baselines/code__light__768.png differ diff --git a/dashboard/audit-baselines/costs__dark__1440.png b/dashboard/audit-baselines/costs__dark__1440.png index a2b416d9d3..1d0894e09b 100644 Binary files a/dashboard/audit-baselines/costs__dark__1440.png and b/dashboard/audit-baselines/costs__dark__1440.png differ diff --git a/dashboard/audit-baselines/costs__dark__320.png b/dashboard/audit-baselines/costs__dark__320.png index 60fa0e7fab..7bf242ce44 100644 Binary files a/dashboard/audit-baselines/costs__dark__320.png and b/dashboard/audit-baselines/costs__dark__320.png differ diff --git a/dashboard/audit-baselines/costs__dark__768.png b/dashboard/audit-baselines/costs__dark__768.png index 9785bbd792..e6aa885366 100644 Binary files a/dashboard/audit-baselines/costs__dark__768.png and b/dashboard/audit-baselines/costs__dark__768.png differ diff --git a/dashboard/audit-baselines/costs__light__1440.png b/dashboard/audit-baselines/costs__light__1440.png index 1fdaa75c3e..93773cab28 100644 Binary files a/dashboard/audit-baselines/costs__light__1440.png and b/dashboard/audit-baselines/costs__light__1440.png differ diff --git a/dashboard/audit-baselines/costs__light__320.png b/dashboard/audit-baselines/costs__light__320.png index 6756d4b763..94f6ec1741 100644 Binary files a/dashboard/audit-baselines/costs__light__320.png and b/dashboard/audit-baselines/costs__light__320.png differ diff --git a/dashboard/audit-baselines/costs__light__768.png b/dashboard/audit-baselines/costs__light__768.png index 6537e32346..73e71d7ac8 100644 Binary files a/dashboard/audit-baselines/costs__light__768.png and b/dashboard/audit-baselines/costs__light__768.png differ diff --git a/dashboard/audit-baselines/delivery__dark__1440.png b/dashboard/audit-baselines/delivery__dark__1440.png index ad4a724ca7..05240addde 100644 Binary files a/dashboard/audit-baselines/delivery__dark__1440.png and b/dashboard/audit-baselines/delivery__dark__1440.png differ diff --git a/dashboard/audit-baselines/delivery__dark__320.png b/dashboard/audit-baselines/delivery__dark__320.png index a7b118b29f..9080049d4d 100644 Binary files a/dashboard/audit-baselines/delivery__dark__320.png and b/dashboard/audit-baselines/delivery__dark__320.png differ diff --git a/dashboard/audit-baselines/delivery__dark__768.png b/dashboard/audit-baselines/delivery__dark__768.png index a1bcc8f967..a461853295 100644 Binary files a/dashboard/audit-baselines/delivery__dark__768.png and b/dashboard/audit-baselines/delivery__dark__768.png differ diff --git a/dashboard/audit-baselines/delivery__light__1440.png b/dashboard/audit-baselines/delivery__light__1440.png index 1bcb197ac2..508513c0a8 100644 Binary files a/dashboard/audit-baselines/delivery__light__1440.png and b/dashboard/audit-baselines/delivery__light__1440.png differ diff --git a/dashboard/audit-baselines/delivery__light__320.png b/dashboard/audit-baselines/delivery__light__320.png index 7de19f50c4..9fb112d4f3 100644 Binary files a/dashboard/audit-baselines/delivery__light__320.png and b/dashboard/audit-baselines/delivery__light__320.png differ diff --git a/dashboard/audit-baselines/delivery__light__768.png b/dashboard/audit-baselines/delivery__light__768.png index ea0e705674..45ab9cd17d 100644 Binary files a/dashboard/audit-baselines/delivery__light__768.png and b/dashboard/audit-baselines/delivery__light__768.png differ diff --git a/dashboard/audit-baselines/explorer__dark__1440.png b/dashboard/audit-baselines/explorer__dark__1440.png index 42dea10fdd..6ef0c42c69 100644 Binary files a/dashboard/audit-baselines/explorer__dark__1440.png and b/dashboard/audit-baselines/explorer__dark__1440.png differ diff --git a/dashboard/audit-baselines/explorer__dark__320.png b/dashboard/audit-baselines/explorer__dark__320.png index 47edad9ed9..11735d9c61 100644 Binary files a/dashboard/audit-baselines/explorer__dark__320.png and b/dashboard/audit-baselines/explorer__dark__320.png differ diff --git a/dashboard/audit-baselines/explorer__dark__768.png b/dashboard/audit-baselines/explorer__dark__768.png index 29e7226113..c61138b455 100644 Binary files a/dashboard/audit-baselines/explorer__dark__768.png and b/dashboard/audit-baselines/explorer__dark__768.png differ diff --git a/dashboard/audit-baselines/explorer__light__1440.png b/dashboard/audit-baselines/explorer__light__1440.png index 38b93b8191..78861f9492 100644 Binary files a/dashboard/audit-baselines/explorer__light__1440.png and b/dashboard/audit-baselines/explorer__light__1440.png differ diff --git a/dashboard/audit-baselines/explorer__light__320.png b/dashboard/audit-baselines/explorer__light__320.png index 4779badb3f..d79d1481eb 100644 Binary files a/dashboard/audit-baselines/explorer__light__320.png and b/dashboard/audit-baselines/explorer__light__320.png differ diff --git a/dashboard/audit-baselines/explorer__light__768.png b/dashboard/audit-baselines/explorer__light__768.png index f499904a53..5a8179d70b 100644 Binary files a/dashboard/audit-baselines/explorer__light__768.png and b/dashboard/audit-baselines/explorer__light__768.png differ diff --git a/dashboard/audit-baselines/knowledge__dark__1440.png b/dashboard/audit-baselines/knowledge__dark__1440.png index 82a51ec278..7f6511d10c 100644 Binary files a/dashboard/audit-baselines/knowledge__dark__1440.png and b/dashboard/audit-baselines/knowledge__dark__1440.png differ diff --git a/dashboard/audit-baselines/knowledge__dark__320.png b/dashboard/audit-baselines/knowledge__dark__320.png index 652cd1a606..221c55c207 100644 Binary files a/dashboard/audit-baselines/knowledge__dark__320.png and b/dashboard/audit-baselines/knowledge__dark__320.png differ diff --git a/dashboard/audit-baselines/knowledge__dark__768.png b/dashboard/audit-baselines/knowledge__dark__768.png index de8e74ab40..09fc4582b4 100644 Binary files a/dashboard/audit-baselines/knowledge__dark__768.png and b/dashboard/audit-baselines/knowledge__dark__768.png differ diff --git a/dashboard/audit-baselines/knowledge__light__1440.png b/dashboard/audit-baselines/knowledge__light__1440.png index 165bdf6e94..0f3133e66f 100644 Binary files a/dashboard/audit-baselines/knowledge__light__1440.png and b/dashboard/audit-baselines/knowledge__light__1440.png differ diff --git a/dashboard/audit-baselines/knowledge__light__320.png b/dashboard/audit-baselines/knowledge__light__320.png index b15db7f157..25871bc9c0 100644 Binary files a/dashboard/audit-baselines/knowledge__light__320.png and b/dashboard/audit-baselines/knowledge__light__320.png differ diff --git a/dashboard/audit-baselines/knowledge__light__768.png b/dashboard/audit-baselines/knowledge__light__768.png index 0c9cdbcdfd..8122e59b52 100644 Binary files a/dashboard/audit-baselines/knowledge__light__768.png and b/dashboard/audit-baselines/knowledge__light__768.png differ diff --git a/dashboard/audit-baselines/loom__dark__1440.png b/dashboard/audit-baselines/loom__dark__1440.png index f76dfc3b48..4c83047d01 100644 Binary files a/dashboard/audit-baselines/loom__dark__1440.png and b/dashboard/audit-baselines/loom__dark__1440.png differ diff --git a/dashboard/audit-baselines/loom__dark__320.png b/dashboard/audit-baselines/loom__dark__320.png index 42282f5ba7..a6513abc0d 100644 Binary files a/dashboard/audit-baselines/loom__dark__320.png and b/dashboard/audit-baselines/loom__dark__320.png differ diff --git a/dashboard/audit-baselines/loom__dark__768.png b/dashboard/audit-baselines/loom__dark__768.png index c42e3c8222..47ef00e9e5 100644 Binary files a/dashboard/audit-baselines/loom__dark__768.png and b/dashboard/audit-baselines/loom__dark__768.png differ diff --git a/dashboard/audit-baselines/loom__light__1440.png b/dashboard/audit-baselines/loom__light__1440.png index 3df0502fc4..4d8972ff97 100644 Binary files a/dashboard/audit-baselines/loom__light__1440.png and b/dashboard/audit-baselines/loom__light__1440.png differ diff --git a/dashboard/audit-baselines/loom__light__320.png b/dashboard/audit-baselines/loom__light__320.png index fd9b7ee62a..c8a234a932 100644 Binary files a/dashboard/audit-baselines/loom__light__320.png and b/dashboard/audit-baselines/loom__light__320.png differ diff --git a/dashboard/audit-baselines/loom__light__768.png b/dashboard/audit-baselines/loom__light__768.png index ad00c63715..0ede6fd1e2 100644 Binary files a/dashboard/audit-baselines/loom__light__768.png and b/dashboard/audit-baselines/loom__light__768.png differ diff --git a/dashboard/audit-baselines/observatory__dark__1440.png b/dashboard/audit-baselines/observatory__dark__1440.png index db87e135b0..8bf0430712 100644 Binary files a/dashboard/audit-baselines/observatory__dark__1440.png and b/dashboard/audit-baselines/observatory__dark__1440.png differ diff --git a/dashboard/audit-baselines/observatory__dark__320.png b/dashboard/audit-baselines/observatory__dark__320.png index 74908288cf..bdad2912e9 100644 Binary files a/dashboard/audit-baselines/observatory__dark__320.png and b/dashboard/audit-baselines/observatory__dark__320.png differ diff --git a/dashboard/audit-baselines/observatory__dark__768.png b/dashboard/audit-baselines/observatory__dark__768.png index 9622ef9998..5d4106d136 100644 Binary files a/dashboard/audit-baselines/observatory__dark__768.png and b/dashboard/audit-baselines/observatory__dark__768.png differ diff --git a/dashboard/audit-baselines/observatory__light__1440.png b/dashboard/audit-baselines/observatory__light__1440.png index 68a599b8ee..a0c05a9354 100644 Binary files a/dashboard/audit-baselines/observatory__light__1440.png and b/dashboard/audit-baselines/observatory__light__1440.png differ diff --git a/dashboard/audit-baselines/observatory__light__320.png b/dashboard/audit-baselines/observatory__light__320.png index 87f8505bf7..655e3932c2 100644 Binary files a/dashboard/audit-baselines/observatory__light__320.png and b/dashboard/audit-baselines/observatory__light__320.png differ diff --git a/dashboard/audit-baselines/observatory__light__768.png b/dashboard/audit-baselines/observatory__light__768.png index 6b1f74a0bd..310f866bed 100644 Binary files a/dashboard/audit-baselines/observatory__light__768.png and b/dashboard/audit-baselines/observatory__light__768.png differ diff --git a/dashboard/audit-baselines/sessions__dark__1440.png b/dashboard/audit-baselines/sessions__dark__1440.png index 2b264710e2..c0d0e736ed 100644 Binary files a/dashboard/audit-baselines/sessions__dark__1440.png and b/dashboard/audit-baselines/sessions__dark__1440.png differ diff --git a/dashboard/audit-baselines/sessions__dark__320.png b/dashboard/audit-baselines/sessions__dark__320.png index a8ac9d4f13..0ba4e0bf47 100644 Binary files a/dashboard/audit-baselines/sessions__dark__320.png and b/dashboard/audit-baselines/sessions__dark__320.png differ diff --git a/dashboard/audit-baselines/sessions__dark__768.png b/dashboard/audit-baselines/sessions__dark__768.png index ce7fc24bf1..5694d5eb68 100644 Binary files a/dashboard/audit-baselines/sessions__dark__768.png and b/dashboard/audit-baselines/sessions__dark__768.png differ diff --git a/dashboard/audit-baselines/sessions__light__1440.png b/dashboard/audit-baselines/sessions__light__1440.png index ae8342f607..a2b8fd0489 100644 Binary files a/dashboard/audit-baselines/sessions__light__1440.png and b/dashboard/audit-baselines/sessions__light__1440.png differ diff --git a/dashboard/audit-baselines/sessions__light__320.png b/dashboard/audit-baselines/sessions__light__320.png index d2841a123b..e25fea0607 100644 Binary files a/dashboard/audit-baselines/sessions__light__320.png and b/dashboard/audit-baselines/sessions__light__320.png differ diff --git a/dashboard/audit-baselines/sessions__light__768.png b/dashboard/audit-baselines/sessions__light__768.png index 8430272443..d72c894712 100644 Binary files a/dashboard/audit-baselines/sessions__light__768.png and b/dashboard/audit-baselines/sessions__light__768.png differ diff --git a/dashboard/audit-baselines/settings__dark__1440.png b/dashboard/audit-baselines/settings__dark__1440.png index ceffa65e62..41d3dcff7d 100644 Binary files a/dashboard/audit-baselines/settings__dark__1440.png and b/dashboard/audit-baselines/settings__dark__1440.png differ diff --git a/dashboard/audit-baselines/settings__dark__320.png b/dashboard/audit-baselines/settings__dark__320.png index 85d5f74c42..a5a5f10b48 100644 Binary files a/dashboard/audit-baselines/settings__dark__320.png and b/dashboard/audit-baselines/settings__dark__320.png differ diff --git a/dashboard/audit-baselines/settings__dark__768.png b/dashboard/audit-baselines/settings__dark__768.png index 22c9c0f0e1..0d383f9322 100644 Binary files a/dashboard/audit-baselines/settings__dark__768.png and b/dashboard/audit-baselines/settings__dark__768.png differ diff --git a/dashboard/audit-baselines/settings__light__1440.png b/dashboard/audit-baselines/settings__light__1440.png index 89d5390892..69410f4f61 100644 Binary files a/dashboard/audit-baselines/settings__light__1440.png and b/dashboard/audit-baselines/settings__light__1440.png differ diff --git a/dashboard/audit-baselines/settings__light__320.png b/dashboard/audit-baselines/settings__light__320.png index 06a17ad732..36c694f690 100644 Binary files a/dashboard/audit-baselines/settings__light__320.png and b/dashboard/audit-baselines/settings__light__320.png differ diff --git a/dashboard/audit-baselines/settings__light__768.png b/dashboard/audit-baselines/settings__light__768.png index 44a255911b..b587636381 100644 Binary files a/dashboard/audit-baselines/settings__light__768.png and b/dashboard/audit-baselines/settings__light__768.png differ diff --git a/dashboard/audit-baselines/work__dark__1440.png b/dashboard/audit-baselines/work__dark__1440.png new file mode 100644 index 0000000000..ccedb080e8 Binary files /dev/null and b/dashboard/audit-baselines/work__dark__1440.png differ diff --git a/dashboard/audit-baselines/work__dark__320.png b/dashboard/audit-baselines/work__dark__320.png new file mode 100644 index 0000000000..b20c4a2296 Binary files /dev/null and b/dashboard/audit-baselines/work__dark__320.png differ diff --git a/dashboard/audit-baselines/work__dark__768.png b/dashboard/audit-baselines/work__dark__768.png new file mode 100644 index 0000000000..09f0eee71c Binary files /dev/null and b/dashboard/audit-baselines/work__dark__768.png differ diff --git a/dashboard/audit-baselines/work__light__1440.png b/dashboard/audit-baselines/work__light__1440.png new file mode 100644 index 0000000000..e24ced9dcd Binary files /dev/null and b/dashboard/audit-baselines/work__light__1440.png differ diff --git a/dashboard/audit-baselines/work__light__320.png b/dashboard/audit-baselines/work__light__320.png new file mode 100644 index 0000000000..102a66e290 Binary files /dev/null and b/dashboard/audit-baselines/work__light__320.png differ diff --git a/dashboard/audit-baselines/work__light__768.png b/dashboard/audit-baselines/work__light__768.png new file mode 100644 index 0000000000..971da86af8 Binary files /dev/null and b/dashboard/audit-baselines/work__light__768.png differ diff --git a/dashboard/audit-baselines/workflows__dark__1440.png b/dashboard/audit-baselines/workflows__dark__1440.png new file mode 100644 index 0000000000..627c913423 Binary files /dev/null and b/dashboard/audit-baselines/workflows__dark__1440.png differ diff --git a/dashboard/audit-baselines/workflows__dark__320.png b/dashboard/audit-baselines/workflows__dark__320.png new file mode 100644 index 0000000000..f6247e387e Binary files /dev/null and b/dashboard/audit-baselines/workflows__dark__320.png differ diff --git a/dashboard/audit-baselines/workflows__dark__768.png b/dashboard/audit-baselines/workflows__dark__768.png new file mode 100644 index 0000000000..8f4f41a8c5 Binary files /dev/null and b/dashboard/audit-baselines/workflows__dark__768.png differ diff --git a/dashboard/audit-baselines/workflows__light__1440.png b/dashboard/audit-baselines/workflows__light__1440.png new file mode 100644 index 0000000000..f19039f66f Binary files /dev/null and b/dashboard/audit-baselines/workflows__light__1440.png differ diff --git a/dashboard/audit-baselines/workflows__light__320.png b/dashboard/audit-baselines/workflows__light__320.png new file mode 100644 index 0000000000..91ac132e5e Binary files /dev/null and b/dashboard/audit-baselines/workflows__light__320.png differ diff --git a/dashboard/audit-baselines/workflows__light__768.png b/dashboard/audit-baselines/workflows__light__768.png new file mode 100644 index 0000000000..4fc9052528 Binary files /dev/null and b/dashboard/audit-baselines/workflows__light__768.png differ diff --git a/dashboard/e2e/responsive.ts b/dashboard/e2e/responsive.ts new file mode 100644 index 0000000000..c7abea6535 --- /dev/null +++ b/dashboard/e2e/responsive.ts @@ -0,0 +1,677 @@ +/** + * The Plan 11 responsive/zoom/media matrix, and the two measurements the plan + * names that no axe rule performs. + * + * Split out of `axe-harness.ts` for the same reason `visibility.ts` was: the + * probes are pure DOM measurement and the pass/fail rules are pure functions + * over their reports, so neither needs a Playwright import and both can be + * driven against a real DOM outside the browser harness. + * + * What the plan mandates (11-dashboard-frontend.md, "Responsive, + * accessibility, performance, and usability gates"): + * + * "Automated tests cover 320x568, 390x844, 768x1024, 1024x768, 1280x720, + * and 1440x900 CSS pixels, 200% and 400% zoom, prefers-reduced-motion, + * prefers-contrast: more, and forced colors. At 320 pixels and 400% zoom + * there is no page-level horizontal scroll, clipped truth state, lost + * scope/provenance, or inaccessible action; labeled code/table/graph + * regions may scroll internally. Touch targets are at least 44x44 CSS + * pixels." + * + * ZOOM IS A CSS VIEWPORT, NOT `deviceScaleFactor`. Playwright's + * `deviceScaleFactor` raises rasterization density and leaves layout at the + * unzoomed CSS size, so a page captured at `deviceScaleFactor: 4` reflows + * exactly as it did at 1x — which is the one thing WCAG 1.4.10 is about. Real + * browser zoom shrinks the CSS viewport: 400% zoom of a 1280x720 window is a + * 320x180 CSS viewport, and that is what is emulated here. It is also why the + * 400% row and the 320px row test the same reflow width from opposite + * directions. + */ + +/** WCAG 2.5.5 / Plan 11 minimum, in CSS pixels. */ +export const MIN_TOUCH_TARGET_PX = 44; + +/** The window this harness models browser zoom against. */ +const ZOOM_BASE = { width: 1280, height: 720 } as const; + +export interface Viewport { + /** Stable id: file names, findings keys, console tags. */ + readonly id: string; + readonly width: number; + readonly height: number; + /** Browser zoom this CSS viewport models, as a percentage. */ + readonly zoom: number; + /** + * Where the plan forbids page-level horizontal scroll outright — "at 320 + * pixels and 400% zoom". Elsewhere the measurement is still taken and + * reported; only these two gate the run. + */ + readonly reflowGated: boolean; +} + +function zoomed(zoom: number): Viewport { + const scale = zoom / 100; + return { + id: `zoom${zoom}`, + width: Math.round(ZOOM_BASE.width / scale), + height: Math.round(ZOOM_BASE.height / scale), + zoom, + reflowGated: zoom >= 400, + }; +} + +function device(width: number, height: number, reflowGated = false): Viewport { + return { id: `${width}x${height}`, width, height, zoom: 100, reflowGated }; +} + +/** + * The three the full scenario sweep runs, in both themes. + * + * These are the plan's own sizes, not the harness's previous 320/768/1440 at a + * uniform height of 900 — a 320-wide viewport 900 tall is a phone width with a + * tablet's vertical room, which hides exactly the "does the truth state fit" + * question the narrow row exists to ask. + */ +export const SHOWCASE_VIEWPORTS: readonly Viewport[] = [ + device(320, 568, true), + device(768, 1024), + device(1440, 900), +]; + +/** The rest of the plan's matrix, swept over a representative subset. */ +export const MATRIX_VIEWPORTS: readonly Viewport[] = [ + device(390, 844), + device(1024, 768), + device(1280, 720), + zoomed(200), + zoomed(400), +]; + +export const PLAN_VIEWPORTS: readonly Viewport[] = [...SHOWCASE_VIEWPORTS, ...MATRIX_VIEWPORTS]; + +/** + * `reduced-motion` is the harness default and the only mode the full sweep + * runs; the other two are emulated live, without reloading, because both + * resolve through CSS media queries. + */ +export type MediaMode = 'reduced-motion' | 'contrast-more' | 'forced-colors'; + +export const MEDIA_MODES: readonly MediaMode[] = ['reduced-motion', 'contrast-more', 'forced-colors']; + +export type Theme = 'light' | 'dark'; + +export interface Combination { + readonly viewport: Viewport; + readonly theme: Theme; + readonly media: MediaMode; +} + +/** + * Everything the matrix subset is scanned at, beyond what the full sweep + * already covers. Thirty combinations per subset scenario: + * + * - 10: the five viewports the full sweep does not reach (390x844, 1024x768, + * 1280x720, and the two zoom levels), in both themes, under reduced motion; + * - 16: all eight plan viewports under `prefers-contrast: more`, and all + * eight under forced colors, in light; + * - 4: a dark spot check of both media modes at 1440x900 and 390x844, the + * widest and the narrowest, where a raised-contrast or forced palette is + * likeliest to collide with the dark token set. + * + * Forced colors replaces the palette wholesale, so sweeping it through both + * themes at every size would buy near-identical renders; the dark rows are the + * spot check rather than a full second pass. That is a deliberate cost + * decision, recorded here rather than left implicit in a nested loop. + */ +export const RESPONSIVE_MATRIX: readonly Combination[] = [ + ...MATRIX_VIEWPORTS.flatMap((viewport): Combination[] => [ + { viewport, theme: 'light', media: 'reduced-motion' }, + { viewport, theme: 'dark', media: 'reduced-motion' }, + ]), + ...PLAN_VIEWPORTS.flatMap((viewport): Combination[] => [ + { viewport, theme: 'light', media: 'contrast-more' }, + { viewport, theme: 'light', media: 'forced-colors' }, + ]), + ...[device(1440, 900), device(390, 844)].flatMap((viewport): Combination[] => [ + { viewport, theme: 'dark', media: 'contrast-more' }, + { viewport, theme: 'dark', media: 'forced-colors' }, + ]), +]; + +export function combinationTag(c: Combination): string { + return `${c.theme}/${c.viewport.id}/${c.media}`; +} + +/* ========================================================================== + * Reflow: page-level horizontal scroll, and what is running past the edge. + * ========================================================================== */ + +export interface OverflowOffender { + /** + * `page-overflow` widens the document itself. `clipped` is content that runs + * past the viewport inside an ancestor that hides its horizontal overflow — + * unreachable rather than merely off-screen, which is the plan's "clipped + * truth state". + */ + readonly kind: 'page-overflow' | 'clipped'; + readonly selector: string; + readonly clipper: string; + readonly right: number; + readonly text: string; +} + +/** A region that scrolls horizontally on its own, and whether it announces a + * name — the plan permits "labeled code/table/graph regions" to do this. */ +export interface InternalScroller { + readonly selector: string; + readonly label: string; + readonly role: string; +} + +/** + * A scroll container with content in it and no height to show it in. + * + * `overflow: auto` on a flex child that lost its height resolves to + * `clientHeight: 0`, which clips every row inside it AND removes the scrollbar + * that would have reached them. Nothing on the page looks broken: the header + * above it still reports "4 loaded of 4 matching rows". + * + * This is the plan's "clipped truth state" in its unambiguous form, and unlike + * the overflow heuristics it needs no judgement — a scroller holding text with + * a zero-height viewport is never intentional. + */ +export interface CollapsedScroller { + readonly selector: string; + readonly scrollHeight: number; + readonly clientHeight: number; + readonly hidden: string; +} + +export interface ReflowReport { + readonly scrollWidth: number; + readonly clientWidth: number; + readonly offenders: readonly OverflowOffender[]; + readonly internalScrollers: readonly InternalScroller[]; + readonly collapsedScrollers: readonly CollapsedScroller[]; +} + +/** + * Everything a pointer or the keyboard can operate. Shared by both probes so + * "an inaccessible action" and "an undersized target" are drawn from one + * definition of what an action is. + */ +const INTERACTIVE_SELECTOR = + 'a[href],area[href],button,input:not([type="hidden"]),select,textarea,summary,' + + '[role="button"],[role="link"],[role="tab"],[role="checkbox"],[role="radio"],' + + '[role="switch"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],' + + '[role="option"],[tabindex]'; + +/** Shared in-page helpers, prepended to both probe sources. */ +const PROBE_PRELUDE = ` + var INTERACTIVE = ${JSON.stringify(INTERACTIVE_SELECTOR)}; + var describe = function (el) { + if (!el) return '(none)'; + var out = el.tagName.toLowerCase(); + if (el.id) out += '#' + el.id; + for (var a = 0; a < el.attributes.length; a += 1) { + var attr = el.attributes[a]; + if (attr.name.indexOf('data-') === 0) { out += '[' + attr.name + ']'; break; } + } + var cls = typeof el.className === 'string' ? el.className.trim() : ''; + if (cls !== '') out += '.' + cls.split(/\\s+/).slice(0, 3).join('.'); + return out.slice(0, 120); + }; + var ownText = function (el) { + var out = ''; + for (var c = 0; c < el.childNodes.length; c += 1) { + if (el.childNodes[c].nodeType === 3) out += el.childNodes[c].nodeValue || ''; + } + return out.trim().replace(/\\s+/g, ' '); + }; +`; + +/** + * Measured inside the page, as source text rather than a function: `tsx` + * compiles this file with esbuild's `keepNames`, whose `__name` helper does + * not exist in the page, so a named callback handed to `page.evaluate` throws + * on arrival. + * + * Decorative geometry is deliberately excluded. A glow layer or a gradient + * that bleeds past the right edge carries no truth state, and listing those + * would bury the rows, figures and controls that do — so an element is only an + * offender if it holds its own text or is something a person operates or + * reads. + */ +export const REFLOW_PROBE = `(function () {${PROBE_PRELUDE} + var doc = document.documentElement; + var limit = doc.clientWidth; + var offenders = []; + var scrollers = []; + var collapsed = []; + var seen = {}; + var all = document.body ? document.body.querySelectorAll('*') : []; + for (var i = 0; i < all.length; i += 1) { + var el = all[i]; + var style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + // Checked BEFORE the size guard below, because a collapsed scroller is + // precisely an element with no painted height, and skipping it as "not + // rendered" is how it stayed invisible to this probe in the first place. + if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && + el.clientHeight < 1 && el.scrollHeight > 1) { + var trapped = (el.textContent || '').trim().replace(/\\s+/g, ' '); + if (trapped !== '') { + collapsed.push({ + selector: describe(el), + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + hidden: trapped.slice(0, 80), + }); + } + } + var rect = el.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) continue; + if ((style.overflowX === 'auto' || style.overflowX === 'scroll') && + el.scrollWidth > el.clientWidth + 1) { + scrollers.push({ + selector: describe(el), + label: (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || + el.getAttribute('title') || '').trim(), + role: el.getAttribute('role') || '', + }); + } + if (rect.right <= limit + 1) continue; + var carriesContent = + ownText(el) !== '' || el.matches(INTERACTIVE) || + el.tagName === 'IMG' || el.tagName === 'CANVAS' || el.tagName === 'SVG'; + if (!carriesContent) continue; + var clipper = null; + var reachable = false; + for (var up = el.parentElement; up; up = up.parentElement) { + var ov = getComputedStyle(up).overflowX; + if (ov === 'auto' || ov === 'scroll') { reachable = true; break; } + if (ov === 'hidden' || ov === 'clip') { clipper = up; break; } + } + if (reachable) continue; + var kind = clipper ? 'clipped' : 'page-overflow'; + var key = describe(el) + '|' + kind; + if (seen[key]) continue; + seen[key] = 1; + offenders.push({ + kind: kind, + selector: describe(el), + clipper: clipper ? describe(clipper) : '', + right: Math.round(rect.right), + text: ownText(el).slice(0, 80), + }); + } + return { + scrollWidth: doc.scrollWidth, + clientWidth: limit, + offenders: offenders.slice(0, 12), + internalScrollers: scrollers.slice(0, 12), + collapsedScrollers: collapsed.slice(0, 12), + }; +})()`; + +/** + * The plan's own sentence, as a predicate: at 320 CSS pixels and at 400% zoom + * the document itself must not scroll sideways. + * + * Returns the failures rather than throwing, so one scan can report a reflow + * failure, an undersized target and an axe violation together instead of + * hiding two of them behind whichever threw first. + */ +export function reflowFailures(report: ReflowReport, tag: string): string[] { + if (report.scrollWidth <= report.clientWidth + 1) return []; + const worst = report.offenders + .filter((o) => o.kind === 'page-overflow') + .slice(0, 4) + .map((o) => `${o.selector} reaches ${o.right}px${o.text === '' ? '' : ` (${o.text})`}`); + return [ + `${tag}: the page scrolls horizontally — document scrollWidth ${report.scrollWidth} > ` + + `clientWidth ${report.clientWidth}. ` + + (worst.length === 0 + ? 'No single content element reaches past the edge, so the width comes from a ' + + 'decorative or zero-text box.' + : `Widest content past the edge: ${worst.join('; ')}`), + ]; +} + +/** + * The other half of the plan's sentence: no clipped truth state at 320 CSS + * pixels or 400% zoom. + * + * Only collapsed scrollers are gated. `OverflowOffender.kind === 'clipped'` + * stays a diagnostic because deciding whether an element that reaches past the + * viewport inside a hidden ancestor has actually lost anything takes judgement; + * a scroller with content and zero height has lost all of it, measurably. + */ +export function clippedContentFailures(report: ReflowReport, tag: string): string[] { + return report.collapsedScrollers.map( + (s) => + `${tag}: ${s.selector} scrolls vertically but has collapsed to ${s.clientHeight}px tall ` + + `while holding ${s.scrollHeight}px of content, so none of it can be reached and no ` + + `scrollbar offers to. Trapped: "${s.hidden}"`, + ); +} + +/* ========================================================================== + * Forced colors: who opts out. + * ========================================================================== */ + +export interface ForcedColorsOptOut { + readonly selector: string; + readonly color: string; + readonly background: string; + readonly text: string; +} + +/** + * Elements that decline the forced palette. + * + * This exists because axe's `color-contrast` rule cannot be trusted here, and + * saying so without replacing it would be exactly the kind of quietly narrowed + * check this harness was rebuilt to prevent. Measured against axe-core 4.12.1 + * under Playwright's `forcedColors: 'active'`: + * + * - Chromium forces the palette correctly. Live `getComputedStyle` on the + * reported nodes returns `rgb(0, 0, 0)` text and `rgb(255, 255, 255)` + * backgrounds, and the captures are legible. + * - Axe nonetheless reports the AUTHORED foreground (`#ecedee`, the dark + * theme's text token) against the FORCED white background, so it scores + * 143 serious contrast failures in dark and none in light — a verdict that + * tracks which theme is selected rather than anything a user sees. + * - axe-core 4.12.1 contains no `forced-colors` or high-contrast handling of + * any kind, so this is not a detection it is opting out of; it simply does + * not know the mode exists. + * + * So `color-contrast` is disabled in that one mode, and the real risk it was + * standing in for is measured directly instead: forced colors can only fail a + * reader where an element opts out with `forced-color-adjust: none`. Opting out + * is legitimate where colour IS the information — a swatch, a chart series — so + * this reports rather than gates, and the report is what makes an illegitimate + * opt-out visible. + */ +export const FORCED_COLORS_PROBE = `(function () {${PROBE_PRELUDE} + var out = []; + var seen = {}; + var all = document.body ? document.body.querySelectorAll('*') : []; + for (var i = 0; i < all.length; i += 1) { + var el = all[i]; + var style = getComputedStyle(el); + if (style.forcedColorAdjust !== 'none') continue; + if (style.display === 'none' || style.visibility === 'hidden') continue; + var selector = describe(el); + if (seen[selector]) continue; + seen[selector] = 1; + out.push({ + selector: selector, + color: style.color, + background: style.backgroundColor, + text: ownText(el).slice(0, 60), + }); + } + return out.slice(0, 20); +})()`; + +/* ========================================================================== + * Touch targets. + * ========================================================================== */ + +export interface UndersizedTarget { + readonly selector: string; + readonly name: string; + readonly width: number; + readonly height: number; +} + +export interface TouchTargetReport { + readonly examined: number; + readonly exempt: number; + readonly undersized: readonly UndersizedTarget[]; +} + +/** + * Every operable target's rendered box. + * + * Exemptions, and why each is not a loophole: + * + * - `[data-axe-canary]`: markup this harness plants to prove the scan is + * live. It is 24px square on purpose and is not product UI. + * - disabled / `aria-disabled`: not operable, so there is no target. + * - `tabindex="-1"` on a non-native control: programmatically focusable, not + * in the tab order and not a pointer target. + * - not rendered: `display: none`, `visibility: hidden`, or a zero box. + * - the visually-hidden idiom — a 1px box that is also clipped, which is how + * `sr-only` parks the skip link off-screen until it takes focus. It is not + * a pointer target at 1x1; it is a keyboard affordance that becomes + * full-size on focus. Both halves are required, so a genuinely tiny + * unclipped control is still reported. + * - WCAG 2.5.5's inline exception: a link inside a sentence is sized by the + * line box around it and cannot be enlarged without breaking the text. + * Detected as `display: inline` with sibling text in the same parent. + * + * Known limit, stated rather than papered over: the measurement is the + * element's border box. A control that enlarges its hit area with an absolutely + * positioned pseudo-element reads as undersized here even though a pointer can + * reach the larger area. No component in this dashboard uses that pattern + * today; if one adopts it, this probe needs the pseudo-element box, not a + * suppression. + * + * Reading a failure: CSS pixels, not `rem`. `tailwind.css` sets + * `html { font-size: 14px }`, and Tailwind's spacing scale is rem-based, so a + * control written `min-h-11` or `size-11` — chosen because 11 x 4px reads as + * 44 — lands at 38.5 CSS pixels. Several offenders were sized for this + * threshold and still miss it for that reason, so check the computed box before + * concluding a utility class is wrong. + */ +export const TOUCH_TARGET_PROBE = `(function () {${PROBE_PRELUDE} + var MIN = ${MIN_TOUCH_TARGET_PX}; + var NATIVE = 'a[href],area[href],button,input,select,textarea,summary'; + var nodes = document.querySelectorAll(INTERACTIVE); + var examined = 0; + var exempt = 0; + var undersized = []; + var seen = {}; + for (var i = 0; i < nodes.length; i += 1) { + var el = nodes[i]; + if (el.closest('[data-axe-canary]') !== null) { exempt += 1; continue; } + if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') { exempt += 1; continue; } + var ti = el.getAttribute('tabindex'); + if (ti !== null && Number(ti) < 0 && !el.matches(NATIVE)) { exempt += 1; continue; } + var style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') { exempt += 1; continue; } + var rect = el.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) { exempt += 1; continue; } + var clipped = (style.clipPath !== 'none' && style.clipPath !== '') || + (style.clip !== 'auto' && style.clip !== ''); + if (rect.width <= 1 && rect.height <= 1 && clipped) { exempt += 1; continue; } + if (style.display === 'inline' && el.parentElement !== null && + ownText(el.parentElement) !== '') { exempt += 1; continue; } + examined += 1; + if (rect.width >= MIN - 0.5 && rect.height >= MIN - 0.5) continue; + var selector = describe(el); + if (seen[selector]) continue; + seen[selector] = 1; + undersized.push({ + selector: selector, + name: (el.getAttribute('aria-label') || el.textContent || '') + .trim().replace(/\\s+/g, ' ').slice(0, 60), + width: Math.round(rect.width * 10) / 10, + height: Math.round(rect.height * 10) / 10, + }); + } + return { examined: examined, exempt: exempt, undersized: undersized.slice(0, 20) }; +})()`; + +/** + * A probe that examined nothing proved nothing — the same failure mode the + * visibility sweep was rewritten to close. Every audited surface renders a nav + * rail full of links, so zero operable targets means the probe did not run + * against the page it was aimed at. + */ +export function touchTargetFailures(report: TouchTargetReport, tag: string): string[] { + if (report.examined === 0) { + return [ + `${tag}: the touch-target probe found no operable target at all, so it ` + + `proved nothing about this capture (${report.exempt} exempt)`, + ]; + } + if (report.undersized.length === 0) return []; + const listed = report.undersized + .slice(0, 6) + .map((t) => `${t.selector} ${t.width}x${t.height}${t.name === '' ? '' : ` "${t.name}"`}`); + return [ + `${tag}: ${report.undersized.length}/${report.examined} operable targets are smaller than ` + + `${MIN_TOUCH_TARGET_PX}x${MIN_TOUCH_TARGET_PX} CSS pixels: ${listed.join('; ')}`, + ]; +} + +/* ========================================================================== + * The workspace header's one line: does anything on it render outside the box. + * ========================================================================== */ + +export interface HeaderOverflowChild { + readonly header: string; + readonly selector: string; + readonly text: string; + /** CSS pixels past each edge of the header's padding box. 0 means inside. */ + readonly pastRight: number; + readonly pastLeft: number; + readonly pastTop: number; + readonly pastBottom: number; + readonly width: number; + readonly height: number; + readonly headerWidth: number; + readonly contentWidth: number; + /** Distance from the child's right edge to the viewport's. Negative means the + * child is painted off-screen entirely. */ + readonly viewportSlack: number; +} + +export interface HeaderBoxReport { + /** Workspace headers on the page. Zero is legitimate — not every route + * renders one — which is why this is reported rather than asserted. */ + readonly headers: number; + readonly examined: number; + readonly offenders: readonly HeaderOverflowChild[]; +} + +/** + * Every rendered child of a workspace header, measured against that header's + * padding box. + * + * This exists because `document.scrollWidth` cannot see the defect it is named + * for. The state chip on `/work` rendered 19 CSS pixels outside its header at + * 320px and at 400% zoom — bezel sheared, label flush against the screen edge — + * through 78 clean scans, because the shell clips instead of scrolling, so the + * document never widened and the reflow gate never fired. The two heuristics + * either side of it missed it for reasons worth recording, since both are still + * in place: + * + * - `REFLOW_PROBE` skips anything whose `rect.right <= clientWidth + 1`, and + * the chip's LABEL ended at exactly 320 of 320. One pixel of luck. + * - It also skips elements carrying no text of their own, and the chip's text + * lives in child spans, so the chip's own box was never a candidate. + * + * Measuring against the container instead of the viewport closes both gaps: a + * child that overruns its header is a defect at any width, whether or not the + * viewport happens to be wider, and it needs no judgement about what the + * element carries. + * + * Out-of-flow children are excluded. An absolutely positioned overlay does not + * spend the line's width budget and is routinely meant to escape it; only + * elements laid out IN the header are held to the header's box. + */ +export const HEADER_BOX_PROBE = `(function () {${PROBE_PRELUDE} + var headers = document.querySelectorAll('[data-workspace-header]'); + var examined = 0; + var offenders = []; + var seen = {}; + var round = function (n) { return Math.round(n * 10) / 10; }; + for (var h = 0; h < headers.length; h += 1) { + var header = headers[h]; + var hs = getComputedStyle(header); + if (hs.display === 'none' || hs.visibility === 'hidden') continue; + var hb = header.getBoundingClientRect(); + var innerLeft = hb.left + (parseFloat(hs.paddingLeft) || 0); + var innerRight = hb.right - (parseFloat(hs.paddingRight) || 0); + for (var i = 0; i < header.children.length; i += 1) { + var el = header.children[i]; + var style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + if (style.position === 'absolute' || style.position === 'fixed') continue; + var r = el.getBoundingClientRect(); + if (r.width < 1 && r.height < 1) continue; + examined += 1; + var pastRight = r.right - innerRight; + var pastLeft = innerLeft - r.left; + var pastTop = hb.top - r.top; + var pastBottom = r.bottom - hb.bottom; + if (pastRight <= 0.5 && pastLeft <= 0.5 && pastTop <= 0.5 && pastBottom <= 0.5) continue; + var key = describe(el); + if (seen[key]) continue; + seen[key] = 1; + offenders.push({ + header: describe(header), + selector: key, + text: (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 60), + pastRight: round(Math.max(0, pastRight)), + pastLeft: round(Math.max(0, pastLeft)), + pastTop: round(Math.max(0, pastTop)), + pastBottom: round(Math.max(0, pastBottom)), + width: round(r.width), + height: round(r.height), + headerWidth: Math.round(hb.width), + contentWidth: Math.round(innerRight - innerLeft), + viewportSlack: round(window.innerWidth - r.right), + }); + } + } + return { headers: headers.length, examined: examined, offenders: offenders.slice(0, 12) }; +})()`; + +/** + * Gated at every width, unlike reflow, and for every child rather than only + * state chips. + * + * Reflow is gated only at 320 and 400% zoom because the plan's sentence is + * about those two sizes. This is a different claim with no width in it: content + * placed inside a container must render inside that container. A wide layout + * that pushes its own header content out of the box is the same defect with a + * bigger budget, so narrowing this to the reflow-gated viewports would only + * hide the easier half. + * + * The chip-only exemption is gone with the bug that motivated it. It existed + * because the 564-scan sweep found one pre-existing offender — a 494.8px + * snapshot/revision strip in the `/settings` header rendering 356.9px outside + * it, 276px of that off-screen — on a surface that change did not own, and + * gating it would have turned a hand-off into someone else's red build. That + * strip now shrinks and wraps, the same sweep records no offender on any + * surface, so the check can finally hold every header child to the invariant + * it always claimed to measure. Anything that overruns its header from here is + * a new defect, and it fails. + */ +export function headerBoxFailures(report: HeaderBoxReport, tag: string): string[] { + return report.offenders + .map((o) => { + const past = [ + o.pastRight > 0 ? `${o.pastRight}px past its right edge` : '', + o.pastLeft > 0 ? `${o.pastLeft}px past its left edge` : '', + o.pastTop > 0 ? `${o.pastTop}px above it` : '', + o.pastBottom > 0 ? `${o.pastBottom}px below it` : '', + ].filter((part) => part !== ''); + return ( + `${tag}: ${o.selector} renders outside ${o.header} — ${past.join(', ')}. ` + + `The child is ${o.width}x${o.height} CSS px and the header offers ` + + `${o.contentWidth}px of content box within ${o.headerWidth}px` + + (o.viewportSlack < 0 ? `; ${round1(-o.viewportSlack)}px of it is off-screen` : '') + + (o.text === '' ? '' : `. Carrying: "${o.text}"`) + ); + }); +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 59428ccb3f..baa9196f86 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -8,8 +8,8 @@ "name": "@tracedecay/dashboard", "version": "0.0.0", "dependencies": { - "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource/chakra-petch": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", "@radix-ui/react-dialog": "^1.1.4", "@tanstack/react-query": "^5.62.0", @@ -27,7 +27,6 @@ "zustand": "^5.0.2" }, "devDependencies": { - "@axe-core/playwright": "^4.12.1", "@playwright/test": "1.61.1", "@rsbuild/core": "^1.7.6", "@rsbuild/plugin-react": "^1.1.0", @@ -112,19 +111,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@axe-core/playwright": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", - "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "axe-core": "~4.12.1" - }, - "peerDependencies": { - "playwright-core": ">= 1.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -807,19 +793,19 @@ } } }, - "node_modules/@fontsource-variable/archivo": { + "node_modules/@fontsource-variable/ibm-plex-sans": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz", - "integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fontsource-variable/ibm-plex-sans": { + "node_modules/@fontsource/chakra-petch": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", - "integrity": "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==", + "resolved": "https://registry.npmjs.org/@fontsource/chakra-petch/-/chakra-petch-5.3.0.tgz", + "integrity": "sha512-QZvbM/q2R6n1pg2x+TJPftZmBNS+ADU37L4Z4L/6UJIz+JZ/SRaEf8usPFRiP7Ht28sIideMu/Bm1SnGlSrwNw==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -2594,16 +2580,6 @@ "node": ">=12" } }, - "node_modules/axe-core": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", - "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 552b8a360f..5096f40696 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -18,8 +18,8 @@ "live:multiproject": "tsx stories/live-multiproject.ts" }, "dependencies": { - "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource/chakra-petch": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", "@radix-ui/react-dialog": "^1.1.4", "@tanstack/react-query": "^5.62.0", diff --git a/dashboard/src/app/RouteChunkBoundary.dom.test.tsx b/dashboard/src/app/RouteChunkBoundary.dom.test.tsx index 000e5fe631..c6930a46df 100644 --- a/dashboard/src/app/RouteChunkBoundary.dom.test.tsx +++ b/dashboard/src/app/RouteChunkBoundary.dom.test.tsx @@ -40,4 +40,39 @@ describe('RouteChunkBoundary', () => { expect(screen.queryByText('Offline')).toBeNull(); expect(loadCalls).toBeGreaterThan(callsAfterFailure); }); + + /** + * Every route mounts this same component type at the same Outlet slot, so a + * client-side navigation is a PROP update, not a remount. The boundary must + * swap to the new loader's page; pinning `lazy(load)` in the constructor + * left the whole app stuck on whichever workspace rendered first. + */ + it('swaps to the new page when the load prop changes without a remount', async () => { + const loadBrain = () => + Promise.resolve({ default: () =>
brain surface
}); + const loadCode = () => + Promise.resolve({ default: () =>
code surface
}); + + const { rerender } = render(); + expect(await screen.findByText('brain surface')).toBeTruthy(); + + rerender(); + expect(await screen.findByText('code surface')).toBeTruthy(); + expect(screen.queryByText('brain surface')).toBeNull(); + }); + + it('clears a previous route failure when navigating to a different loader', async () => { + const error = new Error('Failed to fetch dynamically imported module'); + error.name = 'ChunkLoadError'; + const loadBroken = () => Promise.reject(error); + const loadHealthy = () => + Promise.resolve({ default: () =>
healthy surface
}); + + const { rerender } = render(); + expect(await screen.findByText('Offline')).toBeTruthy(); + + rerender(); + expect(await screen.findByText('healthy surface')).toBeTruthy(); + expect(screen.queryByText('Offline')).toBeNull(); + }); }); diff --git a/dashboard/src/app/RouteChunkBoundary.tsx b/dashboard/src/app/RouteChunkBoundary.tsx index 33d74c8fac..fbe75a8c8e 100644 --- a/dashboard/src/app/RouteChunkBoundary.tsx +++ b/dashboard/src/app/RouteChunkBoundary.tsx @@ -89,30 +89,69 @@ type RouteChunkBoundaryProps = { type RouteChunkBoundaryState = { error: Error | null; attempt: number; + /** The loader the current `error`/`attempt` belong to. Every route mounts + * this same component type at the same Outlet position, so React reconciles + * a navigation as a PROP change — the boundary must notice the new loader + * itself rather than assuming a fresh mount. */ + loadedFor: RouteChunkLoader; }; /** - * Per-route lazy boundary. React `lazy()` caches a rejected import on that - * instance, so retry allocates a new `lazy(load)` and remounts it. + * One lazy component per loader, shared across renders and navigations. + * + * `lazy()` memoizes its resolved module on the instance, so a per-loader cache + * means revisiting a workspace renders it synchronously instead of suspending + * again. A rejected import is also cached on the instance, which is why + * `retry` evicts the entry and allocates a fresh one. + */ +const CHUNKS = new WeakMap>(); + +function lazyFor(load: RouteChunkLoader): LazyExoticComponent { + let page = CHUNKS.get(load); + if (page === undefined) { + page = lazy(load); + CHUNKS.set(load, page); + } + return page; +} + +/** + * Per-route lazy boundary. + * + * THE PROP CAN CHANGE WITHOUT A REMOUNT. React Router keeps the `` + * slot's component type identical across workspace navigations, so switching + * from `/brain` to `/code` updates `load` on the existing instance. The first + * version of this class computed `lazy(props.load)` once in its constructor, + * which pinned every client-side navigation to whichever workspace loaded + * first — the URL and the nav rail moved, the content never did. */ export class RouteChunkBoundary extends Component< RouteChunkBoundaryProps, RouteChunkBoundaryState > { - state: RouteChunkBoundaryState = { error: null, attempt: 0 }; - private Page: LazyExoticComponent; - constructor(props: RouteChunkBoundaryProps) { super(props); - this.Page = lazy(props.load); + this.state = { error: null, attempt: 0, loadedFor: props.load }; } static getDerivedStateFromError(error: unknown): Pick { return { error: asError(error) }; } + static getDerivedStateFromProps( + props: RouteChunkBoundaryProps, + state: RouteChunkBoundaryState, + ): Partial | null { + // A navigation to a different route: drop the previous route's failure so + // one broken chunk does not paint every workspace unavailable. + if (props.load !== state.loadedFor) { + return { error: null, attempt: 0, loadedFor: props.load }; + } + return null; + } + private retry = () => { - this.Page = lazy(this.props.load); + CHUNKS.delete(this.props.load); this.setState((state) => ({ error: null, attempt: state.attempt + 1 })); }; @@ -120,7 +159,7 @@ export class RouteChunkBoundary extends Component< if (this.state.error) { return ; } - const Page = this.Page; + const Page = lazyFor(this.props.load); return ( }> diff --git a/dashboard/src/app/channels.ts b/dashboard/src/app/channels.ts index 348c75d545..c21adbe169 100644 --- a/dashboard/src/app/channels.ts +++ b/dashboard/src/app/channels.ts @@ -8,26 +8,35 @@ * is a plain data module with no JSX so the shell chrome and the pages can both * read it without an import cycle through the router. */ +/** Which register of the tactical dock a channel files under. `workspace` is + * where the operator reads and explores; `ops` is where the system reports on + * itself and its deliveries; `config` is the one channel that changes the + * instrument instead of reading it. */ +export type ChannelGroup = 'workspace' | 'ops' | 'config'; + export interface Channel { readonly path: string; readonly label: string; + readonly group: ChannelGroup; } export const CHANNELS: readonly Channel[] = [ - { path: 'brain', label: 'Brain' }, - { path: 'explorer', label: 'Explorer' }, - { path: 'loom', label: 'Loom' }, - { path: 'sessions', label: 'Sessions' }, - { path: 'agents', label: 'Agents' }, - { path: 'code', label: 'Code' }, - { path: 'knowledge', label: 'Knowledge' }, - { path: 'delivery', label: 'Delivery' }, - { path: 'automations', label: 'Automations' }, - { path: 'observatory', label: 'Observatory' }, - { path: 'costs', label: 'Costs' }, - { path: 'settings', label: 'Settings' }, - { path: 'work', label: 'Work' }, - { path: 'workflows', label: 'Workflows' }, + { path: 'brain', label: 'Brain', group: 'workspace' }, + { path: 'explorer', label: 'Explorer', group: 'workspace' }, + { path: 'loom', label: 'Loom', group: 'workspace' }, + { path: 'sessions', label: 'Sessions', group: 'workspace' }, + { path: 'agents', label: 'Agents', group: 'workspace' }, + { path: 'code', label: 'Code', group: 'workspace' }, + { path: 'knowledge', label: 'Knowledge', group: 'workspace' }, + { path: 'delivery', label: 'Delivery', group: 'ops' }, + { path: 'automations', label: 'Automations', group: 'ops' }, + { path: 'observatory', label: 'Observatory', group: 'ops' }, + { path: 'costs', label: 'Costs', group: 'ops' }, + // Settings keeps channel 12: the number is the channel's identity, so + // regrouping the rail must never renumber an instrument. + { path: 'settings', label: 'Settings', group: 'config' }, + { path: 'work', label: 'Work', group: 'ops' }, + { path: 'workflows', label: 'Workflows', group: 'ops' }, ] as const; /** Zero-padded channel number for a workspace path (`code` → `06`). Unknown diff --git a/dashboard/src/app/shell/NavRail.tsx b/dashboard/src/app/shell/NavRail.tsx index 0d9777dd56..4cdbbbd01b 100644 --- a/dashboard/src/app/shell/NavRail.tsx +++ b/dashboard/src/app/shell/NavRail.tsx @@ -19,7 +19,7 @@ import { NavLink } from 'react-router'; import type { StorageFindingKindStatusV1 } from '../../contracts/generated.ts'; import { useStorageFindings } from '../../data/query/storageFindings.ts'; import { cn } from '../../ui/cn'; -import { CHANNELS, channelNumber } from '../channels.ts'; +import { CHANNELS, channelNumber, type ChannelGroup } from '../channels.ts'; const ICONS: Record = { brain: Brain, @@ -40,7 +40,13 @@ const ICONS: Record = { workflows: Waypoints, }; -const MAIN = CHANNELS.filter((channel) => channel.path !== 'settings'); +/** The dock's two scrolling registers, in rail order. `config` (Settings) is + * pinned to the rail's foot rather than filed here. Channels keep their + * numbers — grouping arranges the dock, it never renumbers an instrument. */ +const REGISTERS: readonly { group: ChannelGroup; label: string }[] = [ + { group: 'workspace', label: 'Workspace' }, + { group: 'ops', label: 'Ops' }, +]; /** * What the app-wide Doctor dot is allowed to say. @@ -238,18 +244,41 @@ export function NavRail() {
- {MAIN.map((channel) => ( - + {REGISTERS.map((register) => ( +
+ + {CHANNELS.filter((channel) => channel.group === register.group).map( + (channel) => ( + + ), + )} +
))}
+
); } + +/** A register divider: the engraved group stamp over its channels. Collapsed + * rails keep the hairline so the grouping survives at icon width, they just + * lose the word. */ +function RailRegisterLabel({ label }: { label: string }) { + return ( +
+ {label} + +
+ ); +} diff --git a/dashboard/src/app/shell/Shell.tsx b/dashboard/src/app/shell/Shell.tsx index 9f4c887ea6..63cd9f9557 100644 --- a/dashboard/src/app/shell/Shell.tsx +++ b/dashboard/src/app/shell/Shell.tsx @@ -3,7 +3,7 @@ import { Outlet } from 'react-router'; import { NavRail } from './NavRail'; import { ScopeBar } from './ScopeBar'; import { ScopeUrlSync } from '../../data/scope/UrlSync.tsx'; -import { QueryActivityStatus, StatusStrip } from './StatusStrip'; +import { QueryActivityStatus, SourceProvenance, StatusStrip } from './StatusStrip'; const CommandPalette = lazy(() => import('./CommandPalette').then((m) => ({ default: m.CommandPalette })), @@ -63,7 +63,14 @@ export function Shell() { > - } /> + + + + + } + /> ); diff --git a/dashboard/src/app/shell/StatusStrip.tsx b/dashboard/src/app/shell/StatusStrip.tsx index cb91dfe311..b54e7daa17 100644 --- a/dashboard/src/app/shell/StatusStrip.tsx +++ b/dashboard/src/app/shell/StatusStrip.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useCallback, useSyncExternalStore, type ReactNode } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useEventStreamState, @@ -53,7 +53,12 @@ function feedReading(sync: ProjectionSync): { } } -/** Telemetry bar for the real `/api/events` connection state. */ +/** + * Telemetry bar for the real `/api/events` connection state, drawn as a + * segmented subsystem header: every cell is a numbered register (01 LINK, + * 02 FEED, 03 SOURCE, 04 QUERY) so the strip reads as the instrument's own + * status word rather than as a footer of prose. + */ export function StatusStrip({ queryActivity }: { queryActivity?: ReactNode } = {}) { const { state } = useEventStreamState(); const feed = feedReading(useProjectionSync()); @@ -68,17 +73,17 @@ export function StatusStrip({ queryActivity }: { queryActivity?: ReactNode } = { className="flex min-h-8 shrink-0 items-stretch border-t border-edge-subtle bg-surface-1" aria-label="Status" > - + {link.value} - - + + {/* * One region over the state and its reason together. * @@ -110,6 +115,62 @@ export function StatusStrip({ queryActivity }: { queryActivity?: ReactNode } = { ); } +/** + * Where the readings on screen actually come from, so the strip cannot lie by + * omission: a healthy plate over a dead link is a CAPTURED read, not a live + * one. + * + * LIVE the event stream is up; plates follow the daemon. + * CAPTURED the stream is down but resolved reads are still on screen — + * fixtures, or the last answers before the link dropped. Stamped + * in the alert register because it is exactly the state a reader + * must not mistake for live. + * NO SOURCE the stream is down and nothing has answered; the plates are + * empty frames, which is its own honest state. + */ +export function SourceProvenance() { + const { state } = useEventStreamState(); + const hasData = useAnyResolvedRead(); + // Only a LIVE stream earns the live stamp. A connecting stream is a stream + // that is not delivering: whatever the plates show meanwhile is a captured + // read, and stamping it anything softer would be the strip vouching for + // freshness it cannot see. + const source = + state === 'live' + ? { value: 'live', tone: 'bg-state-ready', ink: 'text-text-primary' } + : hasData + ? { value: 'captured', tone: 'bg-alert', ink: 'text-alert' } + : { value: 'no source', tone: 'bg-state-offline', ink: 'text-text-muted' }; + return ( + + + + {source.value} + + + ); +} + +/** Whether any read model on screen has resolved with data — the difference + * between CAPTURED (plates hold a real read) and NO SOURCE (empty frames). */ +function useAnyResolvedRead(): boolean { + const client = useQueryClient(); + const subscribe = useCallback( + (onChange: () => void) => client.getQueryCache().subscribe(onChange), + [client], + ); + return useSyncExternalStore(subscribe, () => + client + .getQueryCache() + .getAll() + .some((query) => query.state.data !== undefined), + ); +} + /** Query-aware cell mounted by Shell, which is inside QueryClientProvider. * Keeping it separate lets the transport strip remain usable in isolation * without inventing a second QueryClient. */ @@ -121,7 +182,7 @@ export function QueryActivityStatus() { if (activeQuery !== undefined) { return ( - + {activeQuery.label} @@ -145,7 +206,7 @@ export function QueryActivityStatus() { } return lastCancellation === null ? null : ( - + cancelled · {lastCancellation.label} @@ -153,9 +214,22 @@ export function QueryActivityStatus() { ); } -function Cell({ label, children }: { label: string; children: ReactNode }) { +/** One numbered register of the strip: segment number engraved first, the + * subsystem stamp beside it, the reading after the hairline. */ +function Cell({ + code, + label, + children, +}: { + code: string; + label: string; + children: ReactNode; +}) { return (
+ + {code} + {label} {children}
diff --git a/dashboard/src/theme/fonts.css b/dashboard/src/theme/fonts.css index 462d232ff9..de7fa85dfd 100644 --- a/dashboard/src/theme/fonts.css +++ b/dashboard/src/theme/fonts.css @@ -8,10 +8,12 @@ * * The three faces and why each is here: * - * Archivo (display) — SIL OFL 1.1. A signage grotesque with a real width - * axis. Set expanded at the uppercase letterspaced tiers it reads as an - * engraved faceplate legend, which is the one thing Inter cannot do at - * 10px. Two axes (wght + wdth) ship in one file. + * Chakra Petch (display) — SIL OFL 1.1. A squared industrial grotesque with + * chamfered terminals, drawn for engineering signage. At the 10-11px + * uppercase letterspaced tiers this console engraves everywhere, the + * clipped corners survive where a round grotesque blurs into anybody's + * sans — it is the stamp face of the instrument, the one register the + * body face cannot reach. * IBM Plex Sans (body) — SIL OFL 1.1. Drawn for engineering documentation, * so it holds its counters at the 11-13px this console actually sets, and * its flared stems and single-storey detailing are unmistakably not Inter. @@ -31,27 +33,69 @@ * missing glyph rather than reveal it. */ -/* ---- Archivo Variable: display (wght 100-900, wdth 62-125%) ---- */ +/* ---- Chakra Petch: display / stamps (500 legends, 600 titles, 700 marks) ---- */ @font-face { - font-family: 'Archivo Variable'; + font-family: 'Chakra Petch'; font-style: normal; font-display: swap; - font-weight: 100 900; - font-stretch: 62% 125%; - src: url('@fontsource-variable/archivo/files/archivo-latin-ext-wdth-normal.woff2') - format('woff2-variations'); + font-weight: 500; + src: url('@fontsource/chakra-petch/files/chakra-petch-latin-ext-500-normal.woff2') + format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { - font-family: 'Archivo Variable'; + font-family: 'Chakra Petch'; font-style: normal; font-display: swap; - font-weight: 100 900; - font-stretch: 62% 125%; - src: url('@fontsource-variable/archivo/files/archivo-latin-wdth-normal.woff2') - format('woff2-variations'); + font-weight: 500; + src: url('@fontsource/chakra-petch/files/chakra-petch-latin-500-normal.woff2') + format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Chakra Petch'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url('@fontsource/chakra-petch/files/chakra-petch-latin-ext-600-normal.woff2') + format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Chakra Petch'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url('@fontsource/chakra-petch/files/chakra-petch-latin-600-normal.woff2') + format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Chakra Petch'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url('@fontsource/chakra-petch/files/chakra-petch-latin-ext-700-normal.woff2') + format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Chakra Petch'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url('@fontsource/chakra-petch/files/chakra-petch-latin-700-normal.woff2') + format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; diff --git a/dashboard/src/theme/tailwind.css b/dashboard/src/theme/tailwind.css index 82aa2c9804..c9f2cf35f0 100644 --- a/dashboard/src/theme/tailwind.css +++ b/dashboard/src/theme/tailwind.css @@ -18,6 +18,8 @@ --color-edge-strong: var(--raw-edge-strong); --color-accent: var(--raw-accent); --color-accent-emphasis: var(--raw-accent-emphasis); + --color-alert: var(--raw-alert); + --color-alert-emphasis: var(--raw-alert-emphasis); --color-state-loading: var(--raw-state-loading); --color-state-ready: var(--raw-state-ready); @@ -66,7 +68,7 @@ --font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', 'Cascadia Mono', monospace; /* The engraved tier. Only ever reached through this token — never a * hard-coded family in a component. */ - --font-display: 'Archivo Variable', 'IBM Plex Sans Variable', system-ui, sans-serif; + --font-display: 'Chakra Petch', 'IBM Plex Sans Variable', system-ui, sans-serif; /* Instrument type scale. `3xs` is the annotation size — used only for * letterspaced uppercase legends and units, never for prose. @@ -128,13 +130,13 @@ * wears this — small, letterspaced, uppercase, quiet. * * This is where the display face earns its keep. At 10px uppercase almost - * nothing of a face's personality survives except weight and WIDTH, so the - * display tier is set expanded: an expanded grotesque under heavy tracking is - * the faceplate-legend idiom, and it is the one register the body face cannot - * reach. The expansion is capped at `--display-width` because it is paid for - * in horizontal room — measured across all twelve surfaces at 1440 and 768, - * 112% costs one extra clipped label out of 464 while 125% costs thirteen, - * and a clipped label is lost information, which outranks the flourish. */ + * nothing of a face's personality survives except weight and STROKE GEOMETRY, + * and Chakra Petch's chamfered terminals are exactly the detail that does + * survive: under heavy tracking the legends read as machine-engraved stamps, + * the register a round grotesque cannot reach. `font-stretch` stays declared + * so a future variable face with a width axis picks the tuned width up + * without touching every call site; Chakra Petch ships one width and + * ignores it. */ .td-legend { font-family: var(--font-display); font-stretch: var(--display-width); @@ -311,7 +313,16 @@ * A quantity printed as text has no size; the same quantity given a length * can be compared across a column without reading a single digit. Every list * of numbers in this console wears one of these under or beside its value, - * which is what turns thirty identical rows into a distribution. */ + * which is what turns thirty identical rows into a distribution. + * + * The fill is SEGMENTED — 5px cells on a 7px pitch — because a continuous + * pill is paint and a row of cells is an instrument register: the same + * length now also reads as a count of lit segments, and a wall of meters + * stops looking like a bar chart smeared across the page. The mask keeps + * the segmentation on whatever hue a call site's `tone` sets. Hairline + * (1px) meters are below the cell size and drop the mask via the + * height-conditional below — a 1px rule diced into 5px dashes reads as a + * dotted border, not a measurement. */ .td-meter { position: relative; overflow: hidden; @@ -322,5 +333,13 @@ inset-block: 0; left: 0; background: var(--raw-accent); + mask-image: repeating-linear-gradient( + to right, + black 0 5px, + transparent 5px 7px + ); + } + .h-px > .td-meter-fill { + mask-image: none; } } diff --git a/dashboard/src/theme/tokens.css b/dashboard/src/theme/tokens.css index a00671dad2..e02da6dab4 100644 --- a/dashboard/src/theme/tokens.css +++ b/dashboard/src/theme/tokens.css @@ -28,6 +28,12 @@ --raw-edge-strong: oklch(0.46 0.006 260); --raw-accent: oklch(0.75 0.15 200); --raw-accent-emphasis: oklch(0.64 0.16 200); + /* The second signal hue: amber is ALERT AND ACTIVITY — elevated but not + * broken. Cyan stays the live/selected signal, red stays critical-only; + * amber is the register in between, for captured provenance, attention + * counts, and activity that wants an eye without an alarm. */ + --raw-alert: oklch(0.78 0.15 75); + --raw-alert-emphasis: oklch(0.68 0.16 65); /* Graticule: the instrument grid drawn behind canvases and readout fields. * Must stay at the edge of visibility — discipline, never texture. */ @@ -93,6 +99,7 @@ --raw-graph-text: oklch(0.84 0.035 248); --raw-graph-edge: oklch(0.43 0.075 245); --raw-graph-accent: oklch(0.82 0.16 205); + --raw-graph-alert: oklch(0.8 0.15 75); --raw-graph-dim: oklch(0.28 0.035 252); /* Five layers, front to back: a vignette that pulls the corners down so the * field has a centre to look INTO rather than an even wash; the central lift @@ -242,6 +249,9 @@ * type can now wear it. */ --raw-accent: oklch(0.47 0.14 220); --raw-accent-emphasis: oklch(0.43 0.15 220); + /* Amber as ink on paper needs the same darkening the accent got. */ + --raw-alert: oklch(0.55 0.14 70); + --raw-alert-emphasis: oklch(0.5 0.15 65); --raw-grid: oklch(0.895 0.002 260); --raw-grid-minor: oklch(0.945 0.001 260); /* Complete first-class light mappings for every domain state (plan 11a: @@ -273,6 +283,7 @@ --raw-graph-text: oklch(0.86 0.035 248); --raw-graph-edge: oklch(0.47 0.075 245); --raw-graph-accent: oklch(0.84 0.16 205); + --raw-graph-alert: oklch(0.8 0.15 75); --raw-graph-dim: oklch(0.3 0.035 252); --raw-graph-field: radial-gradient(125% 125% at 50% 44%, transparent 34%, oklch(0.045 0.012 272 / 0.58) 100%), @@ -350,6 +361,28 @@ background: var(--raw-graph-field); } +/** The night optical window: EVERY analytical drawing in the product — chart, + * map, canvas, distribution well — is projected light on this recessed dark + * glass, inside either shell theme. The graph canvas gets the full five-layer + * field above; this is the flat-glass sibling for the smaller apertures, so a + * 70px sparkline pays for a hairline and a vignette rather than nebulae. + * + * It is a class, not a per-surface style, because the claim it makes is + * global: bloom and brightness encodings only mean anything on a dark field, + * so no quantitative mark may sit directly on paper. */ +.td-optic { + position: relative; + background: + radial-gradient(130% 130% at 50% 40%, transparent 40%, oklch(0.05 0.012 272 / 0.5) 100%), + linear-gradient(160deg, oklch(0.135 0.022 268) 0%, var(--raw-graph-substrate) 60%, oklch(0.08 0.018 272) 100%); + border: 1px solid oklch(0.32 0.05 250 / 0.55); + box-shadow: var(--shadow-field); +} +[data-contrast='more'] .td-optic { + background: var(--raw-surface-0); + border-color: var(--raw-edge-strong); +} + /** Sensor grain over a field, so no region of it is perfectly even. * * Requires a positioned ancestor. Stacked UNDER `.td-scanlines`' own ::after so diff --git a/dashboard/src/viz/chart/Chart.tsx b/dashboard/src/viz/chart/Chart.tsx index 6be189eac8..dc1149dea8 100644 --- a/dashboard/src/viz/chart/Chart.tsx +++ b/dashboard/src/viz/chart/Chart.tsx @@ -32,10 +32,17 @@ function themedOption( const style = getComputedStyle(container); const token = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback; - const text = token('--raw-text-secondary', '#aab0bd'); - const muted = token('--raw-text-muted', '#8a90a0'); - const edge = token('--raw-edge-subtle', '#333a46'); - const accent = token('--raw-accent', '#7aa2f7'); + // Night-window palette, not shell ink: every chart canvas is projected + // light on the dark optical glass (`.td-optic`), inside either shell + // theme, so its marks sample the graph tokens. Under + // `data-contrast='more'` those tokens collapse to the shell palette and + // the chart flattens with the rest of the atmosphere. + const text = token('--raw-graph-text', '#aab0bd'); + // Axis labels are the smallest type on the glass, so they take the full + // night-text ink; only the ruled split lines drop to the dim register. + const muted = text; + const edge = token('--raw-graph-edge', '#333a46'); + const accent = token('--raw-graph-accent', '#7aa2f7'); // The chart's type has to come from the same token as the rest of the app. // This used to name Inter directly, which meant a chart axis silently kept // typing in the old face after the design system changed its body face -- @@ -54,7 +61,9 @@ function themedOption( yAxis: undefined, grid: { left: 8, right: 8, top: 24, bottom: 8, containLabel: true }, tooltip: { - backgroundColor: token('--raw-surface-2', '#22252d'), + // The tooltip floats over the night glass, so it is dark glass too — + // a paper tooltip over the optical window would blow the exposure. + backgroundColor: token('--raw-graph-substrate', '#0b0d14'), borderColor: edge, textStyle: { color: text, fontSize: 11 }, }, @@ -170,5 +179,19 @@ export const Chart = memo(function Chart({ ); } - return
; + return ( + // The night optical window: the canvas is inset a hairline from the + // glass so axis ink never touches the bezel, and the sensor grain keeps + // the field from reading as a flat fill. `td-optic` carries the + // positioned ancestor `td-grain` requires. +
+
+
+ ); }); diff --git a/dashboard/src/workspaces/agents/AgentsPage.dom.test.tsx b/dashboard/src/workspaces/agents/AgentsPage.dom.test.tsx index f836b89564..cb6a5b44b2 100644 --- a/dashboard/src/workspaces/agents/AgentsPage.dom.test.tsx +++ b/dashboard/src/workspaces/agents/AgentsPage.dom.test.tsx @@ -188,7 +188,9 @@ describe('AgentsPage read coverage', () => { renderAgents(); expect(await screen.findByText('Handoff frontier')).toBeTruthy(); - expect(screen.getByText('Tool activity')).toBeTruthy(); + // Tool activity lives on the demoted telemetry register, which renders + // once its own usage read lands — awaited rather than assumed. + expect(await screen.findByText('Tool activity')).toBeTruthy(); expect(screen.getByText('Failure context')).toBeTruthy(); // Tool activity is fed by the diagnostics read that landed. diff --git a/dashboard/src/workspaces/agents/AgentsPage.transport.dom.test.tsx b/dashboard/src/workspaces/agents/AgentsPage.transport.dom.test.tsx index 0bb94a1a31..aa3141583c 100644 --- a/dashboard/src/workspaces/agents/AgentsPage.transport.dom.test.tsx +++ b/dashboard/src/workspaces/agents/AgentsPage.transport.dom.test.tsx @@ -16,10 +16,10 @@ import { AgentsPage } from './AgentsPage.tsx'; * that always succeeds and varies the body; these vary the transport instead, * through the same MSW handlers the visual audit resolves its fixtures from. * - * Agents is the subject for the whole matrix because its outer boundary wraps - * the entire page: a failed read has nowhere to hide, so the single rendered - * state is the page's whole answer, and any invented number would have to - * appear beside it. + * Agents is the subject for the whole matrix because every plate on it is its + * own read boundary: under a page-wide fault each plate must report that + * fault on its own face, no plate may invent a number, and no two faults with + * different meanings may collapse into one reading. */ const server = fixtureServer(); beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); @@ -68,26 +68,31 @@ const MATRIX: ReadonlyArray<{ fault: HttpFault; kind: FailKind; detail: string | describe('AgentsPage under HTTP transport faults', () => { it.each(MATRIX)( - 'renders $fault as the $kind state and invents no analytics counts', + 'renders $fault as the $kind state on every plate and invents no analytics counts', async ({ fault, kind, detail }) => { server.use(allRoutesFail(fault)); const { container } = renderAgents(); - const chip = await findChip(container); - expect(chip.getAttribute('data-state')).toBe(kind satisfies DomainStateKind); - if (detail) expect(chip.textContent).toContain(detail); + const chips = await findFaultChips(container); + // Every read boundary on the page reports the same wire fault; a plate + // rendering any other state would be inventing a reading. + for (const chip of chips) { + expect(chip.getAttribute('data-state')).toBe(kind satisfies DomainStateKind); + if (detail) expect(chip.textContent).toContain(detail); + } - // The state's own sentence, and neither of the other two failure - // sentences: an unreachable daemon must not read as a bad payload. - expect(screen.getByText(GUIDANCE[kind])).toBeTruthy(); + // The state's own sentence (once per failed plate), and none of the + // other failure sentences: an unreachable daemon must not read as a bad + // payload. + expect(screen.getAllByText(GUIDANCE[kind]).length).toBeGreaterThan(0); for (const other of Object.keys(GUIDANCE) as FailKind[]) { - if (other !== kind) expect(screen.queryByText(GUIDANCE[other])).toBeNull(); + if (other !== kind) expect(screen.queryAllByText(GUIDANCE[other])).toHaveLength(0); } - // Nothing was read, so nothing may be reported. The page's success - // surfaces are absent rather than present-and-empty: no content region, - // no event-window readouts, and none of the counts the fixtures serve. - expect(screen.queryByRole('region', { name: 'Agents content' })).toBeNull(); + // The delegation plates stay on screen as named, failed regions — the + // frame is honest — but nothing was read, so nothing may be reported: + // no event-window readouts and none of the counts the fixtures serve. + expect(screen.getByRole('region', { name: 'Agents content' })).toBeTruthy(); expect(screen.queryByText('Event window')).toBeNull(); expect(screen.queryByText('mcp tool calls')).toBeNull(); expect(screen.queryByText('hook calls')).toBeNull(); @@ -105,8 +110,8 @@ describe('AgentsPage under HTTP transport faults', () => { server.resetHandlers(); server.use(allRoutesFail(fault)); const view = renderAgents(); - const chip = await findChip(view.container); - rendered.set(fault, `${chip.getAttribute('data-state')}${chip.textContent}`); + const [chip] = await findFaultChips(view.container); + rendered.set(fault, `${chip!.getAttribute('data-state')}${chip!.textContent}`); view.unmount(); } @@ -129,7 +134,8 @@ describe('AgentsPage under HTTP transport faults', () => { server.use(faultHandler('*/api/plugins/analytics/diagnostics', 'server_error')); renderAgents(); - await screen.findByRole('region', { name: 'Agents content' }); + // The telemetry register renders once its own usage read lands. + await screen.findByText('events (capped)'); // The neighbouring read is untouched and still reports its own measurement. expect(readout('events (capped)')).toBe('10,000'); // The failed one reports nothing, and an em dash is not a quantity. @@ -163,12 +169,14 @@ function renderAgents() { ); } -/** The one state chip a fully failed page renders, once it has settled. */ -async function findChip(container: HTMLElement): Promise { - await screen.findByText(/^(Error|Offline|Unauthorized|Denied|Unsupported schema)$/); +/** Every plate's state chip once the fully failed page has settled: each read + * boundary reports the fault on its own face, so there are several, and every + * one of them is part of the page's answer. */ +async function findFaultChips(container: HTMLElement): Promise { + await screen.findAllByText(/^(Error|Offline|Unauthorized|Denied|Unsupported schema)$/); const chips = [...container.querySelectorAll('[data-state]')]; - expect(chips).toHaveLength(1); - return chips[0]!; + expect(chips.length).toBeGreaterThan(0); + return chips; } /** The value printed under a readout's engraved legend, or null when that diff --git a/dashboard/src/workspaces/agents/AgentsPage.tsx b/dashboard/src/workspaces/agents/AgentsPage.tsx index e00b7fef96..9e8737c6a0 100644 --- a/dashboard/src/workspaces/agents/AgentsPage.tsx +++ b/dashboard/src/workspaces/agents/AgentsPage.tsx @@ -10,6 +10,9 @@ import { AnalyticsUnderusedPayloadV1Schema, AnalyticsUsageSummaryV1Schema, type AnalyticsAgentUsageV1, + type AnalyticsDiagnosticsPayloadV1, + type AnalyticsUnderusedPayloadV1, + type AnalyticsUsageSummaryV1, } from '../../contracts/generated.ts'; import { envelopePayload, useEnvelope } from '../../data/query/useEnvelope.ts'; import { logFraction } from '../../viz/scale.ts'; @@ -111,315 +114,359 @@ export function AgentsPage() { const attemptFailures = readAttemptFailures(workGraph.isPending ? undefined : workGraph.data); return ( - - {(envelope) => { - const data = envelope.payload; - const rows: UsageRow[] = data.by_category; - const dominance = summarizeDominance(rows); - const diag = envelopePayload(diagnostics.data); - const diagnosticsRead = diag?.available === false ? undefined : diag ?? undefined; - // A dash in the diagnostics figures is three different facts: a read - // still in flight, a read that failed, and a source that answered and - // declared itself unavailable. Only the last one may be called - // "unavailable" — the strip used to label all three that way. - const diagnosticsAbsence = diagnostics.isPending - ? 'diagnostics still loading' - : diag == null - ? 'analytics diagnostics could not be read' - : diag.available === false - ? 'analytics diagnostics unavailable' - : null; - const window = describeWindow( - data.available ? data.event_count : diagnosticsRead?.event_count, - diagnosticsRead?.events_per_hour, - ); - const hookNote = diagnosticsRead - ? diagnosticsRead.hook_window.truncated - ? `recent suffix · ${diagnosticsRead.hook_window.rows_scanned.toLocaleString()} rows scanned` - : `${diagnosticsRead.hook_window.rows_scanned.toLocaleString()} hook rows scanned` - : (diagnosticsAbsence ?? 'analytics diagnostics unavailable'); - return ( -
-
-

Agents

- - {data.available - ? window.events == null - ? 'session-message fallback · event count unavailable' - : `analytics_events · ${window.capped ? `most recent ${ANALYTICS_EVENT_LIMIT.toLocaleString()} (endpoint cap)` : `${window.events.toLocaleString()} events`}` - : 'analytics store unavailable'} - -
+
+

Agents

+ + delegation trees, handoffs, failure context, and the tool telemetry behind them + +
- {/* The window itself, which the page previously never stated. Every - * count below is taken inside it, and the endpoint refuses to - * count past `ANALYTICS_EVENT_LIMIT` — so the cap, the span and - * the rate are the frame the rest of the page hangs in, not a - * footnote. */} - + {/* THE HERO REGISTER IS THE ORCHESTRATION, NOT THE EVENT COUNTER. What + * an operator opens this channel for is who delegated to whom, where + * the handoff frontier stands, and what failed — so those plates lead, + * each behind its own read, and the usage telemetry that used to gate + * the whole page is a demoted register underneath. */} +
+ + + + {(envelope) => { + const payload = envelope.payload; + return payload == null || payload.available === false ? ( + + ) : ( + + ); + }} + + - - - {!data.available ? ( - - ) : window.events == null && rows.length === 0 ? ( - + + + {(envelope) => { + const payload = envelope.payload; + return payload == null || payload.available === false ? ( + ) : ( - // The window's own count, or null. Substituting the - // categorized total made the two agree by construction, which - // is a claim that every event was categorized. - - )} - + + ); + }} + + - - - {(envelope) => { - const payload = envelope.payload; - return ( - payload.available === false ? ( - - ) : ( - - ) - ); - }} - - + {/* Handoffs and attempt failures come off the work-product + * graph read; tool activity comes off the diagnostics fold + * this page already pays for. */} + + + - - - {(envelope) => { - const payload = envelope.payload; - return ( - payload.available === false ? ( - - ) : ( - - ) - ); - }} - - + + + - - - {(envelope) => { - const payload = envelope.payload; - return ( - payload.available === false ? ( - - ) : ( - - ) - ); - }} - - + + + {(envelope) => { + const payload = envelope.payload; + return payload.available === false ? ( + + ) : ( + + ); + }} + + + +
- - - {(envelope) => { - const hintData = envelope.payload; - return ( - hintData.available === false ? ( - - ) : ( - - ) - ); - }} - - + +
+ ); +} - - - {(envelope) => { - const payload = envelope.payload; - return payload == null || payload.available === false ? ( - - ) : ( - - ); - }} - - +/** The demoted telemetry register: the analytics event window and everything + * counted inside it. Gated on its own read so a dead analytics store never + * takes the delegation plates above down with it. */ +function UsageRegister({ + usagePending, + usageResult, + diagnostics, + underused, +}: { + usagePending: boolean; + usageResult: ReturnType>['data']; + diagnostics: ReturnType>; + underused: ReturnType>; +}) { + return ( +
+ + {(envelope) => { + const data = envelope.payload; + const rows: UsageRow[] = data.by_category; + const dominance = summarizeDominance(rows); + const diag = envelopePayload(diagnostics.data); + const diagnosticsRead = diag?.available === false ? undefined : diag ?? undefined; + // A dash in the diagnostics figures is three different facts: a read + // still in flight, a read that failed, and a source that answered and + // declared itself unavailable. Only the last one may be called + // "unavailable" — the strip used to label all three that way. + const diagnosticsAbsence = diagnostics.isPending + ? 'diagnostics still loading' + : diag == null + ? 'analytics diagnostics could not be read' + : diag.available === false + ? 'analytics diagnostics unavailable' + : null; + const window = describeWindow( + data.available ? data.event_count : diagnosticsRead?.event_count, + diagnosticsRead?.events_per_hour, + ); + const hookNote = diagnosticsRead + ? diagnosticsRead.hook_window.truncated + ? `recent suffix · ${diagnosticsRead.hook_window.rows_scanned.toLocaleString()} rows scanned` + : `${diagnosticsRead.hook_window.rows_scanned.toLocaleString()} hook rows scanned` + : (diagnosticsAbsence ?? 'analytics diagnostics unavailable'); + return ( + <> +
+

Tool telemetry

+ + {data.available + ? window.events == null + ? 'session-message fallback · event count unavailable' + : `analytics_events · ${window.capped ? `most recent ${ANALYTICS_EVENT_LIMIT.toLocaleString()} (endpoint cap)` : `${window.events.toLocaleString()} events`}` + : 'analytics store unavailable'} + +
- - - {(envelope) => { - const payload = envelope.payload; - return payload == null || payload.available === false ? ( - - ) : ( - - ); - }} - - + {/* The window itself. Every count below is taken inside it, and + * the endpoint refuses to count past `ANALYTICS_EVENT_LIMIT` — + * so the cap, the span and the rate are the frame the rest of + * the register hangs in, not a footnote. */} + - {/* Handoffs and attempt failures come off the work-product - * graph read; tool activity comes off the diagnostics fold - * this page already pays for. */} - - - + + + {!data.available ? ( + + ) : window.events == null && rows.length === 0 ? ( + + ) : ( + // The window's own count, or null. Substituting the + // categorized total made the two agree by construction, which + // is a claim that every event was categorized. + + )} + - - - + + + {(diagEnvelope) => { + const payload = diagEnvelope.payload; + return ( + payload.available === false ? ( + + ) : ( + + ) + ); + }} + + - - - {(envelope) => { - const payload = envelope.payload; - return payload.available === false ? ( - - ) : ( - - ); - }} - - + + + {(diagEnvelope) => { + const payload = diagEnvelope.payload; + return ( + payload.available === false ? ( + + ) : ( + + ) + ); + }} + + - - - {(envelope) => { - const payload = envelope.payload; - return payload.available === false ? ( - - ) : ( - - ); - }} - - - -
- ); - }} - + + + {(diagEnvelope) => { + const payload = diagEnvelope.payload; + return ( + payload.available === false ? ( + + ) : ( + + ) + ); + }} + + + + + + {(hintEnvelope) => { + const hintData = hintEnvelope.payload; + return ( + hintData.available === false ? ( + + ) : ( + + ) + ); + }} + + + + + + {(diagEnvelope) => { + const payload = diagEnvelope.payload; + return payload.available === false ? ( + + ) : ( + + ); + }} + + + + + ); + }} + + ); } diff --git a/dashboard/src/workspaces/code/CortexRelief.tsx b/dashboard/src/workspaces/code/CortexRelief.tsx index 4e33223215..a968e7b49b 100644 --- a/dashboard/src/workspaces/code/CortexRelief.tsx +++ b/dashboard/src/workspaces/code/CortexRelief.tsx @@ -382,11 +382,13 @@ function RegionRow({ className={cn(REGION_ROW, 'border-b border-edge-subtle', active && 'bg-surface-2')} >
+ {/* The row's only control, so it carries the touch minimum itself — + * the cell's text metrics left it 16.5px tall. */} + ); + })} +
+ ); +} diff --git a/dashboard/src/workspaces/observatory/SystemsHeader.dom.test.tsx b/dashboard/src/workspaces/observatory/SystemsHeader.dom.test.tsx new file mode 100644 index 0000000000..2cf7a9d5c2 --- /dev/null +++ b/dashboard/src/workspaces/observatory/SystemsHeader.dom.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveFixture } from '../../../stories/fixtures/data.ts'; +import { useScope } from '../../data/scope/store.ts'; +import { SystemsHeader } from './SystemsHeader.tsx'; + +/** + * The segmented subsystem header must stamp real readings and reserve amber + * for an actually-observed problem finding — never for a source that merely + * failed to answer, and never OK for one that did not. + */ + +/** The wire-true fixture envelopes, with one served problem finding added: + * hand-rolled wrappers here would drift from the generated contract and turn + * this into a test of the fixture author's memory. */ +function findingsEnvelope(): unknown { + const envelope = structuredClone(resolveFixture('/api/storage/findings')) as { + payload: { + entries: unknown[]; + kind_statuses: { kind: string; state: string }[]; + }; + }; + envelope.payload.entries = [ + { + finding: { + family: 'storage', + state: 'degraded', + coverage: { + completeness: 'complete', + statement: 'store size observed against soft budget', + }, + evidence: [{ family: 'storage', reference: 'storage.over_budget_store.lcm.overage' }], + }, + storage_kind: 'over_budget_store', + }, + ]; + return envelope; +} + +describe('SystemsHeader', () => { + beforeEach(() => { + useScope.getState().selectAllProjects(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost'); + if (url.pathname === '/api/storage/findings') { + return json(findingsEnvelope()); + } + if (url.pathname === '/api/storage/telemetry') { + return json(resolveFixture('/api/storage/telemetry')); + } + // The freshness read fails on the wire: its segment must say so + // rather than stamping OK or inventing amber. + return new Response('down', { status: 500 }); + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('stamps each subsystem from its own real reading', async () => { + render( + + + , + ); + + const header = await screen.findByRole('list', { name: 'Subsystem status' }); + + // An observed degraded budget finding is the one thing that earns amber. + // findBy*, because the header renders its READING stamps synchronously and + // settles as each read lands. + const budget = header.querySelector('[data-subsystem="budget"]') as HTMLElement; + expect(await within(budget).findByText('alert · 1')).toBeTruthy(); + expect(budget.getAttribute('data-subsystem-tone')).toBe('alert'); + + // A producer that looked and found nothing is OK; an unwired one says so. + const orphans = header.querySelector('[data-subsystem="orphans"]') as HTMLElement; + expect(await within(orphans).findByText('ok')).toBeTruthy(); + expect(orphans.getAttribute('data-subsystem-tone')).toBe('ok'); + expect( + await within(header.querySelector('[data-subsystem="debris"]') as HTMLElement).findByText( + 'unsupported', + ), + ).toBeTruthy(); + + // Envelope-backed segments stamp the envelope's own domain state. + expect( + await within(header.querySelector('[data-subsystem="stores"]') as HTMLElement).findByText( + 'ok', + ), + ).toBeTruthy(); + // A transport failure is an alert-toned error word, not OK. + const index = header.querySelector('[data-subsystem="index"]') as HTMLElement; + expect(await within(index).findByText('error')).toBeTruthy(); + expect(index.getAttribute('data-subsystem-tone')).toBe('alert'); + }); +}); + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} diff --git a/dashboard/src/workspaces/observatory/SystemsHeader.tsx b/dashboard/src/workspaces/observatory/SystemsHeader.tsx new file mode 100644 index 0000000000..686ef5e6c2 --- /dev/null +++ b/dashboard/src/workspaces/observatory/SystemsHeader.tsx @@ -0,0 +1,179 @@ +import { useQuery } from '@tanstack/react-query'; +import { + CodeIndexFreshnessPayloadV1Schema, + StorageTelemetryPayloadV1Schema, + type DashboardDomainStateV1, + type DoctorStorageFindingKindV1, + type StorageFindingsPayloadV1, +} from '../../contracts/generated.ts'; +import { fetchEnvelope, type EnvelopeResult } from '../../data/query/envelope.ts'; +import { useStorageFindings } from '../../data/query/storageFindings.ts'; +import { scopeKey, scopedUrl, useScope } from '../../data/scope/store.ts'; +import { cn } from '../../ui/cn'; + +/** + * The Observatory's segmented subsystem header: six numbered registers across + * the top of the channel — STORES / INDEX / DOCTOR / BUDGET / ORPHANS / + * DEBRIS — each stamped with the current reading of a source this page + * already pays for. The grammar is the segmented mission header (numbered + * cells, engraved label, stamp word beside a lamp); the readings are real: + * + * STORES the store-telemetry envelope's own domain state + * INDEX the code-index freshness envelope's domain state + * DOCTOR the canonical Doctor storage projection's domain state + * BUDGET the over-budget producer: AMBER ALERT when a served finding is + * degraded or stale, otherwise the producer's source coverage + * ORPHANS the orphan-store producer, same rule + * DEBRIS the incident-debris producer, same rule + * + * A stamp is never invented: a read still in flight says READING, a transport + * failure says its transport state, and an unsupported producer says + * UNSUPPORTED. Amber is reserved for an actually-observed problem finding — + * alert is a report, not a decoration. + */ + +type SegmentTone = 'ok' | 'alert' | 'attention'; + +interface Segment { + code: string; + label: string; + word: string; + tone: SegmentTone; +} + +const TONE_LAMP: Record = { + ok: 'bg-state-ready', + alert: 'bg-alert', + attention: 'bg-state-unknown', +}; + +const TONE_WORD: Record = { + ok: 'text-text-secondary', + alert: 'text-alert', + attention: 'text-text-muted', +}; + +/** One envelope-backed segment: the domain state, said as a stamp word. */ +function envelopeSegment( + code: string, + label: string, + pending: boolean, + result: EnvelopeResult | undefined, +): Segment { + if (pending) return { code, label, word: 'reading', tone: 'attention' }; + if (!result) return { code, label, word: 'unknown', tone: 'attention' }; + const state: DashboardDomainStateV1 = + result.outcome === 'transport' ? result.state : result.envelope.domain_state; + return { code, label, ...domainStamp(state) }; +} + +function domainStamp(state: DashboardDomainStateV1): { word: string; tone: SegmentTone } { + switch (state) { + case 'ready': + case 'complete_zero_findings': + return { word: 'ok', tone: 'ok' }; + case 'error': + case 'denied': + case 'unauthorized': + case 'conflicting': + return { word: state.replaceAll('_', ' '), tone: 'alert' }; + default: + return { word: state.replaceAll('_', ' '), tone: 'attention' }; + } +} + +/** One producer-backed segment off the storage-findings projection. */ +function producerSegment( + code: string, + label: string, + pending: boolean, + result: EnvelopeResult | undefined, + kind: DoctorStorageFindingKindV1, +): Segment { + if (pending) return { code, label, word: 'reading', tone: 'attention' }; + if (!result) return { code, label, word: 'unknown', tone: 'attention' }; + if (result.outcome === 'transport') { + return { code, label, ...domainStamp(result.state) }; + } + const payload = result.envelope.payload; + // A served finding in a problem state is the one thing that earns amber. + const problems = payload.entries.filter( + (entry) => + entry.storage_kind === kind && + (entry.finding.state === 'degraded' || entry.finding.state === 'stale'), + ).length; + if (problems > 0) { + return { code, label, word: `alert · ${problems}`, tone: 'alert' }; + } + const status = payload.kind_statuses.find((entry) => entry.kind === kind); + if (!status) return { code, label, word: 'unknown', tone: 'attention' }; + switch (status.state) { + case 'real': + return { code, label, word: 'ok', tone: 'ok' }; + case 'partial': + return { code, label, word: 'partial', tone: 'attention' }; + case 'unsupported': + return { code, label, word: 'unsupported', tone: 'attention' }; + default: + return { code, label, word: 'unknown', tone: 'attention' }; + } +} + +export function SystemsHeader() { + const scope = useScope((s) => s.scope); + const key = scopeKey(scope); + // The same cache entries the Diagnosis wing reads (same keys, same routes), + // so this header costs no extra requests while that wing is open. + const telemetry = useQuery({ + queryKey: ['storage', 'telemetry', key], + queryFn: () => + fetchEnvelope(scopedUrl(scope, '/api/storage/telemetry'), StorageTelemetryPayloadV1Schema), + refetchInterval: 30_000, + }); + const freshness = useQuery({ + queryKey: ['code-index', 'freshness', key], + queryFn: () => + fetchEnvelope( + scopedUrl(scope, '/api/code-index/freshness'), + CodeIndexFreshnessPayloadV1Schema, + ), + refetchInterval: 30_000, + }); + const findings = useStorageFindings(); + + const segments: Segment[] = [ + envelopeSegment('01', 'Stores', telemetry.isPending, telemetry.data), + envelopeSegment('02', 'Index', freshness.isPending, freshness.data), + envelopeSegment('03', 'Doctor', findings.isPending, findings.data), + producerSegment('04', 'Budget', findings.isPending, findings.data, 'over_budget_store'), + producerSegment('05', 'Orphans', findings.isPending, findings.data, 'orphan_store'), + producerSegment('06', 'Debris', findings.isPending, findings.data, 'incident_debris_present'), + ]; + + return ( +
    + {segments.map((segment) => ( +
  • + + {segment.code} + + {segment.label} + + {segment.word} +
  • + ))} +
+ ); +} diff --git a/dashboard/src/workspaces/sessions/SessionsPage.tsx b/dashboard/src/workspaces/sessions/SessionsPage.tsx index 947713f7b7..190eba21dd 100644 --- a/dashboard/src/workspaces/sessions/SessionsPage.tsx +++ b/dashboard/src/workspaces/sessions/SessionsPage.tsx @@ -262,10 +262,31 @@ export function SessionsPage() { (max, row) => Math.max(max, row.message_count), 0, ); + const sessionsTotal = data.overview.sessions_total; return ( row.session_id} + header={ +
+ {/* ONE raised count for the whole ledger — the loaded + * slice against the store total, stated once here rather + * than repeated per pane. */} +

+ + {rows.length.toLocaleString()} loaded + + of {sessionsTotal.toLocaleString()} sessions on record +

+
+ Session + Messages + + Last activity + +
+
+ } renderItem={(row) => { const id = row.session_id; const count = row.message_count; diff --git a/dashboard/src/workspaces/work/WorkEvidencePanel.tsx b/dashboard/src/workspaces/work/WorkEvidencePanel.tsx index f752438502..4703808302 100644 --- a/dashboard/src/workspaces/work/WorkEvidencePanel.tsx +++ b/dashboard/src/workspaces/work/WorkEvidencePanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react'; import type { TemporalModeV1, WorkEvidenceContinuationV1, + WorkEvidenceOmissionReasonV1, WorkEvidenceRetrievalV1, WorkEvidenceSourceV1, WorkGraphReadV1, @@ -27,6 +28,19 @@ function contentText(content: number[] | null): string | null { return new TextDecoder('utf-8', { fatal: false }).decode(Uint8Array.from(content)); } +/** An omission reason as one printable sentence fragment. Most reasons are + * bare wire words; the structural refusals carry their own evidence (which + * stage of the budget ran out, or which manifest ceiling was crossed and by + * how much) and that evidence is the actionable part of the reason. */ +function omissionReasonText(reason: WorkEvidenceOmissionReasonV1): string { + if (typeof reason === 'string') return reason; + const refusal = reason.structural_refusal; + if (refusal.refusal === 'budget_exhausted') { + return `structural refusal: ${refusal.stage.replaceAll('_', ' ')} budget exhausted`; + } + return `structural refusal: ${refusal.kind} manifest limit exceeded (${refusal.observed} observed, maximum ${refusal.maximum})`; +} + function EvidenceResult({ value }: { value: WorkEvidenceRetrievalV1 }) { const sessions = value.sources.filter((source) => source.kind === 'task_session'); const receipts = value.sources.filter((source) => source.kind === 'attempt_receipt'); @@ -90,8 +104,8 @@ function EvidenceResult({ value }: { value: WorkEvidenceRetrievalV1 }) { {value.omissions.length === 0 ? null : (
    {value.omissions.map((omission, index) => ( -
  • - {omission.relation}: {omission.reason} +
  • + {omission.relation}: {omissionReasonText(omission.reason)}
  • ))}
@@ -142,7 +156,7 @@ export function WorkEvidencePanel({