Skip to content

ci(OMN-9834): lift contract-validation repointed to central onex_change_control - #6

Closed
jonahgabriel wants to merge 235 commits into
mainfrom
jonah/omn-9834-contract-validation
Closed

jonahgabriel wants to merge 235 commits into
mainfrom
jonah/omn-9834-contract-validation

Conversation

@jonahgabriel

Copy link
Copy Markdown
Contributor

Summary

  • Adds .github/workflows/contract-validation.yml to omnidash-v2
  • Implements Epic B central-contracts pattern: checks out OmniNode-ai/onex_change_control at pinned SHA and validates against onex_change_control/contracts/ — no local contracts/ directory on v2
  • Lifts v1 omnidash workflow logic intact; only adapts the contracts path and adds the checkout step

Key changes vs v1 omnidash

v1 omnidash v2 omnidash-v2
contracts-path: contracts (local, no-op — dir doesn't exist) contracts-path: onex_change_control/contracts (central)
No checkout of onex_change_control Checks out OmniNode-ai/onex_change_control at pinned SHA before validate step

Pinned SHA

onex_change_control pinned to 3c8fa62f138f632157218df58e215d654004d1c2 (main HEAD 2026-04-26).

Re-pin via:

gh api repos/OmniNode-ai/onex_change_control/commits/main --jq '.sha'

Composite action verified

OmniNode-ai/onex_change_control/.github/actions/validate-contract/action.yml confirmed to exist before ship.

Acceptance criteria (from plan Task 10)

  • Workflow checks out onex_change_control before validate step
  • Validates against onex_change_control/contracts/<OMN-XXXX>.yaml, not a local path
  • Skips cleanly when no contract file exists (lenient mode preserved)
  • Fails on schema-invalid contract (strict mode preserved)
  • v2 has zero local contracts/ directory
  • Behavioral 3-state proof (requires real PRs post-merge): SKIP → FAIL → PASS runs captured as evidence

Ticket

OMN-9834

dod_evidence

  • workflow_file: .github/workflows/contract-validation.yml
  • central_contracts_path: onex_change_control/contracts
  • pinned_sha: 3c8fa62f138f632157218df58e215d654004d1c2
  • composite_action_verified: OmniNode-ai/onex_change_control/.github/actions/validate-contract/action.yml
  • no_local_contracts_dir: true

clone45 added 30 commits April 19, 2026 13:26
* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)
* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path
#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]
* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test
* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel
…ty [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]
…tokens from Claude Design prototype [OMN-42] (#18)
…N-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files
… class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.
…OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.
clone45 and others added 14 commits April 25, 2026 11:07
Independent client review (spawned agent against the 04-17 spec)
pointed out that my requirements-audit.md claimed "433/434 tests
passing" but never re-ran the suite after a series of polish-week
commits. Four regressions had accumulated:

1-3. Integration tests `integration.part2/3/4.test.tsx` failed at
     module load with ENOENT for component-registry.json. Caused by
     commit b911b6d (registry move out of public/) — the import
     path was changed but the leading `../` was kept, so the test
     looked at omnidash-v2/registry/ instead of
     omnidash-v2/src/registry/. One-character fix in each file
     (`../registry/...` → `./registry/...`).

4. `src/agent/buildSystemPrompt.test.ts` asserted that the prompt
   contains `'metrics'` and `'stream'` — both pre-rename category
   strings. The fixture mocks were already updated by the regroup
   sweep, but the assertion lines weren't. Updated assertions to
   `'cost'` and `'activity'` to match the fixtures' actual
   categories.

5. `integration.part2.test.tsx` config-validation test passed
   `showBudgetLine: true` to `validateConfig`. The widget-config
   audit removed `showBudgetLine` from the cost-trend-panel
   manifest schema; with `additionalProperties: false`, the test
   payload was now correctly rejected. Replaced with `chartType:
   'bar'` (still in the schema) so the "valid config" branch
   actually validates.

Also deleted a stale `shared/types/component-manifest.test.js`
build artifact (gitignored, local-only). The `.ts` source file
covers the same four tests.

Suite now: 439/439 pass across 57 files. Updated the audit's row
2 to reflect the real number and admit the under-count was a
polish-week miss.
- index.html title from "omnidash v2" to "OmniDash".
- New public/icon.svg lifted from the Sidebar's BrandMark component
  (same hexagon + D-chevron + linear gradient). Linked in <head>
  via <link rel="icon" type="image/svg+xml">.

Modern browsers support SVG favicons; the oklch() gradient stops
also render in current Chrome/Firefox/Safari.
…MN-143..147]

PR 1 of the review-remediation plan (Brett review §6 PR 1). Bundles five
CRITICAL findings so the bleed is stopped in one merge.

T1 — env-var sweep (OMN-143):
- server/db.ts: OMNIDASH_ANALYTICS_DB_URL is required; fail fast at startup.
- src/agent/llmClient.ts: VITE_LLM_BASE_URL required for prod builds; the
  fallback config lazy-throws unless VITE_LLM_FALLBACK_URL is set.
- vite.config.ts: dev proxy registers only when VITE_LLM_BASE_URL is set.
  Vite port now reads VITE_DEV_PORT (default 3001).
- package.json: generate:fixtures is now a shell wrapper requiring
  OMNIBASE_INFRA_PATH; no more relative path baked into the script field.
- scripts/run-types-generate.sh: requires OMNIBASE_CORE_PATH; drops the
  /mnt/c/ default so the script works on any contributor's box.
- .env.example: every required env var documented with no real endpoints.

T2 — I/O boundary validation (OMN-144):
- shared/types/dashboard.ts: new parseDashboardDefinition() — tolerant
  variant for I/O sites that take unknown JSON.
- src/store/dashboardSlice.ts: hydrateList() drops corrupted entries with
  console.warn instead of casting through `as` and crashing render.
- src/layout/layout-persistence.ts: HttpLayoutPersistence.read() validates
  the response body and throws on malformed shape.
- src/registry/ComponentRegistry.ts: removed the three (schema as any)
  casts; validateConfig now uses JSONSchema7 types end-to-end.

T3 — pg.Pool idle-client error listener (OMN-145):
- server/db.ts: pool.on('error', ...) added so idle errors no longer
  crash the Node process.

T4 — WebSocket URL carve-out (OMN-146, closes OMN-37):
- src/data-source/index.ts: new getWebSocketUrl() — symmetric to the HTTP
  carve-out; reads VITE_WS_URL or derives from VITE_HTTP_DATA_SOURCE_URL.
- src/components/dashboard/events/EventStream.tsx and
  src/hooks/useWebSocketInvalidation.ts: deleted the two
  ws://localhost:3002 literals and now consume getWebSocketUrl().

T5 — env-contamination grep gate (OMN-147):
- scripts/check-no-env-contamination.sh: blocks 192.168., localhost:300,
  /Users/, /Volumes/, /mnt/c/ outside a documented allowlist
  (.env.example, src/data-source/, docs/, the gate itself).
- .github/workflows/ci.yml: gate runs before tsc/lint/tests.

Tests:
- shared/types/dashboard.test.ts: parseDashboardDefinition coverage.
- src/layout/layout-persistence.test.ts: malformed-payload acceptance test.
- src/store/dashboardSlice.hydrate.test.ts: corrupted-entry hydration test.
- All 446 tests pass; tsc clean; lint clean; env-contamination gate clean.

Plan file lands with this PR so reviewers can trace each task back to the
review finding it addresses.
PR 2 of the review-remediation plan (Brett review §6 PR 2). Closes the
gaps the review called out around manifests advertising features that
don't exist, server code with no tests, and the Express server being
outside the repo's typecheck discipline.

T6 — drop empty configSchema entries (OMN-148):
- scripts/generate-registry.ts: cost-trend-3d, cost-by-model,
  baselines-roi-card, readiness-gate now omit configSchema entirely
  (the kebab gate already hid the empty modal; this is the matching
  registry-side cleanup).
- shared/types/component-manifest.ts: configSchema is now optional.
- src/registry/ComponentRegistry.ts, src/pages/DashboardView.tsx,
  src/config/ComponentConfigPanel.tsx: handle absent configSchema.

T7 — align CostTrend 2D/3D palette (OMN-149):
- src/components/dashboard/cost-trend-3d/CostTrend3D.tsx: model palette
  now comes from useThemeColors().chart instead of two hardcoded arrays,
  so the same model gets the same color across 2D and 3D.

T9 — CostTrend3D unit test (OMN-150):
- src/components/dashboard/cost-trend-3d/CostTrend3D.test.tsx: mirrors
  the CostByModelPie test pattern (vi.mock('three') + FakeWebGLRenderer +
  CSS2DRenderer mock); covers loading, empty, populated.

T10 — server route + WebSocket tests (OMN-151):
- server/routes.test.ts: supertest smoke coverage for all six REST
  routes with mocked db.query.
- server/broadcast.test.ts: channel-filter unit test for broadcast()
  (exact match, wildcard, no-match, closed connection).
- server/index.ts: refactored to defer httpServer.listen() unless the
  module is the entrypoint (ESM equivalent of require.main === module),
  so tests can import broadcast/clients without binding a port.
  Also exports `clients` for test access.

T11 — scorecard enforces .test.tsx existence (OMN-152):
- src/storybook-coverage-compliance.test.ts: new Phase 4 mirrors Phase 2
  but checks for <widget>.test.tsx existence next to <widget>.tsx for
  every entry flagged `widget: true`. Infra components (frame, agent,
  config dialog, shared chrome) are still encouraged to gain tests but
  not gated here.

T12 — move @types/* to devDependencies (OMN-153):
- package.json: @types/express, @types/pg, @types/ws moved out of
  runtime dependencies. supertest + @types/supertest added under devDeps
  for T10.

T13 — tsconfig.node.json strict + CI build:server (OMN-154):
- tsconfig.node.json: include adds 'server', strict + noUnusedLocals +
  noUnusedParameters added, lib pinned to ES2022, types pinned to node.
- package.json: build:server now passes --noEmit so the strict check
  doesn't conflict with composite emit.
- vite.config.ts: unused `next` middleware param renamed to `_next` to
  satisfy strict noUnusedParameters.
- .github/workflows/ci.yml: server typecheck step added between the
  frontend tsc and lint.
- .gitignore: *.tsbuildinfo (composite project cache).

Tests:
- 473/473 pass; tsc clean (frontend + server); lint clean;
  env-contamination gate clean.
… context, topic SoT [OMN-155..157]

PR 3a of the review-remediation plan (Brett review §6 PR 3a). Implements
the architecture decision (Option 1 from §3 cluster C): collapse the three
competing stores (Zustand, localStorage, /_layouts files) into one
canonical write path through DashboardService; promote SnapshotSource to
a singleton via React context; centralize projection topic strings.

T14 — DashboardService canonical write path (OMN-155):
- src/services/dashboardService.ts: now owns the LS_LIST_KEY +
  LS_ACTIVE_KEY localStorage keys via persistList/persistActiveId/
  hydrateList/hydrateActiveId methods, plus a default singleton export
  `dashboardService`. hydrateList runs the parseDashboardDefinition
  validator and drops corrupt entries with console.warn.
- src/store/dashboardSlice.ts: all persist/hydrate helpers delegate to
  dashboardService. The slice no longer references localStorage
  directly.
- src/pages/DashboardView.tsx: handleSave calls dashboardService.save();
  mount-load uses dashboardService.loadByName('default'). The widget
  no longer imports layoutPersistence.
- src/services/dashboardService.test.ts: round-trip integration test
  (save → loadByName), list-persistence test, corrupted-entry test,
  active-id test. Existing tests inject a stub LayoutPersistence so
  fire-and-forget writes don't leak.

T15 — SnapshotSource singleton via context (OMN-156):
- src/data-source/SnapshotSourceProvider.tsx: new React context provider
  + useSnapshotSource() hook. Calling the hook outside a provider
  throws a clear error (the documented contract).
- src/hooks/useProjectionQuery.ts: source comes from useSnapshotSource()
  rather than a per-call createSnapshotSource(). One source per app
  tree, swappable by tests/Storybook.
- src/providers/Providers.tsx: app tree wrapped in
  <SnapshotSourceProvider>.
- src/storybook/decorators/withDashboardContext.tsx: decorator wraps
  stories in the provider so widgets can read context safely.
- src/test-utils/dataSourceTestProvider.tsx: shared test wrapper that
  pairs QueryClientProvider with SnapshotSourceProvider.
- 9 widget tests + useProjectionQuery hook test + integration test
  updated to use the shared wrapper. New "throws outside provider"
  test in useProjectionQuery.test.tsx as the acceptance.

T16 — single source of truth for topic strings (OMN-157):
- shared/types/topics.ts: new TOPICS const + TopicSymbol type. Naming
  follows the backend `onex.snapshot.projection.{producer}.{event}.v{N}`
  convention. Every projection topic the frontend reads is here.
- 9 widgets swap their literal topic strings for TOPICS.* symbols. A
  rename in the registry now breaks compilation in every consumer
  rather than silently missing at runtime.
- shared/types/component-manifest.ts: validateComponentManifest now
  enforces that websocket dataSources have a non-empty topic and api
  dataSources have a non-empty endpoint.
- scripts/generate-registry.ts: runs validateComponentManifest over
  every merged manifest and exits non-zero on any failure (OMN-157
  generator-time check).
- shared/types/component-manifest.test.ts: three new tests covering
  the websocket/api/both validation paths.

Tests:
- 477/477 pass; tsc clean (frontend + server); lint clean;
  env-contamination gate clean; generator green.

Stacked on PR 2 (#34); PR 2 stacks on PR 1 (#33).
…-158..162]

PR 3b of the review-remediation plan (Brett review §6 PR 3b). Adds the
two 2D-companion widgets, makes the DelegationMetrics threshold
configurable, swaps PROVISIONAL ID generators for crypto.randomUUID, and
clears the M-items + LOW backlog the review called out.

T17 — Cost by Model 2D (OMN-158):
- src/components/dashboard/cost-by-model-2d/CostByModelBars.{tsx,test.tsx,stories.tsx}:
  horizontal bar chart, sorted by cost desc, length-encoded magnitude,
  reuses useThemeColors().chart so the same model gets the same color
  across the 2D bars, the 3D pie, and the Cost Trend widgets.
- The 3D pie kept as an alternative under displayName "Cost by Model
  (3D)"; the new 2D version takes the canonical "Cost by Model" name.
- scripts/generate-registry.ts + src/components/dashboard/index.ts +
  src/storybook-coverage-compliance.test.ts updated.

T18 — Quality Score Panel 2D (OMN-159):
- src/components/dashboard/quality-score-panel-2d/QualityScoreHistogram.{tsx,test.tsx,stories.tsx}:
  vertical histogram, 5 buckets coloured red→green by tier, threshold
  line and mean marker overlaid as DOM elements (no three.js needed).
  Lower priority than T17 since the 3D quality bars don't suffer the
  same chart-literacy issue, but useful as a flat alternative.

T19 — DelegationMetrics qualityGateThreshold (OMN-160):
- scripts/generate-registry.ts: added `qualityGateThreshold` (number,
  0..1, default 0.8) to the delegation-metrics configSchema.
- src/components/dashboard/delegation/DelegationMetrics.tsx: reads the
  threshold from config; the green-vs-warn gate is no longer hardcoded
  at 0.8.

T20 — crypto.randomUUID swap (OMN-161):
- src/store/dashboardSlice.ts: dashboard + component IDs use
  `crypto.randomUUID()`; itemCounter module variable removed (no
  longer needed).
- src/services/dashboardService.ts: clone + import IDs use randomUUID
  too. PROVISIONAL comments removed.
- src/store/conversationSlice.ts: message IDs use randomUUID.
- shared/types/dashboard.ts: createEmptyDashboard uses randomUUID;
  the per-module counter is gone.
- Acceptance: grep for `Date.now() + Math.random` in src/ returns
  zero hits.

T21 — M-items + LOW cleanup bundle (OMN-162):
- M5: vite.config.ts middleware handlers typed with IncomingMessage /
  ServerResponse / a local ConnectNext alias; no more `any` triple.
- M7: server/routes.ts /api/baselines/summary returns 204 No Content
  on the no-snapshot branch instead of `res.json(null)` so typed
  consumers don't have to special-case a literal-null body. Test
  updated to assert 204.
- M10: src/test-utils/mockFetch.ts — extracted the duplicated
  mockFetchWithItems helper. 11 widget test files updated to import
  the shared version.
- M11: tsx added to devDependencies (was used by scripts but
  undeclared).
- M14: package.json `test:coverage` script (vitest run --coverage).
- M15: removed unused @neondatabase/serverless from runtime deps.

Deferred to follow-up tickets (called out in the plan but larger than
this bundle should hold):
- M12 ESLint 9 upgrade
- M13 enable @typescript-eslint/recommended + react-hooks/rules-of-hooks
- M16 Vite port single source (already partially addressed in PR 1 via
  VITE_DEV_PORT)
- M17 wire generate:fixtures into CI test setup
- M1 CostTrendPanel zonedComponents() time formatting
- M4 ComponentCell.emptyMessage prop forwarding
- M8 FileSnapshotSource / HttpSnapshotSource error-path coverage
- M9 QualityScorePanel.test.tsx fixture alignment
- LOW items (BaselinesROICard hsl, OMN-44 shim, fuzzy_confidence
  column decision, BUCKET_MIDPOINTS hardening)

Tests:
- 493/493 pass; tsc clean (frontend + server); lint clean;
  env-contamination gate clean.
- Two integration tests updated to expect 11 components (was 9 — the
  two new 2D widgets).

Stacked on PR 3a (#35); PR 3a stacks on PR 2 (#34), which stacks on
PR 1 (#33). Merging from oldest first is the intended path.
Picks up four review items from the OMN-142 deferred list.

M4 — ComponentCell.emptyMessage prop forwarding
- src/components/dashboard/ComponentCell.tsx: the long-declared but unused
  emptyMessage prop is now consumed in the not-available fallback so a host
  can render a friendlier message than "<componentName> — not available"
  for plugin widgets that aren't installed. Pure addition; no caller-side
  change needed.

M16 — Vite port single source of truth
- package.json: dev script no longer passes --port 3001, so the
  vite.config.ts setting (Number(env.VITE_DEV_PORT ?? 3001)) is the only
  source. Setting VITE_DEV_PORT now actually changes the dev port.

LOW.6 — DashboardBuilder shim removal
- src/pages/DashboardBuilder.tsx: deleted (was a re-export of DashboardView
  for OMN-43 backwards-compat).
- src/pages/DashboardBuilder.css.ts: deleted (orphan vanilla-extract file
  with no remaining imports anywhere in the tree).
- src/pages/DashboardBuilder.test.tsx → src/pages/DashboardView.test.tsx
  with the imports + describe block + helper renamed to match what they
  actually test. (The OMN-44 reference in the original shim comment was a
  stale pointer to an unrelated backlog feature ticket — not a cleanup
  ticket.)
- src/integration.part2.test.tsx, src/integration.part3.test.tsx: import
  DashboardView directly instead of via the shim.
- src/components/dashboard/DashboardGrid.tsx: header comment updated to
  drop the "kept around so the legacy shim compiles" reasoning.

LOW.8 — QualityScorePanel BUCKET_MIDPOINTS hardening
- src/components/dashboard/quality/QualityScorePanel.tsx: BUCKET_MIDPOINTS
  is now derived from BAR_COUNT (Array.from with (i + 0.5) / BAR_COUNT) so
  the two stay in sync if BAR_COUNT ever changes. The pass-rate loop now
  guards against undefined midpoint indices and emits a one-shot warning
  when the backend returns a distribution length other than the expected
  five — silent undercount turns into an explicit operator signal.

Also adds:
- docs/2026-04-25-review-response.md: per-finding ledger of how the
  Brett review's CRITICAL / HIGH / MEDIUM / LOW items were addressed
  across PRs 1–4 (and which were intentionally deferred). Matches the
  plan file at docs/plans/2026-04-25-review-remediation.md so reviewers
  have both directions of the trace.

493/493 tests pass; tsc clean (frontend + server); lint clean; env
contamination gate clean.
Pulls four review items off the deferred list, all relating to test
honesty and timezone correctness.

M8 — data-source error-path coverage
- src/data-source/file-snapshot-source.test.ts: five new cases mirroring
  the HttpSnapshotSource set — manifest 500, non-array body, partial
  per-file failure, baseUrl trailing-slash normalization, and network
  error propagation.
- src/data-source/http-snapshot-source.test.ts: empty-array branch + a
  network-error propagation case.

M9 — QualityScorePanel fixture alignment to actual server output
- The widget's BAR_COUNT=5 contract was paired with a server returning 10
  buckets (`WIDTH_BUCKET(quality_score, 0, 1, 10)`), so the widget was
  silently undercounting any backend distribution past index 4. Aligning
  the server to the consumer:
  - server/routes.ts: switched to `WIDTH_BUCKET(..., 5)` so the response
    shape matches the widget's expected length.
  - server/routes.test.ts: bucket fixtures updated from "7"/"8" to
    "4"/"5" (in-range for the new contract).
  - src/components/dashboard/quality/QualityScorePanel.test.tsx: bucket
    labels updated from "0.0-0.2"-style ranges to integer-string indices
    "1".."5", matching what the server actually returns.
  - src/components/dashboard/quality-score-panel-2d/QualityScore
    Histogram.{test,stories}.tsx: same fixture realignment.
  - src/storybook/fixtures/quality.ts: BUCKET_LABELS const updated from
    range labels to integer strings.

M14 — coverage threshold gate
- vitest.config.ts: coverage block added with v8 provider, conservative
  50% floor on lines/branches/functions/statements, and a curated
  include/exclude that ignores stories, tests, generated types, and
  vanilla-extract css files. Reporter set to text + lcov + html so CI
  can both gate and surface a browseable report.
- .github/workflows/ci.yml: tests step replaced with `npm run test:
  coverage` so the threshold gate fires in CI. Coverage directory is
  uploaded as a workflow artifact (14-day retention) for inspection.
- .gitignore: coverage/ excluded.
- Current coverage at the time of this commit: 60.1% lines, 71.82%
  branches, 55.88% functions, 60.1% statements — comfortably above the
  initial 50% floor.

M1 — CostTrendPanel timezone correctness
- src/lib/zonedComponents.ts: new shared util (extracted from
  CostTrend3D's local helper) so any widget that formats bucket labels
  by zone reads the same logic.
- src/components/dashboard/cost-trend-3d/CostTrend3D.tsx: imports the
  shared helper instead of defining its own.
- src/components/dashboard/cost-trend/CostTrendPanel.tsx: formatBucket
  Tick now reads useTimezone() and runs through zonedComponents()
  rather than calling raw Date.prototype getters that resolve to
  browser-local values.

500/500 tests pass (was 493 — +5 file-source error-path cases, +2
http-source cases). tsc clean (frontend + server); lint clean; env
contamination gate clean.
Four review items from the deferred list, all about affordance honesty
on the dashboard chrome.

H5 — DashboardGrid.onLayoutChange wire-or-remove
- src/components/dashboard/DashboardGrid.tsx: removed the dead
  onLayoutChange prop. The component renders a static 2-column grid;
  drag/drop reordering lives in DashboardView (which renders the grid
  inline), not here, so the prop has nothing to fire.
- src/components/dashboard/DashboardGrid.test.tsx: dropped the
  onLayoutChange={() => {}} stubs from both render() calls.

H16 — Edit Layout / Add Widget header split
- src/pages/DashboardView.tsx: view-mode header now exposes two
  buttons. "Edit Layout" (ghost, Pencil icon) for users who want to
  rearrange/configure existing widgets; "Add Widget" (primary, Plus
  icon) for the common new-widget case. Both call handleEdit() since
  the right-rail palette opens automatically in edit mode either way.
  The split is purely about affordance honesty — the button label now
  matches the user's intent instead of overloading "Add Widget" as the
  generic edit-mode toggle.

LOW.1 — BaselinesROICard fragile hsl()
- src/components/dashboard/baselines/BaselinesROICard.tsx: the
  borderTop was building a CSS color via hsl(${colors.border})
  interpolation. Since useThemeColors().border may resolve to oklch(),
  hex, or HSL shorthand depending on the active theme, wrapping it in
  hsl(...) produces invalid CSS in two of three cases (only the HSL
  shorthand path actually worked). Replaced with the direct token
  reference `var(--line)`. Also dropped the now-unused
  useThemeColors() import.

LOW.7 — RoutingDecisionTable fuzzy_confidence column
- The widget's RoutingDecision row already exposed fuzzy_confidence
  but didn't render it — only llm_confidence had a column. Added a
  symmetric "Fuzzy Conf." column between LLM Conf. and Cost. Sort key
  for fuzzy_confidence added so the new column header is sortable.
  Column widths re-balanced from 6 columns to 7 (timestamp 22, llm
  agent 20, fuzzy agent 20, agreement 11, llm conf. 8, fuzzy conf. 8,
  cost 11 — sums to 100%).

Plus:
- scripts/check-no-env-contamination.sh: added 'coverage' to
  EXCLUDE_DIRS so the gate doesn't trip on the lcov HTML report (the
  report embeds source snippets that contain the carve-out's literal
  ws://localhost:3002 reference, which would otherwise read as a
  violation in the post-coverage tree).

24/24 tests pass in the targeted run (DashboardGrid, BaselinesROICard,
RoutingDecisionTable, DashboardView, integration parts 2 + 3) — 40s
vs the full-suite ~110s. Full suite runs in CI post-merge.
tsc clean (frontend + server); lint clean; env-gate clean.
… M13, LOW.3, cluster-B capstone)

Closes the linting/typing-discipline batch from the deferred list.

M12 — ESLint 9 + flat config migration
- .eslintrc.cjs deleted; eslint.config.js added.
- Dependencies upgraded: eslint ^9, @eslint/js ^10, typescript-eslint
  ^8, eslint-plugin-react-hooks ^7, globals ^17. @typescript-eslint/
  parser stayed on ^8 (compatible with both major lines).
- Flat config layers: js.configs.recommended → tseslint.configs.
  recommended → react-hooks rules → local plugin rules. Each layer is
  documented inline in eslint.config.js for future maintenance.
- Ignore patterns moved to flat-config form; .storybook/, coverage/,
  storybook-static/ added to the ignore set (the legacy .eslintrc.cjs
  predated those directories).

M13 — recommended rule sets
- typescript-eslint/recommended turned on. Conservative pragmas:
  - @typescript-eslint/no-explicit-any: off (the codebase has many
    intentional any uses; banning would cascade — separate ticket).
  - @typescript-eslint/no-unused-vars: errors with `argsIgnorePattern:
    '^_'` so the existing `_next` / `_config` convention is honored.
- react-hooks/rules-of-hooks turned on (catches conditional hook bugs
  in production code). react-hooks/exhaustive-deps deliberately left
  off — there are intentional one-shot effects whose dep arrays are
  intentionally narrow, and turning it on without auditing would
  generate noise.

LOW.3 — usePageAgent Zod cast leak
- src/agent/usePageAgent.ts: removed the `as unknown as ZodString`
  outer cast on the z.enum(...).describe(...) ternary. The destination
  variable was already typed `ZodType`, so the cast was forcing a
  narrower type onto a value that's correctly the wider type. The
  inner `as [string, ...string[]]` is still needed because z.enum
  requires a non-empty tuple at the type level — that one is a real
  necessary shape coercion, not a leak.

Cluster B capstone — local/no-cast-on-parsed-json rule
- eslint-rules/no-cast-on-parsed-json.cjs (new): bans `as Foo` casts
  directly on JSON.parse(...) or response.json() results.
  - Allowed: `as unknown` (the safe halfway step before validation).
  - Allowed in test/stories files and src/data-source/ (the documented
    raw-access carve-out).
- eslint-rules/index.cjs: rule registered under local plugin.
- eslint.config.js: rule turned on as `error`.

Real cluster-B catches the new rule surfaced (and we fixed):
- src/hooks/useWebSocketInvalidation.ts: WebSocket onmessage handler
  was casting JSON.parse() straight to `{ type: string; channel?:
  string }`. Replaced with a structural typeof check that only treats
  the frame as INVALIDATE when `type === 'INVALIDATE'` and channel is
  a string. Same observable behavior on malformed frames (still
  ignored), but the cast is gone.
- src/services/dashboardService.ts: importJson() was casting
  JSON.parse(json) to DashboardDefinition and then validating *after*
  spreading — which meant a malformed import could crash the spread
  before validation ran. Now runs parseDashboardDefinition() against
  the raw parsed value, throws on invalid, and only spreads the typed
  result.

Plus two pre-existing items the new typescript-eslint rules surfaced:
- src/components/dashboard/cost-trend-3d/CostTrend3D.tsx: removed an
  unused `// eslint-disable-next-line no-console` directive (we don't
  ban console anywhere, so the disable was noise).
- src/components/dashboard/routing/RoutingDecisionTable.tsx: dead
  `let cmp = 0` initializer in the sort comparator was caught by
  no-useless-assignment. Restructured to a const ternary chain — same
  semantics, no dead write.

32/32 tests pass on the targeted run (usePageAgent, CostTrend3D,
RoutingDecisionTable, dashboardService, integration parts 2 + 3) in
34s. tsc clean (frontend + server); lint clean; env-gate clean.
Closes the remaining ledger items from the OMN-142 review.

M17 — proof-of-life CI fixtures
- tests/proof-of-life.test.tsx: removed the `it.skipIf(inCI)` fixture-
  existence check entirely. The test only asserted that a contributor
  had run `generate:fixtures` locally before invoking `npm test` — it
  never verified that the fixture pipeline actually worked, and was
  permanently skipped in CI. The actual fixture-rendering surface is
  covered by every widget's unit test (which mocks fetch against the
  same shape) and by integration.part2/3 tests. Net effect: one fewer
  meaningless skip + the inCI/path/fileURLToPath imports are gone.

LOW.4 — react-hooks/exhaustive-deps audit
- eslint.config.js: turned on react-hooks/exhaustive-deps as `warn`.
  Combined with `--max-warnings=0` in the lint script this fails the
  build on any unaddressed dep mismatch but leaves a clean line for
  intentional one-shot effects to carry an inline disable comment with
  a reason.
- src/components/dashboard/routing/RoutingDecisionTable.tsx: the
  `pageRows` useMemo was missing `pageSize` from its deps array. This
  was a real bug — when a user changed pageSize via widget config, the
  memo wouldn't re-derive and the table would keep showing the old
  page size. Fixed.
- src/pages/DashboardView.tsx: the mount-load useEffect had an empty
  deps array with a comment about "exhaustive-deps not wired". Now
  that the rule IS wired, replaced with an inline `eslint-disable-
  next-line react-hooks/exhaustive-deps -- mount-only by design`
  carrying the reason. The setActiveDashboard reference would
  re-trigger this effect every render under normal exhaustive-deps —
  intentional one-shot behavior preserved.

§8.B (review acceptance) — per-widget config-pass-through tests
- src/components/dashboard/delegation/DelegationMetrics.test.tsx:
  three new tests covering showSavings (toggle Cost Savings off),
  showQualityGates (toggle Quality Gate Pass Rate off), and
  qualityGateThreshold (color flip from warn → ok at the configured
  pass rate boundary).
- src/components/dashboard/routing/RoutingDecisionTable.test.tsx:
  pageSize=10 against 12 rows asserts that rows 10 + 11 are paginated
  out of view (would have failed on the LOW.4 missing-dep bug above
  before its fix).
- src/components/dashboard/quality-score-panel-2d/QualityScore
  Histogram.test.tsx: passThreshold variation test (33% at 0.8
  threshold, 67% at 0.6 threshold against a fixed fixture).
- Coverage gap closed: every widget that exposes a configSchema field
  now has at least one test that flows a config value through and
  asserts an observable effect, matching the §8.B contract.

20/20 tests pass in the targeted run (DelegationMetrics, Routing
DecisionTable, QualityScoreHistogram, DashboardView, proof-of-life)
in 38s. tsc clean (frontend + server); lint clean — including the
new exhaustive-deps rule running across the tree; env-gate clean.

This is the final bundle for OMN-142. Only LOW.2 (CostTrend3D
1499-line god-component refactor) remains intentionally deferred —
not merge-blocking, not a bundle-shaped item, and the review's note
itself recommended doing it during a focused config-support pass.
…ismatch

Bundle 4 introduced @eslint/js@^10.0.1 alongside eslint@^9.39.4.
@eslint/js@10 declares `peerOptional eslint@^10.0.0`; locally
`npm install` resolved permissively and didn't surface the conflict,
but CI's `npm ci` runs strict and failed both Bundle 4 and Bundle 5.

Fix: pin @eslint/js to ^9.39.4 (matches the eslint major). The
package only re-exports configs.recommended which is API-stable
across the 9 → 10 line, so no flat-config changes needed.

`npm run lint` still clean post-downgrade. Future ESLint-10 upgrade
is a separate ticket and would bump both packages together.
The response doc landed in Bundle 1 with PR-1..4 status frozen at that
moment. Since then, bundles 1-5 closed every item the doc claimed was
"deferred" except LOW.2 (the 1,499-line CostTrend3D refactor, which
remains intentionally deferred). Without this refresh the doc would
read as inaccurate to a re-reviewer — every "Status: Not fixed" line
that's actually been fixed is exactly the kind of detail an adversarial
reviewer would dock points for.

Updates:
- Top-of-doc bundle log table extended with bundles 1-5 + the CI fix
  commit, with each bundle's commit SHA on main.
- Test status line updated: 500/500 (was 493/493 at PR-merge time);
  ESLint 9 + flat config + recommended rule sets + react-hooks rules-
  of-hooks + react-hooks/exhaustive-deps + the local rules now all
  active; 50% coverage floor passing at 60.1% lines.
- Per-finding status lines flipped for: H5, H16, M1, M4, M8, M9, M12,
  M13, M14, M16, M17, LOW.1, LOW.3, LOW.4, LOW.6, LOW.7, LOW.8.
  Each entry now points at the specific bundle commit that closed it
  and what changed.
- §5 cluster table tightened: every cluster now reads "Fully fixed"
  except where the original framing already covered the nuance. The
  cluster-B capstone (local/no-cast-on-parsed-json rule) is named.
- §7 acceptance checkboxes flipped: §8.B last bullet (per-widget
  config-pass-through tests) and the WebSocket end-to-end smoke item
  are now both checked, with notes on what proves them.
- §8 deferred list collapsed to just LOW.2.
- Bottom-line rewritten: the "every deferred item closed except LOW.2"
  framing replaces the "M-list items are deferred" framing. Adds a
  closing note inviting the adversarial reviewer to flag any "Status:
  Fixed" line that doesn't pan out against the codebase — every
  status line should be greppable to a named commit.

No source changes, doc-only.
…ge_control

Adds contract-validation.yml to omnidash-v2 using the Epic B central-contracts
pattern. Checks out OmniNode-ai/onex_change_control at pinned SHA
3c8fa62f138f632157218df58e215d654004d1c2 (main HEAD 2026-04-26) and validates
against onex_change_control/contracts/ — no local contracts/ directory.
@linear

linear Bot commented Apr 26, 2026

Copy link
Copy Markdown

OMN-9834

jonahgabriel added a commit that referenced this pull request Apr 26, 2026
* feat(fixtures): Python fixture generator [OMN-30] (#1)

* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24] (#2)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path

* feat(data-source): ProtocolSnapshotSource interface [OMN-25] (#4)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26] (#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]

* feat(data-source): HttpSnapshotSource [OMN-28] (#6)

* feat(vite): fixturesMiddleware with testable handler + 5 contract tests [OMN-27] (#7)

* refactor(hooks): useProjectionQuery consumes ProtocolSnapshotSource [OMN-29] (#8)

* feat(hint): starter widget_type -> category preference heuristic [OMN-34] (#9)

* feat(registry): scan node_modules/@omninode/* for dashboardComponents [OMN-31] (#10)

* feat(layout): file-based layout persistence [OMN-32] (#11)

* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test

* feat(config): per-component config panel via @rjsf [OMN-33] (#12)

* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]

* feat(header): New dashboard button with inline create form [OMN-38] (#14)

* fix(registry): call resolveImplementations() eagerly so built-ins show as available [OMN-39] (#15)

* feat(fixtures): emit summary DTOs matching widget contracts; split shared topics [OMN-40] (#16)

* fix(dashboard): Save button now persists layout via layoutPersistence.write [OMN-41] (#17)

* feat(ui): install Tailwind + shadcn utilities + IBM Plex, port OKLCH tokens from Claude Design prototype [OMN-42] (#18)

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView (Phase 2) [OMN-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files

* fix(ci): drop unused waitFor import in Sidebar.test.tsx [OMN-43] (#20)

* feat(ui): verbatim CSS port of prototype; rewrite TSX to use semantic class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.

* chore(audit): scaffold chunked UI fidelity audit folders + README + example chunk [OMN-48]

* chore(audit): generate remaining 38 chunk files across 6 components [OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.

* audit: claim widget-library-06 [OMN-48]

* audit: complete widget-library-06 [OMN-48]

* audit: complete widget-library-01 [OMN-48]

* audit: complete widget-library-02 [OMN-48]

* audit: complete widget-library-03 [OMN-48]

* audit: complete widget-library-04 [OMN-48]

* audit: complete widget-library-05 [OMN-48]

* audit: complete empty-state-01 [OMN-48]

* audit: complete empty-state-02 [OMN-48]

* audit: complete empty-state-03 [OMN-48]

* audit: complete empty-state-04 [OMN-48]

* audit: complete empty-state-05 [OMN-48]

* audit: complete widget-card-01 [OMN-48]

* audit: complete widget-card-02 [OMN-48]

* audit: complete widget-card-03 [OMN-48]

* audit: complete widget-card-04 [OMN-48]

* audit: complete topbar-01 [OMN-48]

* audit: complete topbar-02 [OMN-48]

* audit: complete topbar-03 [OMN-48]

* audit: complete sidebar-01 [OMN-48]

* audit: complete sidebar-02 [OMN-48]

* audit: complete sidebar-03 [OMN-48]

* audit: complete sidebar-04 [OMN-48]

* audit: complete sidebar-05 [OMN-48]

* audit: complete sidebar-06 [OMN-48]

* audit: complete sidebar-07 [OMN-48]

* audit: complete sidebar-08 [OMN-48]

* audit: complete sidebar-09 [OMN-48]

* audit: complete sidebar-10 [OMN-48]

* audit: complete dashboard-01 [OMN-48]

* audit: complete dashboard-02 [OMN-48]

* audit: complete dashboard-03 [OMN-48]

* audit: complete dashboard-04 [OMN-48]

* audit: complete dashboard-05 [OMN-48]

* audit: complete dashboard-06 [OMN-48]

* audit: complete dashboard-07 [OMN-48]

* audit: complete dashboard-08 [OMN-48]

* audit: complete dashboard-09 [OMN-48]

* audit: complete dashboard-10 [OMN-48]

* audit: complete dashboard-11 [OMN-48]

* feat(ui): port EmptyState + WidgetLibrary missing structure [OMN-48]

Addresses audit findings grouped by theme:

- EmptyState (chunks 01-05): new component at src/components/dashboard/EmptyState.tsx
  rebuilds the prototype's dashed-card layout: diagonal stripe overlay,
  48x48 icon tile, "This dashboard is empty" headline, helper copy, and
  "Add first widget" CTA button. Wired in DashboardView in place of the
  prior single-line text fallback. onAdd enters edit mode when not
  already editing.

- WidgetLibrary (chunks 02, 03, 05, 06):
  - Adds close button (X) in .lib-head wired to onClose prop.
  - Adds .lib-search input with local q state; filters catalog by
    displayName + description substring match.
  - Adds .lib-card-thumb with a category-derived lucide icon
    (visualization→LineChart, metrics→Gauge, table→Table,
    status→ShieldCheck, stream→Radio).
  - Adds .lib-foot with "{N} widgets available" + "drag or click" hint.
  - Adds no-results empty-state branch inside .lib-body.
  - Keeps "not implemented" badge text (v2-specific semantic — v2 has
    component status; prototype's "Already on dashboard" semantic is not
    applicable until OMN-44 wires active-widget tracking).

DashboardView wires onClose to handleDiscard so the close button
exits edit mode + drops drafts — nearest equivalent to the prototype's
separate libOpen state.

* audit: mark EmptyState + WidgetLibrary chunks done [OMN-48]

Moves 9 chunks from audited/ to done/ after commit 4145b96:
- empty-state-01 through empty-state-05
- widget-library-02, widget-library-03, widget-library-05, widget-library-06

* feat(ui): port dashboard header + meta row per prototype [OMN-48]

Addresses audit findings for dashboard chunks 02-06:

- Rename title branch (dashboard-03, 04): adds click-to-edit affordance
  on .dash-title with chevron-down glyph + "Click to rename" tooltip.
  Input renders on edit; blur commits via renameDashboard(id, name),
  Enter blurs, Escape cancels.

- Meta row (dashboard-05): .dash-meta now renders the full prototype
  structure — widget count + middle-dot separators + "Timezone: {tz}"
  + "Auto-refresh 30s" with .mono class on numeric spans. Timezone is
  computed from the browser (e.g. "UTC−07:00") rather than hardcoded.

- Header actions (dashboard-06): "Edit" button renamed to "Add Widget"
  (primary variant) with Plus icon to match prototype intent; Save and
  Discard buttons gain Check and X icons respectively. Refresh and
  Share buttons from the prototype are deliberately omitted — they were
  decorative stubs with no v2 backing behavior and would be dead UI.

Tests updated to match new button label (/add widget/i); functionality
preserved. 242/242 passing.

* audit: mark dashboard header + meta chunks done [OMN-48]

* fix(ui): gate widget Live badge on isLive prop [OMN-48]

Addresses widget-card-03 finding: the .widget-live badge was rendered
unconditionally for every widget, falsely advertising real-time behavior
for static summary widgets (BaselinesROI, ReadinessGate, QualityScore).

- Adds isLive?: boolean prop to ComponentWrapper, default false.
- EventStream opts in (matches prototype's real-time-stream semantic).
- Other widgets stay silent by omission; they can opt in individually.

Prototype gates the badge on widget type ∈ {requests,logs,regions};
v2's category-based equivalent is the `stream` category, of which
EventStream is the sole current member.

* audit: close drag-scope chunks (blocked on OMN-44) + live badge fix done [OMN-48]

- 6 chunks moved to blocked/ with blocked_reason set: widget-card-01/02/04
  (drag + grip + kebab) and dashboard-08/09/10 (widget drag wiring,
  drop-slot, DropIndicator). All blocked on OMN-44 (drag-and-drop scope)
  per existing deferrals documented in DashboardView.tsx and
  ComponentWrapper.tsx headers.

- 2 chunks moved to done/:
  - widget-card-03 (Live badge gated in commit 31d988e)
  - dashboard-11 (dead `.library-open .grid .widget` CSS removed from
    src/styles/dashboard.css; v2 uses editMode state not library-open
    class, so the selector could never fire)

* fix(ui): sidebar + topbar polish per audit [OMN-48]

- sidebar-03: workspace label gets the prototype's tiny uppercase tracking
  (fontSize:10, textTransform:uppercase, letterSpacing:0.08em).
- sidebar-04: new-dashboard `+` button gains native `title="New dashboard"`
  tooltip (was aria-label only).
- sidebar-09: empty-state lineHeight 1.6 → 1.5 to match prototype.
- topbar-01: breadcrumb gains leading `<Menu size={16}/>` icon before "Home".

* audit: close remaining 17 chunks — 4 fixed, 13 accepted deviations [OMN-48]

Fixed (commit d15bc4c): sidebar-03, sidebar-04, sidebar-09, topbar-01.

Accepted with Resolution notes:
- Clean-audit chunks: sidebar-01, sidebar-02, sidebar-05 (no changes needed).
- False positives: sidebar-07 (Tailwind ≡ inline style), sidebar-10 (v2.15
  version bump was user-requested).
- Documented v2 architectural deviations: sidebar-06 (RenameInput
  subcomponent), sidebar-08 (shadcn DropdownMenu), topbar-02 (lucide
  icons), topbar-03 (role=button a11y), widget-library-01 (conditional
  render vs class toggle), widget-library-04 (enum-ordered grouping).
- Partially OMN-44 deferred: dashboard-01 (libOpen paddingRight),
  dashboard-07 (DropIndicator fragment — empty branch itself resolved
  in 4145b96).

Final state: todo 0 · audited 0 · blocked 6 (all OMN-44) · done 33.

* fix(ui): widgets added via palette now persist + no longer hidden behind rail

Two bugs introduced by OMN-48's rebuilt WidgetLibrary integration:

1. Clicking widgets in the palette produced only the `.lib-card:active`
   scale animation with no visible result, because `.library` is
   position:fixed (360px wide, covering the right side of the
   viewport). The grid rendered the added widget under the palette.
   Fix: add `paddingRight: 360` to the main content area when
   editMode is active, reserving space for the fixed rail.

2. Closing the palette (X button) destroyed widgets added during the
   session. Root cause: `onClose={handleDiscard}` reverted activeDashboard
   to the edit-start snapshot (empty for newly-created dashboards).
   Fix: `onClose={handleSave}` — closing the library now commits
   changes and exits edit mode, matching the prototype's implicit-save
   semantic. Save button remains the explicit commit; Discard still
   rolls back.

Also: palette moved out of the inline flex wrapper since it manages
its own fixed positioning; the 280px column now belongs solely to the
ConfigComponentPanel.

* docs: add OMN-22 UX polish plan (10 tickets OMN-49..OMN-58) [OMN-22]

Plan file for the post-OMN-48 UX polish round. Triaged 28 user-
surfaced observations into 10 PR-sized tickets under epic OMN-22:

- OMN-49 Task 1: Sidebar footer + workspace cleanup
- OMN-50 Task 2: DashboardView interaction polish
- OMN-51 Task 4: Widget chrome — kebab menu + removal + flatten
- OMN-52 Task 3: Timezone + auto-refresh countdown (blocked by OMN-50)
- OMN-53 Task 5: Widget contrast + chart sizing
- OMN-54 Task 6: Config panel as floating modal (blocked by OMN-51)
- OMN-55 Task 7: Routing decisions table polish
- OMN-56 Task 8: Drag-and-drop (blocked by OMN-51)
- OMN-57 Task 9: Grid reflow (masonry)
- OMN-58 Task 10: Light/dark theme wiring

Already-shipped items (Header cleanup, Refresh button, Agent UI)
documented in the plan's "Already addressed" section.

* fix(ui): rename inputs are now readable and auto-select text [OMN-22]

Sidebar dashboard rename input got no styling — user-agent defaults
rendered black text on white, which is invisible against the dark
sidebar. Added transparent background with a subtle brand-colored
border, dark tinted bg, and explicit sidebar-ink color so the input
reads as a clear edit affordance.

Also fixed two related issues that surfaced while testing:

- Dashboard title rename input (top of DashboardView) inherited color
  via CSS but the inheritance was flaky in some themes. Made the color
  explicit via inline style.

- Text was not auto-selecting when the input mounted from the kebab
  menu — Radix DropdownMenu's onCloseAutoFocus was returning focus to
  the kebab trigger after close, stealing it from the newly-mounted
  input. Fix: added onCloseAutoFocus={e => e.preventDefault()} on the
  dashboard-kebab DropdownMenuContent, and switched RenameInput from
  autoFocus+useEffect to a callback ref + requestAnimationFrame so
  focus+select runs after Radix's focus sequence settles.

Checklist item #1 addressed.

* fix(ui): sidebar rename flow + remove workspace chip [OMN-22]

Two checklist items from the post-OMN-48 UX polish round:

#1 Rename input fix (follow-up): after the initial color/background
  fix, fast-clicking Rename from the kebab menu caused the input to
  flash and revert, and the text didn't highlight. Root cause:
  Radix DropdownMenu's focus-scope teardown dispatches a blur on the
  previously-focused element during its close sequence, which fired
  onBlur and committed the empty value.

  Corrected approach:
    - Defer setRenamingId via queueMicrotask so the input mounts
      after Radix finishes closing.
    - Move focus+select into useLayoutEffect with a stable ref, so
      it runs once after defaultValue is applied.
    - Replace onBlur commit with a document-level pointerdown listener
      that only commits on genuine click-outside.
    - Enter/Escape still commit/cancel via onKeyDown.

  Reference: https://medium.com/@ojasskapre/fixing-input-focus-issues-in-a-shadcn-radix-card-ui-dropdown-menu-in-next-js-7b5931e049c8

#2 Workspace chip removal: dropped the hardcoded "Platform Eng"
  Workspace dropdown from the sidebar. No behavior was wired, no
  workspaces concept exists yet, and it was visual noise. Dropped the
  CSS rules (.workspace, .workspace-chip, .ws-name), the now-unused
  ChevronDown import, and updated the corresponding Sidebar test.

Also adds docs/ux-polish-checklist.md tracking the 28-item list.

* refactor(ui): remove widget count from dash-meta row [OMN-22]

Checklist #20: '0 widgets' / 'N widgets' text under the dashboard
title carried no real value — the library footer already shows the
catalog count, and users can see their widget count by glancing at
the grid. Dropped the span plus its trailing separator.

* refactor(ui): remove sidebar-foot status + version strings [OMN-22]

Checklist #26 + #27: neither the "All systems normal" indicator nor
the hardcoded "v2.15" version string had anything real backing them.
No health monitoring is wired, and the version string was a literal
not sourced from package.json. Dropped both.

Changes:
- Sidebar.tsx: removed the .sidebar-foot <div> (dot, status text, version).
- sidebar.css: dropped .sidebar-foot and .sidebar-foot .dot rules.
- globals.css: moved @keyframes pulse here (was in sidebar.css); still
  used by .widget-live::before in dashboard.css.
- Sidebar.test.tsx: replaced "renders ...normal" assertion with a
  "does NOT render sidebar-foot" assertion.
- FrameLayout.test.tsx: rebased its chrome-smoke assertion onto the
  "an omninode product" tagline (unique, stable).

* refactor(ui): remove Header chrome without backing systems [OMN-22]

Checklist #23 + #24 + #28:

- #23 removed the user chip (avatar "JS", "Jamie Sun", "Platform Eng"):
  there is no user system yet, the chip was static placeholder data.
- #24 removed the Notifications bell and Help-circle icon buttons:
  no notifications system, no help system, clicking them did nothing.
- #28 removed the Menu icon prefixing the breadcrumbs: looked like a
  hamburger menu trigger but had no onClick handler, inviting clicks
  that did nothing.

Header now contains: breadcrumbs, Refresh button (retained), theme
toggle (retained). Test updated to assert the removed elements are
absent.

This reproduces the manual edits that were lost this morning when
shared-worktree branch swapping during the OmniNode workflow cleared
the working tree.

* docs(checklist): mark tasks 20, 23, 24, 26, 27, 28 done [OMN-22]

* feat(ui): library rail smooth transition + close on switch + Escape [OMN-22]

Checklist #7 + #15 + #21, all clustered around widget-library edit-mode
behavior:

#15 Smooth open/close: ComponentPalette now accepts an isOpen prop and
  toggles the .library.open class instead of being conditionally
  mounted. The CSS transition (transform 0.3s cubic-bezier) fires in
  both directions because the element stays in the DOM. aria-hidden
  mirrors isOpen so screen readers ignore the off-screen rail.
  DashboardView renders <ComponentPalette isOpen={editMode} /> once,
  unconditionally.

#7 Close on dashboard switch: a useEffect watches activeDashboard?.id.
  When it changes (using a prevRef to skip the initial mount), it
  calls setEditMode(false), which slides the library out.

#21 Escape closes library: a useEffect binds a keydown listener when
  editMode is true. Pressing Escape calls setEditMode(false) and
  preventsDefault on the event. Listener is torn down when editMode
  goes false.

* fix(ui): explicit fixed height on CostTrend + QualityScore charts [OMN-22]

Checklist #5 + #6. Both charts were sized against parent containers
with no height constraint, so the percentage/flex values resolved to
nothing:

- CostTrendPanel: height:100% fell back to minHeight:200px — half
  what the design called for.
- QualityScorePanel: flex:1 inside a height:100% flex column inside
  an unconstrained .widget-body collapsed the chart to ~7px.

Both now set an explicit height:320px on the ReactECharts element.
QualityScorePanel no longer needs the outer flex column since its
children stack in normal flow. Simplified that wrapper to a plain div.

* fix(theme): expose mutedForeground; widgets no longer render text in bg color [OMN-22]

Checklist #4. Root cause was not per-widget — it was in useThemeColors.
The hook exposed `muted` as `readToken('muted')`, but the `--muted`
shadcn token maps to `var(--panel-2)` — a background tint, not a text
color. Every widget labelled `color: colors.muted` was rendering text
in the same color as its panel surface.

Changes:

- useThemeColors now exposes a new `mutedForeground` field that reads
  `--muted-foreground` (→ `--ink-2`). JSDoc on `muted` warns not to
  use it as a text color.
- DelegationMetrics: promoted "Total Delegations", "Quality Gate Pass
  Rate", "Cost Savings" labels from muted (bg) to foreground (full
  ink) — these sit beside primary metric values and are primary.
- BaselinesROICard: promoted all delta labels, "Recommendations"
  heading, and kv sub-labels to foreground.
- ReadinessGate: "Overall:" promoted to foreground; dimension detail
  and "Checked {time}" bumped to mutedForeground (visible secondary).
- QualityScorePanel: "Mean Score" summary and chart axis labels moved
  to mutedForeground.
- CostTrendPanel: chart axis labels moved to mutedForeground.
- EventStream: timestamp + source columns moved to mutedForeground.

After this change, all widget text renders in the correct ink tone
for its emphasis level in both light and dark themes.

* fix(layout): .main constrained to 100vh so content below fold is scrollable [OMN-22]

Checklist #3. Root cause was in src/styles/topbar.css: .main had
display:flex + flex-direction:column + min-width:0 but no height
constraint. Its parent .app sets height:100vh with display:grid but
default auto rows, so .main grew to content size. When content
(Header + DashboardView) exceeded 100vh, body { overflow:hidden }
in globals.css clipped the excess unreachably.

Fix: add height:100% + min-height:0 + overflow:hidden to .main so it
is pinned to its 100vh grid cell, its flex children can shrink, and
internal scroll happens inside .dash-body (which already has
overflow-y:auto).

Also tracks new task #29 (sidebar kebab menu hover + pointer) in the
checklist for later.

* fix(ui): DropdownMenuItem hover now visible; cursor shows pointer [OMN-22]

Checklist #29. Two issues in the shared shadcn DropdownMenuItem:

- cursor was cursor-default. Changed to cursor-pointer.
- focus:bg-accent did fire on hover (Radix sets focus via pointermove),
  but --accent resolves to oklch(92% 0.08 120) in light mode — a nearly
  white pale green that had no visible contrast against the panel's
  100% white background. Switched highlight to --panel-2, which is the
  prototype's own convention for menu hover (see dashboard.css
  .menu-item:hover). --panel-2 has meaningful contrast in both themes.

Covered three selectors — hover:, focus:, and data-[highlighted]: — to
catch every path Radix uses for highlighting.

Applied at the component level so every DropdownMenu in the app
benefits, not just the sidebar.

* fix(theme): ThemeProvider now drives both data-theme + .theme-* class [OMN-22]

Checklist #22. Root cause: ThemeProvider only toggled the legacy
vanilla-extract `.theme-dark`/`.theme-light` class on <body>. The
prototype-ported chrome (sidebar, topbar, widgets, dashboard) activates
its dark theme via `[data-theme="dark"]` on <html> in globals.css. That
attribute was never being set, so the toggle was a no-op for everything
newer than the vanilla-extract layer.

Fix: ThemeProvider now drives both systems from the same theme state:

- Sets/removes `data-theme="dark"` on document.documentElement (drives
  the globals.css OKLCH tokens used by all prototype-ported chrome).
- Keeps the `.theme-dark`/`.theme-light` body class toggle (drives the
  legacy vanilla-extract consumers like AgentChatPanel).

Also added a synchronous `<script>` in index.html that sets
`data-theme="dark"` before React hydrates. The default theme is dark, so
without this the user sees a brief light-mode flash on every page load
before ThemeProvider's useEffect runs.

* refactor(ui): grid now uses CSS columns for masonry-style flow [OMN-22]

Checklist #9. Replaced:
  .grid { display: grid; grid-template-columns: 1fr 1fr; }
with:
  .grid { columns: 2; column-gap: var(--row-gap); }
and added break-inside:avoid + margin-bottom:var(--row-gap) to .widget.

CSS columns flows children top-to-bottom within each column, so a
short widget no longer leaves dead vertical space under a tall
row-mate. Previously a 2-col grid with [tall, short] in row 1 pushed
row 2 past the tall widget's bottom edge, leaving a visible gap under
the short one.

* feat(ui): widget kebab menu + flatten sub-card chrome + rename grid class [OMN-22]

Checklist #8, #13, #14, and a follow-up rename:

#14 Kebab menu on every widget: new WidgetChromeContext carries
  {onConfigure, onDelete} from ComponentCell down to ComponentWrapper
  without threading them through widget props. ComponentWrapper renders
  a MoreVertical kebab button in the widget-head when at least one
  handler is provided. Menu items: Configure, Delete (destructive).
  Available in both view and edit modes.

#13 Easier widget removal: Delete lives in the kebab menu, reachable
  with one click from any mode (previously required entering edit
  mode and finding a less-obvious affordance).

#8 Flatten sub-card chrome: DashboardView no longer wraps each grid
  item in its own `<div className="widget">...widget-head...widget-body`
  outer shell. The widget is ComponentCell directly, which delegates
  chrome to the widget's own ComponentWrapper. One card level, not two.
  Also removes the click-widget-to-configure handler — Configure now
  only fires from the kebab menu.

  Prior ComponentCell also rendered an intervening
  `<div data-testid="grid-cell" style={{ overflow: 'hidden' }}>` wrapper.
  That overflow:hidden created a new block formatting context between
  .grid and .widget and prevented CSS columns flow. Normal path now
  skips this wrapper; `grid-cell` testid retained only on the fallback
  "component not available" path.

Follow-up rename: `.grid` → `.dash-grid`. Tailwind's utility layer
generates `.grid { display: grid; }` which loaded after dashboard.css
and overrode our `columns: 2`. Once `display: grid` is set the browser
ignores `columns: N` entirely, which collapsed widgets into a single
column. Renaming makes our custom rule win unambiguously.

* feat(ui): port prototype menu + modal patterns; drop shadcn dropdown/dialog [OMN-22]

Stay-close-to-prototype pass. The prototype's menus and config modal
use hand-authored markup with custom CSS, not a third-party primitive.
Mirroring that pattern lets future design iterations from Claude Design
drop in with minimal translation.

New shared infrastructure:

- src/components/ui/positioned-menu.tsx — small PositionedMenu +
  MenuItem + MenuSeparator components plus a usePositionedMenu() hook
  that captures the trigger's bounding rect on open and manages
  dismiss (outside-click + Escape). Portaled to document.body so it
  isn't clipped by any overflow:hidden ancestor. Uses the prototype's
  .menu / .menu-item / .menu-item.danger / .menu-sep CSS (already
  ported verbatim into dashboard.css).

- src/styles/modals.css additions — RJSF form class-name mappings
  (.form-group, .rjsf-field, .control-label, .form-control, .checkbox)
  styled to match the prototype's .field/.check look. Reset <fieldset>
  user-agent defaults so its content no longer overflows modal-body's
  max-height scroll container.

Config modal:

- ComponentConfigPanel rewritten to use the prototype's
  .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot
  markup verbatim. Cancel + Save changes footer buttons match the
  prototype's pattern. Save commits that placement's draft via a new
  commitDraft store action; Cancel discards. Escape + backdrop click
  also dismiss.

- src/store/configSlice.ts — commitDraft(placementId) writes a
  placement's draft config back to its layout item and clears the
  draft.

Widget kebab menu:

- ComponentWrapper renders a MoreVertical kebab button that opens a
  PositionedMenu with three items matching the prototype: Configure
  Widget, Duplicate, Remove Widget (danger). onDuplicate is a new
  handler supplied via WidgetChromeContext.

- src/store/dashboardSlice.ts — duplicateLayoutItem(itemId) clones a
  placement with a fresh id and deep-copied config, appended to the
  layout.

Sidebar dashboard kebab:

- Replaced Radix DropdownMenu with the shared PositionedMenu pattern.
  Items: Rename, Duplicate, Share Link (no-op placeholder), Delete.

- src/store/dashboardSlice.ts — duplicateDashboard(id) clones a
  dashboard with a fresh id, "(copy)" name suffix, deep-copied
  layout.

Fieldset / #root selector collision:

- The React mount <div id="root"> and RJSF's <fieldset id="root">
  both matched a bare `#root` selector in globals.css, which gave the
  fieldset height:100vh and overflowed the modal. Changed the
  selector to `div#root` so it only targets the React mount.

Cleanup:

- Removed src/components/ui/dropdown-menu.tsx (shadcn Radix shim, no
  remaining consumers) and uninstalled @radix-ui/react-dropdown-menu.
- Removed the briefly-lived src/components/ui/dialog.tsx + uninstalled
  @radix-ui/react-dialog (the modal moved to prototype markup before
  anything else picked it up).
- Deleted src/config/ComponentConfigPanel.css.ts (vanilla-extract
  styling no longer used after the prototype-markup port).
- grid-item testid moved from ComponentWrapper to a
  display:contents wrapper in ComponentCell so tests find it even
  when the widget's LazyComponent is still Suspending. Redundant
  grid-item testid dropped from the legacy DashboardGrid shim.

Reference breadcrumb:

- reference/claude-design/ now holds a local copy of the Claude Design
  prototype (tarball extracted from the share URL) so the files
  survive between sessions. reference/claude-design/ is gitignored
  (large), but reference/README.md is tracked as a breadcrumb
  pointing at the source URL and describing what's in each subdir.

Checklist #10 (config panel modal) + #14 follow-up. 243/243 tests pass.

* fix(widgets): promote primary data labels to --ink + bump tiny label fonts [OMN-22]

Closes ux-polish-checklist #4. Audit found DelegationMetrics and
BaselinesROICard labels were already rendered in --ink (darkest), not
--ink-3 as the dictated note assumed — perceived faintness came from
the 0.6875rem (~11px) font size. Bumped those to 0.75rem (12px).

QualityScorePanel's Mean Score summary row was genuinely secondary
(rendered in --muted-foreground). That row is the widget's only
non-chart content, so promoted to --foreground and bumped 0.75rem →
0.8125rem.

All other --ink-3 / mutedForeground usage is chart-chrome (axes,
loading/empty states, timestamps, search placeholders) and stays
appropriate for those roles.

* feat(theme): add --chart-* tokens + --status-* aliases on :root [OMN-22]

Closes ux-polish-checklist #30. useThemeColors() reads CSS vars from
document.documentElement, but --chart-1..7 only lived on the
.theme-* classes that vanilla-extract applied to <body>. Every chart
color was therefore resolving to the '#888888' fallback (all series
rendered as gray).

Moved --chart-1..7 onto :root with seven distinct hues at matched
lightness/chroma, clear of the brand green (--accent-h=120), plus a
brighter variant for [data-theme="dark"] so series identity is
consistent across theme toggle.

Also added --status-healthy/warning/error aliases pointing at the
prototype's --status-ok/warn/bad, so useThemeColors's status map
finds values too.

* refactor(readiness): convert ReadinessGate from cards to table layout [OMN-22]

Off-checklist request. The card grid (7 columns of status boxes) read
well at desktop widths but wasted vertical space and didn't scale to
more than 7 dimensions. Tabular layout with Status | Dimension |
Detail columns reads the same information more compactly and is
directly extensible to additional dimensions without layout changes.

Status is rendered as a color-outlined pill that picks up
--status-ok/warn/bad from the theme. Header uses the shared prototype
colors (--ink-2 / --panel-2 / --line). Dropped useThemeColors() in
favor of direct CSS vars to match the pattern established by
RoutingDecisionTable.

* feat(grid): drag-and-drop reorder + palette-to-grid insert [OMN-22]

Closes ux-polish-checklist #12.

Two drag sources: palette cards (copy into grid) and existing widgets
(reorder). Drop onto any widget inserts before its position; a
trailing "drop to append" zone appears at the grid tail while
dragging. The empty-state placeholder is replaced by a "drop to add"
slot during drags so a widget can be placed on an empty dashboard.

Store gets two new actions: insertComponentAt(name, version, size,
atIndex) and moveLayoutItem(itemId, toIndex). moveLayoutItem adjusts
the target index when fromIndex < toIndex so callers can pass the
pre-removal target index naturally. Both covered by unit tests.

Chrome context extended with draggable/isDragging/isDropTarget flags
plus HTML5 drag handlers. ComponentWrapper applies them to the
.widget root and adds a GripVertical icon in the head when
draggable. ComponentCell forwards the props through. ComponentPalette
cards become draggable drag sources that also fall back to
click-to-add.

Visual feedback: dragging source drops to 35% opacity; drop target
gets a 3px brand-colored top-edge bar via ::before (new CSS rule).
Append zone uses existing .drop-slot styling with an explicit
column-span so it reaches across the masonry columns.

data-drag-exclude convention added so interactive widget content can
opt out — ComponentWrapper checks the mousedown target's ancestors
for the attribute and cancels dragstart when found. Applied to
RoutingDecisionTable for future-proofing.

* docs(claude): rewrite widget-add recipe to match actual two-track mechanism [OMN-22]

The existing "Add a new widget" recipe described a per-widget
manifest.ts file colocated next to each widget — that file never
existed. In reality widgets live in two tracks:

  A. Local MVP widgets (all seven current widgets): component lives
     under src/components/dashboard/<slug>/, lazy import registered
     in src/components/dashboard/index.ts, manifest entry added to
     MVP_COMPONENTS in scripts/generate-registry.ts.
  B. External @omninode/* package widgets: published as npm packages
     with a dashboardComponents field in package.json pointing at a
     JSON manifest; generate-registry.ts scans node_modules for
     matching packages.

Rewrote the recipe into two clearly-labeled paths and updated "Where
to look" to point at the correct files. No behavior change.

* feat(cost-trend-3d): experimental Tron-style 3D cost visualization widget [OMN-22]

An interactive 3D cost widget that sits alongside the original 2D
CostTrend — same llm_cost topic, different implementation. Built for
exploration; not a replacement for the ECharts-based CostTrend.

Raw three.js (not @react-three/fiber) — started on R3F but hit WebGL
context loss on first render due to three@0.184 / r3f 9.6 skew and
swapped to plain three.js in a useEffect, which has been stable. R3F
was uninstalled after the switch.

Visualization: each bar = one (time-bucket, model) cell. X=time,
Z=model, Y=cost. Translucent cores + bright edge lines on a
near-black background (light theme uses greys + pastels). Grid lines
aligned to cell boundaries. Axis labels projected world→screen as
HTML overlays so they track the camera. Dates dedupe at day
boundaries so hourly buckets don't print MM/DD 24 times in a row.

Camera: faces the strip head-on (azimuth pinned). Horizontal
navigation via a scrollbar below the canvas — no free orbit. Tilt
via mouse-drag Y within a clamped range. Widescreen 200px canvas
height with the scene tuned to fill vertically via FOV=30,
distance=11, target-Y=0.6, Y-offset=-1px (values dialed in via a
temporary slider panel then baked in).

Interaction:
  - Hover: bar under pointer becomes fully opaque (clickability hint)
  - Plain click: cinematic focus animation — camera swoops in on the
    bar, non-focused bars + grid fade to near-transparent, clicked
    bar's opacity ramps to near-solid, stats panel slides in from
    the right with cost/tokens/requests/derived ratios. Escape or
    clicking elsewhere reverses. Mid-flight retargeting retreats to
    default first then re-launches (simpler than pose blending and
    reads as intentional).
  - Shift-click: existing isolate behavior (desaturate other models
    to neutral grey).
  - Model filter legend below the scrollbar — click to toggle
    visibility; filtered-out bars drop to near-zero opacity but stay
    spatially present.

Theme-aware. Two ThemeConfig objects (DARK_THEME = neon Tron,
LIGHT_THEME = greys + pastels on white) selected by a useThemeName
hook that watches data-theme on documentElement.

Fixture generator: _llm_cost_points now emits a full (time × model)
cross product (48 hourly buckets × 4 models = 192 records) so the
3D grid is dense and rectangular. Per-model cost multipliers
(opus > sonnet > qwen > deepseek) make the series visually distinct
rather than uniformly noisy.

Registry + imports + integration tests updated for the 8th widget.
Added three + @types/three as deps.

* docs(checklist): mark polish items done [OMN-22]

Marked #3, #4, #5, #6, #7, #8, #9, #10, #11, #13, #14, #15, #20,
#21, #22, #29, #30 as done, plus the off-checklist ReadinessGate
tabular rework. Updated progress totals (17 done / 10 pending / 2
n/a / 1 keep-as-is). Remaining pending: #7/#8/#10/#13/#14/#15/#21
turned out to already be implemented (confirmed later and marked);
next pass will tackle #12, #16, #19.

* feat(filters): global time range selector + stacked-area Cost Trend [OMN-22]

Adds a dashboard-level time range selector (header, left of the mode
buttons) that all time-series widgets consume for a consistent view
of the same window. Defaults to "Last 24h" on first mount so fresh
dashboards don't show 48h of raw fixture data out of the gate.

Changes by layer:

- ComponentManifest.capabilities gains optional `supports_time_range`.
  True = widget participates in the global range. False = snapshot or
  pre-aggregated summary; the selector is ignored. Existing manifest
  fixtures without the field continue to pass validation.
- TimeRange gains an optional `label` (e.g. "Last 24h") so the
  selector can render "Last 24h" instead of formatting absolute
  timestamps. Absent for custom ranges.
- useTimeRange.ts centralizes resolveTimeRange() + applyTimeRange()
  helpers and the TIME_RANGE_PRESETS constant. Widgets call
  applyTimeRange(data, d => d.<timestamp_field>, resolved) and get a
  filtered slice; no changes required to queryKey (caching stays
  keyed on topic, filtering is post-fetch).
- DateRangeSelector uses the existing PositionedMenu primitive —
  presets (1h/6h/24h/7d/30d) + "All time". Resolves presets to
  absolute ISO timestamps at click-time; window does not auto-advance
  on refetch.

Participating widgets (supports_time_range: true):
  cost-trend-panel, cost-trend-3d, routing-decision-table
Non-participating (false):
  delegation-metrics (summary), baselines-roi-card (snapshot),
  quality-score-panel (summary), readiness-gate (snapshot),
  event-stream (live tail)

CostTrendPanel switches from per-model lines to a stacked area chart
— each model is an additive layer so the top reads as total cost
over the window and each band reads as that model's contribution.
Smoothed curves, no symbols, 55% area opacity, 1px line. Also:
granularity default flipped from "day" to "hour" to match the
fixture's hourly buckets, and x-axis ticks include the hour in
hourly mode so adjacent dates don't collapse to the same label.

Both CostTrend widgets now render a "No cost data in the selected
time range" overlay when the underlying topic has data but the
current window clips it all away — distinct from the all-empty
state.

RoutingDecisionTable's footer "filtered from N" now shows the
in-range count instead of the raw total so the search filter's
denominator reflects the time-windowed set the user is searching
within.

* fix(cost-trend): legend below chart + drop emphasis.focus to fix hover flicker [OMN-22]

Two related changes to CostTrendPanel, landing together because they
both restructure the ECharts option:

1. Hover flicker (task #9). With tooltip.trigger='axis' showing all
   series' values at the hovered x-position AND
   emphasis.focus='series' on each series dimming non-hovered bands,
   the two behaviors fought each other — tooltip appeared, then the
   series emphasis kicked in and dimmed everything else, which read
   as "the line isolated on hover." Removed emphasis.focus; tooltip
   now stays stable across mouse movement with no visual isolation.

2. Legend placement (task #8). Replaced the built-in ECharts top
   legend with a custom HTML legend below the chart, styled like the
   3D Cost Trend's model-chip row (rounded pills with colored dot +
   model name, wraps to new rows if needed). The two Cost Trend
   widgets now read as a matched pair.

Behavioral differences vs 3D:
  - Click toggles visibility only; no isolate-on-click (3D has that;
    2D doesn't need it).
  - No "Clear focus" button.
  - No isolate highlight on the chip.

When a model is toggled off, its series is filtered out of the chart
entirely (not just hidden), so the remaining stack re-fits
vertically. The model list itself is derived from the full (un-
range-filtered) dataset so the legend stays stable as the user
scrubs the time range.

grid.top reduced 34 → 12 now that the built-in legend is gone — the
chart area reclaims the vertical space.

* feat(filters): custom start/end date range picker [OMN-22]

Extends the DateRangeSelector's popover with a "Custom range…" option
that opens an inline calendar + time-input UI. Keeps all existing
preset behavior intact — the custom view is a second view inside the
same popover, reached via a menu item that intentionally doesn't
close the popover on select.

Calendar: react-day-picker v9 in range mode (single month, 30px
cells). Time: two <input type="time"> controls for start/end HH:MM.
Apply builds a TimeRange with no `label` so the selector shows
"MM/DD – MM/DD" instead of a preset name; Cancel returns to the
preset list.

Theming: react-day-picker's --rdp-* CSS variables are bound to the
dashboard's existing tokens (--brand, --brand-soft, --brand-ink, --ink,
--panel-2, --line) on the calendar's wrapper div. Calendar picks up
the active light/dark theme with no extra JS. Compact sizing
(30x30 day cells) so the popover fits cleanly at 320px wide.

Safety: start/end swap if the user ends up with start > end after
combining calendar day + time input; PositionedMenu's outside-click
dismissal is blocked on pointerdown inside the calendar so clicking a
day doesn't close the whole popover.

Adds react-day-picker ^9.14.0. Zero transitive runtime deps in v9.

* fix(range-picker): theming cascade + sensible default times [OMN-22]

Two fixes to the custom-range date picker:

1. Calendar colors. The --rdp-* vars were being set inline on an outer
   wrapper, but react-day-picker's `.rdp-root` declares its own
   defaults at the same specificity — and that stylesheet loads into
   the bundle after the component mounts, so the library's "blue"
   accent was winning. Moved the overrides into CustomRangePicker.css
   which is imported AFTER `react-day-picker/style.css`, targeting
   `.dash-calendar-wrapper .rdp-root` (specificity 0,2,0 vs library's
   0,1,0). Also swapped the range_start/end text color from
   --brand-ink (dark green) to #ffffff — dark-green-on-brand-green
   didn't contrast enough — and range_middle text from --brand-ink to
   --ink so days inside the range read in the neutral dark-ink color
   rather than competing with the brand.

2. Default times. The picker was seeding its start + end time inputs
   from the currently-active range's timestamps, which meant if the
   active preset was "Last 24h" (clicked at 14:37) both inputs
   defaulted to 14:37. Now they default to 00:00 and 23:59 — the
   common case is "whole days between these two dates" and users who
   want a precise minute window can still set it explicitly.

* feat(cost-trend): replace ECharts with three.js 2D stacked-area chart [OMN-22]

Rewrites the 2D Cost Trend chart body in three.js using an orthographic
camera whose frustum matches canvas pixel coordinates (top-left origin,
y-down). ECharts stays as a dependency for other widgets (Quality,
Delegation) — this swap is scoped to CostTrendPanel.

Why: ECharts' default hover model interacted awkwardly with the
stacked-area layout, producing flicker and unintended band-isolation
effects on pointer move. Owning the render loop gives us full control
over hover and tooltip behavior, and matches the technology choice of
the 3D Cost Trend widget so both widgets share a mental model.

Architecture:
  - New StackedAreaChart component owns the three.js renderer/scene/
    camera for the chart body. Re-renders on size / data / theme
    changes only; no rAF loop (preserveDrawingBuffer:true so the canvas
    holds its last frame).
  - Each visible model's band is a BufferGeometry of 2N vertices and
    2(N-1) triangles (top+bottom polyline swept into a filled strip),
    with a sibling Line for the top-edge outline. Horizontal grid
    lines at "nice" y-tick values.
  - CostTrendPanel outer stays unchanged in shape: same data fetch,
    same time-range filter, same in-range-empty overlay, same custom
    legend from task #8 below the chart.

Interaction:
  - Hover: pointermove on the wrapper maps pointer X to the nearest
    bucket index. A vertical guide line (DOM div, not geometry)
    appears at that X; an HTML tooltip follows the cursor showing
    bucket timestamp, per-model cost breakdown in stack order (top to
    bottom), and the cumulative total.
  - No hover emphasis on the bands themselves — fixes the flicker the
    user was seeing with the ECharts implementation.

Color parsing fix: globals.css declares --chart-1..7 as oklch(...)
values, which THREE.Color.setStyle() doesn't understand (it supports
rgb/hsl/hex/named only). Added cssColorToHex() helper that pipes the
CSS value through a 1×1 canvas — Canvas 2D's fillStyle accepts the
full CSS color syntax and gives back a normalized sRGB pixel we can
convert to the numeric hex THREE wants.

Test updated to stub StackedAreaChart (jsdom has no WebGL context);
verifies the outer component plumbs correct bucket + model counts into
the chart child.

* feat(cost-trend): chartType config — stacked area or stacked bar [OMN-22]

Adds a `chartType` option to the 2D Cost Trend widget's manifest
configSchema (enum: 'area' | 'bar', default 'area'). Users pick via
the widget's Configure dialog (kebab menu → Configure Widget), the
value persists with the dashboard layout.

Rendering: same StackedSlice data structure in both modes; only the
geometry builder differs.
  - 'area' (existing): filled bands (triangle strip per model) + top-
    edge polyline outlines. Smooth trend read across time.
  - 'bar' (new): one rectangle per (bucket × visible-model) cell
    stacked on the previous model's cumulative total. Reads as
    discrete per-bucket observations. Bar width = plotWidth /
    bucketCount × 0.72 (with a floor at 2px so dense datasets still
    render). Zero-height cells skip their triangle indices so we
    don't emit degenerate geometry.

File rename: StackedAreaChart.tsx → StackedChart.tsx (via git mv) now
that the component handles both modes. Exported types renamed to
match; the test file's vi.mock path and testid updated accordingly.

Grid + axis labels + hover guide + tooltip are unchanged across modes
— all the chrome lives on the common StackedSlice, not the render
shape.

* fix(cost-trend): bar-mode cell-center x-mapping + suppress zero y-label [OMN-22]

Two visual fixes on the 2D stacked chart, both noticed at "Last 24h"
where the post-filter dataset has only a handful of buckets:

1. Bars overflowed the plot on both sides. The x-scale was
   edge-to-edge (plotLeft..plotRight) regardless of mode. With N=4
   and plotWidth=400 that gave 100px between bar centers and a bar
   width of ~72px, so the leftmost bar's left edge landed 36px inside
   the y-axis label column and the rightmost bar's right edge spilled
   36px past the canvas.

   Moved to a per-mode x-scale:
     - area: edge-to-edge (unchanged). A trend line should span the
       full plot area.
     - bar: cell-center. Each of N cells gets width plotWidth/N and
       its bar is centered. Every bar stays fully inside the plot
       area at any bucket count.

2. The $0 y-axis tick label sat directly above the leftmost x-axis
   date label at narrow widths and read as a single blob. The $0 tick
   is redundant with the x-axis baseline anyway, so the y-label for
   value 0 is now suppressed. The horizontal gridline at the baseline
   still renders; only the text label is dropped.

No test changes — the fixes are rendering-layer, and the chart
component is already stubbed in tests (jsdom has no WebGL context).

* fix(routing-table): tighten narrow columns + shorter timestamp [OMN-22]

Without an explicit <colgroup>, tableLayout:fixed distributed equal
1/6 widths to all six columns. "LLM Conf." held 3 chars ("83%") and
"Cost" held 7 ("$0.0231"), so those got far more space than they
needed while Timestamp ("4/20/2026, 10:40:00 AM" at 22 chars) wrapped
at typical widget widths.

Two-part fix:

1. Added <colgroup> with per-column widths that match content size:
   Timestamp 24%, LLM Agent 21%, Fuzzy Agent 21%, Agreement 13%,
   LLM Conf. 9%, Cost 12%. Totals to 100; tableLayout:fixed respects
   those exact proportions.

2. Shortened the displayed timestamp to "MM/DD HH:MM AM/PM" (dropping
   the year and seconds). Added whiteSpace:nowrap + text-overflow
   ellipsis on the cell and a title attribute carrying the full
   toLocaleString() form so the richer timestamp is still available
   on hover.

* feat(event-stream): search, sticky header, paused indicator, source pills [OMN-22]

First round of professionalism polish. Scope chosen from the suggestion
list: the clear bugs, the straightforward visual upgrades, and the two
highest-leverage interactions. Connection-status dot, click-to-expand,
correlation-id grouping, and live/paused toggle are all deferred.

Bug fixes
  - Dropped `hsl(${colors.border})` on the row border — useThemeColors
    returns a CSS-resolvable string already, so wrapping it in hsl()
    produced invalid CSS and the dividers were not rendering. Now uses
    `var(--line-2)` directly.
  - Gave the scroll container a definite height (SCROLL_HEIGHT = 320).
    The previous `height: 100%` collapsed or grew unpredictably since
    `.widget-body` has no height constraint.

Visual
  - Sticky column header (TIME · EVENT · SOURCE) at the top of the
    scroll region so the semantics of each column are obvious without
    inferring from content.
  - Row padding bumped from 4px to 6px × 10px for breathing room.
  - Timestamp column tightened 140 → 96px; new formatter shows
    HH:MM:SS for events in the last 24h and MM/DD HH:MM for older.
    A title attribute on each row exposes the full event_type,
    source, correlation_id, and ISO timestamp for debugging.
  - Timestamp and event_type both use the mono font stack; source
    moved into a small outlined pill whose hue is derived from a hash
    of the source string — stable per source so the eye can pick one
    out while scanning a fast stream.

Interaction
  - Search input over event_type / source / correlation_id. Mirrors
    the RoutingDecisionTable pattern.
  - Auto-scroll pause is now surfaced: when the user scrolls up, the
    component tracks the arrival count since that moment and shows a
    floating "↑ N new events" button pinned at the top of the scroll
    region. Click to jump back to top and resume auto-scroll. Also
    added an "auto-scroll paused" hint in the footer.
  - Footer shows `filtered / buffered` with a "buffer full" note
    when events have saturated maxEvents.

Internal: `userScrolledUp` promoted from a ref to state so the UI can
react to the paused condition (needed for the new-events button).

* feat(quality): three.js rewrite — pass-rate headline + tier-coloured bars [OMN-22]

Replaces the ECharts-backed Quality Score widget. Two changes in one
pass because the full rewrite touches both the render layer and the
data model of the stat pane.

Why the rewrite:
  - The old widget coloured each of the 5 score buckets with a
    different entry from the chart palette. Buckets are slices of the
    same metric (score), not separate series; giving them unrelated
    hues made the histogram read as five unrelated bars rather than
    one distribution.
  - The mean marker used ECharts' `type: 'average'` which averages
    the Y values (bucket counts), not the weighted score mean that
    the fixture computes. The line was in one place and the label
    claimed a different number. Deleted.
  - "Mean Score: 0.67" was buried in small text above the chart.
    On a glance dashboard the most actionable number ("am I passing?")
    wasn't the biggest thing on screen.

New design:
  - Split layout. Left pane: big pass-rate % in a traffic-light
    colour (green / amber / red based on the value), with mean +
    measurement-count as supporting captions. Right pane: the 3D
    bar chart.
  - 3D chart in three.js. 5 bars along a strip, coloured via a
    red → amber → green hue sweep (hsl 0°..120°) so the shape of
    the distribution also encodes the quality story. MeshStandard
    material + one directional light gives the bars readable
    dimensionality without needing shadows.
  - Translucent vertical "wall" behind the bars at the x-position
    corresponding to the pass threshold, with a bright-outlined edge.
    Spatially communicates "right of the wall = passing".
  - Floating cone marker above the bars pointing down at the mean
    score's x-position, with a dashed drop-line to the ground. Uses
    the brand token.
  - Hover a bar → HTML tooltip with bucket label, count, and % of
    total. Fixed camera, no orbit.
  - Title shortened "Quality Score Distribution" → "Quality Scores".

Config:
  - Added `passThreshold` (default 0.8, range [0, 1]) to the widget
    manifest. Drives both the pass-rate calculation and the wall's
    x-position so a dashboard can calibrate per use case.

Manifest:
  - displayName unchanged ("Quality Scores"), version bumped 1.0.0 →
    1.1.0 to reflect the behaviour + config change.
  - minSize bumped w 3 → 4 so the split layout has room. defaultSize
    and maxSize unchanged.

Shared helper: cssColorToHex extracted from cost-trend/StackedChart
to src/theme/cssColorToHex.ts now that two three.js widgets need it.
Exported through src/theme/index.ts alongside useThemeColors. No
behaviour change to the cost-trend chart; StackedChart just imports
from the new location.

Test: mocks three.js WebGLRenderer with a no-op class (jsdom has no
WebGL context) and shims ResizeObserver. New assertions verify the
pass-rate arithmetic for two thresholds (default 0.8 and a custom
0.6) so the stat pane's headline is locked in.

* fix(quality): Tron-style bars + clearer mean marker [OMN-22]

Bar rendering swapped from MeshStandardMaterial (shaded, semi-opaque)
to the cost-trend-3d Tron recipe: translucent coloured core at 0.55
opacity + bright edge lines on every cube edge at 0.95 opacity. No
lighting (materials are unlit), so dropped the ambient + directional
lights from scene setup. Saturation bumped 0.6 → 0.85 and lightness
0.55 → 0.62 on the red→green tier gradient for the neon feel. The
two three.js widgets now read as siblings visually.

Ground grid switched from a subtle --line colour to cyan (#00e5ff) at
0.35 opacity for the same reason. Threshold plane outline opacity
bumped 0.5 → 0.75 and the wall itself dropped 0.12 → 0.08 so the
outline carries more of the read.

Mean marker reworked. The dashed drop line from the floating cone
down to the ground was getting hidden behind the tallest bar on
common data shapes — one of those "what IS that thing?" visuals.
Dropped it entirely. The cone is slightly larger (radius 0.16 → 0.2,
height 0.3 → 0.42) and now explicitly labelled: we project the
cone's world position to canvas pixels at scene-build time and
anchor an HTML "MEAN 0.XX" chip just above it. The relationship
reads at a glance without needing the line.

* fix(quality): theme-aware contrast for light mode [OMN-22]

The Tron colour recipe I baked in last commit worked against the dark
background but failed in light mode on several specific pairs:

  - Cyan grid (0x00e5ff, opacity 0.35) on the near-white panel — the
    lines were effectively invisible, leaving the ground plane with
    no tick markers.
  - Threshold plane and outline, both white, on a near-white canvas —
    "pass wall" disappeared entirely. Users had no visual cue that
    the scene was partitioned into pass/fail regions.
  - Bar cores at 0.55 opacity on a light background — values washed
    out instead of reading as solid bars.
  - Bar edges at full neon saturation felt garish against the light
    theme's pastel aesthetic.

Introduced a per-theme EffectiveColors block (DARK_EFFECTIVE /
LIGHT_EFFECTIVE) covering grid colour + opacity, threshold plane +
outline colour + opacity, bar core + edge opacity, and bar HSL
saturation + lightness. Light theme swaps:
  grid:      cyan  → slate grey (0x6b7280) at higher opacity
  threshold: white → dark slate (0x334155) on both plane + outline
  bar core:  0.55  → 0.72 opacity so fills read on white
  bar edges: 0.95  → 0.80 opacity (softer against the lighter bg)
  bar hue:   0.85 sat / 0.62 light → 0.60 / 0.50 (pastel, matches
             cost-trend-3d's light theme)
  ground:    one step off canvas-bg in both themes via --panel vs
             --panel-2 so the floor has a visible boundary

Mechanics: extracted the existing useThemeName hook (previously
duplicated inside cost-trend-3d) to src/theme/useThemeName.ts and
exported from the theme barrel. cost-trend-3d updated to import from
the shared location; QualityScorePanel now subscribes and picks the
right EffectiveColors block. Scene rebuilds on theme toggle via the
hook's MutationObserver on data-theme.

* fix(quality): light-mode floor was black — explicit hex + new cssVarToHex helper [OMN-22]

Root cause: the ground plane was built via
`cssColorToHex('var(--panel)', 0x0b0e14)`. Canvas 2D's fillStyle
setter (which cssColorToHex uses for parsing) silently rejects
`var()` syntax and retains the previous value, so the sentinel-check
fell through to the fallback on every call. In dark mode the
fallback 0x0b0e14 was close enough to the actual dark panel colour
that nobody noticed; in light mode a near-black floor against a
near-white canvas stood out as clearly wrong.

Two fixes:

1. Floor set as explicit hex per theme, mirroring the values
   cost-trend-3d ships in DARK_THEME / LIGHT_THEME so the two
   three.js widgets stay visually consistent:
     dark:  0x020812   (near-black, slight blue tint)
     light: 0xf7f8fa   (near-white, barely-there grey tint)

2. Added cssVarToHex(varName, fallback) to src/theme/cssColorToHex.ts
   — reads the computed CSS variable value off documentElement and
   passes the resolved literal to cssColorToHex. The mean cone's
   `var(--brand)` lookup was silently falling through to the same
   fallback for the same reason; switching to cssVarToHex means the
   cone now picks up the actual brand token in both themes.

* feat(cost-by-model): three.js pie widget — cost share per model [OMN-22]

New widget that complements Cost Trend. Where Cost Trend answers
"how has spend moved over time", this answers "who is spending the
money right now". Same llm_cost.v1 topic, client-side aggregation
by model name.

Scene: one pie lying flat on the XZ plane with a small extrusion
depth for 3D read. Each slice is built from THREE.Shape (center →
outer edge → arc → center) + ExtrudeGeometry so a single mesh holds
the whole wedge (top face + sides) and the pointer raycast is
straightforward. Slice colors come from the shared `--chart-*`
palette, indexed by a stable model-to-color map derived from the
full (un-range-filtered) dataset so a model keeps its colour even
if it drops out of the current window and later re-enters.

Interaction: hover pops the hovered slice outward by 0.09 world
units along its mid-angle, with an HTML tooltip showing the model,
cost, and share. Hover-only updates are done in a separate effect
from the slice-build effect so pointer moves don't tear down and
rebuild geometry.

Participates in the dashboard time range (supports_time_range:
true). Legend on the right shows each model with its colour dot,
percentage, and a footer line with the total cost in range.

Theme-aware via the existing PieTheme split (dark = Tron-leaning
with a touch of emissive, light = pastel with no self-glow). Floor
hex values match the cost-trend-3d floor so the two three.js
visualisations stay consistent across themes.

Registry entry `cost-by-model` added (category: visualization,
defaultSize 6×4, minSize 4×3). Both integration tests bumped from
expecting 8 components to 9. Lazy import registered in
src/components/dashboard/index.ts under its implementationKey.

New unit test mocks three.js WebGLRenderer + ResizeObserver and
locks the aggregation math: 4 input rows at $2/$1/$1/$1 across
three models should yield the expected 60% / 20% / 20% legend
shares and a $5.00 total.

* refactor(styles): unify font-family tokens — single source in :root [OMN-22]

Consolidate the font-family token system so a single CSS variable
determines every mono/sans rendering in the app.

- Define --font-sans and --font-mono in globals.css :root as primitives.
  Body and .mono now reference the tokens (previously hardcoded).
- Remove font from the vanilla-extract contract (tokens.css.ts,
  themes.css.ts). Legacy .theme-dark/.theme-light classes no longer
  set 'Inter' or 'JetBrains Mono' which were conflicting with the
  actually-loaded IBM Plex fonts.
- Update CSS files (dashboard.css, library.css, sidebar.css) +
  Header.css.ts + MessageBubble.tsx to use var(--font-mono).
- Simplify widget inline fontFamily from
  'var(--font-mono, "IBM Plex Mono", monospace)' to 'var(--font-mono)'
 …
@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@jonahgabriel has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 39 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 9 minutes and 39 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Lite

Run ID: 90f151ff-e6e8-458f-8b55-adfe9dda035e

📥 Commits

Reviewing files that changed from the base of the PR and between 628282c and 991f96d.

⛔ Files ignored due to path filters (5)
  • package-lock.json is excluded by !**/package-lock.json
  • public/icon.svg is excluded by !**/*.svg
  • src/shared/types/generated/enum-dashboard-widget-type.ts is excluded by !**/generated/**
  • src/shared/types/generated/index.ts is excluded by !**/generated/**
  • src/shared/types/generated/onex-models.ts is excluded by !**/generated/**
📒 Files selected for processing (269)
  • .env.example
  • .github/workflows/ci.yml
  • .github/workflows/contract-validation.yml
  • .gitignore
  • .storybook/main.ts
  • .storybook/preview.tsx
  • CLAUDE.md
  • components.json
  • docs/2026-04-25-review-response.md
  • docs/adr/001-typography-system.md
  • docs/adr/002-storybook-widget-coverage.md
  • docs/audit/README.md
  • docs/audit/audited/.gitkeep
  • docs/audit/blocked/.gitkeep
  • docs/audit/blocked/dashboard-08-widget-card-render-and-drag-wiring.md
  • docs/audit/blocked/dashboard-09-drop-slot-append.md
  • docs/audit/blocked/dashboard-10-drop-indicator-component.md
  • docs/audit/blocked/widget-card-01-outer-article-and-drag-state.md
  • docs/audit/blocked/widget-card-02-head-and-grip.md
  • docs/audit/blocked/widget-card-04-kebab-and-body.md
  • docs/audit/done/.gitkeep
  • docs/audit/done/dashboard-01-root-dash-body.md
  • docs/audit/done/dashboard-02-dash-header-and-title-wrap.md
  • docs/audit/done/dashboard-03-dash-title-rename-input.md
  • docs/audit/done/dashboard-04-dash-title-display.md
  • docs/audit/done/dashboard-05-dash-meta-row.md
  • docs/audit/done/dashboard-06-header-actions.md
  • docs/audit/done/dashboard-07-empty-vs-grid-conditional.md
  • docs/audit/done/dashboard-11-library-open-grid-transition.md
  • docs/audit/done/empty-state-01-outer-container.md
  • docs/audit/done/empty-state-02-diagonal-stripe-overlay.md
  • docs/audit/done/empty-state-03-icon-tile.md
  • docs/audit/done/empty-state-04-headline-and-helper.md
  • docs/audit/done/empty-state-05-cta-button.md
  • docs/audit/done/sidebar-01-root-and-brand-svg.md
  • docs/audit/done/sidebar-02-brand-name.md
  • docs/audit/done/sidebar-03-workspace-chip.md
  • docs/audit/done/sidebar-04-nav-section-and-new-button.md
  • docs/audit/done/sidebar-05-dash-list-and-item-marker.md
  • docs/audit/done/sidebar-06-rename-input-handlers.md
  • docs/audit/done/sidebar-07-rename-input-style-and-dash-name.md
  • docs/audit/done/sidebar-08-dash-kebab-button.md
  • docs/audit/done/sidebar-09-empty-state.md
  • docs/audit/done/sidebar-10-sidebar-foot.md
  • docs/audit/done/topbar-01-root-and-breadcrumbs.md
  • docs/audit/done/topbar-02-right-cluster-icon-buttons.md
  • docs/audit/done/topbar-03-user-chip.md
  • docs/audit/done/widget-card-03-title-and-live-badge.md
  • docs/audit/done/widget-library-01-root-and-open-state.md
  • docs/audit/done/widget-library-02-lib-head.md
  • docs/audit/done/widget-library-03-lib-search.md
  • docs/audit/done/widget-library-04-lib-body-and-group-title.md
  • docs/audit/done/widget-library-05-lib-card-structure-and-thumb.md
  • docs/audit/done/widget-library-06-added-state-badge-and-foot.md
  • docs/audit/in-progress/.gitkeep
  • docs/audit/todo/.gitkeep
  • docs/plans/2026-04-19-omn-38-new-dashboard-button.md
  • docs/plans/2026-04-19-omn-39-registry-resolve-impls.md
  • docs/plans/2026-04-20-omn-41-save-persistence.md
  • docs/plans/2026-04-20-omn-42-ui-rebuild-phase-1.md
  • docs/plans/2026-04-20-omn-43-ui-rebuild-phase-2.md
  • docs/plans/2026-04-20-omn-47-verbatim-css-port.md
  • docs/plans/2026-04-21-omn-22-ux-polish.md
  • docs/plans/2026-04-25-review-remediation.md
  • docs/plans/storybook-widget-coverage.md
  • docs/plans/typography-refactor.md
  • docs/requirements-audit.md
  • docs/ui-rebuild-traceability.md
  • docs/ux-polish-checklist.md
  • docs/widget-config-audit.md
  • eslint-rules/index.cjs
  • eslint-rules/no-cast-on-parsed-json.cjs
  • eslint-rules/no-typography-inline.cjs
  • eslint-rules/package.json
  • eslint.config.js
  • index.html
  • package.json
  • postcss.config.js
  • reference/README.md
  • scripts/check-no-env-contamination.sh
  • scripts/generate-registry.test.ts
  • scripts/generate-registry.ts
  • scripts/generate_fixtures.py
  • scripts/run-generate-fixtures.sh
  • scripts/run-types-generate.sh
  • server/broadcast.test.ts
  • server/db.ts
  • server/index.ts
  • server/routes.test.ts
  • server/routes.ts
  • shared/types/component-manifest.test.ts
  • shared/types/component-manifest.ts
  • shared/types/dashboard.test.ts
  • shared/types/dashboard.ts
  • shared/types/topics.ts
  • src/App.integration.test.tsx
  • src/App.test.tsx
  • src/App.tsx
  • src/agent/AgentOrchestrator.tsx
  • src/agent/buildSystemPrompt.test.ts
  • src/agent/llmClient.ts
  • src/agent/usePageAgent.ts
  • src/components/agent/AgentChatPanel.stories.tsx
  • src/components/agent/AgentChatPanel.tsx
  • src/components/agent/AgentLauncher.stories.tsx
  • src/components/agent/AgentLauncher.tsx
  • src/components/agent/MessageBubble.tsx
  • src/components/agent/SuggestedPrompts.tsx
  • src/components/dashboard/AutoRefreshSelector.tsx
  • src/components/dashboard/ComponentCell.tsx
  • src/components/dashboard/ComponentPalette.stories.tsx
  • src/components/dashboard/ComponentPalette.test.tsx
  • src/components/dashboard/ComponentPalette.tsx
  • src/components/dashboard/ComponentWrapper.stories.tsx
  • src/components/dashboard/ComponentWrapper.tsx
  • src/components/dashboard/CustomRangePicker.css
  • src/components/dashboard/CustomRangePicker.stories.tsx
  • src/components/dashboard/CustomRangePicker.tsx
  • src/components/dashboard/DashboardGrid.test.tsx
  • src/components/dashboard/DashboardGrid.tsx
  • src/components/dashboard/DateRangeSelector.stories.tsx
  • src/components/dashboard/DateRangeSelector.tsx
  • src/components/dashboard/EmptyState.tsx
  • src/components/dashboard/Selectors.stories.tsx
  • src/components/dashboard/TimezoneSelector.tsx
  • src/components/dashboard/WidgetChromeContext.tsx
  • src/components/dashboard/baselines/BaselinesROICard.stories.tsx
  • src/components/dashboard/baselines/BaselinesROICard.test.tsx
  • src/components/dashboard/baselines/BaselinesROICard.tsx
  • src/components/dashboard/cost-by-model-2d/CostByModelBars.stories.tsx
  • src/components/dashboard/cost-by-model-2d/CostByModelBars.test.tsx
  • src/components/dashboard/cost-by-model-2d/CostByModelBars.tsx
  • src/components/dashboard/cost-by-model/CostByModelPie.stories.tsx
  • src/components/dashboard/cost-by-model/CostByModelPie.test.tsx
  • src/components/dashboard/cost-by-model/CostByModelPie.tsx
  • src/components/dashboard/cost-trend-3d/CostTrend3D.stories.tsx
  • src/components/dashboard/cost-trend-3d/CostTrend3D.test.tsx
  • src/components/dashboard/cost-trend-3d/CostTrend3D.tsx
  • src/components/dashboard/cost-trend/CostTrendPanel.stories.tsx
  • src/components/dashboard/cost-trend/CostTrendPanel.test.tsx
  • src/components/dashboard/cost-trend/CostTrendPanel.tsx
  • src/components/dashboard/cost-trend/StackedChart.tsx
  • src/components/dashboard/delegation/DelegationMetrics.stories.tsx
  • src/components/dashboard/delegation/DelegationMetrics.test.tsx
  • src/components/dashboard/delegation/DelegationMetrics.tsx
  • src/components/dashboard/delegation/DoughnutChart.tsx
  • src/components/dashboard/events/EventStream.stories.tsx
  • src/components/dashboard/events/EventStream.test.tsx
  • src/components/dashboard/events/EventStream.tsx
  • src/components/dashboard/index.ts
  • src/components/dashboard/quality-score-panel-2d/QualityScoreHistogram.stories.tsx
  • src/components/dashboard/quality-score-panel-2d/QualityScoreHistogram.test.tsx
  • src/components/dashboard/quality-score-panel-2d/QualityScoreHistogram.tsx
  • src/components/dashboard/quality/QualityScorePanel.stories.tsx
  • src/components/dashboard/quality/QualityScorePanel.test.tsx
  • src/components/dashboard/quality/QualityScorePanel.tsx
  • src/components/dashboard/readiness/ReadinessGate.stories.tsx
  • src/components/dashboard/readiness/ReadinessGate.test.tsx
  • src/components/dashboard/readiness/ReadinessGate.tsx
  • src/components/dashboard/routing/RoutingDecisionTable.stories.tsx
  • src/components/dashboard/routing/RoutingDecisionTable.test.tsx
  • src/components/dashboard/routing/RoutingDecisionTable.tsx
  • src/components/frame/DeleteDashboardDialog.stories.tsx
  • src/components/frame/DeleteDashboardDialog.tsx
  • src/components/frame/FrameLayout.stories.tsx
  • src/components/frame/FrameLayout.test.tsx
  • src/components/frame/FrameLayout.tsx
  • src/components/frame/Header.css.ts
  • src/components/frame/Header.stories.tsx
  • src/components/frame/Header.test.tsx
  • src/components/frame/Header.tsx
  • src/components/frame/Sidebar.stories.tsx
  • src/components/frame/Sidebar.test.tsx
  • src/components/frame/Sidebar.tsx
  • src/components/ui/button.tsx
  • src/components/ui/input.tsx
  • src/components/ui/positioned-menu.tsx
  • src/components/ui/separator.tsx
  • src/components/ui/tooltip.tsx
  • src/components/ui/typography/Heading.stories.tsx
  • src/components/ui/typography/Heading.test.tsx
  • src/components/ui/typography/Heading.tsx
  • src/components/ui/typography/README.md
  • src/components/ui/typography/Text.test.tsx
  • src/components/ui/typography/Text.tsx
  • src/components/ui/typography/Typography.stories.tsx
  • src/components/ui/typography/index.ts
  • src/components/ui/typography/tokens.ts
  • src/config/ComponentConfigPanel.stories.tsx
  • src/config/ComponentConfigPanel.test.tsx
  • src/config/ComponentConfigPanel.tsx
  • src/data-source/SnapshotSourceProvider.tsx
  • src/data-source/file-snapshot-source.test.ts
  • src/data-source/file-snapshot-source.ts
  • src/data-source/http-snapshot-source.test.ts
  • src/data-source/http-snapshot-source.ts
  • src/data-source/index.ts
  • src/data-source/protocol-snapshot-source.ts
  • src/hint/hint-matcher.test.ts
  • src/hint/hint-matcher.ts
  • src/hooks/useProjectionQuery.test.tsx
  • src/hooks/useProjectionQuery.ts
  • src/hooks/useTimeRange.ts
  • src/hooks/useTimezone.ts
  • src/hooks/useWebSocketInvalidation.ts
  • src/integration.part2.test.tsx
  • src/integration.part3.test.tsx
  • src/integration.part4.test.tsx
  • src/layout/layout-persistence.test.ts
  • src/layout/layout-persistence.ts
  • src/lib/utils.ts
  • src/lib/zonedComponents.ts
  • src/main.tsx
  • src/pages/DashboardBuilder.css.ts
  • src/pages/DashboardBuilder.tsx
  • src/pages/DashboardView.test.tsx
  • src/pages/DashboardView.tsx
  • src/providers/Providers.tsx
  • src/registry/ComponentRegistry.test.ts
  • src/registry/ComponentRegistry.ts
  • src/registry/RegistryProvider.test.tsx
  • src/registry/RegistryProvider.tsx
  • src/registry/component-registry.json
  • src/services/dashboardService.test.ts
  • src/services/dashboardService.ts
  • src/store/configSlice.ts
  • src/store/conversationSlice.ts
  • src/store/dashboardSlice.hydrate.test.ts
  • src/store/dashboardSlice.test.ts
  • src/store/dashboardSlice.ts
  • src/store/filtersSlice.ts
  • src/store/store.ts
  • src/store/types.ts
  • src/store/uiSlice.ts
  • src/storybook-coverage-compliance.test.ts
  • src/storybook/decorators/withDashboardContext.tsx
  • src/storybook/fixtures/baselines.ts
  • src/storybook/fixtures/cost.ts
  • src/storybook/fixtures/delegation.ts
  • src/storybook/fixtures/events.ts
  • src/storybook/fixtures/index.ts
  • src/storybook/fixtures/quality.ts
  • src/storybook/fixtures/readiness.ts
  • src/storybook/fixtures/routing.ts
  • src/styles/buttons.css
  • src/styles/dashboard.css
  • src/styles/globals.css
  • src/styles/library.css
  • src/styles/modals.css
  • src/styles/sidebar.css
  • src/styles/topbar.css
  • src/test-utils/dataSourceTestProvider.tsx
  • src/test-utils/mockFetch.ts
  • src/theme/ThemeProvider.tsx
  • src/theme/cssColorToHex.ts
  • src/theme/index.ts
  • src/theme/themes.css.ts
  • src/theme/tokens.css.ts
  • src/theme/useThemeColors.ts
  • src/theme/useThemeName.ts
  • src/typography-compliance.test.ts
  • tailwind.config.ts
  • tests/proof-of-life.test.tsx
  • tests/vite-middleware/fixtures-middleware.test.ts
  • tests/vite-middleware/layouts-middleware.test.ts
  • tsconfig.json
  • tsconfig.node.json
  • vite.config.ts
  • vitest.config.ts

Comment @coderabbitai help to get the list of available commands and usage tips.

jonahgabriel added a commit that referenced this pull request Apr 26, 2026
* feat(fixtures): Python fixture generator [OMN-30] (#1)

* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24] (#2)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path

* feat(data-source): ProtocolSnapshotSource interface [OMN-25] (#4)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26] (#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]

* feat(data-source): HttpSnapshotSource [OMN-28] (#6)

* feat(vite): fixturesMiddleware with testable handler + 5 contract tests [OMN-27] (#7)

* refactor(hooks): useProjectionQuery consumes ProtocolSnapshotSource [OMN-29] (#8)

* feat(hint): starter widget_type -> category preference heuristic [OMN-34] (#9)

* feat(registry): scan node_modules/@omninode/* for dashboardComponents [OMN-31] (#10)

* feat(layout): file-based layout persistence [OMN-32] (#11)

* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test

* feat(config): per-component config panel via @rjsf [OMN-33] (#12)

* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]

* feat(header): New dashboard button with inline create form [OMN-38] (#14)

* fix(registry): call resolveImplementations() eagerly so built-ins show as available [OMN-39] (#15)

* feat(fixtures): emit summary DTOs matching widget contracts; split shared topics [OMN-40] (#16)

* fix(dashboard): Save button now persists layout via layoutPersistence.write [OMN-41] (#17)

* feat(ui): install Tailwind + shadcn utilities + IBM Plex, port OKLCH tokens from Claude Design prototype [OMN-42] (#18)

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView (Phase 2) [OMN-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files

* fix(ci): drop unused waitFor import in Sidebar.test.tsx [OMN-43] (#20)

* feat(ui): verbatim CSS port of prototype; rewrite TSX to use semantic class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.

* chore(audit): scaffold chunked UI fidelity audit folders + README + example chunk [OMN-48]

* chore(audit): generate remaining 38 chunk files across 6 components [OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.

* audit: claim widget-library-06 [OMN-48]

* audit: complete widget-library-06 [OMN-48]

* audit: complete widget-library-01 [OMN-48]

* audit: complete widget-library-02 [OMN-48]

* audit: complete widget-library-03 [OMN-48]

* audit: complete widget-library-04 [OMN-48]

* audit: complete widget-library-05 [OMN-48]

* audit: complete empty-state-01 [OMN-48]

* audit: complete empty-state-02 [OMN-48]

* audit: complete empty-state-03 [OMN-48]

* audit: complete empty-state-04 [OMN-48]

* audit: complete empty-state-05 [OMN-48]

* audit: complete widget-card-01 [OMN-48]

* audit: complete widget-card-02 [OMN-48]

* audit: complete widget-card-03 [OMN-48]

* audit: complete widget-card-04 [OMN-48]

* audit: complete topbar-01 [OMN-48]

* audit: complete topbar-02 [OMN-48]

* audit: complete topbar-03 [OMN-48]

* audit: complete sidebar-01 [OMN-48]

* audit: complete sidebar-02 [OMN-48]

* audit: complete sidebar-03 [OMN-48]

* audit: complete sidebar-04 [OMN-48]

* audit: complete sidebar-05 [OMN-48]

* audit: complete sidebar-06 [OMN-48]

* audit: complete sidebar-07 [OMN-48]

* audit: complete sidebar-08 [OMN-48]

* audit: complete sidebar-09 [OMN-48]

* audit: complete sidebar-10 [OMN-48]

* audit: complete dashboard-01 [OMN-48]

* audit: complete dashboard-02 [OMN-48]

* audit: complete dashboard-03 [OMN-48]

* audit: complete dashboard-04 [OMN-48]

* audit: complete dashboard-05 [OMN-48]

* audit: complete dashboard-06 [OMN-48]

* audit: complete dashboard-07 [OMN-48]

* audit: complete dashboard-08 [OMN-48]

* audit: complete dashboard-09 [OMN-48]

* audit: complete dashboard-10 [OMN-48]

* audit: complete dashboard-11 [OMN-48]

* feat(ui): port EmptyState + WidgetLibrary missing structure [OMN-48]

Addresses audit findings grouped by theme:

- EmptyState (chunks 01-05): new component at src/components/dashboard/EmptyState.tsx
  rebuilds the prototype's dashed-card layout: diagonal stripe overlay,
  48x48 icon tile, "This dashboard is empty" headline, helper copy, and
  "Add first widget" CTA button. Wired in DashboardView in place of the
  prior single-line text fallback. onAdd enters edit mode when not
  already editing.

- WidgetLibrary (chunks 02, 03, 05, 06):
  - Adds close button (X) in .lib-head wired to onClose prop.
  - Adds .lib-search input with local q state; filters catalog by
    displayName + description substring match.
  - Adds .lib-card-thumb with a category-derived lucide icon
    (visualization→LineChart, metrics→Gauge, table→Table,
    status→ShieldCheck, stream→Radio).
  - Adds .lib-foot with "{N} widgets available" + "drag or click" hint.
  - Adds no-results empty-state branch inside .lib-body.
  - Keeps "not implemented" badge text (v2-specific semantic — v2 has
    component status; prototype's "Already on dashboard" semantic is not
    applicable until OMN-44 wires active-widget tracking).

DashboardView wires onClose to handleDiscard so the close button
exits edit mode + drops drafts — nearest equivalent to the prototype's
separate libOpen state.

* audit: mark EmptyState + WidgetLibrary chunks done [OMN-48]

Moves 9 chunks from audited/ to done/ after commit 4145b96:
- empty-state-01 through empty-state-05
- widget-library-02, widget-library-03, widget-library-05, widget-library-06

* feat(ui): port dashboard header + meta row per prototype [OMN-48]

Addresses audit findings for dashboard chunks 02-06:

- Rename title branch (dashboard-03, 04): adds click-to-edit affordance
  on .dash-title with chevron-down glyph + "Click to rename" tooltip.
  Input renders on edit; blur commits via renameDashboard(id, name),
  Enter blurs, Escape cancels.

- Meta row (dashboard-05): .dash-meta now renders the full prototype
  structure — widget count + middle-dot separators + "Timezone: {tz}"
  + "Auto-refresh 30s" with .mono class on numeric spans. Timezone is
  computed from the browser (e.g. "UTC−07:00") rather than hardcoded.

- Header actions (dashboard-06): "Edit" button renamed to "Add Widget"
  (primary variant) with Plus icon to match prototype intent; Save and
  Discard buttons gain Check and X icons respectively. Refresh and
  Share buttons from the prototype are deliberately omitted — they were
  decorative stubs with no v2 backing behavior and would be dead UI.

Tests updated to match new button label (/add widget/i); functionality
preserved. 242/242 passing.

* audit: mark dashboard header + meta chunks done [OMN-48]

* fix(ui): gate widget Live badge on isLive prop [OMN-48]

Addresses widget-card-03 finding: the .widget-live badge was rendered
unconditionally for every widget, falsely advertising real-time behavior
for static summary widgets (BaselinesROI, ReadinessGate, QualityScore).

- Adds isLive?: boolean prop to ComponentWrapper, default false.
- EventStream opts in (matches prototype's real-time-stream semantic).
- Other widgets stay silent by omission; they can opt in individually.

Prototype gates the badge on widget type ∈ {requests,logs,regions};
v2's category-based equivalent is the `stream` category, of which
EventStream is the sole current member.

* audit: close drag-scope chunks (blocked on OMN-44) + live badge fix done [OMN-48]

- 6 chunks moved to blocked/ with blocked_reason set: widget-card-01/02/04
  (drag + grip + kebab) and dashboard-08/09/10 (widget drag wiring,
  drop-slot, DropIndicator). All blocked on OMN-44 (drag-and-drop scope)
  per existing deferrals documented in DashboardView.tsx and
  ComponentWrapper.tsx headers.

- 2 chunks moved to done/:
  - widget-card-03 (Live badge gated in commit 31d988e)
  - dashboard-11 (dead `.library-open .grid .widget` CSS removed from
    src/styles/dashboard.css; v2 uses editMode state not library-open
    class, so the selector could never fire)

* fix(ui): sidebar + topbar polish per audit [OMN-48]

- sidebar-03: workspace label gets the prototype's tiny uppercase tracking
  (fontSize:10, textTransform:uppercase, letterSpacing:0.08em).
- sidebar-04: new-dashboard `+` button gains native `title="New dashboard"`
  tooltip (was aria-label only).
- sidebar-09: empty-state lineHeight 1.6 → 1.5 to match prototype.
- topbar-01: breadcrumb gains leading `<Menu size={16}/>` icon before "Home".

* audit: close remaining 17 chunks — 4 fixed, 13 accepted deviations [OMN-48]

Fixed (commit d15bc4c): sidebar-03, sidebar-04, sidebar-09, topbar-01.

Accepted with Resolution notes:
- Clean-audit chunks: sidebar-01, sidebar-02, sidebar-05 (no changes needed).
- False positives: sidebar-07 (Tailwind ≡ inline style), sidebar-10 (v2.15
  version bump was user-requested).
- Documented v2 architectural deviations: sidebar-06 (RenameInput
  subcomponent), sidebar-08 (shadcn DropdownMenu), topbar-02 (lucide
  icons), topbar-03 (role=button a11y), widget-library-01 (conditional
  render vs class toggle), widget-library-04 (enum-ordered grouping).
- Partially OMN-44 deferred: dashboard-01 (libOpen paddingRight),
  dashboard-07 (DropIndicator fragment — empty branch itself resolved
  in 4145b96).

Final state: todo 0 · audited 0 · blocked 6 (all OMN-44) · done 33.

* fix(ui): widgets added via palette now persist + no longer hidden behind rail

Two bugs introduced by OMN-48's rebuilt WidgetLibrary integration:

1. Clicking widgets in the palette produced only the `.lib-card:active`
   scale animation with no visible result, because `.library` is
   position:fixed (360px wide, covering the right side of the
   viewport). The grid rendered the added widget under the palette.
   Fix: add `paddingRight: 360` to the main content area when
   editMode is active, reserving space for the fixed rail.

2. Closing the palette (X button) destroyed widgets added during the
   session. Root cause: `onClose={handleDiscard}` reverted activeDashboard
   to the edit-start snapshot (empty for newly-created dashboards).
   Fix: `onClose={handleSave}` — closing the library now commits
   changes and exits edit mode, matching the prototype's implicit-save
   semantic. Save button remains the explicit commit; Discard still
   rolls back.

Also: palette moved out of the inline flex wrapper since it manages
its own fixed positioning; the 280px column now belongs solely to the
ConfigComponentPanel.

* docs: add OMN-22 UX polish plan (10 tickets OMN-49..OMN-58) [OMN-22]

Plan file for the post-OMN-48 UX polish round. Triaged 28 user-
surfaced observations into 10 PR-sized tickets under epic OMN-22:

- OMN-49 Task 1: Sidebar footer + workspace cleanup
- OMN-50 Task 2: DashboardView interaction polish
- OMN-51 Task 4: Widget chrome — kebab menu + removal + flatten
- OMN-52 Task 3: Timezone + auto-refresh countdown (blocked by OMN-50)
- OMN-53 Task 5: Widget contrast + chart sizing
- OMN-54 Task 6: Config panel as floating modal (blocked by OMN-51)
- OMN-55 Task 7: Routing decisions table polish
- OMN-56 Task 8: Drag-and-drop (blocked by OMN-51)
- OMN-57 Task 9: Grid reflow (masonry)
- OMN-58 Task 10: Light/dark theme wiring

Already-shipped items (Header cleanup, Refresh button, Agent UI)
documented in the plan's "Already addressed" section.

* fix(ui): rename inputs are now readable and auto-select text [OMN-22]

Sidebar dashboard rename input got no styling — user-agent defaults
rendered black text on white, which is invisible against the dark
sidebar. Added transparent background with a subtle brand-colored
border, dark tinted bg, and explicit sidebar-ink color so the input
reads as a clear edit affordance.

Also fixed two related issues that surfaced while testing:

- Dashboard title rename input (top of DashboardView) inherited color
  via CSS but the inheritance was flaky in some themes. Made the color
  explicit via inline style.

- Text was not auto-selecting when the input mounted from the kebab
  menu — Radix DropdownMenu's onCloseAutoFocus was returning focus to
  the kebab trigger after close, stealing it from the newly-mounted
  input. Fix: added onCloseAutoFocus={e => e.preventDefault()} on the
  dashboard-kebab DropdownMenuContent, and switched RenameInput from
  autoFocus+useEffect to a callback ref + requestAnimationFrame so
  focus+select runs after Radix's focus sequence settles.

Checklist item #1 addressed.

* fix(ui): sidebar rename flow + remove workspace chip [OMN-22]

Two checklist items from the post-OMN-48 UX polish round:

#1 Rename input fix (follow-up): after the initial color/background
  fix, fast-clicking Rename from the kebab menu caused the input to
  flash and revert, and the text didn't highlight. Root cause:
  Radix DropdownMenu's focus-scope teardown dispatches a blur on the
  previously-focused element during its close sequence, which fired
  onBlur and committed the empty value.

  Corrected approach:
    - Defer setRenamingId via queueMicrotask so the input mounts
      after Radix finishes closing.
    - Move focus+select into useLayoutEffect with a stable ref, so
      it runs once after defaultValue is applied.
    - Replace onBlur commit with a document-level pointerdown listener
      that only commits on genuine click-outside.
    - Enter/Escape still commit/cancel via onKeyDown.

  Reference: https://medium.com/@ojasskapre/fixing-input-focus-issues-in-a-shadcn-radix-card-ui-dropdown-menu-in-next-js-7b5931e049c8

#2 Workspace chip removal: dropped the hardcoded "Platform Eng"
  Workspace dropdown from the sidebar. No behavior was wired, no
  workspaces concept exists yet, and it was visual noise. Dropped the
  CSS rules (.workspace, .workspace-chip, .ws-name), the now-unused
  ChevronDown import, and updated the corresponding Sidebar test.

Also adds docs/ux-polish-checklist.md tracking the 28-item list.

* refactor(ui): remove widget count from dash-meta row [OMN-22]

Checklist #20: '0 widgets' / 'N widgets' text under the dashboard
title carried no real value — the library footer already shows the
catalog count, and users can see their widget count by glancing at
the grid. Dropped the span plus its trailing separator.

* refactor(ui): remove sidebar-foot status + version strings [OMN-22]

Checklist #26 + #27: neither the "All systems normal" indicator nor
the hardcoded "v2.15" version string had anything real backing them.
No health monitoring is wired, and the version string was a literal
not sourced from package.json. Dropped both.

Changes:
- Sidebar.tsx: removed the .sidebar-foot <div> (dot, status text, version).
- sidebar.css: dropped .sidebar-foot and .sidebar-foot .dot rules.
- globals.css: moved @keyframes pulse here (was in sidebar.css); still
  used by .widget-live::before in dashboard.css.
- Sidebar.test.tsx: replaced "renders ...normal" assertion with a
  "does NOT render sidebar-foot" assertion.
- FrameLayout.test.tsx: rebased its chrome-smoke assertion onto the
  "an omninode product" tagline (unique, stable).

* refactor(ui): remove Header chrome without backing systems [OMN-22]

Checklist #23 + #24 + #28:

- #23 removed the user chip (avatar "JS", "Jamie Sun", "Platform Eng"):
  there is no user system yet, the chip was static placeholder data.
- #24 removed the Notifications bell and Help-circle icon buttons:
  no notifications system, no help system, clicking them did nothing.
- #28 removed the Menu icon prefixing the breadcrumbs: looked like a
  hamburger menu trigger but had no onClick handler, inviting clicks
  that did nothing.

Header now contains: breadcrumbs, Refresh button (retained), theme
toggle (retained). Test updated to assert the removed elements are
absent.

This reproduces the manual edits that were lost this morning when
shared-worktree branch swapping during the OmniNode workflow cleared
the working tree.

* docs(checklist): mark tasks 20, 23, 24, 26, 27, 28 done [OMN-22]

* feat(ui): library rail smooth transition + close on switch + Escape [OMN-22]

Checklist #7 + #15 + #21, all clustered around widget-library edit-mode
behavior:

#15 Smooth open/close: ComponentPalette now accepts an isOpen prop and
  toggles the .library.open class instead of being conditionally
  mounted. The CSS transition (transform 0.3s cubic-bezier) fires in
  both directions because the element stays in the DOM. aria-hidden
  mirrors isOpen so screen readers ignore the off-screen rail.
  DashboardView renders <ComponentPalette isOpen={editMode} /> once,
  unconditionally.

#7 Close on dashboard switch: a useEffect watches activeDashboard?.id.
  When it changes (using a prevRef to skip the initial mount), it
  calls setEditMode(false), which slides the library out.

#21 Escape closes library: a useEffect binds a keydown listener when
  editMode is true. Pressing Escape calls setEditMode(false) and
  preventsDefault on the event. Listener is torn down when editMode
  goes false.

* fix(ui): explicit fixed height on CostTrend + QualityScore charts [OMN-22]

Checklist #5 + #6. Both charts were sized against parent containers
with no height constraint, so the percentage/flex values resolved to
nothing:

- CostTrendPanel: height:100% fell back to minHeight:200px — half
  what the design called for.
- QualityScorePanel: flex:1 inside a height:100% flex column inside
  an unconstrained .widget-body collapsed the chart to ~7px.

Both now set an explicit height:320px on the ReactECharts element.
QualityScorePanel no longer needs the outer flex column since its
children stack in normal flow. Simplified that wrapper to a plain div.

* fix(theme): expose mutedForeground; widgets no longer render text in bg color [OMN-22]

Checklist #4. Root cause was not per-widget — it was in useThemeColors.
The hook exposed `muted` as `readToken('muted')`, but the `--muted`
shadcn token maps to `var(--panel-2)` — a background tint, not a text
color. Every widget labelled `color: colors.muted` was rendering text
in the same color as its panel surface.

Changes:

- useThemeColors now exposes a new `mutedForeground` field that reads
  `--muted-foreground` (→ `--ink-2`). JSDoc on `muted` warns not to
  use it as a text color.
- DelegationMetrics: promoted "Total Delegations", "Quality Gate Pass
  Rate", "Cost Savings" labels from muted (bg) to foreground (full
  ink) — these sit beside primary metric values and are primary.
- BaselinesROICard: promoted all delta labels, "Recommendations"
  heading, and kv sub-labels to foreground.
- ReadinessGate: "Overall:" promoted to foreground; dimension detail
  and "Checked {time}" bumped to mutedForeground (visible secondary).
- QualityScorePanel: "Mean Score" summary and chart axis labels moved
  to mutedForeground.
- CostTrendPanel: chart axis labels moved to mutedForeground.
- EventStream: timestamp + source columns moved to mutedForeground.

After this change, all widget text renders in the correct ink tone
for its emphasis level in both light and dark themes.

* fix(layout): .main constrained to 100vh so content below fold is scrollable [OMN-22]

Checklist #3. Root cause was in src/styles/topbar.css: .main had
display:flex + flex-direction:column + min-width:0 but no height
constraint. Its parent .app sets height:100vh with display:grid but
default auto rows, so .main grew to content size. When content
(Header + DashboardView) exceeded 100vh, body { overflow:hidden }
in globals.css clipped the excess unreachably.

Fix: add height:100% + min-height:0 + overflow:hidden to .main so it
is pinned to its 100vh grid cell, its flex children can shrink, and
internal scroll happens inside .dash-body (which already has
overflow-y:auto).

Also tracks new task #29 (sidebar kebab menu hover + pointer) in the
checklist for later.

* fix(ui): DropdownMenuItem hover now visible; cursor shows pointer [OMN-22]

Checklist #29. Two issues in the shared shadcn DropdownMenuItem:

- cursor was cursor-default. Changed to cursor-pointer.
- focus:bg-accent did fire on hover (Radix sets focus via pointermove),
  but --accent resolves to oklch(92% 0.08 120) in light mode — a nearly
  white pale green that had no visible contrast against the panel's
  100% white background. Switched highlight to --panel-2, which is the
  prototype's own convention for menu hover (see dashboard.css
  .menu-item:hover). --panel-2 has meaningful contrast in both themes.

Covered three selectors — hover:, focus:, and data-[highlighted]: — to
catch every path Radix uses for highlighting.

Applied at the component level so every DropdownMenu in the app
benefits, not just the sidebar.

* fix(theme): ThemeProvider now drives both data-theme + .theme-* class [OMN-22]

Checklist #22. Root cause: ThemeProvider only toggled the legacy
vanilla-extract `.theme-dark`/`.theme-light` class on <body>. The
prototype-ported chrome (sidebar, topbar, widgets, dashboard) activates
its dark theme via `[data-theme="dark"]` on <html> in globals.css. That
attribute was never being set, so the toggle was a no-op for everything
newer than the vanilla-extract layer.

Fix: ThemeProvider now drives both systems from the same theme state:

- Sets/removes `data-theme="dark"` on document.documentElement (drives
  the globals.css OKLCH tokens used by all prototype-ported chrome).
- Keeps the `.theme-dark`/`.theme-light` body class toggle (drives the
  legacy vanilla-extract consumers like AgentChatPanel).

Also added a synchronous `<script>` in index.html that sets
`data-theme="dark"` before React hydrates. The default theme is dark, so
without this the user sees a brief light-mode flash on every page load
before ThemeProvider's useEffect runs.

* refactor(ui): grid now uses CSS columns for masonry-style flow [OMN-22]

Checklist #9. Replaced:
  .grid { display: grid; grid-template-columns: 1fr 1fr; }
with:
  .grid { columns: 2; column-gap: var(--row-gap); }
and added break-inside:avoid + margin-bottom:var(--row-gap) to .widget.

CSS columns flows children top-to-bottom within each column, so a
short widget no longer leaves dead vertical space under a tall
row-mate. Previously a 2-col grid with [tall, short] in row 1 pushed
row 2 past the tall widget's bottom edge, leaving a visible gap under
the short one.

* feat(ui): widget kebab menu + flatten sub-card chrome + rename grid class [OMN-22]

Checklist #8, #13, #14, and a follow-up rename:

#14 Kebab menu on every widget: new WidgetChromeContext carries
  {onConfigure, onDelete} from ComponentCell down to ComponentWrapper
  without threading them through widget props. ComponentWrapper renders
  a MoreVertical kebab button in the widget-head when at least one
  handler is provided. Menu items: Configure, Delete (destructive).
  Available in both view and edit modes.

#13 Easier widget removal: Delete lives in the kebab menu, reachable
  with one click from any mode (previously required entering edit
  mode and finding a less-obvious affordance).

#8 Flatten sub-card chrome: DashboardView no longer wraps each grid
  item in its own `<div className="widget">...widget-head...widget-body`
  outer shell. The widget is ComponentCell directly, which delegates
  chrome to the widget's own ComponentWrapper. One card level, not two.
  Also removes the click-widget-to-configure handler — Configure now
  only fires from the kebab menu.

  Prior ComponentCell also rendered an intervening
  `<div data-testid="grid-cell" style={{ overflow: 'hidden' }}>` wrapper.
  That overflow:hidden created a new block formatting context between
  .grid and .widget and prevented CSS columns flow. Normal path now
  skips this wrapper; `grid-cell` testid retained only on the fallback
  "component not available" path.

Follow-up rename: `.grid` → `.dash-grid`. Tailwind's utility layer
generates `.grid { display: grid; }` which loaded after dashboard.css
and overrode our `columns: 2`. Once `display: grid` is set the browser
ignores `columns: N` entirely, which collapsed widgets into a single
column. Renaming makes our custom rule win unambiguously.

* feat(ui): port prototype menu + modal patterns; drop shadcn dropdown/dialog [OMN-22]

Stay-close-to-prototype pass. The prototype's menus and config modal
use hand-authored markup with custom CSS, not a third-party primitive.
Mirroring that pattern lets future design iterations from Claude Design
drop in with minimal translation.

New shared infrastructure:

- src/components/ui/positioned-menu.tsx — small PositionedMenu +
  MenuItem + MenuSeparator components plus a usePositionedMenu() hook
  that captures the trigger's bounding rect on open and manages
  dismiss (outside-click + Escape). Portaled to document.body so it
  isn't clipped by any overflow:hidden ancestor. Uses the prototype's
  .menu / .menu-item / .menu-item.danger / .menu-sep CSS (already
  ported verbatim into dashboard.css).

- src/styles/modals.css additions — RJSF form class-name mappings
  (.form-group, .rjsf-field, .control-label, .form-control, .checkbox)
  styled to match the prototype's .field/.check look. Reset <fieldset>
  user-agent defaults so its content no longer overflows modal-body's
  max-height scroll container.

Config modal:

- ComponentConfigPanel rewritten to use the prototype's
  .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot
  markup verbatim. Cancel + Save changes footer buttons match the
  prototype's pattern. Save commits that placement's draft via a new
  commitDraft store action; Cancel discards. Escape + backdrop click
  also dismiss.

- src/store/configSlice.ts — commitDraft(placementId) writes a
  placement's draft config back to its layout item and clears the
  draft.

Widget kebab menu:

- ComponentWrapper renders a MoreVertical kebab button that opens a
  PositionedMenu with three items matching the prototype: Configure
  Widget, Duplicate, Remove Widget (danger). onDuplicate is a new
  handler supplied via WidgetChromeContext.

- src/store/dashboardSlice.ts — duplicateLayoutItem(itemId) clones a
  placement with a fresh id and deep-copied config, appended to the
  layout.

Sidebar dashboard kebab:

- Replaced Radix DropdownMenu with the shared PositionedMenu pattern.
  Items: Rename, Duplicate, Share Link (no-op placeholder), Delete.

- src/store/dashboardSlice.ts — duplicateDashboard(id) clones a
  dashboard with a fresh id, "(copy)" name suffix, deep-copied
  layout.

Fieldset / #root selector collision:

- The React mount <div id="root"> and RJSF's <fieldset id="root">
  both matched a bare `#root` selector in globals.css, which gave the
  fieldset height:100vh and overflowed the modal. Changed the
  selector to `div#root` so it only targets the React mount.

Cleanup:

- Removed src/components/ui/dropdown-menu.tsx (shadcn Radix shim, no
  remaining consumers) and uninstalled @radix-ui/react-dropdown-menu.
- Removed the briefly-lived src/components/ui/dialog.tsx + uninstalled
  @radix-ui/react-dialog (the modal moved to prototype markup before
  anything else picked it up).
- Deleted src/config/ComponentConfigPanel.css.ts (vanilla-extract
  styling no longer used after the prototype-markup port).
- grid-item testid moved from ComponentWrapper to a
  display:contents wrapper in ComponentCell so tests find it even
  when the widget's LazyComponent is still Suspending. Redundant
  grid-item testid dropped from the legacy DashboardGrid shim.

Reference breadcrumb:

- reference/claude-design/ now holds a local copy of the Claude Design
  prototype (tarball extracted from the share URL) so the files
  survive between sessions. reference/claude-design/ is gitignored
  (large), but reference/README.md is tracked as a breadcrumb
  pointing at the source URL and describing what's in each subdir.

Checklist #10 (config panel modal) + #14 follow-up. 243/243 tests pass.

* fix(widgets): promote primary data labels to --ink + bump tiny label fonts [OMN-22]

Closes ux-polish-checklist #4. Audit found DelegationMetrics and
BaselinesROICard labels were already rendered in --ink (darkest), not
--ink-3 as the dictated note assumed — perceived faintness came from
the 0.6875rem (~11px) font size. Bumped those to 0.75rem (12px).

QualityScorePanel's Mean Score summary row was genuinely secondary
(rendered in --muted-foreground). That row is the widget's only
non-chart content, so promoted to --foreground and bumped 0.75rem →
0.8125rem.

All other --ink-3 / mutedForeground usage is chart-chrome (axes,
loading/empty states, timestamps, search placeholders) and stays
appropriate for those roles.

* feat(theme): add --chart-* tokens + --status-* aliases on :root [OMN-22]

Closes ux-polish-checklist #30. useThemeColors() reads CSS vars from
document.documentElement, but --chart-1..7 only lived on the
.theme-* classes that vanilla-extract applied to <body>. Every chart
color was therefore resolving to the '#888888' fallback (all series
rendered as gray).

Moved --chart-1..7 onto :root with seven distinct hues at matched
lightness/chroma, clear of the brand green (--accent-h=120), plus a
brighter variant for [data-theme="dark"] so series identity is
consistent across theme toggle.

Also added --status-healthy/warning/error aliases pointing at the
prototype's --status-ok/warn/bad, so useThemeColors's status map
finds values too.

* refactor(readiness): convert ReadinessGate from cards to table layout [OMN-22]

Off-checklist request. The card grid (7 columns of status boxes) read
well at desktop widths but wasted vertical space and didn't scale to
more than 7 dimensions. Tabular layout with Status | Dimension |
Detail columns reads the same information more compactly and is
directly extensible to additional dimensions without layout changes.

Status is rendered as a color-outlined pill that picks up
--status-ok/warn/bad from the theme. Header uses the shared prototype
colors (--ink-2 / --panel-2 / --line). Dropped useThemeColors() in
favor of direct CSS vars to match the pattern established by
RoutingDecisionTable.

* feat(grid): drag-and-drop reorder + palette-to-grid insert [OMN-22]

Closes ux-polish-checklist #12.

Two drag sources: palette cards (copy into grid) and existing widgets
(reorder). Drop onto any widget inserts before its position; a
trailing "drop to append" zone appears at the grid tail while
dragging. The empty-state placeholder is replaced by a "drop to add"
slot during drags so a widget can be placed on an empty dashboard.

Store gets two new actions: insertComponentAt(name, version, size,
atIndex) and moveLayoutItem(itemId, toIndex). moveLayoutItem adjusts
the target index when fromIndex < toIndex so callers can pass the
pre-removal target index naturally. Both covered by unit tests.

Chrome context extended with draggable/isDragging/isDropTarget flags
plus HTML5 drag handlers. ComponentWrapper applies them to the
.widget root and adds a GripVertical icon in the head when
draggable. ComponentCell forwards the props through. ComponentPalette
cards become draggable drag sources that also fall back to
click-to-add.

Visual feedback: dragging source drops to 35% opacity; drop target
gets a 3px brand-colored top-edge bar via ::before (new CSS rule).
Append zone uses existing .drop-slot styling with an explicit
column-span so it reaches across the masonry columns.

data-drag-exclude convention added so interactive widget content can
opt out — ComponentWrapper checks the mousedown target's ancestors
for the attribute and cancels dragstart when found. Applied to
RoutingDecisionTable for future-proofing.

* docs(claude): rewrite widget-add recipe to match actual two-track mechanism [OMN-22]

The existing "Add a new widget" recipe described a per-widget
manifest.ts file colocated next to each widget — that file never
existed. In reality widgets live in two tracks:

  A. Local MVP widgets (all seven current widgets): component lives
     under src/components/dashboard/<slug>/, lazy import registered
     in src/components/dashboard/index.ts, manifest entry added to
     MVP_COMPONENTS in scripts/generate-registry.ts.
  B. External @omninode/* package widgets: published as npm packages
     with a dashboardComponents field in package.json pointing at a
     JSON manifest; generate-registry.ts scans node_modules for
     matching packages.

Rewrote the recipe into two clearly-labeled paths and updated "Where
to look" to point at the correct files. No behavior change.

* feat(cost-trend-3d): experimental Tron-style 3D cost visualization widget [OMN-22]

An interactive 3D cost widget that sits alongside the original 2D
CostTrend — same llm_cost topic, different implementation. Built for
exploration; not a replacement for the ECharts-based CostTrend.

Raw three.js (not @react-three/fiber) — started on R3F but hit WebGL
context loss on first render due to three@0.184 / r3f 9.6 skew and
swapped to plain three.js in a useEffect, which has been stable. R3F
was uninstalled after the switch.

Visualization: each bar = one (time-bucket, model) cell. X=time,
Z=model, Y=cost. Translucent cores + bright edge lines on a
near-black background (light theme uses greys + pastels). Grid lines
aligned to cell boundaries. Axis labels projected world→screen as
HTML overlays so they track the camera. Dates dedupe at day
boundaries so hourly buckets don't print MM/DD 24 times in a row.

Camera: faces the strip head-on (azimuth pinned). Horizontal
navigation via a scrollbar below the canvas — no free orbit. Tilt
via mouse-drag Y within a clamped range. Widescreen 200px canvas
height with the scene tuned to fill vertically via FOV=30,
distance=11, target-Y=0.6, Y-offset=-1px (values dialed in via a
temporary slider panel then baked in).

Interaction:
  - Hover: bar under pointer becomes fully opaque (clickability hint)
  - Plain click: cinematic focus animation — camera swoops in on the
    bar, non-focused bars + grid fade to near-transparent, clicked
    bar's opacity ramps to near-solid, stats panel slides in from
    the right with cost/tokens/requests/derived ratios. Escape or
    clicking elsewhere reverses. Mid-flight retargeting retreats to
    default first then re-launches (simpler than pose blending and
    reads as intentional).
  - Shift-click: existing isolate behavior (desaturate other models
    to neutral grey).
  - Model filter legend below the scrollbar — click to toggle
    visibility; filtered-out bars drop to near-zero opacity but stay
    spatially present.

Theme-aware. Two ThemeConfig objects (DARK_THEME = neon Tron,
LIGHT_THEME = greys + pastels on white) selected by a useThemeName
hook that watches data-theme on documentElement.

Fixture generator: _llm_cost_points now emits a full (time × model)
cross product (48 hourly buckets × 4 models = 192 records) so the
3D grid is dense and rectangular. Per-model cost multipliers
(opus > sonnet > qwen > deepseek) make the series visually distinct
rather than uniformly noisy.

Registry + imports + integration tests updated for the 8th widget.
Added three + @types/three as deps.

* docs(checklist): mark polish items done [OMN-22]

Marked #3, #4, #5, #6, #7, #8, #9, #10, #11, #13, #14, #15, #20,
#21, #22, #29, #30 as done, plus the off-checklist ReadinessGate
tabular rework. Updated progress totals (17 done / 10 pending / 2
n/a / 1 keep-as-is). Remaining pending: #7/#8/#10/#13/#14/#15/#21
turned out to already be implemented (confirmed later and marked);
next pass will tackle #12, #16, #19.

* feat(filters): global time range selector + stacked-area Cost Trend [OMN-22]

Adds a dashboard-level time range selector (header, left of the mode
buttons) that all time-series widgets consume for a consistent view
of the same window. Defaults to "Last 24h" on first mount so fresh
dashboards don't show 48h of raw fixture data out of the gate.

Changes by layer:

- ComponentManifest.capabilities gains optional `supports_time_range`.
  True = widget participates in the global range. False = snapshot or
  pre-aggregated summary; the selector is ignored. Existing manifest
  fixtures without the field continue to pass validation.
- TimeRange gains an optional `label` (e.g. "Last 24h") so the
  selector can render "Last 24h" instead of formatting absolute
  timestamps. Absent for custom ranges.
- useTimeRange.ts centralizes resolveTimeRange() + applyTimeRange()
  helpers and the TIME_RANGE_PRESETS constant. Widgets call
  applyTimeRange(data, d => d.<timestamp_field>, resolved) and get a
  filtered slice; no changes required to queryKey (caching stays
  keyed on topic, filtering is post-fetch).
- DateRangeSelector uses the existing PositionedMenu primitive —
  presets (1h/6h/24h/7d/30d) + "All time". Resolves presets to
  absolute ISO timestamps at click-time; window does not auto-advance
  on refetch.

Participating widgets (supports_time_range: true):
  cost-trend-panel, cost-trend-3d, routing-decision-table
Non-participating (false):
  delegation-metrics (summary), baselines-roi-card (snapshot),
  quality-score-panel (summary), readiness-gate (snapshot),
  event-stream (live tail)

CostTrendPanel switches from per-model lines to a stacked area chart
— each model is an additive layer so the top reads as total cost
over the window and each band reads as that model's contribution.
Smoothed curves, no symbols, 55% area opacity, 1px line. Also:
granularity default flipped from "day" to "hour" to match the
fixture's hourly buckets, and x-axis ticks include the hour in
hourly mode so adjacent dates don't collapse to the same label.

Both CostTrend widgets now render a "No cost data in the selected
time range" overlay when the underlying topic has data but the
current window clips it all away — distinct from the all-empty
state.

RoutingDecisionTable's footer "filtered from N" now shows the
in-range count instead of the raw total so the search filter's
denominator reflects the time-windowed set the user is searching
within.

* fix(cost-trend): legend below chart + drop emphasis.focus to fix hover flicker [OMN-22]

Two related changes to CostTrendPanel, landing together because they
both restructure the ECharts option:

1. Hover flicker (task #9). With tooltip.trigger='axis' showing all
   series' values at the hovered x-position AND
   emphasis.focus='series' on each series dimming non-hovered bands,
   the two behaviors fought each other — tooltip appeared, then the
   series emphasis kicked in and dimmed everything else, which read
   as "the line isolated on hover." Removed emphasis.focus; tooltip
   now stays stable across mouse movement with no visual isolation.

2. Legend placement (task #8). Replaced the built-in ECharts top
   legend with a custom HTML legend below the chart, styled like the
   3D Cost Trend's model-chip row (rounded pills with colored dot +
   model name, wraps to new rows if needed). The two Cost Trend
   widgets now read as a matched pair.

Behavioral differences vs 3D:
  - Click toggles visibility only; no isolate-on-click (3D has that;
    2D doesn't need it).
  - No "Clear focus" button.
  - No isolate highlight on the chip.

When a model is toggled off, its series is filtered out of the chart
entirely (not just hidden), so the remaining stack re-fits
vertically. The model list itself is derived from the full (un-
range-filtered) dataset so the legend stays stable as the user
scrubs the time range.

grid.top reduced 34 → 12 now that the built-in legend is gone — the
chart area reclaims the vertical space.

* feat(filters): custom start/end date range picker [OMN-22]

Extends the DateRangeSelector's popover with a "Custom range…" option
that opens an inline calendar + time-input UI. Keeps all existing
preset behavior intact — the custom view is a second view inside the
same popover, reached via a menu item that intentionally doesn't
close the popover on select.

Calendar: react-day-picker v9 in range mode (single month, 30px
cells). Time: two <input type="time"> controls for start/end HH:MM.
Apply builds a TimeRange with no `label` so the selector shows
"MM/DD – MM/DD" instead of a preset name; Cancel returns to the
preset list.

Theming: react-day-picker's --rdp-* CSS variables are bound to the
dashboard's existing tokens (--brand, --brand-soft, --brand-ink, --ink,
--panel-2, --line) on the calendar's wrapper div. Calendar picks up
the active light/dark theme with no extra JS. Compact sizing
(30x30 day cells) so the popover fits cleanly at 320px wide.

Safety: start/end swap if the user ends up with start > end after
combining calendar day + time input; PositionedMenu's outside-click
dismissal is blocked on pointerdown inside the calendar so clicking a
day doesn't close the whole popover.

Adds react-day-picker ^9.14.0. Zero transitive runtime deps in v9.

* fix(range-picker): theming cascade + sensible default times [OMN-22]

Two fixes to the custom-range date picker:

1. Calendar colors. The --rdp-* vars were being set inline on an outer
   wrapper, but react-day-picker's `.rdp-root` declares its own
   defaults at the same specificity — and that stylesheet loads into
   the bundle after the component mounts, so the library's "blue"
   accent was winning. Moved the overrides into CustomRangePicker.css
   which is imported AFTER `react-day-picker/style.css`, targeting
   `.dash-calendar-wrapper .rdp-root` (specificity 0,2,0 vs library's
   0,1,0). Also swapped the range_start/end text color from
   --brand-ink (dark green) to #ffffff — dark-green-on-brand-green
   didn't contrast enough — and range_middle text from --brand-ink to
   --ink so days inside the range read in the neutral dark-ink color
   rather than competing with the brand.

2. Default times. The picker was seeding its start + end time inputs
   from the currently-active range's timestamps, which meant if the
   active preset was "Last 24h" (clicked at 14:37) both inputs
   defaulted to 14:37. Now they default to 00:00 and 23:59 — the
   common case is "whole days between these two dates" and users who
   want a precise minute window can still set it explicitly.

* feat(cost-trend): replace ECharts with three.js 2D stacked-area chart [OMN-22]

Rewrites the 2D Cost Trend chart body in three.js using an orthographic
camera whose frustum matches canvas pixel coordinates (top-left origin,
y-down). ECharts stays as a dependency for other widgets (Quality,
Delegation) — this swap is scoped to CostTrendPanel.

Why: ECharts' default hover model interacted awkwardly with the
stacked-area layout, producing flicker and unintended band-isolation
effects on pointer move. Owning the render loop gives us full control
over hover and tooltip behavior, and matches the technology choice of
the 3D Cost Trend widget so both widgets share a mental model.

Architecture:
  - New StackedAreaChart component owns the three.js renderer/scene/
    camera for the chart body. Re-renders on size / data / theme
    changes only; no rAF loop (preserveDrawingBuffer:true so the canvas
    holds its last frame).
  - Each visible model's band is a BufferGeometry of 2N vertices and
    2(N-1) triangles (top+bottom polyline swept into a filled strip),
    with a sibling Line for the top-edge outline. Horizontal grid
    lines at "nice" y-tick values.
  - CostTrendPanel outer stays unchanged in shape: same data fetch,
    same time-range filter, same in-range-empty overlay, same custom
    legend from task #8 below the chart.

Interaction:
  - Hover: pointermove on the wrapper maps pointer X to the nearest
    bucket index. A vertical guide line (DOM div, not geometry)
    appears at that X; an HTML tooltip follows the cursor showing
    bucket timestamp, per-model cost breakdown in stack order (top to
    bottom), and the cumulative total.
  - No hover emphasis on the bands themselves — fixes the flicker the
    user was seeing with the ECharts implementation.

Color parsing fix: globals.css declares --chart-1..7 as oklch(...)
values, which THREE.Color.setStyle() doesn't understand (it supports
rgb/hsl/hex/named only). Added cssColorToHex() helper that pipes the
CSS value through a 1×1 canvas — Canvas 2D's fillStyle accepts the
full CSS color syntax and gives back a normalized sRGB pixel we can
convert to the numeric hex THREE wants.

Test updated to stub StackedAreaChart (jsdom has no WebGL context);
verifies the outer component plumbs correct bucket + model counts into
the chart child.

* feat(cost-trend): chartType config — stacked area or stacked bar [OMN-22]

Adds a `chartType` option to the 2D Cost Trend widget's manifest
configSchema (enum: 'area' | 'bar', default 'area'). Users pick via
the widget's Configure dialog (kebab menu → Configure Widget), the
value persists with the dashboard layout.

Rendering: same StackedSlice data structure in both modes; only the
geometry builder differs.
  - 'area' (existing): filled bands (triangle strip per model) + top-
    edge polyline outlines. Smooth trend read across time.
  - 'bar' (new): one rectangle per (bucket × visible-model) cell
    stacked on the previous model's cumulative total. Reads as
    discrete per-bucket observations. Bar width = plotWidth /
    bucketCount × 0.72 (with a floor at 2px so dense datasets still
    render). Zero-height cells skip their triangle indices so we
    don't emit degenerate geometry.

File rename: StackedAreaChart.tsx → StackedChart.tsx (via git mv) now
that the component handles both modes. Exported types renamed to
match; the test file's vi.mock path and testid updated accordingly.

Grid + axis labels + hover guide + tooltip are unchanged across modes
— all the chrome lives on the common StackedSlice, not the render
shape.

* fix(cost-trend): bar-mode cell-center x-mapping + suppress zero y-label [OMN-22]

Two visual fixes on the 2D stacked chart, both noticed at "Last 24h"
where the post-filter dataset has only a handful of buckets:

1. Bars overflowed the plot on both sides. The x-scale was
   edge-to-edge (plotLeft..plotRight) regardless of mode. With N=4
   and plotWidth=400 that gave 100px between bar centers and a bar
   width of ~72px, so the leftmost bar's left edge landed 36px inside
   the y-axis label column and the rightmost bar's right edge spilled
   36px past the canvas.

   Moved to a per-mode x-scale:
     - area: edge-to-edge (unchanged). A trend line should span the
       full plot area.
     - bar: cell-center. Each of N cells gets width plotWidth/N and
       its bar is centered. Every bar stays fully inside the plot
       area at any bucket count.

2. The $0 y-axis tick label sat directly above the leftmost x-axis
   date label at narrow widths and read as a single blob. The $0 tick
   is redundant with the x-axis baseline anyway, so the y-label for
   value 0 is now suppressed. The horizontal gridline at the baseline
   still renders; only the text label is dropped.

No test changes — the fixes are rendering-layer, and the chart
component is already stubbed in tests (jsdom has no WebGL context).

* fix(routing-table): tighten narrow columns + shorter timestamp [OMN-22]

Without an explicit <colgroup>, tableLayout:fixed distributed equal
1/6 widths to all six columns. "LLM Conf." held 3 chars ("83%") and
"Cost" held 7 ("$0.0231"), so those got far more space than they
needed while Timestamp ("4/20/2026, 10:40:00 AM" at 22 chars) wrapped
at typical widget widths.

Two-part fix:

1. Added <colgroup> with per-column widths that match content size:
   Timestamp 24%, LLM Agent 21%, Fuzzy Agent 21%, Agreement 13%,
   LLM Conf. 9%, Cost 12%. Totals to 100; tableLayout:fixed respects
   those exact proportions.

2. Shortened the displayed timestamp to "MM/DD HH:MM AM/PM" (dropping
   the year and seconds). Added whiteSpace:nowrap + text-overflow
   ellipsis on the cell and a title attribute carrying the full
   toLocaleString() form so the richer timestamp is still available
   on hover.

* feat(event-stream): search, sticky header, paused indicator, source pills [OMN-22]

First round of professionalism polish. Scope chosen from the suggestion
list: the clear bugs, the straightforward visual upgrades, and the two
highest-leverage interactions. Connection-status dot, click-to-expand,
correlation-id grouping, and live/paused toggle are all deferred.

Bug fixes
  - Dropped `hsl(${colors.border})` on the row border — useThemeColors
    returns a CSS-resolvable string already, so wrapping it in hsl()
    produced invalid CSS and the dividers were not rendering. Now uses
    `var(--line-2)` directly.
  - Gave the scroll container a definite height (SCROLL_HEIGHT = 320).
    The previous `height: 100%` collapsed or grew unpredictably since
    `.widget-body` has no height constraint.

Visual
  - Sticky column header (TIME · EVENT · SOURCE) at the top of the
    scroll region so the semantics of each column are obvious without
    inferring from content.
  - Row padding bumped from 4px to 6px × 10px for breathing room.
  - Timestamp column tightened 140 → 96px; new formatter shows
    HH:MM:SS for events in the last 24h and MM/DD HH:MM for older.
    A title attribute on each row exposes the full event_type,
    source, correlation_id, and ISO timestamp for debugging.
  - Timestamp and event_type both use the mono font stack; source
    moved into a small outlined pill whose hue is derived from a hash
    of the source string — stable per source so the eye can pick one
    out while scanning a fast stream.

Interaction
  - Search input over event_type / source / correlation_id. Mirrors
    the RoutingDecisionTable pattern.
  - Auto-scroll pause is now surfaced: when the user scrolls up, the
    component tracks the arrival count since that moment and shows a
    floating "↑ N new events" button pinned at the top of the scroll
    region. Click to jump back to top and resume auto-scroll. Also
    added an "auto-scroll paused" hint in the footer.
  - Footer shows `filtered / buffered` with a "buffer full" note
    when events have saturated maxEvents.

Internal: `userScrolledUp` promoted from a ref to state so the UI can
react to the paused condition (needed for the new-events button).

* feat(quality): three.js rewrite — pass-rate headline + tier-coloured bars [OMN-22]

Replaces the ECharts-backed Quality Score widget. Two changes in one
pass because the full rewrite touches both the render layer and the
data model of the stat pane.

Why the rewrite:
  - The old widget coloured each of the 5 score buckets with a
    different entry from the chart palette. Buckets are slices of the
    same metric (score), not separate series; giving them unrelated
    hues made the histogram read as five unrelated bars rather than
    one distribution.
  - The mean marker used ECharts' `type: 'average'` which averages
    the Y values (bucket counts), not the weighted score mean that
    the fixture computes. The line was in one place and the label
    claimed a different number. Deleted.
  - "Mean Score: 0.67" was buried in small text above the chart.
    On a glance dashboard the most actionable number ("am I passing?")
    wasn't the biggest thing on screen.

New design:
  - Split layout. Left pane: big pass-rate % in a traffic-light
    colour (green / amber / red based on the value), with mean +
    measurement-count as supporting captions. Right pane: the 3D
    bar chart.
  - 3D chart in three.js. 5 bars along a strip, coloured via a
    red → amber → green hue sweep (hsl 0°..120°) so the shape of
    the distribution also encodes the quality story. MeshStandard
    material + one directional light gives the bars readable
    dimensionality without needing shadows.
  - Translucent vertical "wall" behind the bars at the x-position
    corresponding to the pass threshold, with a bright-outlined edge.
    Spatially communicates "right of the wall = passing".
  - Floating cone marker above the bars pointing down at the mean
    score's x-position, with a dashed drop-line to the ground. Uses
    the brand token.
  - Hover a bar → HTML tooltip with bucket label, count, and % of
    total. Fixed camera, no orbit.
  - Title shortened "Quality Score Distribution" → "Quality Scores".

Config:
  - Added `passThreshold` (default 0.8, range [0, 1]) to the widget
    manifest. Drives both the pass-rate calculation and the wall's
    x-position so a dashboard can calibrate per use case.

Manifest:
  - displayName unchanged ("Quality Scores"), version bumped 1.0.0 →
    1.1.0 to reflect the behaviour + config change.
  - minSize bumped w 3 → 4 so the split layout has room. defaultSize
    and maxSize unchanged.

Shared helper: cssColorToHex extracted from cost-trend/StackedChart
to src/theme/cssColorToHex.ts now that two three.js widgets need it.
Exported through src/theme/index.ts alongside useThemeColors. No
behaviour change to the cost-trend chart; StackedChart just imports
from the new location.

Test: mocks three.js WebGLRenderer with a no-op class (jsdom has no
WebGL context) and shims ResizeObserver. New assertions verify the
pass-rate arithmetic for two thresholds (default 0.8 and a custom
0.6) so the stat pane's headline is locked in.

* fix(quality): Tron-style bars + clearer mean marker [OMN-22]

Bar rendering swapped from MeshStandardMaterial (shaded, semi-opaque)
to the cost-trend-3d Tron recipe: translucent coloured core at 0.55
opacity + bright edge lines on every cube edge at 0.95 opacity. No
lighting (materials are unlit), so dropped the ambient + directional
lights from scene setup. Saturation bumped 0.6 → 0.85 and lightness
0.55 → 0.62 on the red→green tier gradient for the neon feel. The
two three.js widgets now read as siblings visually.

Ground grid switched from a subtle --line colour to cyan (#00e5ff) at
0.35 opacity for the same reason. Threshold plane outline opacity
bumped 0.5 → 0.75 and the wall itself dropped 0.12 → 0.08 so the
outline carries more of the read.

Mean marker reworked. The dashed drop line from the floating cone
down to the ground was getting hidden behind the tallest bar on
common data shapes — one of those "what IS that thing?" visuals.
Dropped it entirely. The cone is slightly larger (radius 0.16 → 0.2,
height 0.3 → 0.42) and now explicitly labelled: we project the
cone's world position to canvas pixels at scene-build time and
anchor an HTML "MEAN 0.XX" chip just above it. The relationship
reads at a glance without needing the line.

* fix(quality): theme-aware contrast for light mode [OMN-22]

The Tron colour recipe I baked in last commit worked against the dark
background but failed in light mode on several specific pairs:

  - Cyan grid (0x00e5ff, opacity 0.35) on the near-white panel — the
    lines were effectively invisible, leaving the ground plane with
    no tick markers.
  - Threshold plane and outline, both white, on a near-white canvas —
    "pass wall" disappeared entirely. Users had no visual cue that
    the scene was partitioned into pass/fail regions.
  - Bar cores at 0.55 opacity on a light background — values washed
    out instead of reading as solid bars.
  - Bar edges at full neon saturation felt garish against the light
    theme's pastel aesthetic.

Introduced a per-theme EffectiveColors block (DARK_EFFECTIVE /
LIGHT_EFFECTIVE) covering grid colour + opacity, threshold plane +
outline colour + opacity, bar core + edge opacity, and bar HSL
saturation + lightness. Light theme swaps:
  grid:      cyan  → slate grey (0x6b7280) at higher opacity
  threshold: white → dark slate (0x334155) on both plane + outline
  bar core:  0.55  → 0.72 opacity so fills read on white
  bar edges: 0.95  → 0.80 opacity (softer against the lighter bg)
  bar hue:   0.85 sat / 0.62 light → 0.60 / 0.50 (pastel, matches
             cost-trend-3d's light theme)
  ground:    one step off canvas-bg in both themes via --panel vs
             --panel-2 so the floor has a visible boundary

Mechanics: extracted the existing useThemeName hook (previously
duplicated inside cost-trend-3d) to src/theme/useThemeName.ts and
exported from the theme barrel. cost-trend-3d updated to import from
the shared location; QualityScorePanel now subscribes and picks the
right EffectiveColors block. Scene rebuilds on theme toggle via the
hook's MutationObserver on data-theme.

* fix(quality): light-mode floor was black — explicit hex + new cssVarToHex helper [OMN-22]

Root cause: the ground plane was built via
`cssColorToHex('var(--panel)', 0x0b0e14)`. Canvas 2D's fillStyle
setter (which cssColorToHex uses for parsing) silently rejects
`var()` syntax and retains the previous value, so the sentinel-check
fell through to the fallback on every call. In dark mode the
fallback 0x0b0e14 was close enough to the actual dark panel colour
that nobody noticed; in light mode a near-black floor against a
near-white canvas stood out as clearly wrong.

Two fixes:

1. Floor set as explicit hex per theme, mirroring the values
   cost-trend-3d ships in DARK_THEME / LIGHT_THEME so the two
   three.js widgets stay visually consistent:
     dark:  0x020812   (near-black, slight blue tint)
     light: 0xf7f8fa   (near-white, barely-there grey tint)

2. Added cssVarToHex(varName, fallback) to src/theme/cssColorToHex.ts
   — reads the computed CSS variable value off documentElement and
   passes the resolved literal to cssColorToHex. The mean cone's
   `var(--brand)` lookup was silently falling through to the same
   fallback for the same reason; switching to cssVarToHex means the
   cone now picks up the actual brand token in both themes.

* feat(cost-by-model): three.js pie widget — cost share per model [OMN-22]

New widget that complements Cost Trend. Where Cost Trend answers
"how has spend moved over time", this answers "who is spending the
money right now". Same llm_cost.v1 topic, client-side aggregation
by model name.

Scene: one pie lying flat on the XZ plane with a small extrusion
depth for 3D read. Each slice is built from THREE.Shape (center →
outer edge → arc → center) + ExtrudeGeometry so a single mesh holds
the whole wedge (top face + sides) and the pointer raycast is
straightforward. Slice colors come from the shared `--chart-*`
palette, indexed by a stable model-to-color map derived from the
full (un-range-filtered) dataset so a model keeps its colour even
if it drops out of the current window and later re-enters.

Interaction: hover pops the hovered slice outward by 0.09 world
units along its mid-angle, with an HTML tooltip showing the model,
cost, and share. Hover-only updates are done in a separate effect
from the slice-build effect so pointer moves don't tear down and
rebuild geometry.

Participates in the dashboard time range (supports_time_range:
true). Legend on the right shows each model with its colour dot,
percentage, and a footer line with the total cost in range.

Theme-aware via the existing PieTheme split (dark = Tron-leaning
with a touch of emissive, light = pastel with no self-glow). Floor
hex values match the cost-trend-3d floor so the two three.js
visualisations stay consistent across themes.

Registry entry `cost-by-model` added (category: visualization,
defaultSize 6×4, minSize 4×3). Both integration tests bumped from
expecting 8 components to 9. Lazy import registered in
src/components/dashboard/index.ts under its implementationKey.

New unit test mocks three.js WebGLRenderer + ResizeObserver and
locks the aggregation math: 4 input rows at $2/$1/$1/$1 across
three models should yield the expected 60% / 20% / 20% legend
shares and a $5.00 total.

* refactor(styles): unify font-family tokens — single source in :root [OMN-22]

Consolidate the font-family token system so a single CSS variable
determines every mono/sans rendering in the app.

- Define --font-sans and --font-mono in globals.css :root as primitives.
  Body and .mono now reference the tokens (previously hardcoded).
- Remove font from the vanilla-extract contract (tokens.css.ts,
  themes.css.ts). Legacy .theme-dark/.theme-light classes no longer
  set 'Inter' or 'JetBrains Mono' which were conflicting with the
  actually-loaded IBM Plex fonts.
- Update CSS files (dashboard.css, library.css, sidebar.css) +
  Header.css.ts + MessageBubble.tsx to use var(--font-mono).
- Simplify widget inline fontFamily from
  'var(--font-mono, "IBM Plex Mono", monospace)' to 'var(--font…
jonahgabriel added a commit that referenced this pull request Apr 26, 2026
* feat(fixtures): Python fixture generator [OMN-30] (#1)

* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24] (#2)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path

* feat(data-source): ProtocolSnapshotSource interface [OMN-25] (#4)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26] (#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]

* feat(data-source): HttpSnapshotSource [OMN-28] (#6)

* feat(vite): fixturesMiddleware with testable handler + 5 contract tests [OMN-27] (#7)

* refactor(hooks): useProjectionQuery consumes ProtocolSnapshotSource [OMN-29] (#8)

* feat(hint): starter widget_type -> category preference heuristic [OMN-34] (#9)

* feat(registry): scan node_modules/@omninode/* for dashboardComponents [OMN-31] (#10)

* feat(layout): file-based layout persistence [OMN-32] (#11)

* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test

* feat(config): per-component config panel via @rjsf [OMN-33] (#12)

* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]

* feat(header): New dashboard button with inline create form [OMN-38] (#14)

* fix(registry): call resolveImplementations() eagerly so built-ins show as available [OMN-39] (#15)

* feat(fixtures): emit summary DTOs matching widget contracts; split shared topics [OMN-40] (#16)

* fix(dashboard): Save button now persists layout via layoutPersistence.write [OMN-41] (#17)

* feat(ui): install Tailwind + shadcn utilities + IBM Plex, port OKLCH tokens from Claude Design prototype [OMN-42] (#18)

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView (Phase 2) [OMN-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files

* fix(ci): drop unused waitFor import in Sidebar.test.tsx [OMN-43] (#20)

* feat(ui): verbatim CSS port of prototype; rewrite TSX to use semantic class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.

* chore(audit): scaffold chunked UI fidelity audit folders + README + example chunk [OMN-48]

* chore(audit): generate remaining 38 chunk files across 6 components [OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.

* audit: claim widget-library-06 [OMN-48]

* audit: complete widget-library-06 [OMN-48]

* audit: complete widget-library-01 [OMN-48]

* audit: complete widget-library-02 [OMN-48]

* audit: complete widget-library-03 [OMN-48]

* audit: complete widget-library-04 [OMN-48]

* audit: complete widget-library-05 [OMN-48]

* audit: complete empty-state-01 [OMN-48]

* audit: complete empty-state-02 [OMN-48]

* audit: complete empty-state-03 [OMN-48]

* audit: complete empty-state-04 [OMN-48]

* audit: complete empty-state-05 [OMN-48]

* audit: complete widget-card-01 [OMN-48]

* audit: complete widget-card-02 [OMN-48]

* audit: complete widget-card-03 [OMN-48]

* audit: complete widget-card-04 [OMN-48]

* audit: complete topbar-01 [OMN-48]

* audit: complete topbar-02 [OMN-48]

* audit: complete topbar-03 [OMN-48]

* audit: complete sidebar-01 [OMN-48]

* audit: complete sidebar-02 [OMN-48]

* audit: complete sidebar-03 [OMN-48]

* audit: complete sidebar-04 [OMN-48]

* audit: complete sidebar-05 [OMN-48]

* audit: complete sidebar-06 [OMN-48]

* audit: complete sidebar-07 [OMN-48]

* audit: complete sidebar-08 [OMN-48]

* audit: complete sidebar-09 [OMN-48]

* audit: complete sidebar-10 [OMN-48]

* audit: complete dashboard-01 [OMN-48]

* audit: complete dashboard-02 [OMN-48]

* audit: complete dashboard-03 [OMN-48]

* audit: complete dashboard-04 [OMN-48]

* audit: complete dashboard-05 [OMN-48]

* audit: complete dashboard-06 [OMN-48]

* audit: complete dashboard-07 [OMN-48]

* audit: complete dashboard-08 [OMN-48]

* audit: complete dashboard-09 [OMN-48]

* audit: complete dashboard-10 [OMN-48]

* audit: complete dashboard-11 [OMN-48]

* feat(ui): port EmptyState + WidgetLibrary missing structure [OMN-48]

Addresses audit findings grouped by theme:

- EmptyState (chunks 01-05): new component at src/components/dashboard/EmptyState.tsx
  rebuilds the prototype's dashed-card layout: diagonal stripe overlay,
  48x48 icon tile, "This dashboard is empty" headline, helper copy, and
  "Add first widget" CTA button. Wired in DashboardView in place of the
  prior single-line text fallback. onAdd enters edit mode when not
  already editing.

- WidgetLibrary (chunks 02, 03, 05, 06):
  - Adds close button (X) in .lib-head wired to onClose prop.
  - Adds .lib-search input with local q state; filters catalog by
    displayName + description substring match.
  - Adds .lib-card-thumb with a category-derived lucide icon
    (visualization→LineChart, metrics→Gauge, table→Table,
    status→ShieldCheck, stream→Radio).
  - Adds .lib-foot with "{N} widgets available" + "drag or click" hint.
  - Adds no-results empty-state branch inside .lib-body.
  - Keeps "not implemented" badge text (v2-specific semantic — v2 has
    component status; prototype's "Already on dashboard" semantic is not
    applicable until OMN-44 wires active-widget tracking).

DashboardView wires onClose to handleDiscard so the close button
exits edit mode + drops drafts — nearest equivalent to the prototype's
separate libOpen state.

* audit: mark EmptyState + WidgetLibrary chunks done [OMN-48]

Moves 9 chunks from audited/ to done/ after commit 4145b96:
- empty-state-01 through empty-state-05
- widget-library-02, widget-library-03, widget-library-05, widget-library-06

* feat(ui): port dashboard header + meta row per prototype [OMN-48]

Addresses audit findings for dashboard chunks 02-06:

- Rename title branch (dashboard-03, 04): adds click-to-edit affordance
  on .dash-title with chevron-down glyph + "Click to rename" tooltip.
  Input renders on edit; blur commits via renameDashboard(id, name),
  Enter blurs, Escape cancels.

- Meta row (dashboard-05): .dash-meta now renders the full prototype
  structure — widget count + middle-dot separators + "Timezone: {tz}"
  + "Auto-refresh 30s" with .mono class on numeric spans. Timezone is
  computed from the browser (e.g. "UTC−07:00") rather than hardcoded.

- Header actions (dashboard-06): "Edit" button renamed to "Add Widget"
  (primary variant) with Plus icon to match prototype intent; Save and
  Discard buttons gain Check and X icons respectively. Refresh and
  Share buttons from the prototype are deliberately omitted — they were
  decorative stubs with no v2 backing behavior and would be dead UI.

Tests updated to match new button label (/add widget/i); functionality
preserved. 242/242 passing.

* audit: mark dashboard header + meta chunks done [OMN-48]

* fix(ui): gate widget Live badge on isLive prop [OMN-48]

Addresses widget-card-03 finding: the .widget-live badge was rendered
unconditionally for every widget, falsely advertising real-time behavior
for static summary widgets (BaselinesROI, ReadinessGate, QualityScore).

- Adds isLive?: boolean prop to ComponentWrapper, default false.
- EventStream opts in (matches prototype's real-time-stream semantic).
- Other widgets stay silent by omission; they can opt in individually.

Prototype gates the badge on widget type ∈ {requests,logs,regions};
v2's category-based equivalent is the `stream` category, of which
EventStream is the sole current member.

* audit: close drag-scope chunks (blocked on OMN-44) + live badge fix done [OMN-48]

- 6 chunks moved to blocked/ with blocked_reason set: widget-card-01/02/04
  (drag + grip + kebab) and dashboard-08/09/10 (widget drag wiring,
  drop-slot, DropIndicator). All blocked on OMN-44 (drag-and-drop scope)
  per existing deferrals documented in DashboardView.tsx and
  ComponentWrapper.tsx headers.

- 2 chunks moved to done/:
  - widget-card-03 (Live badge gated in commit 31d988e)
  - dashboard-11 (dead `.library-open .grid .widget` CSS removed from
    src/styles/dashboard.css; v2 uses editMode state not library-open
    class, so the selector could never fire)

* fix(ui): sidebar + topbar polish per audit [OMN-48]

- sidebar-03: workspace label gets the prototype's tiny uppercase tracking
  (fontSize:10, textTransform:uppercase, letterSpacing:0.08em).
- sidebar-04: new-dashboard `+` button gains native `title="New dashboard"`
  tooltip (was aria-label only).
- sidebar-09: empty-state lineHeight 1.6 → 1.5 to match prototype.
- topbar-01: breadcrumb gains leading `<Menu size={16}/>` icon before "Home".

* audit: close remaining 17 chunks — 4 fixed, 13 accepted deviations [OMN-48]

Fixed (commit d15bc4c): sidebar-03, sidebar-04, sidebar-09, topbar-01.

Accepted with Resolution notes:
- Clean-audit chunks: sidebar-01, sidebar-02, sidebar-05 (no changes needed).
- False positives: sidebar-07 (Tailwind ≡ inline style), sidebar-10 (v2.15
  version bump was user-requested).
- Documented v2 architectural deviations: sidebar-06 (RenameInput
  subcomponent), sidebar-08 (shadcn DropdownMenu), topbar-02 (lucide
  icons), topbar-03 (role=button a11y), widget-library-01 (conditional
  render vs class toggle), widget-library-04 (enum-ordered grouping).
- Partially OMN-44 deferred: dashboard-01 (libOpen paddingRight),
  dashboard-07 (DropIndicator fragment — empty branch itself resolved
  in 4145b96).

Final state: todo 0 · audited 0 · blocked 6 (all OMN-44) · done 33.

* fix(ui): widgets added via palette now persist + no longer hidden behind rail

Two bugs introduced by OMN-48's rebuilt WidgetLibrary integration:

1. Clicking widgets in the palette produced only the `.lib-card:active`
   scale animation with no visible result, because `.library` is
   position:fixed (360px wide, covering the right side of the
   viewport). The grid rendered the added widget under the palette.
   Fix: add `paddingRight: 360` to the main content area when
   editMode is active, reserving space for the fixed rail.

2. Closing the palette (X button) destroyed widgets added during the
   session. Root cause: `onClose={handleDiscard}` reverted activeDashboard
   to the edit-start snapshot (empty for newly-created dashboards).
   Fix: `onClose={handleSave}` — closing the library now commits
   changes and exits edit mode, matching the prototype's implicit-save
   semantic. Save button remains the explicit commit; Discard still
   rolls back.

Also: palette moved out of the inline flex wrapper since it manages
its own fixed positioning; the 280px column now belongs solely to the
ConfigComponentPanel.

* docs: add OMN-22 UX polish plan (10 tickets OMN-49..OMN-58) [OMN-22]

Plan file for the post-OMN-48 UX polish round. Triaged 28 user-
surfaced observations into 10 PR-sized tickets under epic OMN-22:

- OMN-49 Task 1: Sidebar footer + workspace cleanup
- OMN-50 Task 2: DashboardView interaction polish
- OMN-51 Task 4: Widget chrome — kebab menu + removal + flatten
- OMN-52 Task 3: Timezone + auto-refresh countdown (blocked by OMN-50)
- OMN-53 Task 5: Widget contrast + chart sizing
- OMN-54 Task 6: Config panel as floating modal (blocked by OMN-51)
- OMN-55 Task 7: Routing decisions table polish
- OMN-56 Task 8: Drag-and-drop (blocked by OMN-51)
- OMN-57 Task 9: Grid reflow (masonry)
- OMN-58 Task 10: Light/dark theme wiring

Already-shipped items (Header cleanup, Refresh button, Agent UI)
documented in the plan's "Already addressed" section.

* fix(ui): rename inputs are now readable and auto-select text [OMN-22]

Sidebar dashboard rename input got no styling — user-agent defaults
rendered black text on white, which is invisible against the dark
sidebar. Added transparent background with a subtle brand-colored
border, dark tinted bg, and explicit sidebar-ink color so the input
reads as a clear edit affordance.

Also fixed two related issues that surfaced while testing:

- Dashboard title rename input (top of DashboardView) inherited color
  via CSS but the inheritance was flaky in some themes. Made the color
  explicit via inline style.

- Text was not auto-selecting when the input mounted from the kebab
  menu — Radix DropdownMenu's onCloseAutoFocus was returning focus to
  the kebab trigger after close, stealing it from the newly-mounted
  input. Fix: added onCloseAutoFocus={e => e.preventDefault()} on the
  dashboard-kebab DropdownMenuContent, and switched RenameInput from
  autoFocus+useEffect to a callback ref + requestAnimationFrame so
  focus+select runs after Radix's focus sequence settles.

Checklist item #1 addressed.

* fix(ui): sidebar rename flow + remove workspace chip [OMN-22]

Two checklist items from the post-OMN-48 UX polish round:

#1 Rename input fix (follow-up): after the initial color/background
  fix, fast-clicking Rename from the kebab menu caused the input to
  flash and revert, and the text didn't highlight. Root cause:
  Radix DropdownMenu's focus-scope teardown dispatches a blur on the
  previously-focused element during its close sequence, which fired
  onBlur and committed the empty value.

  Corrected approach:
    - Defer setRenamingId via queueMicrotask so the input mounts
      after Radix finishes closing.
    - Move focus+select into useLayoutEffect with a stable ref, so
      it runs once after defaultValue is applied.
    - Replace onBlur commit with a document-level pointerdown listener
      that only commits on genuine click-outside.
    - Enter/Escape still commit/cancel via onKeyDown.

  Reference: https://medium.com/@ojasskapre/fixing-input-focus-issues-in-a-shadcn-radix-card-ui-dropdown-menu-in-next-js-7b5931e049c8

#2 Workspace chip removal: dropped the hardcoded "Platform Eng"
  Workspace dropdown from the sidebar. No behavior was wired, no
  workspaces concept exists yet, and it was visual noise. Dropped the
  CSS rules (.workspace, .workspace-chip, .ws-name), the now-unused
  ChevronDown import, and updated the corresponding Sidebar test.

Also adds docs/ux-polish-checklist.md tracking the 28-item list.

* refactor(ui): remove widget count from dash-meta row [OMN-22]

Checklist #20: '0 widgets' / 'N widgets' text under the dashboard
title carried no real value — the library footer already shows the
catalog count, and users can see their widget count by glancing at
the grid. Dropped the span plus its trailing separator.

* refactor(ui): remove sidebar-foot status + version strings [OMN-22]

Checklist #26 + #27: neither the "All systems normal" indicator nor
the hardcoded "v2.15" version string had anything real backing them.
No health monitoring is wired, and the version string was a literal
not sourced from package.json. Dropped both.

Changes:
- Sidebar.tsx: removed the .sidebar-foot <div> (dot, status text, version).
- sidebar.css: dropped .sidebar-foot and .sidebar-foot .dot rules.
- globals.css: moved @keyframes pulse here (was in sidebar.css); still
  used by .widget-live::before in dashboard.css.
- Sidebar.test.tsx: replaced "renders ...normal" assertion with a
  "does NOT render sidebar-foot" assertion.
- FrameLayout.test.tsx: rebased its chrome-smoke assertion onto the
  "an omninode product" tagline (unique, stable).

* refactor(ui): remove Header chrome without backing systems [OMN-22]

Checklist #23 + #24 + #28:

- #23 removed the user chip (avatar "JS", "Jamie Sun", "Platform Eng"):
  there is no user system yet, the chip was static placeholder data.
- #24 removed the Notifications bell and Help-circle icon buttons:
  no notifications system, no help system, clicking them did nothing.
- #28 removed the Menu icon prefixing the breadcrumbs: looked like a
  hamburger menu trigger but had no onClick handler, inviting clicks
  that did nothing.

Header now contains: breadcrumbs, Refresh button (retained), theme
toggle (retained). Test updated to assert the removed elements are
absent.

This reproduces the manual edits that were lost this morning when
shared-worktree branch swapping during the OmniNode workflow cleared
the working tree.

* docs(checklist): mark tasks 20, 23, 24, 26, 27, 28 done [OMN-22]

* feat(ui): library rail smooth transition + close on switch + Escape [OMN-22]

Checklist #7 + #15 + #21, all clustered around widget-library edit-mode
behavior:

#15 Smooth open/close: ComponentPalette now accepts an isOpen prop and
  toggles the .library.open class instead of being conditionally
  mounted. The CSS transition (transform 0.3s cubic-bezier) fires in
  both directions because the element stays in the DOM. aria-hidden
  mirrors isOpen so screen readers ignore the off-screen rail.
  DashboardView renders <ComponentPalette isOpen={editMode} /> once,
  unconditionally.

#7 Close on dashboard switch: a useEffect watches activeDashboard?.id.
  When it changes (using a prevRef to skip the initial mount), it
  calls setEditMode(false), which slides the library out.

#21 Escape closes library: a useEffect binds a keydown listener when
  editMode is true. Pressing Escape calls setEditMode(false) and
  preventsDefault on the event. Listener is torn down when editMode
  goes false.

* fix(ui): explicit fixed height on CostTrend + QualityScore charts [OMN-22]

Checklist #5 + #6. Both charts were sized against parent containers
with no height constraint, so the percentage/flex values resolved to
nothing:

- CostTrendPanel: height:100% fell back to minHeight:200px — half
  what the design called for.
- QualityScorePanel: flex:1 inside a height:100% flex column inside
  an unconstrained .widget-body collapsed the chart to ~7px.

Both now set an explicit height:320px on the ReactECharts element.
QualityScorePanel no longer needs the outer flex column since its
children stack in normal flow. Simplified that wrapper to a plain div.

* fix(theme): expose mutedForeground; widgets no longer render text in bg color [OMN-22]

Checklist #4. Root cause was not per-widget — it was in useThemeColors.
The hook exposed `muted` as `readToken('muted')`, but the `--muted`
shadcn token maps to `var(--panel-2)` — a background tint, not a text
color. Every widget labelled `color: colors.muted` was rendering text
in the same color as its panel surface.

Changes:

- useThemeColors now exposes a new `mutedForeground` field that reads
  `--muted-foreground` (→ `--ink-2`). JSDoc on `muted` warns not to
  use it as a text color.
- DelegationMetrics: promoted "Total Delegations", "Quality Gate Pass
  Rate", "Cost Savings" labels from muted (bg) to foreground (full
  ink) — these sit beside primary metric values and are primary.
- BaselinesROICard: promoted all delta labels, "Recommendations"
  heading, and kv sub-labels to foreground.
- ReadinessGate: "Overall:" promoted to foreground; dimension detail
  and "Checked {time}" bumped to mutedForeground (visible secondary).
- QualityScorePanel: "Mean Score" summary and chart axis labels moved
  to mutedForeground.
- CostTrendPanel: chart axis labels moved to mutedForeground.
- EventStream: timestamp + source columns moved to mutedForeground.

After this change, all widget text renders in the correct ink tone
for its emphasis level in both light and dark themes.

* fix(layout): .main constrained to 100vh so content below fold is scrollable [OMN-22]

Checklist #3. Root cause was in src/styles/topbar.css: .main had
display:flex + flex-direction:column + min-width:0 but no height
constraint. Its parent .app sets height:100vh with display:grid but
default auto rows, so .main grew to content size. When content
(Header + DashboardView) exceeded 100vh, body { overflow:hidden }
in globals.css clipped the excess unreachably.

Fix: add height:100% + min-height:0 + overflow:hidden to .main so it
is pinned to its 100vh grid cell, its flex children can shrink, and
internal scroll happens inside .dash-body (which already has
overflow-y:auto).

Also tracks new task #29 (sidebar kebab menu hover + pointer) in the
checklist for later.

* fix(ui): DropdownMenuItem hover now visible; cursor shows pointer [OMN-22]

Checklist #29. Two issues in the shared shadcn DropdownMenuItem:

- cursor was cursor-default. Changed to cursor-pointer.
- focus:bg-accent did fire on hover (Radix sets focus via pointermove),
  but --accent resolves to oklch(92% 0.08 120) in light mode — a nearly
  white pale green that had no visible contrast against the panel's
  100% white background. Switched highlight to --panel-2, which is the
  prototype's own convention for menu hover (see dashboard.css
  .menu-item:hover). --panel-2 has meaningful contrast in both themes.

Covered three selectors — hover:, focus:, and data-[highlighted]: — to
catch every path Radix uses for highlighting.

Applied at the component level so every DropdownMenu in the app
benefits, not just the sidebar.

* fix(theme): ThemeProvider now drives both data-theme + .theme-* class [OMN-22]

Checklist #22. Root cause: ThemeProvider only toggled the legacy
vanilla-extract `.theme-dark`/`.theme-light` class on <body>. The
prototype-ported chrome (sidebar, topbar, widgets, dashboard) activates
its dark theme via `[data-theme="dark"]` on <html> in globals.css. That
attribute was never being set, so the toggle was a no-op for everything
newer than the vanilla-extract layer.

Fix: ThemeProvider now drives both systems from the same theme state:

- Sets/removes `data-theme="dark"` on document.documentElement (drives
  the globals.css OKLCH tokens used by all prototype-ported chrome).
- Keeps the `.theme-dark`/`.theme-light` body class toggle (drives the
  legacy vanilla-extract consumers like AgentChatPanel).

Also added a synchronous `<script>` in index.html that sets
`data-theme="dark"` before React hydrates. The default theme is dark, so
without this the user sees a brief light-mode flash on every page load
before ThemeProvider's useEffect runs.

* refactor(ui): grid now uses CSS columns for masonry-style flow [OMN-22]

Checklist #9. Replaced:
  .grid { display: grid; grid-template-columns: 1fr 1fr; }
with:
  .grid { columns: 2; column-gap: var(--row-gap); }
and added break-inside:avoid + margin-bottom:var(--row-gap) to .widget.

CSS columns flows children top-to-bottom within each column, so a
short widget no longer leaves dead vertical space under a tall
row-mate. Previously a 2-col grid with [tall, short] in row 1 pushed
row 2 past the tall widget's bottom edge, leaving a visible gap under
the short one.

* feat(ui): widget kebab menu + flatten sub-card chrome + rename grid class [OMN-22]

Checklist #8, #13, #14, and a follow-up rename:

#14 Kebab menu on every widget: new WidgetChromeContext carries
  {onConfigure, onDelete} from ComponentCell down to ComponentWrapper
  without threading them through widget props. ComponentWrapper renders
  a MoreVertical kebab button in the widget-head when at least one
  handler is provided. Menu items: Configure, Delete (destructive).
  Available in both view and edit modes.

#13 Easier widget removal: Delete lives in the kebab menu, reachable
  with one click from any mode (previously required entering edit
  mode and finding a less-obvious affordance).

#8 Flatten sub-card chrome: DashboardView no longer wraps each grid
  item in its own `<div className="widget">...widget-head...widget-body`
  outer shell. The widget is ComponentCell directly, which delegates
  chrome to the widget's own ComponentWrapper. One card level, not two.
  Also removes the click-widget-to-configure handler — Configure now
  only fires from the kebab menu.

  Prior ComponentCell also rendered an intervening
  `<div data-testid="grid-cell" style={{ overflow: 'hidden' }}>` wrapper.
  That overflow:hidden created a new block formatting context between
  .grid and .widget and prevented CSS columns flow. Normal path now
  skips this wrapper; `grid-cell` testid retained only on the fallback
  "component not available" path.

Follow-up rename: `.grid` → `.dash-grid`. Tailwind's utility layer
generates `.grid { display: grid; }` which loaded after dashboard.css
and overrode our `columns: 2`. Once `display: grid` is set the browser
ignores `columns: N` entirely, which collapsed widgets into a single
column. Renaming makes our custom rule win unambiguously.

* feat(ui): port prototype menu + modal patterns; drop shadcn dropdown/dialog [OMN-22]

Stay-close-to-prototype pass. The prototype's menus and config modal
use hand-authored markup with custom CSS, not a third-party primitive.
Mirroring that pattern lets future design iterations from Claude Design
drop in with minimal translation.

New shared infrastructure:

- src/components/ui/positioned-menu.tsx — small PositionedMenu +
  MenuItem + MenuSeparator components plus a usePositionedMenu() hook
  that captures the trigger's bounding rect on open and manages
  dismiss (outside-click + Escape). Portaled to document.body so it
  isn't clipped by any overflow:hidden ancestor. Uses the prototype's
  .menu / .menu-item / .menu-item.danger / .menu-sep CSS (already
  ported verbatim into dashboard.css).

- src/styles/modals.css additions — RJSF form class-name mappings
  (.form-group, .rjsf-field, .control-label, .form-control, .checkbox)
  styled to match the prototype's .field/.check look. Reset <fieldset>
  user-agent defaults so its content no longer overflows modal-body's
  max-height scroll container.

Config modal:

- ComponentConfigPanel rewritten to use the prototype's
  .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot
  markup verbatim. Cancel + Save changes footer buttons match the
  prototype's pattern. Save commits that placement's draft via a new
  commitDraft store action; Cancel discards. Escape + backdrop click
  also dismiss.

- src/store/configSlice.ts — commitDraft(placementId) writes a
  placement's draft config back to its layout item and clears the
  draft.

Widget kebab menu:

- ComponentWrapper renders a MoreVertical kebab button that opens a
  PositionedMenu with three items matching the prototype: Configure
  Widget, Duplicate, Remove Widget (danger). onDuplicate is a new
  handler supplied via WidgetChromeContext.

- src/store/dashboardSlice.ts — duplicateLayoutItem(itemId) clones a
  placement with a fresh id and deep-copied config, appended to the
  layout.

Sidebar dashboard kebab:

- Replaced Radix DropdownMenu with the shared PositionedMenu pattern.
  Items: Rename, Duplicate, Share Link (no-op placeholder), Delete.

- src/store/dashboardSlice.ts — duplicateDashboard(id) clones a
  dashboard with a fresh id, "(copy)" name suffix, deep-copied
  layout.

Fieldset / #root selector collision:

- The React mount <div id="root"> and RJSF's <fieldset id="root">
  both matched a bare `#root` selector in globals.css, which gave the
  fieldset height:100vh and overflowed the modal. Changed the
  selector to `div#root` so it only targets the React mount.

Cleanup:

- Removed src/components/ui/dropdown-menu.tsx (shadcn Radix shim, no
  remaining consumers) and uninstalled @radix-ui/react-dropdown-menu.
- Removed the briefly-lived src/components/ui/dialog.tsx + uninstalled
  @radix-ui/react-dialog (the modal moved to prototype markup before
  anything else picked it up).
- Deleted src/config/ComponentConfigPanel.css.ts (vanilla-extract
  styling no longer used after the prototype-markup port).
- grid-item testid moved from ComponentWrapper to a
  display:contents wrapper in ComponentCell so tests find it even
  when the widget's LazyComponent is still Suspending. Redundant
  grid-item testid dropped from the legacy DashboardGrid shim.

Reference breadcrumb:

- reference/claude-design/ now holds a local copy of the Claude Design
  prototype (tarball extracted from the share URL) so the files
  survive between sessions. reference/claude-design/ is gitignored
  (large), but reference/README.md is tracked as a breadcrumb
  pointing at the source URL and describing what's in each subdir.

Checklist #10 (config panel modal) + #14 follow-up. 243/243 tests pass.

* fix(widgets): promote primary data labels to --ink + bump tiny label fonts [OMN-22]

Closes ux-polish-checklist #4. Audit found DelegationMetrics and
BaselinesROICard labels were already rendered in --ink (darkest), not
--ink-3 as the dictated note assumed — perceived faintness came from
the 0.6875rem (~11px) font size. Bumped those to 0.75rem (12px).

QualityScorePanel's Mean Score summary row was genuinely secondary
(rendered in --muted-foreground). That row is the widget's only
non-chart content, so promoted to --foreground and bumped 0.75rem →
0.8125rem.

All other --ink-3 / mutedForeground usage is chart-chrome (axes,
loading/empty states, timestamps, search placeholders) and stays
appropriate for those roles.

* feat(theme): add --chart-* tokens + --status-* aliases on :root [OMN-22]

Closes ux-polish-checklist #30. useThemeColors() reads CSS vars from
document.documentElement, but --chart-1..7 only lived on the
.theme-* classes that vanilla-extract applied to <body>. Every chart
color was therefore resolving to the '#888888' fallback (all series
rendered as gray).

Moved --chart-1..7 onto :root with seven distinct hues at matched
lightness/chroma, clear of the brand green (--accent-h=120), plus a
brighter variant for [data-theme="dark"] so series identity is
consistent across theme toggle.

Also added --status-healthy/warning/error aliases pointing at the
prototype's --status-ok/warn/bad, so useThemeColors's status map
finds values too.

* refactor(readiness): convert ReadinessGate from cards to table layout [OMN-22]

Off-checklist request. The card grid (7 columns of status boxes) read
well at desktop widths but wasted vertical space and didn't scale to
more than 7 dimensions. Tabular layout with Status | Dimension |
Detail columns reads the same information more compactly and is
directly extensible to additional dimensions without layout changes.

Status is rendered as a color-outlined pill that picks up
--status-ok/warn/bad from the theme. Header uses the shared prototype
colors (--ink-2 / --panel-2 / --line). Dropped useThemeColors() in
favor of direct CSS vars to match the pattern established by
RoutingDecisionTable.

* feat(grid): drag-and-drop reorder + palette-to-grid insert [OMN-22]

Closes ux-polish-checklist #12.

Two drag sources: palette cards (copy into grid) and existing widgets
(reorder). Drop onto any widget inserts before its position; a
trailing "drop to append" zone appears at the grid tail while
dragging. The empty-state placeholder is replaced by a "drop to add"
slot during drags so a widget can be placed on an empty dashboard.

Store gets two new actions: insertComponentAt(name, version, size,
atIndex) and moveLayoutItem(itemId, toIndex). moveLayoutItem adjusts
the target index when fromIndex < toIndex so callers can pass the
pre-removal target index naturally. Both covered by unit tests.

Chrome context extended with draggable/isDragging/isDropTarget flags
plus HTML5 drag handlers. ComponentWrapper applies them to the
.widget root and adds a GripVertical icon in the head when
draggable. ComponentCell forwards the props through. ComponentPalette
cards become draggable drag sources that also fall back to
click-to-add.

Visual feedback: dragging source drops to 35% opacity; drop target
gets a 3px brand-colored top-edge bar via ::before (new CSS rule).
Append zone uses existing .drop-slot styling with an explicit
column-span so it reaches across the masonry columns.

data-drag-exclude convention added so interactive widget content can
opt out — ComponentWrapper checks the mousedown target's ancestors
for the attribute and cancels dragstart when found. Applied to
RoutingDecisionTable for future-proofing.

* docs(claude): rewrite widget-add recipe to match actual two-track mechanism [OMN-22]

The existing "Add a new widget" recipe described a per-widget
manifest.ts file colocated next to each widget — that file never
existed. In reality widgets live in two tracks:

  A. Local MVP widgets (all seven current widgets): component lives
     under src/components/dashboard/<slug>/, lazy import registered
     in src/components/dashboard/index.ts, manifest entry added to
     MVP_COMPONENTS in scripts/generate-registry.ts.
  B. External @omninode/* package widgets: published as npm packages
     with a dashboardComponents field in package.json pointing at a
     JSON manifest; generate-registry.ts scans node_modules for
     matching packages.

Rewrote the recipe into two clearly-labeled paths and updated "Where
to look" to point at the correct files. No behavior change.

* feat(cost-trend-3d): experimental Tron-style 3D cost visualization widget [OMN-22]

An interactive 3D cost widget that sits alongside the original 2D
CostTrend — same llm_cost topic, different implementation. Built for
exploration; not a replacement for the ECharts-based CostTrend.

Raw three.js (not @react-three/fiber) — started on R3F but hit WebGL
context loss on first render due to three@0.184 / r3f 9.6 skew and
swapped to plain three.js in a useEffect, which has been stable. R3F
was uninstalled after the switch.

Visualization: each bar = one (time-bucket, model) cell. X=time,
Z=model, Y=cost. Translucent cores + bright edge lines on a
near-black background (light theme uses greys + pastels). Grid lines
aligned to cell boundaries. Axis labels projected world→screen as
HTML overlays so they track the camera. Dates dedupe at day
boundaries so hourly buckets don't print MM/DD 24 times in a row.

Camera: faces the strip head-on (azimuth pinned). Horizontal
navigation via a scrollbar below the canvas — no free orbit. Tilt
via mouse-drag Y within a clamped range. Widescreen 200px canvas
height with the scene tuned to fill vertically via FOV=30,
distance=11, target-Y=0.6, Y-offset=-1px (values dialed in via a
temporary slider panel then baked in).

Interaction:
  - Hover: bar under pointer becomes fully opaque (clickability hint)
  - Plain click: cinematic focus animation — camera swoops in on the
    bar, non-focused bars + grid fade to near-transparent, clicked
    bar's opacity ramps to near-solid, stats panel slides in from
    the right with cost/tokens/requests/derived ratios. Escape or
    clicking elsewhere reverses. Mid-flight retargeting retreats to
    default first then re-launches (simpler than pose blending and
    reads as intentional).
  - Shift-click: existing isolate behavior (desaturate other models
    to neutral grey).
  - Model filter legend below the scrollbar — click to toggle
    visibility; filtered-out bars drop to near-zero opacity but stay
    spatially present.

Theme-aware. Two ThemeConfig objects (DARK_THEME = neon Tron,
LIGHT_THEME = greys + pastels on white) selected by a useThemeName
hook that watches data-theme on documentElement.

Fixture generator: _llm_cost_points now emits a full (time × model)
cross product (48 hourly buckets × 4 models = 192 records) so the
3D grid is dense and rectangular. Per-model cost multipliers
(opus > sonnet > qwen > deepseek) make the series visually distinct
rather than uniformly noisy.

Registry + imports + integration tests updated for the 8th widget.
Added three + @types/three as deps.

* docs(checklist): mark polish items done [OMN-22]

Marked #3, #4, #5, #6, #7, #8, #9, #10, #11, #13, #14, #15, #20,
#21, #22, #29, #30 as done, plus the off-checklist ReadinessGate
tabular rework. Updated progress totals (17 done / 10 pending / 2
n/a / 1 keep-as-is). Remaining pending: #7/#8/#10/#13/#14/#15/#21
turned out to already be implemented (confirmed later and marked);
next pass will tackle #12, #16, #19.

* feat(filters): global time range selector + stacked-area Cost Trend [OMN-22]

Adds a dashboard-level time range selector (header, left of the mode
buttons) that all time-series widgets consume for a consistent view
of the same window. Defaults to "Last 24h" on first mount so fresh
dashboards don't show 48h of raw fixture data out of the gate.

Changes by layer:

- ComponentManifest.capabilities gains optional `supports_time_range`.
  True = widget participates in the global range. False = snapshot or
  pre-aggregated summary; the selector is ignored. Existing manifest
  fixtures without the field continue to pass validation.
- TimeRange gains an optional `label` (e.g. "Last 24h") so the
  selector can render "Last 24h" instead of formatting absolute
  timestamps. Absent for custom ranges.
- useTimeRange.ts centralizes resolveTimeRange() + applyTimeRange()
  helpers and the TIME_RANGE_PRESETS constant. Widgets call
  applyTimeRange(data, d => d.<timestamp_field>, resolved) and get a
  filtered slice; no changes required to queryKey (caching stays
  keyed on topic, filtering is post-fetch).
- DateRangeSelector uses the existing PositionedMenu primitive —
  presets (1h/6h/24h/7d/30d) + "All time". Resolves presets to
  absolute ISO timestamps at click-time; window does not auto-advance
  on refetch.

Participating widgets (supports_time_range: true):
  cost-trend-panel, cost-trend-3d, routing-decision-table
Non-participating (false):
  delegation-metrics (summary), baselines-roi-card (snapshot),
  quality-score-panel (summary), readiness-gate (snapshot),
  event-stream (live tail)

CostTrendPanel switches from per-model lines to a stacked area chart
— each model is an additive layer so the top reads as total cost
over the window and each band reads as that model's contribution.
Smoothed curves, no symbols, 55% area opacity, 1px line. Also:
granularity default flipped from "day" to "hour" to match the
fixture's hourly buckets, and x-axis ticks include the hour in
hourly mode so adjacent dates don't collapse to the same label.

Both CostTrend widgets now render a "No cost data in the selected
time range" overlay when the underlying topic has data but the
current window clips it all away — distinct from the all-empty
state.

RoutingDecisionTable's footer "filtered from N" now shows the
in-range count instead of the raw total so the search filter's
denominator reflects the time-windowed set the user is searching
within.

* fix(cost-trend): legend below chart + drop emphasis.focus to fix hover flicker [OMN-22]

Two related changes to CostTrendPanel, landing together because they
both restructure the ECharts option:

1. Hover flicker (task #9). With tooltip.trigger='axis' showing all
   series' values at the hovered x-position AND
   emphasis.focus='series' on each series dimming non-hovered bands,
   the two behaviors fought each other — tooltip appeared, then the
   series emphasis kicked in and dimmed everything else, which read
   as "the line isolated on hover." Removed emphasis.focus; tooltip
   now stays stable across mouse movement with no visual isolation.

2. Legend placement (task #8). Replaced the built-in ECharts top
   legend with a custom HTML legend below the chart, styled like the
   3D Cost Trend's model-chip row (rounded pills with colored dot +
   model name, wraps to new rows if needed). The two Cost Trend
   widgets now read as a matched pair.

Behavioral differences vs 3D:
  - Click toggles visibility only; no isolate-on-click (3D has that;
    2D doesn't need it).
  - No "Clear focus" button.
  - No isolate highlight on the chip.

When a model is toggled off, its series is filtered out of the chart
entirely (not just hidden), so the remaining stack re-fits
vertically. The model list itself is derived from the full (un-
range-filtered) dataset so the legend stays stable as the user
scrubs the time range.

grid.top reduced 34 → 12 now that the built-in legend is gone — the
chart area reclaims the vertical space.

* feat(filters): custom start/end date range picker [OMN-22]

Extends the DateRangeSelector's popover with a "Custom range…" option
that opens an inline calendar + time-input UI. Keeps all existing
preset behavior intact — the custom view is a second view inside the
same popover, reached via a menu item that intentionally doesn't
close the popover on select.

Calendar: react-day-picker v9 in range mode (single month, 30px
cells). Time: two <input type="time"> controls for start/end HH:MM.
Apply builds a TimeRange with no `label` so the selector shows
"MM/DD – MM/DD" instead of a preset name; Cancel returns to the
preset list.

Theming: react-day-picker's --rdp-* CSS variables are bound to the
dashboard's existing tokens (--brand, --brand-soft, --brand-ink, --ink,
--panel-2, --line) on the calendar's wrapper div. Calendar picks up
the active light/dark theme with no extra JS. Compact sizing
(30x30 day cells) so the popover fits cleanly at 320px wide.

Safety: start/end swap if the user ends up with start > end after
combining calendar day + time input; PositionedMenu's outside-click
dismissal is blocked on pointerdown inside the calendar so clicking a
day doesn't close the whole popover.

Adds react-day-picker ^9.14.0. Zero transitive runtime deps in v9.

* fix(range-picker): theming cascade + sensible default times [OMN-22]

Two fixes to the custom-range date picker:

1. Calendar colors. The --rdp-* vars were being set inline on an outer
   wrapper, but react-day-picker's `.rdp-root` declares its own
   defaults at the same specificity — and that stylesheet loads into
   the bundle after the component mounts, so the library's "blue"
   accent was winning. Moved the overrides into CustomRangePicker.css
   which is imported AFTER `react-day-picker/style.css`, targeting
   `.dash-calendar-wrapper .rdp-root` (specificity 0,2,0 vs library's
   0,1,0). Also swapped the range_start/end text color from
   --brand-ink (dark green) to #ffffff — dark-green-on-brand-green
   didn't contrast enough — and range_middle text from --brand-ink to
   --ink so days inside the range read in the neutral dark-ink color
   rather than competing with the brand.

2. Default times. The picker was seeding its start + end time inputs
   from the currently-active range's timestamps, which meant if the
   active preset was "Last 24h" (clicked at 14:37) both inputs
   defaulted to 14:37. Now they default to 00:00 and 23:59 — the
   common case is "whole days between these two dates" and users who
   want a precise minute window can still set it explicitly.

* feat(cost-trend): replace ECharts with three.js 2D stacked-area chart [OMN-22]

Rewrites the 2D Cost Trend chart body in three.js using an orthographic
camera whose frustum matches canvas pixel coordinates (top-left origin,
y-down). ECharts stays as a dependency for other widgets (Quality,
Delegation) — this swap is scoped to CostTrendPanel.

Why: ECharts' default hover model interacted awkwardly with the
stacked-area layout, producing flicker and unintended band-isolation
effects on pointer move. Owning the render loop gives us full control
over hover and tooltip behavior, and matches the technology choice of
the 3D Cost Trend widget so both widgets share a mental model.

Architecture:
  - New StackedAreaChart component owns the three.js renderer/scene/
    camera for the chart body. Re-renders on size / data / theme
    changes only; no rAF loop (preserveDrawingBuffer:true so the canvas
    holds its last frame).
  - Each visible model's band is a BufferGeometry of 2N vertices and
    2(N-1) triangles (top+bottom polyline swept into a filled strip),
    with a sibling Line for the top-edge outline. Horizontal grid
    lines at "nice" y-tick values.
  - CostTrendPanel outer stays unchanged in shape: same data fetch,
    same time-range filter, same in-range-empty overlay, same custom
    legend from task #8 below the chart.

Interaction:
  - Hover: pointermove on the wrapper maps pointer X to the nearest
    bucket index. A vertical guide line (DOM div, not geometry)
    appears at that X; an HTML tooltip follows the cursor showing
    bucket timestamp, per-model cost breakdown in stack order (top to
    bottom), and the cumulative total.
  - No hover emphasis on the bands themselves — fixes the flicker the
    user was seeing with the ECharts implementation.

Color parsing fix: globals.css declares --chart-1..7 as oklch(...)
values, which THREE.Color.setStyle() doesn't understand (it supports
rgb/hsl/hex/named only). Added cssColorToHex() helper that pipes the
CSS value through a 1×1 canvas — Canvas 2D's fillStyle accepts the
full CSS color syntax and gives back a normalized sRGB pixel we can
convert to the numeric hex THREE wants.

Test updated to stub StackedAreaChart (jsdom has no WebGL context);
verifies the outer component plumbs correct bucket + model counts into
the chart child.

* feat(cost-trend): chartType config — stacked area or stacked bar [OMN-22]

Adds a `chartType` option to the 2D Cost Trend widget's manifest
configSchema (enum: 'area' | 'bar', default 'area'). Users pick via
the widget's Configure dialog (kebab menu → Configure Widget), the
value persists with the dashboard layout.

Rendering: same StackedSlice data structure in both modes; only the
geometry builder differs.
  - 'area' (existing): filled bands (triangle strip per model) + top-
    edge polyline outlines. Smooth trend read across time.
  - 'bar' (new): one rectangle per (bucket × visible-model) cell
    stacked on the previous model's cumulative total. Reads as
    discrete per-bucket observations. Bar width = plotWidth /
    bucketCount × 0.72 (with a floor at 2px so dense datasets still
    render). Zero-height cells skip their triangle indices so we
    don't emit degenerate geometry.

File rename: StackedAreaChart.tsx → StackedChart.tsx (via git mv) now
that the component handles both modes. Exported types renamed to
match; the test file's vi.mock path and testid updated accordingly.

Grid + axis labels + hover guide + tooltip are unchanged across modes
— all the chrome lives on the common StackedSlice, not the render
shape.

* fix(cost-trend): bar-mode cell-center x-mapping + suppress zero y-label [OMN-22]

Two visual fixes on the 2D stacked chart, both noticed at "Last 24h"
where the post-filter dataset has only a handful of buckets:

1. Bars overflowed the plot on both sides. The x-scale was
   edge-to-edge (plotLeft..plotRight) regardless of mode. With N=4
   and plotWidth=400 that gave 100px between bar centers and a bar
   width of ~72px, so the leftmost bar's left edge landed 36px inside
   the y-axis label column and the rightmost bar's right edge spilled
   36px past the canvas.

   Moved to a per-mode x-scale:
     - area: edge-to-edge (unchanged). A trend line should span the
       full plot area.
     - bar: cell-center. Each of N cells gets width plotWidth/N and
       its bar is centered. Every bar stays fully inside the plot
       area at any bucket count.

2. The $0 y-axis tick label sat directly above the leftmost x-axis
   date label at narrow widths and read as a single blob. The $0 tick
   is redundant with the x-axis baseline anyway, so the y-label for
   value 0 is now suppressed. The horizontal gridline at the baseline
   still renders; only the text label is dropped.

No test changes — the fixes are rendering-layer, and the chart
component is already stubbed in tests (jsdom has no WebGL context).

* fix(routing-table): tighten narrow columns + shorter timestamp [OMN-22]

Without an explicit <colgroup>, tableLayout:fixed distributed equal
1/6 widths to all six columns. "LLM Conf." held 3 chars ("83%") and
"Cost" held 7 ("$0.0231"), so those got far more space than they
needed while Timestamp ("4/20/2026, 10:40:00 AM" at 22 chars) wrapped
at typical widget widths.

Two-part fix:

1. Added <colgroup> with per-column widths that match content size:
   Timestamp 24%, LLM Agent 21%, Fuzzy Agent 21%, Agreement 13%,
   LLM Conf. 9%, Cost 12%. Totals to 100; tableLayout:fixed respects
   those exact proportions.

2. Shortened the displayed timestamp to "MM/DD HH:MM AM/PM" (dropping
   the year and seconds). Added whiteSpace:nowrap + text-overflow
   ellipsis on the cell and a title attribute carrying the full
   toLocaleString() form so the richer timestamp is still available
   on hover.

* feat(event-stream): search, sticky header, paused indicator, source pills [OMN-22]

First round of professionalism polish. Scope chosen from the suggestion
list: the clear bugs, the straightforward visual upgrades, and the two
highest-leverage interactions. Connection-status dot, click-to-expand,
correlation-id grouping, and live/paused toggle are all deferred.

Bug fixes
  - Dropped `hsl(${colors.border})` on the row border — useThemeColors
    returns a CSS-resolvable string already, so wrapping it in hsl()
    produced invalid CSS and the dividers were not rendering. Now uses
    `var(--line-2)` directly.
  - Gave the scroll container a definite height (SCROLL_HEIGHT = 320).
    The previous `height: 100%` collapsed or grew unpredictably since
    `.widget-body` has no height constraint.

Visual
  - Sticky column header (TIME · EVENT · SOURCE) at the top of the
    scroll region so the semantics of each column are obvious without
    inferring from content.
  - Row padding bumped from 4px to 6px × 10px for breathing room.
  - Timestamp column tightened 140 → 96px; new formatter shows
    HH:MM:SS for events in the last 24h and MM/DD HH:MM for older.
    A title attribute on each row exposes the full event_type,
    source, correlation_id, and ISO timestamp for debugging.
  - Timestamp and event_type both use the mono font stack; source
    moved into a small outlined pill whose hue is derived from a hash
    of the source string — stable per source so the eye can pick one
    out while scanning a fast stream.

Interaction
  - Search input over event_type / source / correlation_id. Mirrors
    the RoutingDecisionTable pattern.
  - Auto-scroll pause is now surfaced: when the user scrolls up, the
    component tracks the arrival count since that moment and shows a
    floating "↑ N new events" button pinned at the top of the scroll
    region. Click to jump back to top and resume auto-scroll. Also
    added an "auto-scroll paused" hint in the footer.
  - Footer shows `filtered / buffered` with a "buffer full" note
    when events have saturated maxEvents.

Internal: `userScrolledUp` promoted from a ref to state so the UI can
react to the paused condition (needed for the new-events button).

* feat(quality): three.js rewrite — pass-rate headline + tier-coloured bars [OMN-22]

Replaces the ECharts-backed Quality Score widget. Two changes in one
pass because the full rewrite touches both the render layer and the
data model of the stat pane.

Why the rewrite:
  - The old widget coloured each of the 5 score buckets with a
    different entry from the chart palette. Buckets are slices of the
    same metric (score), not separate series; giving them unrelated
    hues made the histogram read as five unrelated bars rather than
    one distribution.
  - The mean marker used ECharts' `type: 'average'` which averages
    the Y values (bucket counts), not the weighted score mean that
    the fixture computes. The line was in one place and the label
    claimed a different number. Deleted.
  - "Mean Score: 0.67" was buried in small text above the chart.
    On a glance dashboard the most actionable number ("am I passing?")
    wasn't the biggest thing on screen.

New design:
  - Split layout. Left pane: big pass-rate % in a traffic-light
    colour (green / amber / red based on the value), with mean +
    measurement-count as supporting captions. Right pane: the 3D
    bar chart.
  - 3D chart in three.js. 5 bars along a strip, coloured via a
    red → amber → green hue sweep (hsl 0°..120°) so the shape of
    the distribution also encodes the quality story. MeshStandard
    material + one directional light gives the bars readable
    dimensionality without needing shadows.
  - Translucent vertical "wall" behind the bars at the x-position
    corresponding to the pass threshold, with a bright-outlined edge.
    Spatially communicates "right of the wall = passing".
  - Floating cone marker above the bars pointing down at the mean
    score's x-position, with a dashed drop-line to the ground. Uses
    the brand token.
  - Hover a bar → HTML tooltip with bucket label, count, and % of
    total. Fixed camera, no orbit.
  - Title shortened "Quality Score Distribution" → "Quality Scores".

Config:
  - Added `passThreshold` (default 0.8, range [0, 1]) to the widget
    manifest. Drives both the pass-rate calculation and the wall's
    x-position so a dashboard can calibrate per use case.

Manifest:
  - displayName unchanged ("Quality Scores"), version bumped 1.0.0 →
    1.1.0 to reflect the behaviour + config change.
  - minSize bumped w 3 → 4 so the split layout has room. defaultSize
    and maxSize unchanged.

Shared helper: cssColorToHex extracted from cost-trend/StackedChart
to src/theme/cssColorToHex.ts now that two three.js widgets need it.
Exported through src/theme/index.ts alongside useThemeColors. No
behaviour change to the cost-trend chart; StackedChart just imports
from the new location.

Test: mocks three.js WebGLRenderer with a no-op class (jsdom has no
WebGL context) and shims ResizeObserver. New assertions verify the
pass-rate arithmetic for two thresholds (default 0.8 and a custom
0.6) so the stat pane's headline is locked in.

* fix(quality): Tron-style bars + clearer mean marker [OMN-22]

Bar rendering swapped from MeshStandardMaterial (shaded, semi-opaque)
to the cost-trend-3d Tron recipe: translucent coloured core at 0.55
opacity + bright edge lines on every cube edge at 0.95 opacity. No
lighting (materials are unlit), so dropped the ambient + directional
lights from scene setup. Saturation bumped 0.6 → 0.85 and lightness
0.55 → 0.62 on the red→green tier gradient for the neon feel. The
two three.js widgets now read as siblings visually.

Ground grid switched from a subtle --line colour to cyan (#00e5ff) at
0.35 opacity for the same reason. Threshold plane outline opacity
bumped 0.5 → 0.75 and the wall itself dropped 0.12 → 0.08 so the
outline carries more of the read.

Mean marker reworked. The dashed drop line from the floating cone
down to the ground was getting hidden behind the tallest bar on
common data shapes — one of those "what IS that thing?" visuals.
Dropped it entirely. The cone is slightly larger (radius 0.16 → 0.2,
height 0.3 → 0.42) and now explicitly labelled: we project the
cone's world position to canvas pixels at scene-build time and
anchor an HTML "MEAN 0.XX" chip just above it. The relationship
reads at a glance without needing the line.

* fix(quality): theme-aware contrast for light mode [OMN-22]

The Tron colour recipe I baked in last commit worked against the dark
background but failed in light mode on several specific pairs:

  - Cyan grid (0x00e5ff, opacity 0.35) on the near-white panel — the
    lines were effectively invisible, leaving the ground plane with
    no tick markers.
  - Threshold plane and outline, both white, on a near-white canvas —
    "pass wall" disappeared entirely. Users had no visual cue that
    the scene was partitioned into pass/fail regions.
  - Bar cores at 0.55 opacity on a light background — values washed
    out instead of reading as solid bars.
  - Bar edges at full neon saturation felt garish against the light
    theme's pastel aesthetic.

Introduced a per-theme EffectiveColors block (DARK_EFFECTIVE /
LIGHT_EFFECTIVE) covering grid colour + opacity, threshold plane +
outline colour + opacity, bar core + edge opacity, and bar HSL
saturation + lightness. Light theme swaps:
  grid:      cyan  → slate grey (0x6b7280) at higher opacity
  threshold: white → dark slate (0x334155) on both plane + outline
  bar core:  0.55  → 0.72 opacity so fills read on white
  bar edges: 0.95  → 0.80 opacity (softer against the lighter bg)
  bar hue:   0.85 sat / 0.62 light → 0.60 / 0.50 (pastel, matches
             cost-trend-3d's light theme)
  ground:    one step off canvas-bg in both themes via --panel vs
             --panel-2 so the floor has a visible boundary

Mechanics: extracted the existing useThemeName hook (previously
duplicated inside cost-trend-3d) to src/theme/useThemeName.ts and
exported from the theme barrel. cost-trend-3d updated to import from
the shared location; QualityScorePanel now subscribes and picks the
right EffectiveColors block. Scene rebuilds on theme toggle via the
hook's MutationObserver on data-theme.

* fix(quality): light-mode floor was black — explicit hex + new cssVarToHex helper [OMN-22]

Root cause: the ground plane was built via
`cssColorToHex('var(--panel)', 0x0b0e14)`. Canvas 2D's fillStyle
setter (which cssColorToHex uses for parsing) silently rejects
`var()` syntax and retains the previous value, so the sentinel-check
fell through to the fallback on every call. In dark mode the
fallback 0x0b0e14 was close enough to the actual dark panel colour
that nobody noticed; in light mode a near-black floor against a
near-white canvas stood out as clearly wrong.

Two fixes:

1. Floor set as explicit hex per theme, mirroring the values
   cost-trend-3d ships in DARK_THEME / LIGHT_THEME so the two
   three.js widgets stay visually consistent:
     dark:  0x020812   (near-black, slight blue tint)
     light: 0xf7f8fa   (near-white, barely-there grey tint)

2. Added cssVarToHex(varName, fallback) to src/theme/cssColorToHex.ts
   — reads the computed CSS variable value off documentElement and
   passes the resolved literal to cssColorToHex. The mean cone's
   `var(--brand)` lookup was silently falling through to the same
   fallback for the same reason; switching to cssVarToHex means the
   cone now picks up the actual brand token in both themes.

* feat(cost-by-model): three.js pie widget — cost share per model [OMN-22]

New widget that complements Cost Trend. Where Cost Trend answers
"how has spend moved over time", this answers "who is spending the
money right now". Same llm_cost.v1 topic, client-side aggregation
by model name.

Scene: one pie lying flat on the XZ plane with a small extrusion
depth for 3D read. Each slice is built from THREE.Shape (center →
outer edge → arc → center) + ExtrudeGeometry so a single mesh holds
the whole wedge (top face + sides) and the pointer raycast is
straightforward. Slice colors come from the shared `--chart-*`
palette, indexed by a stable model-to-color map derived from the
full (un-range-filtered) dataset so a model keeps its colour even
if it drops out of the current window and later re-enters.

Interaction: hover pops the hovered slice outward by 0.09 world
units along its mid-angle, with an HTML tooltip showing the model,
cost, and share. Hover-only updates are done in a separate effect
from the slice-build effect so pointer moves don't tear down and
rebuild geometry.

Participates in the dashboard time range (supports_time_range:
true). Legend on the right shows each model with its colour dot,
percentage, and a footer line with the total cost in range.

Theme-aware via the existing PieTheme split (dark = Tron-leaning
with a touch of emissive, light = pastel with no self-glow). Floor
hex values match the cost-trend-3d floor so the two three.js
visualisations stay consistent across themes.

Registry entry `cost-by-model` added (category: visualization,
defaultSize 6×4, minSize 4×3). Both integration tests bumped from
expecting 8 components to 9. Lazy import registered in
src/components/dashboard/index.ts under its implementationKey.

New unit test mocks three.js WebGLRenderer + ResizeObserver and
locks the aggregation math: 4 input rows at $2/$1/$1/$1 across
three models should yield the expected 60% / 20% / 20% legend
shares and a $5.00 total.

* refactor(styles): unify font-family tokens — single source in :root [OMN-22]

Consolidate the font-family token system so a single CSS variable
determines every mono/sans rendering in the app.

- Define --font-sans and --font-mono in globals.css :root as primitives.
  Body and .mono now reference the tokens (previously hardcoded).
- Remove font from the vanilla-extract contract (tokens.css.ts,
  themes.css.ts). Legacy .theme-dark/.theme-light classes no longer
  set 'Inter' or 'JetBrains Mono' which were conflicting with the
  actually-loaded IBM Plex fonts.
- Update CSS files (dashboard.css, library.css, sidebar.css) +
  Header.css.ts + MessageBubble.tsx to use var(--font-mono).
- Simplify widget inline fontFamily from
  'var(--font-mono, "IBM Plex Mono", monospace)' to 'var(--fon…
jonahgabriel added a commit that referenced this pull request Apr 26, 2026
* feat(fixtures): Python fixture generator [OMN-30] (#1)

* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24] (#2)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path

* feat(data-source): ProtocolSnapshotSource interface [OMN-25] (#4)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26] (#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]

* feat(data-source): HttpSnapshotSource [OMN-28] (#6)

* feat(vite): fixturesMiddleware with testable handler + 5 contract tests [OMN-27] (#7)

* refactor(hooks): useProjectionQuery consumes ProtocolSnapshotSource [OMN-29] (#8)

* feat(hint): starter widget_type -> category preference heuristic [OMN-34] (#9)

* feat(registry): scan node_modules/@omninode/* for dashboardComponents [OMN-31] (#10)

* feat(layout): file-based layout persistence [OMN-32] (#11)

* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test

* feat(config): per-component config panel via @rjsf [OMN-33] (#12)

* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]

* feat(header): New dashboard button with inline create form [OMN-38] (#14)

* fix(registry): call resolveImplementations() eagerly so built-ins show as available [OMN-39] (#15)

* feat(fixtures): emit summary DTOs matching widget contracts; split shared topics [OMN-40] (#16)

* fix(dashboard): Save button now persists layout via layoutPersistence.write [OMN-41] (#17)

* feat(ui): install Tailwind + shadcn utilities + IBM Plex, port OKLCH tokens from Claude Design prototype [OMN-42] (#18)

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView (Phase 2) [OMN-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files

* fix(ci): drop unused waitFor import in Sidebar.test.tsx [OMN-43] (#20)

* feat(ui): verbatim CSS port of prototype; rewrite TSX to use semantic class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.

* chore(audit): scaffold chunked UI fidelity audit folders + README + example chunk [OMN-48]

* chore(audit): generate remaining 38 chunk files across 6 components [OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.

* audit: claim widget-library-06 [OMN-48]

* audit: complete widget-library-06 [OMN-48]

* audit: complete widget-library-01 [OMN-48]

* audit: complete widget-library-02 [OMN-48]

* audit: complete widget-library-03 [OMN-48]

* audit: complete widget-library-04 [OMN-48]

* audit: complete widget-library-05 [OMN-48]

* audit: complete empty-state-01 [OMN-48]

* audit: complete empty-state-02 [OMN-48]

* audit: complete empty-state-03 [OMN-48]

* audit: complete empty-state-04 [OMN-48]

* audit: complete empty-state-05 [OMN-48]

* audit: complete widget-card-01 [OMN-48]

* audit: complete widget-card-02 [OMN-48]

* audit: complete widget-card-03 [OMN-48]

* audit: complete widget-card-04 [OMN-48]

* audit: complete topbar-01 [OMN-48]

* audit: complete topbar-02 [OMN-48]

* audit: complete topbar-03 [OMN-48]

* audit: complete sidebar-01 [OMN-48]

* audit: complete sidebar-02 [OMN-48]

* audit: complete sidebar-03 [OMN-48]

* audit: complete sidebar-04 [OMN-48]

* audit: complete sidebar-05 [OMN-48]

* audit: complete sidebar-06 [OMN-48]

* audit: complete sidebar-07 [OMN-48]

* audit: complete sidebar-08 [OMN-48]

* audit: complete sidebar-09 [OMN-48]

* audit: complete sidebar-10 [OMN-48]

* audit: complete dashboard-01 [OMN-48]

* audit: complete dashboard-02 [OMN-48]

* audit: complete dashboard-03 [OMN-48]

* audit: complete dashboard-04 [OMN-48]

* audit: complete dashboard-05 [OMN-48]

* audit: complete dashboard-06 [OMN-48]

* audit: complete dashboard-07 [OMN-48]

* audit: complete dashboard-08 [OMN-48]

* audit: complete dashboard-09 [OMN-48]

* audit: complete dashboard-10 [OMN-48]

* audit: complete dashboard-11 [OMN-48]

* feat(ui): port EmptyState + WidgetLibrary missing structure [OMN-48]

Addresses audit findings grouped by theme:

- EmptyState (chunks 01-05): new component at src/components/dashboard/EmptyState.tsx
  rebuilds the prototype's dashed-card layout: diagonal stripe overlay,
  48x48 icon tile, "This dashboard is empty" headline, helper copy, and
  "Add first widget" CTA button. Wired in DashboardView in place of the
  prior single-line text fallback. onAdd enters edit mode when not
  already editing.

- WidgetLibrary (chunks 02, 03, 05, 06):
  - Adds close button (X) in .lib-head wired to onClose prop.
  - Adds .lib-search input with local q state; filters catalog by
    displayName + description substring match.
  - Adds .lib-card-thumb with a category-derived lucide icon
    (visualization→LineChart, metrics→Gauge, table→Table,
    status→ShieldCheck, stream→Radio).
  - Adds .lib-foot with "{N} widgets available" + "drag or click" hint.
  - Adds no-results empty-state branch inside .lib-body.
  - Keeps "not implemented" badge text (v2-specific semantic — v2 has
    component status; prototype's "Already on dashboard" semantic is not
    applicable until OMN-44 wires active-widget tracking).

DashboardView wires onClose to handleDiscard so the close button
exits edit mode + drops drafts — nearest equivalent to the prototype's
separate libOpen state.

* audit: mark EmptyState + WidgetLibrary chunks done [OMN-48]

Moves 9 chunks from audited/ to done/ after commit 4145b96:
- empty-state-01 through empty-state-05
- widget-library-02, widget-library-03, widget-library-05, widget-library-06

* feat(ui): port dashboard header + meta row per prototype [OMN-48]

Addresses audit findings for dashboard chunks 02-06:

- Rename title branch (dashboard-03, 04): adds click-to-edit affordance
  on .dash-title with chevron-down glyph + "Click to rename" tooltip.
  Input renders on edit; blur commits via renameDashboard(id, name),
  Enter blurs, Escape cancels.

- Meta row (dashboard-05): .dash-meta now renders the full prototype
  structure — widget count + middle-dot separators + "Timezone: {tz}"
  + "Auto-refresh 30s" with .mono class on numeric spans. Timezone is
  computed from the browser (e.g. "UTC−07:00") rather than hardcoded.

- Header actions (dashboard-06): "Edit" button renamed to "Add Widget"
  (primary variant) with Plus icon to match prototype intent; Save and
  Discard buttons gain Check and X icons respectively. Refresh and
  Share buttons from the prototype are deliberately omitted — they were
  decorative stubs with no v2 backing behavior and would be dead UI.

Tests updated to match new button label (/add widget/i); functionality
preserved. 242/242 passing.

* audit: mark dashboard header + meta chunks done [OMN-48]

* fix(ui): gate widget Live badge on isLive prop [OMN-48]

Addresses widget-card-03 finding: the .widget-live badge was rendered
unconditionally for every widget, falsely advertising real-time behavior
for static summary widgets (BaselinesROI, ReadinessGate, QualityScore).

- Adds isLive?: boolean prop to ComponentWrapper, default false.
- EventStream opts in (matches prototype's real-time-stream semantic).
- Other widgets stay silent by omission; they can opt in individually.

Prototype gates the badge on widget type ∈ {requests,logs,regions};
v2's category-based equivalent is the `stream` category, of which
EventStream is the sole current member.

* audit: close drag-scope chunks (blocked on OMN-44) + live badge fix done [OMN-48]

- 6 chunks moved to blocked/ with blocked_reason set: widget-card-01/02/04
  (drag + grip + kebab) and dashboard-08/09/10 (widget drag wiring,
  drop-slot, DropIndicator). All blocked on OMN-44 (drag-and-drop scope)
  per existing deferrals documented in DashboardView.tsx and
  ComponentWrapper.tsx headers.

- 2 chunks moved to done/:
  - widget-card-03 (Live badge gated in commit 31d988e)
  - dashboard-11 (dead `.library-open .grid .widget` CSS removed from
    src/styles/dashboard.css; v2 uses editMode state not library-open
    class, so the selector could never fire)

* fix(ui): sidebar + topbar polish per audit [OMN-48]

- sidebar-03: workspace label gets the prototype's tiny uppercase tracking
  (fontSize:10, textTransform:uppercase, letterSpacing:0.08em).
- sidebar-04: new-dashboard `+` button gains native `title="New dashboard"`
  tooltip (was aria-label only).
- sidebar-09: empty-state lineHeight 1.6 → 1.5 to match prototype.
- topbar-01: breadcrumb gains leading `<Menu size={16}/>` icon before "Home".

* audit: close remaining 17 chunks — 4 fixed, 13 accepted deviations [OMN-48]

Fixed (commit d15bc4c): sidebar-03, sidebar-04, sidebar-09, topbar-01.

Accepted with Resolution notes:
- Clean-audit chunks: sidebar-01, sidebar-02, sidebar-05 (no changes needed).
- False positives: sidebar-07 (Tailwind ≡ inline style), sidebar-10 (v2.15
  version bump was user-requested).
- Documented v2 architectural deviations: sidebar-06 (RenameInput
  subcomponent), sidebar-08 (shadcn DropdownMenu), topbar-02 (lucide
  icons), topbar-03 (role=button a11y), widget-library-01 (conditional
  render vs class toggle), widget-library-04 (enum-ordered grouping).
- Partially OMN-44 deferred: dashboard-01 (libOpen paddingRight),
  dashboard-07 (DropIndicator fragment — empty branch itself resolved
  in 4145b96).

Final state: todo 0 · audited 0 · blocked 6 (all OMN-44) · done 33.

* fix(ui): widgets added via palette now persist + no longer hidden behind rail

Two bugs introduced by OMN-48's rebuilt WidgetLibrary integration:

1. Clicking widgets in the palette produced only the `.lib-card:active`
   scale animation with no visible result, because `.library` is
   position:fixed (360px wide, covering the right side of the
   viewport). The grid rendered the added widget under the palette.
   Fix: add `paddingRight: 360` to the main content area when
   editMode is active, reserving space for the fixed rail.

2. Closing the palette (X button) destroyed widgets added during the
   session. Root cause: `onClose={handleDiscard}` reverted activeDashboard
   to the edit-start snapshot (empty for newly-created dashboards).
   Fix: `onClose={handleSave}` — closing the library now commits
   changes and exits edit mode, matching the prototype's implicit-save
   semantic. Save button remains the explicit commit; Discard still
   rolls back.

Also: palette moved out of the inline flex wrapper since it manages
its own fixed positioning; the 280px column now belongs solely to the
ConfigComponentPanel.

* docs: add OMN-22 UX polish plan (10 tickets OMN-49..OMN-58) [OMN-22]

Plan file for the post-OMN-48 UX polish round. Triaged 28 user-
surfaced observations into 10 PR-sized tickets under epic OMN-22:

- OMN-49 Task 1: Sidebar footer + workspace cleanup
- OMN-50 Task 2: DashboardView interaction polish
- OMN-51 Task 4: Widget chrome — kebab menu + removal + flatten
- OMN-52 Task 3: Timezone + auto-refresh countdown (blocked by OMN-50)
- OMN-53 Task 5: Widget contrast + chart sizing
- OMN-54 Task 6: Config panel as floating modal (blocked by OMN-51)
- OMN-55 Task 7: Routing decisions table polish
- OMN-56 Task 8: Drag-and-drop (blocked by OMN-51)
- OMN-57 Task 9: Grid reflow (masonry)
- OMN-58 Task 10: Light/dark theme wiring

Already-shipped items (Header cleanup, Refresh button, Agent UI)
documented in the plan's "Already addressed" section.

* fix(ui): rename inputs are now readable and auto-select text [OMN-22]

Sidebar dashboard rename input got no styling — user-agent defaults
rendered black text on white, which is invisible against the dark
sidebar. Added transparent background with a subtle brand-colored
border, dark tinted bg, and explicit sidebar-ink color so the input
reads as a clear edit affordance.

Also fixed two related issues that surfaced while testing:

- Dashboard title rename input (top of DashboardView) inherited color
  via CSS but the inheritance was flaky in some themes. Made the color
  explicit via inline style.

- Text was not auto-selecting when the input mounted from the kebab
  menu — Radix DropdownMenu's onCloseAutoFocus was returning focus to
  the kebab trigger after close, stealing it from the newly-mounted
  input. Fix: added onCloseAutoFocus={e => e.preventDefault()} on the
  dashboard-kebab DropdownMenuContent, and switched RenameInput from
  autoFocus+useEffect to a callback ref + requestAnimationFrame so
  focus+select runs after Radix's focus sequence settles.

Checklist item #1 addressed.

* fix(ui): sidebar rename flow + remove workspace chip [OMN-22]

Two checklist items from the post-OMN-48 UX polish round:

#1 Rename input fix (follow-up): after the initial color/background
  fix, fast-clicking Rename from the kebab menu caused the input to
  flash and revert, and the text didn't highlight. Root cause:
  Radix DropdownMenu's focus-scope teardown dispatches a blur on the
  previously-focused element during its close sequence, which fired
  onBlur and committed the empty value.

  Corrected approach:
    - Defer setRenamingId via queueMicrotask so the input mounts
      after Radix finishes closing.
    - Move focus+select into useLayoutEffect with a stable ref, so
      it runs once after defaultValue is applied.
    - Replace onBlur commit with a document-level pointerdown listener
      that only commits on genuine click-outside.
    - Enter/Escape still commit/cancel via onKeyDown.

  Reference: https://medium.com/@ojasskapre/fixing-input-focus-issues-in-a-shadcn-radix-card-ui-dropdown-menu-in-next-js-7b5931e049c8

#2 Workspace chip removal: dropped the hardcoded "Platform Eng"
  Workspace dropdown from the sidebar. No behavior was wired, no
  workspaces concept exists yet, and it was visual noise. Dropped the
  CSS rules (.workspace, .workspace-chip, .ws-name), the now-unused
  ChevronDown import, and updated the corresponding Sidebar test.

Also adds docs/ux-polish-checklist.md tracking the 28-item list.

* refactor(ui): remove widget count from dash-meta row [OMN-22]

Checklist #20: '0 widgets' / 'N widgets' text under the dashboard
title carried no real value — the library footer already shows the
catalog count, and users can see their widget count by glancing at
the grid. Dropped the span plus its trailing separator.

* refactor(ui): remove sidebar-foot status + version strings [OMN-22]

Checklist #26 + #27: neither the "All systems normal" indicator nor
the hardcoded "v2.15" version string had anything real backing them.
No health monitoring is wired, and the version string was a literal
not sourced from package.json. Dropped both.

Changes:
- Sidebar.tsx: removed the .sidebar-foot <div> (dot, status text, version).
- sidebar.css: dropped .sidebar-foot and .sidebar-foot .dot rules.
- globals.css: moved @keyframes pulse here (was in sidebar.css); still
  used by .widget-live::before in dashboard.css.
- Sidebar.test.tsx: replaced "renders ...normal" assertion with a
  "does NOT render sidebar-foot" assertion.
- FrameLayout.test.tsx: rebased its chrome-smoke assertion onto the
  "an omninode product" tagline (unique, stable).

* refactor(ui): remove Header chrome without backing systems [OMN-22]

Checklist #23 + #24 + #28:

- #23 removed the user chip (avatar "JS", "Jamie Sun", "Platform Eng"):
  there is no user system yet, the chip was static placeholder data.
- #24 removed the Notifications bell and Help-circle icon buttons:
  no notifications system, no help system, clicking them did nothing.
- #28 removed the Menu icon prefixing the breadcrumbs: looked like a
  hamburger menu trigger but had no onClick handler, inviting clicks
  that did nothing.

Header now contains: breadcrumbs, Refresh button (retained), theme
toggle (retained). Test updated to assert the removed elements are
absent.

This reproduces the manual edits that were lost this morning when
shared-worktree branch swapping during the OmniNode workflow cleared
the working tree.

* docs(checklist): mark tasks 20, 23, 24, 26, 27, 28 done [OMN-22]

* feat(ui): library rail smooth transition + close on switch + Escape [OMN-22]

Checklist #7 + #15 + #21, all clustered around widget-library edit-mode
behavior:

#15 Smooth open/close: ComponentPalette now accepts an isOpen prop and
  toggles the .library.open class instead of being conditionally
  mounted. The CSS transition (transform 0.3s cubic-bezier) fires in
  both directions because the element stays in the DOM. aria-hidden
  mirrors isOpen so screen readers ignore the off-screen rail.
  DashboardView renders <ComponentPalette isOpen={editMode} /> once,
  unconditionally.

#7 Close on dashboard switch: a useEffect watches activeDashboard?.id.
  When it changes (using a prevRef to skip the initial mount), it
  calls setEditMode(false), which slides the library out.

#21 Escape closes library: a useEffect binds a keydown listener when
  editMode is true. Pressing Escape calls setEditMode(false) and
  preventsDefault on the event. Listener is torn down when editMode
  goes false.

* fix(ui): explicit fixed height on CostTrend + QualityScore charts [OMN-22]

Checklist #5 + #6. Both charts were sized against parent containers
with no height constraint, so the percentage/flex values resolved to
nothing:

- CostTrendPanel: height:100% fell back to minHeight:200px — half
  what the design called for.
- QualityScorePanel: flex:1 inside a height:100% flex column inside
  an unconstrained .widget-body collapsed the chart to ~7px.

Both now set an explicit height:320px on the ReactECharts element.
QualityScorePanel no longer needs the outer flex column since its
children stack in normal flow. Simplified that wrapper to a plain div.

* fix(theme): expose mutedForeground; widgets no longer render text in bg color [OMN-22]

Checklist #4. Root cause was not per-widget — it was in useThemeColors.
The hook exposed `muted` as `readToken('muted')`, but the `--muted`
shadcn token maps to `var(--panel-2)` — a background tint, not a text
color. Every widget labelled `color: colors.muted` was rendering text
in the same color as its panel surface.

Changes:

- useThemeColors now exposes a new `mutedForeground` field that reads
  `--muted-foreground` (→ `--ink-2`). JSDoc on `muted` warns not to
  use it as a text color.
- DelegationMetrics: promoted "Total Delegations", "Quality Gate Pass
  Rate", "Cost Savings" labels from muted (bg) to foreground (full
  ink) — these sit beside primary metric values and are primary.
- BaselinesROICard: promoted all delta labels, "Recommendations"
  heading, and kv sub-labels to foreground.
- ReadinessGate: "Overall:" promoted to foreground; dimension detail
  and "Checked {time}" bumped to mutedForeground (visible secondary).
- QualityScorePanel: "Mean Score" summary and chart axis labels moved
  to mutedForeground.
- CostTrendPanel: chart axis labels moved to mutedForeground.
- EventStream: timestamp + source columns moved to mutedForeground.

After this change, all widget text renders in the correct ink tone
for its emphasis level in both light and dark themes.

* fix(layout): .main constrained to 100vh so content below fold is scrollable [OMN-22]

Checklist #3. Root cause was in src/styles/topbar.css: .main had
display:flex + flex-direction:column + min-width:0 but no height
constraint. Its parent .app sets height:100vh with display:grid but
default auto rows, so .main grew to content size. When content
(Header + DashboardView) exceeded 100vh, body { overflow:hidden }
in globals.css clipped the excess unreachably.

Fix: add height:100% + min-height:0 + overflow:hidden to .main so it
is pinned to its 100vh grid cell, its flex children can shrink, and
internal scroll happens inside .dash-body (which already has
overflow-y:auto).

Also tracks new task #29 (sidebar kebab menu hover + pointer) in the
checklist for later.

* fix(ui): DropdownMenuItem hover now visible; cursor shows pointer [OMN-22]

Checklist #29. Two issues in the shared shadcn DropdownMenuItem:

- cursor was cursor-default. Changed to cursor-pointer.
- focus:bg-accent did fire on hover (Radix sets focus via pointermove),
  but --accent resolves to oklch(92% 0.08 120) in light mode — a nearly
  white pale green that had no visible contrast against the panel's
  100% white background. Switched highlight to --panel-2, which is the
  prototype's own convention for menu hover (see dashboard.css
  .menu-item:hover). --panel-2 has meaningful contrast in both themes.

Covered three selectors — hover:, focus:, and data-[highlighted]: — to
catch every path Radix uses for highlighting.

Applied at the component level so every DropdownMenu in the app
benefits, not just the sidebar.

* fix(theme): ThemeProvider now drives both data-theme + .theme-* class [OMN-22]

Checklist #22. Root cause: ThemeProvider only toggled the legacy
vanilla-extract `.theme-dark`/`.theme-light` class on <body>. The
prototype-ported chrome (sidebar, topbar, widgets, dashboard) activates
its dark theme via `[data-theme="dark"]` on <html> in globals.css. That
attribute was never being set, so the toggle was a no-op for everything
newer than the vanilla-extract layer.

Fix: ThemeProvider now drives both systems from the same theme state:

- Sets/removes `data-theme="dark"` on document.documentElement (drives
  the globals.css OKLCH tokens used by all prototype-ported chrome).
- Keeps the `.theme-dark`/`.theme-light` body class toggle (drives the
  legacy vanilla-extract consumers like AgentChatPanel).

Also added a synchronous `<script>` in index.html that sets
`data-theme="dark"` before React hydrates. The default theme is dark, so
without this the user sees a brief light-mode flash on every page load
before ThemeProvider's useEffect runs.

* refactor(ui): grid now uses CSS columns for masonry-style flow [OMN-22]

Checklist #9. Replaced:
  .grid { display: grid; grid-template-columns: 1fr 1fr; }
with:
  .grid { columns: 2; column-gap: var(--row-gap); }
and added break-inside:avoid + margin-bottom:var(--row-gap) to .widget.

CSS columns flows children top-to-bottom within each column, so a
short widget no longer leaves dead vertical space under a tall
row-mate. Previously a 2-col grid with [tall, short] in row 1 pushed
row 2 past the tall widget's bottom edge, leaving a visible gap under
the short one.

* feat(ui): widget kebab menu + flatten sub-card chrome + rename grid class [OMN-22]

Checklist #8, #13, #14, and a follow-up rename:

#14 Kebab menu on every widget: new WidgetChromeContext carries
  {onConfigure, onDelete} from ComponentCell down to ComponentWrapper
  without threading them through widget props. ComponentWrapper renders
  a MoreVertical kebab button in the widget-head when at least one
  handler is provided. Menu items: Configure, Delete (destructive).
  Available in both view and edit modes.

#13 Easier widget removal: Delete lives in the kebab menu, reachable
  with one click from any mode (previously required entering edit
  mode and finding a less-obvious affordance).

#8 Flatten sub-card chrome: DashboardView no longer wraps each grid
  item in its own `<div className="widget">...widget-head...widget-body`
  outer shell. The widget is ComponentCell directly, which delegates
  chrome to the widget's own ComponentWrapper. One card level, not two.
  Also removes the click-widget-to-configure handler — Configure now
  only fires from the kebab menu.

  Prior ComponentCell also rendered an intervening
  `<div data-testid="grid-cell" style={{ overflow: 'hidden' }}>` wrapper.
  That overflow:hidden created a new block formatting context between
  .grid and .widget and prevented CSS columns flow. Normal path now
  skips this wrapper; `grid-cell` testid retained only on the fallback
  "component not available" path.

Follow-up rename: `.grid` → `.dash-grid`. Tailwind's utility layer
generates `.grid { display: grid; }` which loaded after dashboard.css
and overrode our `columns: 2`. Once `display: grid` is set the browser
ignores `columns: N` entirely, which collapsed widgets into a single
column. Renaming makes our custom rule win unambiguously.

* feat(ui): port prototype menu + modal patterns; drop shadcn dropdown/dialog [OMN-22]

Stay-close-to-prototype pass. The prototype's menus and config modal
use hand-authored markup with custom CSS, not a third-party primitive.
Mirroring that pattern lets future design iterations from Claude Design
drop in with minimal translation.

New shared infrastructure:

- src/components/ui/positioned-menu.tsx — small PositionedMenu +
  MenuItem + MenuSeparator components plus a usePositionedMenu() hook
  that captures the trigger's bounding rect on open and manages
  dismiss (outside-click + Escape). Portaled to document.body so it
  isn't clipped by any overflow:hidden ancestor. Uses the prototype's
  .menu / .menu-item / .menu-item.danger / .menu-sep CSS (already
  ported verbatim into dashboard.css).

- src/styles/modals.css additions — RJSF form class-name mappings
  (.form-group, .rjsf-field, .control-label, .form-control, .checkbox)
  styled to match the prototype's .field/.check look. Reset <fieldset>
  user-agent defaults so its content no longer overflows modal-body's
  max-height scroll container.

Config modal:

- ComponentConfigPanel rewritten to use the prototype's
  .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot
  markup verbatim. Cancel + Save changes footer buttons match the
  prototype's pattern. Save commits that placement's draft via a new
  commitDraft store action; Cancel discards. Escape + backdrop click
  also dismiss.

- src/store/configSlice.ts — commitDraft(placementId) writes a
  placement's draft config back to its layout item and clears the
  draft.

Widget kebab menu:

- ComponentWrapper renders a MoreVertical kebab button that opens a
  PositionedMenu with three items matching the prototype: Configure
  Widget, Duplicate, Remove Widget (danger). onDuplicate is a new
  handler supplied via WidgetChromeContext.

- src/store/dashboardSlice.ts — duplicateLayoutItem(itemId) clones a
  placement with a fresh id and deep-copied config, appended to the
  layout.

Sidebar dashboard kebab:

- Replaced Radix DropdownMenu with the shared PositionedMenu pattern.
  Items: Rename, Duplicate, Share Link (no-op placeholder), Delete.

- src/store/dashboardSlice.ts — duplicateDashboard(id) clones a
  dashboard with a fresh id, "(copy)" name suffix, deep-copied
  layout.

Fieldset / #root selector collision:

- The React mount <div id="root"> and RJSF's <fieldset id="root">
  both matched a bare `#root` selector in globals.css, which gave the
  fieldset height:100vh and overflowed the modal. Changed the
  selector to `div#root` so it only targets the React mount.

Cleanup:

- Removed src/components/ui/dropdown-menu.tsx (shadcn Radix shim, no
  remaining consumers) and uninstalled @radix-ui/react-dropdown-menu.
- Removed the briefly-lived src/components/ui/dialog.tsx + uninstalled
  @radix-ui/react-dialog (the modal moved to prototype markup before
  anything else picked it up).
- Deleted src/config/ComponentConfigPanel.css.ts (vanilla-extract
  styling no longer used after the prototype-markup port).
- grid-item testid moved from ComponentWrapper to a
  display:contents wrapper in ComponentCell so tests find it even
  when the widget's LazyComponent is still Suspending. Redundant
  grid-item testid dropped from the legacy DashboardGrid shim.

Reference breadcrumb:

- reference/claude-design/ now holds a local copy of the Claude Design
  prototype (tarball extracted from the share URL) so the files
  survive between sessions. reference/claude-design/ is gitignored
  (large), but reference/README.md is tracked as a breadcrumb
  pointing at the source URL and describing what's in each subdir.

Checklist #10 (config panel modal) + #14 follow-up. 243/243 tests pass.

* fix(widgets): promote primary data labels to --ink + bump tiny label fonts [OMN-22]

Closes ux-polish-checklist #4. Audit found DelegationMetrics and
BaselinesROICard labels were already rendered in --ink (darkest), not
--ink-3 as the dictated note assumed — perceived faintness came from
the 0.6875rem (~11px) font size. Bumped those to 0.75rem (12px).

QualityScorePanel's Mean Score summary row was genuinely secondary
(rendered in --muted-foreground). That row is the widget's only
non-chart content, so promoted to --foreground and bumped 0.75rem →
0.8125rem.

All other --ink-3 / mutedForeground usage is chart-chrome (axes,
loading/empty states, timestamps, search placeholders) and stays
appropriate for those roles.

* feat(theme): add --chart-* tokens + --status-* aliases on :root [OMN-22]

Closes ux-polish-checklist #30. useThemeColors() reads CSS vars from
document.documentElement, but --chart-1..7 only lived on the
.theme-* classes that vanilla-extract applied to <body>. Every chart
color was therefore resolving to the '#888888' fallback (all series
rendered as gray).

Moved --chart-1..7 onto :root with seven distinct hues at matched
lightness/chroma, clear of the brand green (--accent-h=120), plus a
brighter variant for [data-theme="dark"] so series identity is
consistent across theme toggle.

Also added --status-healthy/warning/error aliases pointing at the
prototype's --status-ok/warn/bad, so useThemeColors's status map
finds values too.

* refactor(readiness): convert ReadinessGate from cards to table layout [OMN-22]

Off-checklist request. The card grid (7 columns of status boxes) read
well at desktop widths but wasted vertical space and didn't scale to
more than 7 dimensions. Tabular layout with Status | Dimension |
Detail columns reads the same information more compactly and is
directly extensible to additional dimensions without layout changes.

Status is rendered as a color-outlined pill that picks up
--status-ok/warn/bad from the theme. Header uses the shared prototype
colors (--ink-2 / --panel-2 / --line). Dropped useThemeColors() in
favor of direct CSS vars to match the pattern established by
RoutingDecisionTable.

* feat(grid): drag-and-drop reorder + palette-to-grid insert [OMN-22]

Closes ux-polish-checklist #12.

Two drag sources: palette cards (copy into grid) and existing widgets
(reorder). Drop onto any widget inserts before its position; a
trailing "drop to append" zone appears at the grid tail while
dragging. The empty-state placeholder is replaced by a "drop to add"
slot during drags so a widget can be placed on an empty dashboard.

Store gets two new actions: insertComponentAt(name, version, size,
atIndex) and moveLayoutItem(itemId, toIndex). moveLayoutItem adjusts
the target index when fromIndex < toIndex so callers can pass the
pre-removal target index naturally. Both covered by unit tests.

Chrome context extended with draggable/isDragging/isDropTarget flags
plus HTML5 drag handlers. ComponentWrapper applies them to the
.widget root and adds a GripVertical icon in the head when
draggable. ComponentCell forwards the props through. ComponentPalette
cards become draggable drag sources that also fall back to
click-to-add.

Visual feedback: dragging source drops to 35% opacity; drop target
gets a 3px brand-colored top-edge bar via ::before (new CSS rule).
Append zone uses existing .drop-slot styling with an explicit
column-span so it reaches across the masonry columns.

data-drag-exclude convention added so interactive widget content can
opt out — ComponentWrapper checks the mousedown target's ancestors
for the attribute and cancels dragstart when found. Applied to
RoutingDecisionTable for future-proofing.

* docs(claude): rewrite widget-add recipe to match actual two-track mechanism [OMN-22]

The existing "Add a new widget" recipe described a per-widget
manifest.ts file colocated next to each widget — that file never
existed. In reality widgets live in two tracks:

  A. Local MVP widgets (all seven current widgets): component lives
     under src/components/dashboard/<slug>/, lazy import registered
     in src/components/dashboard/index.ts, manifest entry added to
     MVP_COMPONENTS in scripts/generate-registry.ts.
  B. External @omninode/* package widgets: published as npm packages
     with a dashboardComponents field in package.json pointing at a
     JSON manifest; generate-registry.ts scans node_modules for
     matching packages.

Rewrote the recipe into two clearly-labeled paths and updated "Where
to look" to point at the correct files. No behavior change.

* feat(cost-trend-3d): experimental Tron-style 3D cost visualization widget [OMN-22]

An interactive 3D cost widget that sits alongside the original 2D
CostTrend — same llm_cost topic, different implementation. Built for
exploration; not a replacement for the ECharts-based CostTrend.

Raw three.js (not @react-three/fiber) — started on R3F but hit WebGL
context loss on first render due to three@0.184 / r3f 9.6 skew and
swapped to plain three.js in a useEffect, which has been stable. R3F
was uninstalled after the switch.

Visualization: each bar = one (time-bucket, model) cell. X=time,
Z=model, Y=cost. Translucent cores + bright edge lines on a
near-black background (light theme uses greys + pastels). Grid lines
aligned to cell boundaries. Axis labels projected world→screen as
HTML overlays so they track the camera. Dates dedupe at day
boundaries so hourly buckets don't print MM/DD 24 times in a row.

Camera: faces the strip head-on (azimuth pinned). Horizontal
navigation via a scrollbar below the canvas — no free orbit. Tilt
via mouse-drag Y within a clamped range. Widescreen 200px canvas
height with the scene tuned to fill vertically via FOV=30,
distance=11, target-Y=0.6, Y-offset=-1px (values dialed in via a
temporary slider panel then baked in).

Interaction:
  - Hover: bar under pointer becomes fully opaque (clickability hint)
  - Plain click: cinematic focus animation — camera swoops in on the
    bar, non-focused bars + grid fade to near-transparent, clicked
    bar's opacity ramps to near-solid, stats panel slides in from
    the right with cost/tokens/requests/derived ratios. Escape or
    clicking elsewhere reverses. Mid-flight retargeting retreats to
    default first then re-launches (simpler than pose blending and
    reads as intentional).
  - Shift-click: existing isolate behavior (desaturate other models
    to neutral grey).
  - Model filter legend below the scrollbar — click to toggle
    visibility; filtered-out bars drop to near-zero opacity but stay
    spatially present.

Theme-aware. Two ThemeConfig objects (DARK_THEME = neon Tron,
LIGHT_THEME = greys + pastels on white) selected by a useThemeName
hook that watches data-theme on documentElement.

Fixture generator: _llm_cost_points now emits a full (time × model)
cross product (48 hourly buckets × 4 models = 192 records) so the
3D grid is dense and rectangular. Per-model cost multipliers
(opus > sonnet > qwen > deepseek) make the series visually distinct
rather than uniformly noisy.

Registry + imports + integration tests updated for the 8th widget.
Added three + @types/three as deps.

* docs(checklist): mark polish items done [OMN-22]

Marked #3, #4, #5, #6, #7, #8, #9, #10, #11, #13, #14, #15, #20,
#21, #22, #29, #30 as done, plus the off-checklist ReadinessGate
tabular rework. Updated progress totals (17 done / 10 pending / 2
n/a / 1 keep-as-is). Remaining pending: #7/#8/#10/#13/#14/#15/#21
turned out to already be implemented (confirmed later and marked);
next pass will tackle #12, #16, #19.

* feat(filters): global time range selector + stacked-area Cost Trend [OMN-22]

Adds a dashboard-level time range selector (header, left of the mode
buttons) that all time-series widgets consume for a consistent view
of the same window. Defaults to "Last 24h" on first mount so fresh
dashboards don't show 48h of raw fixture data out of the gate.

Changes by layer:

- ComponentManifest.capabilities gains optional `supports_time_range`.
  True = widget participates in the global range. False = snapshot or
  pre-aggregated summary; the selector is ignored. Existing manifest
  fixtures without the field continue to pass validation.
- TimeRange gains an optional `label` (e.g. "Last 24h") so the
  selector can render "Last 24h" instead of formatting absolute
  timestamps. Absent for custom ranges.
- useTimeRange.ts centralizes resolveTimeRange() + applyTimeRange()
  helpers and the TIME_RANGE_PRESETS constant. Widgets call
  applyTimeRange(data, d => d.<timestamp_field>, resolved) and get a
  filtered slice; no changes required to queryKey (caching stays
  keyed on topic, filtering is post-fetch).
- DateRangeSelector uses the existing PositionedMenu primitive —
  presets (1h/6h/24h/7d/30d) + "All time". Resolves presets to
  absolute ISO timestamps at click-time; window does not auto-advance
  on refetch.

Participating widgets (supports_time_range: true):
  cost-trend-panel, cost-trend-3d, routing-decision-table
Non-participating (false):
  delegation-metrics (summary), baselines-roi-card (snapshot),
  quality-score-panel (summary), readiness-gate (snapshot),
  event-stream (live tail)

CostTrendPanel switches from per-model lines to a stacked area chart
— each model is an additive layer so the top reads as total cost
over the window and each band reads as that model's contribution.
Smoothed curves, no symbols, 55% area opacity, 1px line. Also:
granularity default flipped from "day" to "hour" to match the
fixture's hourly buckets, and x-axis ticks include the hour in
hourly mode so adjacent dates don't collapse to the same label.

Both CostTrend widgets now render a "No cost data in the selected
time range" overlay when the underlying topic has data but the
current window clips it all away — distinct from the all-empty
state.

RoutingDecisionTable's footer "filtered from N" now shows the
in-range count instead of the raw total so the search filter's
denominator reflects the time-windowed set the user is searching
within.

* fix(cost-trend): legend below chart + drop emphasis.focus to fix hover flicker [OMN-22]

Two related changes to CostTrendPanel, landing together because they
both restructure the ECharts option:

1. Hover flicker (task #9). With tooltip.trigger='axis' showing all
   series' values at the hovered x-position AND
   emphasis.focus='series' on each series dimming non-hovered bands,
   the two behaviors fought each other — tooltip appeared, then the
   series emphasis kicked in and dimmed everything else, which read
   as "the line isolated on hover." Removed emphasis.focus; tooltip
   now stays stable across mouse movement with no visual isolation.

2. Legend placement (task #8). Replaced the built-in ECharts top
   legend with a custom HTML legend below the chart, styled like the
   3D Cost Trend's model-chip row (rounded pills with colored dot +
   model name, wraps to new rows if needed). The two Cost Trend
   widgets now read as a matched pair.

Behavioral differences vs 3D:
  - Click toggles visibility only; no isolate-on-click (3D has that;
    2D doesn't need it).
  - No "Clear focus" button.
  - No isolate highlight on the chip.

When a model is toggled off, its series is filtered out of the chart
entirely (not just hidden), so the remaining stack re-fits
vertically. The model list itself is derived from the full (un-
range-filtered) dataset so the legend stays stable as the user
scrubs the time range.

grid.top reduced 34 → 12 now that the built-in legend is gone — the
chart area reclaims the vertical space.

* feat(filters): custom start/end date range picker [OMN-22]

Extends the DateRangeSelector's popover with a "Custom range…" option
that opens an inline calendar + time-input UI. Keeps all existing
preset behavior intact — the custom view is a second view inside the
same popover, reached via a menu item that intentionally doesn't
close the popover on select.

Calendar: react-day-picker v9 in range mode (single month, 30px
cells). Time: two <input type="time"> controls for start/end HH:MM.
Apply builds a TimeRange with no `label` so the selector shows
"MM/DD – MM/DD" instead of a preset name; Cancel returns to the
preset list.

Theming: react-day-picker's --rdp-* CSS variables are bound to the
dashboard's existing tokens (--brand, --brand-soft, --brand-ink, --ink,
--panel-2, --line) on the calendar's wrapper div. Calendar picks up
the active light/dark theme with no extra JS. Compact sizing
(30x30 day cells) so the popover fits cleanly at 320px wide.

Safety: start/end swap if the user ends up with start > end after
combining calendar day + time input; PositionedMenu's outside-click
dismissal is blocked on pointerdown inside the calendar so clicking a
day doesn't close the whole popover.

Adds react-day-picker ^9.14.0. Zero transitive runtime deps in v9.

* fix(range-picker): theming cascade + sensible default times [OMN-22]

Two fixes to the custom-range date picker:

1. Calendar colors. The --rdp-* vars were being set inline on an outer
   wrapper, but react-day-picker's `.rdp-root` declares its own
   defaults at the same specificity — and that stylesheet loads into
   the bundle after the component mounts, so the library's "blue"
   accent was winning. Moved the overrides into CustomRangePicker.css
   which is imported AFTER `react-day-picker/style.css`, targeting
   `.dash-calendar-wrapper .rdp-root` (specificity 0,2,0 vs library's
   0,1,0). Also swapped the range_start/end text color from
   --brand-ink (dark green) to #ffffff — dark-green-on-brand-green
   didn't contrast enough — and range_middle text from --brand-ink to
   --ink so days inside the range read in the neutral dark-ink color
   rather than competing with the brand.

2. Default times. The picker was seeding its start + end time inputs
   from the currently-active range's timestamps, which meant if the
   active preset was "Last 24h" (clicked at 14:37) both inputs
   defaulted to 14:37. Now they default to 00:00 and 23:59 — the
   common case is "whole days between these two dates" and users who
   want a precise minute window can still set it explicitly.

* feat(cost-trend): replace ECharts with three.js 2D stacked-area chart [OMN-22]

Rewrites the 2D Cost Trend chart body in three.js using an orthographic
camera whose frustum matches canvas pixel coordinates (top-left origin,
y-down). ECharts stays as a dependency for other widgets (Quality,
Delegation) — this swap is scoped to CostTrendPanel.

Why: ECharts' default hover model interacted awkwardly with the
stacked-area layout, producing flicker and unintended band-isolation
effects on pointer move. Owning the render loop gives us full control
over hover and tooltip behavior, and matches the technology choice of
the 3D Cost Trend widget so both widgets share a mental model.

Architecture:
  - New StackedAreaChart component owns the three.js renderer/scene/
    camera for the chart body. Re-renders on size / data / theme
    changes only; no rAF loop (preserveDrawingBuffer:true so the canvas
    holds its last frame).
  - Each visible model's band is a BufferGeometry of 2N vertices and
    2(N-1) triangles (top+bottom polyline swept into a filled strip),
    with a sibling Line for the top-edge outline. Horizontal grid
    lines at "nice" y-tick values.
  - CostTrendPanel outer stays unchanged in shape: same data fetch,
    same time-range filter, same in-range-empty overlay, same custom
    legend from task #8 below the chart.

Interaction:
  - Hover: pointermove on the wrapper maps pointer X to the nearest
    bucket index. A vertical guide line (DOM div, not geometry)
    appears at that X; an HTML tooltip follows the cursor showing
    bucket timestamp, per-model cost breakdown in stack order (top to
    bottom), and the cumulative total.
  - No hover emphasis on the bands themselves — fixes the flicker the
    user was seeing with the ECharts implementation.

Color parsing fix: globals.css declares --chart-1..7 as oklch(...)
values, which THREE.Color.setStyle() doesn't understand (it supports
rgb/hsl/hex/named only). Added cssColorToHex() helper that pipes the
CSS value through a 1×1 canvas — Canvas 2D's fillStyle accepts the
full CSS color syntax and gives back a normalized sRGB pixel we can
convert to the numeric hex THREE wants.

Test updated to stub StackedAreaChart (jsdom has no WebGL context);
verifies the outer component plumbs correct bucket + model counts into
the chart child.

* feat(cost-trend): chartType config — stacked area or stacked bar [OMN-22]

Adds a `chartType` option to the 2D Cost Trend widget's manifest
configSchema (enum: 'area' | 'bar', default 'area'). Users pick via
the widget's Configure dialog (kebab menu → Configure Widget), the
value persists with the dashboard layout.

Rendering: same StackedSlice data structure in both modes; only the
geometry builder differs.
  - 'area' (existing): filled bands (triangle strip per model) + top-
    edge polyline outlines. Smooth trend read across time.
  - 'bar' (new): one rectangle per (bucket × visible-model) cell
    stacked on the previous model's cumulative total. Reads as
    discrete per-bucket observations. Bar width = plotWidth /
    bucketCount × 0.72 (with a floor at 2px so dense datasets still
    render). Zero-height cells skip their triangle indices so we
    don't emit degenerate geometry.

File rename: StackedAreaChart.tsx → StackedChart.tsx (via git mv) now
that the component handles both modes. Exported types renamed to
match; the test file's vi.mock path and testid updated accordingly.

Grid + axis labels + hover guide + tooltip are unchanged across modes
— all the chrome lives on the common StackedSlice, not the render
shape.

* fix(cost-trend): bar-mode cell-center x-mapping + suppress zero y-label [OMN-22]

Two visual fixes on the 2D stacked chart, both noticed at "Last 24h"
where the post-filter dataset has only a handful of buckets:

1. Bars overflowed the plot on both sides. The x-scale was
   edge-to-edge (plotLeft..plotRight) regardless of mode. With N=4
   and plotWidth=400 that gave 100px between bar centers and a bar
   width of ~72px, so the leftmost bar's left edge landed 36px inside
   the y-axis label column and the rightmost bar's right edge spilled
   36px past the canvas.

   Moved to a per-mode x-scale:
     - area: edge-to-edge (unchanged). A trend line should span the
       full plot area.
     - bar: cell-center. Each of N cells gets width plotWidth/N and
       its bar is centered. Every bar stays fully inside the plot
       area at any bucket count.

2. The $0 y-axis tick label sat directly above the leftmost x-axis
   date label at narrow widths and read as a single blob. The $0 tick
   is redundant with the x-axis baseline anyway, so the y-label for
   value 0 is now suppressed. The horizontal gridline at the baseline
   still renders; only the text label is dropped.

No test changes — the fixes are rendering-layer, and the chart
component is already stubbed in tests (jsdom has no WebGL context).

* fix(routing-table): tighten narrow columns + shorter timestamp [OMN-22]

Without an explicit <colgroup>, tableLayout:fixed distributed equal
1/6 widths to all six columns. "LLM Conf." held 3 chars ("83%") and
"Cost" held 7 ("$0.0231"), so those got far more space than they
needed while Timestamp ("4/20/2026, 10:40:00 AM" at 22 chars) wrapped
at typical widget widths.

Two-part fix:

1. Added <colgroup> with per-column widths that match content size:
   Timestamp 24%, LLM Agent 21%, Fuzzy Agent 21%, Agreement 13%,
   LLM Conf. 9%, Cost 12%. Totals to 100; tableLayout:fixed respects
   those exact proportions.

2. Shortened the displayed timestamp to "MM/DD HH:MM AM/PM" (dropping
   the year and seconds). Added whiteSpace:nowrap + text-overflow
   ellipsis on the cell and a title attribute carrying the full
   toLocaleString() form so the richer timestamp is still available
   on hover.

* feat(event-stream): search, sticky header, paused indicator, source pills [OMN-22]

First round of professionalism polish. Scope chosen from the suggestion
list: the clear bugs, the straightforward visual upgrades, and the two
highest-leverage interactions. Connection-status dot, click-to-expand,
correlation-id grouping, and live/paused toggle are all deferred.

Bug fixes
  - Dropped `hsl(${colors.border})` on the row border — useThemeColors
    returns a CSS-resolvable string already, so wrapping it in hsl()
    produced invalid CSS and the dividers were not rendering. Now uses
    `var(--line-2)` directly.
  - Gave the scroll container a definite height (SCROLL_HEIGHT = 320).
    The previous `height: 100%` collapsed or grew unpredictably since
    `.widget-body` has no height constraint.

Visual
  - Sticky column header (TIME · EVENT · SOURCE) at the top of the
    scroll region so the semantics of each column are obvious without
    inferring from content.
  - Row padding bumped from 4px to 6px × 10px for breathing room.
  - Timestamp column tightened 140 → 96px; new formatter shows
    HH:MM:SS for events in the last 24h and MM/DD HH:MM for older.
    A title attribute on each row exposes the full event_type,
    source, correlation_id, and ISO timestamp for debugging.
  - Timestamp and event_type both use the mono font stack; source
    moved into a small outlined pill whose hue is derived from a hash
    of the source string — stable per source so the eye can pick one
    out while scanning a fast stream.

Interaction
  - Search input over event_type / source / correlation_id. Mirrors
    the RoutingDecisionTable pattern.
  - Auto-scroll pause is now surfaced: when the user scrolls up, the
    component tracks the arrival count since that moment and shows a
    floating "↑ N new events" button pinned at the top of the scroll
    region. Click to jump back to top and resume auto-scroll. Also
    added an "auto-scroll paused" hint in the footer.
  - Footer shows `filtered / buffered` with a "buffer full" note
    when events have saturated maxEvents.

Internal: `userScrolledUp` promoted from a ref to state so the UI can
react to the paused condition (needed for the new-events button).

* feat(quality): three.js rewrite — pass-rate headline + tier-coloured bars [OMN-22]

Replaces the ECharts-backed Quality Score widget. Two changes in one
pass because the full rewrite touches both the render layer and the
data model of the stat pane.

Why the rewrite:
  - The old widget coloured each of the 5 score buckets with a
    different entry from the chart palette. Buckets are slices of the
    same metric (score), not separate series; giving them unrelated
    hues made the histogram read as five unrelated bars rather than
    one distribution.
  - The mean marker used ECharts' `type: 'average'` which averages
    the Y values (bucket counts), not the weighted score mean that
    the fixture computes. The line was in one place and the label
    claimed a different number. Deleted.
  - "Mean Score: 0.67" was buried in small text above the chart.
    On a glance dashboard the most actionable number ("am I passing?")
    wasn't the biggest thing on screen.

New design:
  - Split layout. Left pane: big pass-rate % in a traffic-light
    colour (green / amber / red based on the value), with mean +
    measurement-count as supporting captions. Right pane: the 3D
    bar chart.
  - 3D chart in three.js. 5 bars along a strip, coloured via a
    red → amber → green hue sweep (hsl 0°..120°) so the shape of
    the distribution also encodes the quality story. MeshStandard
    material + one directional light gives the bars readable
    dimensionality without needing shadows.
  - Translucent vertical "wall" behind the bars at the x-position
    corresponding to the pass threshold, with a bright-outlined edge.
    Spatially communicates "right of the wall = passing".
  - Floating cone marker above the bars pointing down at the mean
    score's x-position, with a dashed drop-line to the ground. Uses
    the brand token.
  - Hover a bar → HTML tooltip with bucket label, count, and % of
    total. Fixed camera, no orbit.
  - Title shortened "Quality Score Distribution" → "Quality Scores".

Config:
  - Added `passThreshold` (default 0.8, range [0, 1]) to the widget
    manifest. Drives both the pass-rate calculation and the wall's
    x-position so a dashboard can calibrate per use case.

Manifest:
  - displayName unchanged ("Quality Scores"), version bumped 1.0.0 →
    1.1.0 to reflect the behaviour + config change.
  - minSize bumped w 3 → 4 so the split layout has room. defaultSize
    and maxSize unchanged.

Shared helper: cssColorToHex extracted from cost-trend/StackedChart
to src/theme/cssColorToHex.ts now that two three.js widgets need it.
Exported through src/theme/index.ts alongside useThemeColors. No
behaviour change to the cost-trend chart; StackedChart just imports
from the new location.

Test: mocks three.js WebGLRenderer with a no-op class (jsdom has no
WebGL context) and shims ResizeObserver. New assertions verify the
pass-rate arithmetic for two thresholds (default 0.8 and a custom
0.6) so the stat pane's headline is locked in.

* fix(quality): Tron-style bars + clearer mean marker [OMN-22]

Bar rendering swapped from MeshStandardMaterial (shaded, semi-opaque)
to the cost-trend-3d Tron recipe: translucent coloured core at 0.55
opacity + bright edge lines on every cube edge at 0.95 opacity. No
lighting (materials are unlit), so dropped the ambient + directional
lights from scene setup. Saturation bumped 0.6 → 0.85 and lightness
0.55 → 0.62 on the red→green tier gradient for the neon feel. The
two three.js widgets now read as siblings visually.

Ground grid switched from a subtle --line colour to cyan (#00e5ff) at
0.35 opacity for the same reason. Threshold plane outline opacity
bumped 0.5 → 0.75 and the wall itself dropped 0.12 → 0.08 so the
outline carries more of the read.

Mean marker reworked. The dashed drop line from the floating cone
down to the ground was getting hidden behind the tallest bar on
common data shapes — one of those "what IS that thing?" visuals.
Dropped it entirely. The cone is slightly larger (radius 0.16 → 0.2,
height 0.3 → 0.42) and now explicitly labelled: we project the
cone's world position to canvas pixels at scene-build time and
anchor an HTML "MEAN 0.XX" chip just above it. The relationship
reads at a glance without needing the line.

* fix(quality): theme-aware contrast for light mode [OMN-22]

The Tron colour recipe I baked in last commit worked against the dark
background but failed in light mode on several specific pairs:

  - Cyan grid (0x00e5ff, opacity 0.35) on the near-white panel — the
    lines were effectively invisible, leaving the ground plane with
    no tick markers.
  - Threshold plane and outline, both white, on a near-white canvas —
    "pass wall" disappeared entirely. Users had no visual cue that
    the scene was partitioned into pass/fail regions.
  - Bar cores at 0.55 opacity on a light background — values washed
    out instead of reading as solid bars.
  - Bar edges at full neon saturation felt garish against the light
    theme's pastel aesthetic.

Introduced a per-theme EffectiveColors block (DARK_EFFECTIVE /
LIGHT_EFFECTIVE) covering grid colour + opacity, threshold plane +
outline colour + opacity, bar core + edge opacity, and bar HSL
saturation + lightness. Light theme swaps:
  grid:      cyan  → slate grey (0x6b7280) at higher opacity
  threshold: white → dark slate (0x334155) on both plane + outline
  bar core:  0.55  → 0.72 opacity so fills read on white
  bar edges: 0.95  → 0.80 opacity (softer against the lighter bg)
  bar hue:   0.85 sat / 0.62 light → 0.60 / 0.50 (pastel, matches
             cost-trend-3d's light theme)
  ground:    one step off canvas-bg in both themes via --panel vs
             --panel-2 so the floor has a visible boundary

Mechanics: extracted the existing useThemeName hook (previously
duplicated inside cost-trend-3d) to src/theme/useThemeName.ts and
exported from the theme barrel. cost-trend-3d updated to import from
the shared location; QualityScorePanel now subscribes and picks the
right EffectiveColors block. Scene rebuilds on theme toggle via the
hook's MutationObserver on data-theme.

* fix(quality): light-mode floor was black — explicit hex + new cssVarToHex helper [OMN-22]

Root cause: the ground plane was built via
`cssColorToHex('var(--panel)', 0x0b0e14)`. Canvas 2D's fillStyle
setter (which cssColorToHex uses for parsing) silently rejects
`var()` syntax and retains the previous value, so the sentinel-check
fell through to the fallback on every call. In dark mode the
fallback 0x0b0e14 was close enough to the actual dark panel colour
that nobody noticed; in light mode a near-black floor against a
near-white canvas stood out as clearly wrong.

Two fixes:

1. Floor set as explicit hex per theme, mirroring the values
   cost-trend-3d ships in DARK_THEME / LIGHT_THEME so the two
   three.js widgets stay visually consistent:
     dark:  0x020812   (near-black, slight blue tint)
     light: 0xf7f8fa   (near-white, barely-there grey tint)

2. Added cssVarToHex(varName, fallback) to src/theme/cssColorToHex.ts
   — reads the computed CSS variable value off documentElement and
   passes the resolved literal to cssColorToHex. The mean cone's
   `var(--brand)` lookup was silently falling through to the same
   fallback for the same reason; switching to cssVarToHex means the
   cone now picks up the actual brand token in both themes.

* feat(cost-by-model): three.js pie widget — cost share per model [OMN-22]

New widget that complements Cost Trend. Where Cost Trend answers
"how has spend moved over time", this answers "who is spending the
money right now". Same llm_cost.v1 topic, client-side aggregation
by model name.

Scene: one pie lying flat on the XZ plane with a small extrusion
depth for 3D read. Each slice is built from THREE.Shape (center →
outer edge → arc → center) + ExtrudeGeometry so a single mesh holds
the whole wedge (top face + sides) and the pointer raycast is
straightforward. Slice colors come from the shared `--chart-*`
palette, indexed by a stable model-to-color map derived from the
full (un-range-filtered) dataset so a model keeps its colour even
if it drops out of the current window and later re-enters.

Interaction: hover pops the hovered slice outward by 0.09 world
units along its mid-angle, with an HTML tooltip showing the model,
cost, and share. Hover-only updates are done in a separate effect
from the slice-build effect so pointer moves don't tear down and
rebuild geometry.

Participates in the dashboard time range (supports_time_range:
true). Legend on the right shows each model with its colour dot,
percentage, and a footer line with the total cost in range.

Theme-aware via the existing PieTheme split (dark = Tron-leaning
with a touch of emissive, light = pastel with no self-glow). Floor
hex values match the cost-trend-3d floor so the two three.js
visualisations stay consistent across themes.

Registry entry `cost-by-model` added (category: visualization,
defaultSize 6×4, minSize 4×3). Both integration tests bumped from
expecting 8 components to 9. Lazy import registered in
src/components/dashboard/index.ts under its implementationKey.

New unit test mocks three.js WebGLRenderer + ResizeObserver and
locks the aggregation math: 4 input rows at $2/$1/$1/$1 across
three models should yield the expected 60% / 20% / 20% legend
shares and a $5.00 total.

* refactor(styles): unify font-family tokens — single source in :root [OMN-22]

Consolidate the font-family token system so a single CSS variable
determines every mono/sans rendering in the app.

- Define --font-sans and --font-mono in globals.css :root as primitives.
  Body and .mono now reference the tokens (previously hardcoded).
- Remove font from the vanilla-extract contract (tokens.css.ts,
  themes.css.ts). Legacy .theme-dark/.theme-light classes no longer
  set 'Inter' or 'JetBrains Mono' which were conflicting with the
  actually-loaded IBM Plex fonts.
- Update CSS files (dashboard.css, library.css, sidebar.css) +
  Header.css.ts + MessageBubble.tsx to use var(--font-mono).
- Simplify widget inline fontFamily from
  'var(--font-mono, "IBM Plex Mono", monospace)' to 'var(--font…
@jonahgabriel

Copy link
Copy Markdown
Contributor Author

Closing in favour of a clean branch rebased on current origin/main. Replacement PR incoming.

jonahgabriel added a commit that referenced this pull request Apr 26, 2026
* feat(fixtures): Python fixture generator [OMN-30] (#1)

* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24] (#2)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path

* feat(data-source): ProtocolSnapshotSource interface [OMN-25] (#4)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26] (#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]

* feat(data-source): HttpSnapshotSource [OMN-28] (#6)

* feat(vite): fixturesMiddleware with testable handler + 5 contract tests [OMN-27] (#7)

* refactor(hooks): useProjectionQuery consumes ProtocolSnapshotSource [OMN-29] (#8)

* feat(hint): starter widget_type -> category preference heuristic [OMN-34] (#9)

* feat(registry): scan node_modules/@omninode/* for dashboardComponents [OMN-31] (#10)

* feat(layout): file-based layout persistence [OMN-32] (#11)

* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test

* feat(config): per-component config panel via @rjsf [OMN-33] (#12)

* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]

* feat(header): New dashboard button with inline create form [OMN-38] (#14)

* fix(registry): call resolveImplementations() eagerly so built-ins show as available [OMN-39] (#15)

* feat(fixtures): emit summary DTOs matching widget contracts; split shared topics [OMN-40] (#16)

* fix(dashboard): Save button now persists layout via layoutPersistence.write [OMN-41] (#17)

* feat(ui): install Tailwind + shadcn utilities + IBM Plex, port OKLCH tokens from Claude Design prototype [OMN-42] (#18)

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView (Phase 2) [OMN-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files

* fix(ci): drop unused waitFor import in Sidebar.test.tsx [OMN-43] (#20)

* feat(ui): verbatim CSS port of prototype; rewrite TSX to use semantic class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.

* chore(audit): scaffold chunked UI fidelity audit folders + README + example chunk [OMN-48]

* chore(audit): generate remaining 38 chunk files across 6 components [OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.

* audit: claim widget-library-06 [OMN-48]

* audit: complete widget-library-06 [OMN-48]

* audit: complete widget-library-01 [OMN-48]

* audit: complete widget-library-02 [OMN-48]

* audit: complete widget-library-03 [OMN-48]

* audit: complete widget-library-04 [OMN-48]

* audit: complete widget-library-05 [OMN-48]

* audit: complete empty-state-01 [OMN-48]

* audit: complete empty-state-02 [OMN-48]

* audit: complete empty-state-03 [OMN-48]

* audit: complete empty-state-04 [OMN-48]

* audit: complete empty-state-05 [OMN-48]

* audit: complete widget-card-01 [OMN-48]

* audit: complete widget-card-02 [OMN-48]

* audit: complete widget-card-03 [OMN-48]

* audit: complete widget-card-04 [OMN-48]

* audit: complete topbar-01 [OMN-48]

* audit: complete topbar-02 [OMN-48]

* audit: complete topbar-03 [OMN-48]

* audit: complete sidebar-01 [OMN-48]

* audit: complete sidebar-02 [OMN-48]

* audit: complete sidebar-03 [OMN-48]

* audit: complete sidebar-04 [OMN-48]

* audit: complete sidebar-05 [OMN-48]

* audit: complete sidebar-06 [OMN-48]

* audit: complete sidebar-07 [OMN-48]

* audit: complete sidebar-08 [OMN-48]

* audit: complete sidebar-09 [OMN-48]

* audit: complete sidebar-10 [OMN-48]

* audit: complete dashboard-01 [OMN-48]

* audit: complete dashboard-02 [OMN-48]

* audit: complete dashboard-03 [OMN-48]

* audit: complete dashboard-04 [OMN-48]

* audit: complete dashboard-05 [OMN-48]

* audit: complete dashboard-06 [OMN-48]

* audit: complete dashboard-07 [OMN-48]

* audit: complete dashboard-08 [OMN-48]

* audit: complete dashboard-09 [OMN-48]

* audit: complete dashboard-10 [OMN-48]

* audit: complete dashboard-11 [OMN-48]

* feat(ui): port EmptyState + WidgetLibrary missing structure [OMN-48]

Addresses audit findings grouped by theme:

- EmptyState (chunks 01-05): new component at src/components/dashboard/EmptyState.tsx
  rebuilds the prototype's dashed-card layout: diagonal stripe overlay,
  48x48 icon tile, "This dashboard is empty" headline, helper copy, and
  "Add first widget" CTA button. Wired in DashboardView in place of the
  prior single-line text fallback. onAdd enters edit mode when not
  already editing.

- WidgetLibrary (chunks 02, 03, 05, 06):
  - Adds close button (X) in .lib-head wired to onClose prop.
  - Adds .lib-search input with local q state; filters catalog by
    displayName + description substring match.
  - Adds .lib-card-thumb with a category-derived lucide icon
    (visualization→LineChart, metrics→Gauge, table→Table,
    status→ShieldCheck, stream→Radio).
  - Adds .lib-foot with "{N} widgets available" + "drag or click" hint.
  - Adds no-results empty-state branch inside .lib-body.
  - Keeps "not implemented" badge text (v2-specific semantic — v2 has
    component status; prototype's "Already on dashboard" semantic is not
    applicable until OMN-44 wires active-widget tracking).

DashboardView wires onClose to handleDiscard so the close button
exits edit mode + drops drafts — nearest equivalent to the prototype's
separate libOpen state.

* audit: mark EmptyState + WidgetLibrary chunks done [OMN-48]

Moves 9 chunks from audited/ to done/ after commit 4145b96:
- empty-state-01 through empty-state-05
- widget-library-02, widget-library-03, widget-library-05, widget-library-06

* feat(ui): port dashboard header + meta row per prototype [OMN-48]

Addresses audit findings for dashboard chunks 02-06:

- Rename title branch (dashboard-03, 04): adds click-to-edit affordance
  on .dash-title with chevron-down glyph + "Click to rename" tooltip.
  Input renders on edit; blur commits via renameDashboard(id, name),
  Enter blurs, Escape cancels.

- Meta row (dashboard-05): .dash-meta now renders the full prototype
  structure — widget count + middle-dot separators + "Timezone: {tz}"
  + "Auto-refresh 30s" with .mono class on numeric spans. Timezone is
  computed from the browser (e.g. "UTC−07:00") rather than hardcoded.

- Header actions (dashboard-06): "Edit" button renamed to "Add Widget"
  (primary variant) with Plus icon to match prototype intent; Save and
  Discard buttons gain Check and X icons respectively. Refresh and
  Share buttons from the prototype are deliberately omitted — they were
  decorative stubs with no v2 backing behavior and would be dead UI.

Tests updated to match new button label (/add widget/i); functionality
preserved. 242/242 passing.

* audit: mark dashboard header + meta chunks done [OMN-48]

* fix(ui): gate widget Live badge on isLive prop [OMN-48]

Addresses widget-card-03 finding: the .widget-live badge was rendered
unconditionally for every widget, falsely advertising real-time behavior
for static summary widgets (BaselinesROI, ReadinessGate, QualityScore).

- Adds isLive?: boolean prop to ComponentWrapper, default false.
- EventStream opts in (matches prototype's real-time-stream semantic).
- Other widgets stay silent by omission; they can opt in individually.

Prototype gates the badge on widget type ∈ {requests,logs,regions};
v2's category-based equivalent is the `stream` category, of which
EventStream is the sole current member.

* audit: close drag-scope chunks (blocked on OMN-44) + live badge fix done [OMN-48]

- 6 chunks moved to blocked/ with blocked_reason set: widget-card-01/02/04
  (drag + grip + kebab) and dashboard-08/09/10 (widget drag wiring,
  drop-slot, DropIndicator). All blocked on OMN-44 (drag-and-drop scope)
  per existing deferrals documented in DashboardView.tsx and
  ComponentWrapper.tsx headers.

- 2 chunks moved to done/:
  - widget-card-03 (Live badge gated in commit 31d988e)
  - dashboard-11 (dead `.library-open .grid .widget` CSS removed from
    src/styles/dashboard.css; v2 uses editMode state not library-open
    class, so the selector could never fire)

* fix(ui): sidebar + topbar polish per audit [OMN-48]

- sidebar-03: workspace label gets the prototype's tiny uppercase tracking
  (fontSize:10, textTransform:uppercase, letterSpacing:0.08em).
- sidebar-04: new-dashboard `+` button gains native `title="New dashboard"`
  tooltip (was aria-label only).
- sidebar-09: empty-state lineHeight 1.6 → 1.5 to match prototype.
- topbar-01: breadcrumb gains leading `<Menu size={16}/>` icon before "Home".

* audit: close remaining 17 chunks — 4 fixed, 13 accepted deviations [OMN-48]

Fixed (commit d15bc4c): sidebar-03, sidebar-04, sidebar-09, topbar-01.

Accepted with Resolution notes:
- Clean-audit chunks: sidebar-01, sidebar-02, sidebar-05 (no changes needed).
- False positives: sidebar-07 (Tailwind ≡ inline style), sidebar-10 (v2.15
  version bump was user-requested).
- Documented v2 architectural deviations: sidebar-06 (RenameInput
  subcomponent), sidebar-08 (shadcn DropdownMenu), topbar-02 (lucide
  icons), topbar-03 (role=button a11y), widget-library-01 (conditional
  render vs class toggle), widget-library-04 (enum-ordered grouping).
- Partially OMN-44 deferred: dashboard-01 (libOpen paddingRight),
  dashboard-07 (DropIndicator fragment — empty branch itself resolved
  in 4145b96).

Final state: todo 0 · audited 0 · blocked 6 (all OMN-44) · done 33.

* fix(ui): widgets added via palette now persist + no longer hidden behind rail

Two bugs introduced by OMN-48's rebuilt WidgetLibrary integration:

1. Clicking widgets in the palette produced only the `.lib-card:active`
   scale animation with no visible result, because `.library` is
   position:fixed (360px wide, covering the right side of the
   viewport). The grid rendered the added widget under the palette.
   Fix: add `paddingRight: 360` to the main content area when
   editMode is active, reserving space for the fixed rail.

2. Closing the palette (X button) destroyed widgets added during the
   session. Root cause: `onClose={handleDiscard}` reverted activeDashboard
   to the edit-start snapshot (empty for newly-created dashboards).
   Fix: `onClose={handleSave}` — closing the library now commits
   changes and exits edit mode, matching the prototype's implicit-save
   semantic. Save button remains the explicit commit; Discard still
   rolls back.

Also: palette moved out of the inline flex wrapper since it manages
its own fixed positioning; the 280px column now belongs solely to the
ConfigComponentPanel.

* docs: add OMN-22 UX polish plan (10 tickets OMN-49..OMN-58) [OMN-22]

Plan file for the post-OMN-48 UX polish round. Triaged 28 user-
surfaced observations into 10 PR-sized tickets under epic OMN-22:

- OMN-49 Task 1: Sidebar footer + workspace cleanup
- OMN-50 Task 2: DashboardView interaction polish
- OMN-51 Task 4: Widget chrome — kebab menu + removal + flatten
- OMN-52 Task 3: Timezone + auto-refresh countdown (blocked by OMN-50)
- OMN-53 Task 5: Widget contrast + chart sizing
- OMN-54 Task 6: Config panel as floating modal (blocked by OMN-51)
- OMN-55 Task 7: Routing decisions table polish
- OMN-56 Task 8: Drag-and-drop (blocked by OMN-51)
- OMN-57 Task 9: Grid reflow (masonry)
- OMN-58 Task 10: Light/dark theme wiring

Already-shipped items (Header cleanup, Refresh button, Agent UI)
documented in the plan's "Already addressed" section.

* fix(ui): rename inputs are now readable and auto-select text [OMN-22]

Sidebar dashboard rename input got no styling — user-agent defaults
rendered black text on white, which is invisible against the dark
sidebar. Added transparent background with a subtle brand-colored
border, dark tinted bg, and explicit sidebar-ink color so the input
reads as a clear edit affordance.

Also fixed two related issues that surfaced while testing:

- Dashboard title rename input (top of DashboardView) inherited color
  via CSS but the inheritance was flaky in some themes. Made the color
  explicit via inline style.

- Text was not auto-selecting when the input mounted from the kebab
  menu — Radix DropdownMenu's onCloseAutoFocus was returning focus to
  the kebab trigger after close, stealing it from the newly-mounted
  input. Fix: added onCloseAutoFocus={e => e.preventDefault()} on the
  dashboard-kebab DropdownMenuContent, and switched RenameInput from
  autoFocus+useEffect to a callback ref + requestAnimationFrame so
  focus+select runs after Radix's focus sequence settles.

Checklist item #1 addressed.

* fix(ui): sidebar rename flow + remove workspace chip [OMN-22]

Two checklist items from the post-OMN-48 UX polish round:

#1 Rename input fix (follow-up): after the initial color/background
  fix, fast-clicking Rename from the kebab menu caused the input to
  flash and revert, and the text didn't highlight. Root cause:
  Radix DropdownMenu's focus-scope teardown dispatches a blur on the
  previously-focused element during its close sequence, which fired
  onBlur and committed the empty value.

  Corrected approach:
    - Defer setRenamingId via queueMicrotask so the input mounts
      after Radix finishes closing.
    - Move focus+select into useLayoutEffect with a stable ref, so
      it runs once after defaultValue is applied.
    - Replace onBlur commit with a document-level pointerdown listener
      that only commits on genuine click-outside.
    - Enter/Escape still commit/cancel via onKeyDown.

  Reference: https://medium.com/@ojasskapre/fixing-input-focus-issues-in-a-shadcn-radix-card-ui-dropdown-menu-in-next-js-7b5931e049c8

#2 Workspace chip removal: dropped the hardcoded "Platform Eng"
  Workspace dropdown from the sidebar. No behavior was wired, no
  workspaces concept exists yet, and it was visual noise. Dropped the
  CSS rules (.workspace, .workspace-chip, .ws-name), the now-unused
  ChevronDown import, and updated the corresponding Sidebar test.

Also adds docs/ux-polish-checklist.md tracking the 28-item list.

* refactor(ui): remove widget count from dash-meta row [OMN-22]

Checklist #20: '0 widgets' / 'N widgets' text under the dashboard
title carried no real value — the library footer already shows the
catalog count, and users can see their widget count by glancing at
the grid. Dropped the span plus its trailing separator.

* refactor(ui): remove sidebar-foot status + version strings [OMN-22]

Checklist #26 + #27: neither the "All systems normal" indicator nor
the hardcoded "v2.15" version string had anything real backing them.
No health monitoring is wired, and the version string was a literal
not sourced from package.json. Dropped both.

Changes:
- Sidebar.tsx: removed the .sidebar-foot <div> (dot, status text, version).
- sidebar.css: dropped .sidebar-foot and .sidebar-foot .dot rules.
- globals.css: moved @keyframes pulse here (was in sidebar.css); still
  used by .widget-live::before in dashboard.css.
- Sidebar.test.tsx: replaced "renders ...normal" assertion with a
  "does NOT render sidebar-foot" assertion.
- FrameLayout.test.tsx: rebased its chrome-smoke assertion onto the
  "an omninode product" tagline (unique, stable).

* refactor(ui): remove Header chrome without backing systems [OMN-22]

Checklist #23 + #24 + #28:

- #23 removed the user chip (avatar "JS", "Jamie Sun", "Platform Eng"):
  there is no user system yet, the chip was static placeholder data.
- #24 removed the Notifications bell and Help-circle icon buttons:
  no notifications system, no help system, clicking them did nothing.
- #28 removed the Menu icon prefixing the breadcrumbs: looked like a
  hamburger menu trigger but had no onClick handler, inviting clicks
  that did nothing.

Header now contains: breadcrumbs, Refresh button (retained), theme
toggle (retained). Test updated to assert the removed elements are
absent.

This reproduces the manual edits that were lost this morning when
shared-worktree branch swapping during the OmniNode workflow cleared
the working tree.

* docs(checklist): mark tasks 20, 23, 24, 26, 27, 28 done [OMN-22]

* feat(ui): library rail smooth transition + close on switch + Escape [OMN-22]

Checklist #7 + #15 + #21, all clustered around widget-library edit-mode
behavior:

#15 Smooth open/close: ComponentPalette now accepts an isOpen prop and
  toggles the .library.open class instead of being conditionally
  mounted. The CSS transition (transform 0.3s cubic-bezier) fires in
  both directions because the element stays in the DOM. aria-hidden
  mirrors isOpen so screen readers ignore the off-screen rail.
  DashboardView renders <ComponentPalette isOpen={editMode} /> once,
  unconditionally.

#7 Close on dashboard switch: a useEffect watches activeDashboard?.id.
  When it changes (using a prevRef to skip the initial mount), it
  calls setEditMode(false), which slides the library out.

#21 Escape closes library: a useEffect binds a keydown listener when
  editMode is true. Pressing Escape calls setEditMode(false) and
  preventsDefault on the event. Listener is torn down when editMode
  goes false.

* fix(ui): explicit fixed height on CostTrend + QualityScore charts [OMN-22]

Checklist #5 + #6. Both charts were sized against parent containers
with no height constraint, so the percentage/flex values resolved to
nothing:

- CostTrendPanel: height:100% fell back to minHeight:200px — half
  what the design called for.
- QualityScorePanel: flex:1 inside a height:100% flex column inside
  an unconstrained .widget-body collapsed the chart to ~7px.

Both now set an explicit height:320px on the ReactECharts element.
QualityScorePanel no longer needs the outer flex column since its
children stack in normal flow. Simplified that wrapper to a plain div.

* fix(theme): expose mutedForeground; widgets no longer render text in bg color [OMN-22]

Checklist #4. Root cause was not per-widget — it was in useThemeColors.
The hook exposed `muted` as `readToken('muted')`, but the `--muted`
shadcn token maps to `var(--panel-2)` — a background tint, not a text
color. Every widget labelled `color: colors.muted` was rendering text
in the same color as its panel surface.

Changes:

- useThemeColors now exposes a new `mutedForeground` field that reads
  `--muted-foreground` (→ `--ink-2`). JSDoc on `muted` warns not to
  use it as a text color.
- DelegationMetrics: promoted "Total Delegations", "Quality Gate Pass
  Rate", "Cost Savings" labels from muted (bg) to foreground (full
  ink) — these sit beside primary metric values and are primary.
- BaselinesROICard: promoted all delta labels, "Recommendations"
  heading, and kv sub-labels to foreground.
- ReadinessGate: "Overall:" promoted to foreground; dimension detail
  and "Checked {time}" bumped to mutedForeground (visible secondary).
- QualityScorePanel: "Mean Score" summary and chart axis labels moved
  to mutedForeground.
- CostTrendPanel: chart axis labels moved to mutedForeground.
- EventStream: timestamp + source columns moved to mutedForeground.

After this change, all widget text renders in the correct ink tone
for its emphasis level in both light and dark themes.

* fix(layout): .main constrained to 100vh so content below fold is scrollable [OMN-22]

Checklist #3. Root cause was in src/styles/topbar.css: .main had
display:flex + flex-direction:column + min-width:0 but no height
constraint. Its parent .app sets height:100vh with display:grid but
default auto rows, so .main grew to content size. When content
(Header + DashboardView) exceeded 100vh, body { overflow:hidden }
in globals.css clipped the excess unreachably.

Fix: add height:100% + min-height:0 + overflow:hidden to .main so it
is pinned to its 100vh grid cell, its flex children can shrink, and
internal scroll happens inside .dash-body (which already has
overflow-y:auto).

Also tracks new task #29 (sidebar kebab menu hover + pointer) in the
checklist for later.

* fix(ui): DropdownMenuItem hover now visible; cursor shows pointer [OMN-22]

Checklist #29. Two issues in the shared shadcn DropdownMenuItem:

- cursor was cursor-default. Changed to cursor-pointer.
- focus:bg-accent did fire on hover (Radix sets focus via pointermove),
  but --accent resolves to oklch(92% 0.08 120) in light mode — a nearly
  white pale green that had no visible contrast against the panel's
  100% white background. Switched highlight to --panel-2, which is the
  prototype's own convention for menu hover (see dashboard.css
  .menu-item:hover). --panel-2 has meaningful contrast in both themes.

Covered three selectors — hover:, focus:, and data-[highlighted]: — to
catch every path Radix uses for highlighting.

Applied at the component level so every DropdownMenu in the app
benefits, not just the sidebar.

* fix(theme): ThemeProvider now drives both data-theme + .theme-* class [OMN-22]

Checklist #22. Root cause: ThemeProvider only toggled the legacy
vanilla-extract `.theme-dark`/`.theme-light` class on <body>. The
prototype-ported chrome (sidebar, topbar, widgets, dashboard) activates
its dark theme via `[data-theme="dark"]` on <html> in globals.css. That
attribute was never being set, so the toggle was a no-op for everything
newer than the vanilla-extract layer.

Fix: ThemeProvider now drives both systems from the same theme state:

- Sets/removes `data-theme="dark"` on document.documentElement (drives
  the globals.css OKLCH tokens used by all prototype-ported chrome).
- Keeps the `.theme-dark`/`.theme-light` body class toggle (drives the
  legacy vanilla-extract consumers like AgentChatPanel).

Also added a synchronous `<script>` in index.html that sets
`data-theme="dark"` before React hydrates. The default theme is dark, so
without this the user sees a brief light-mode flash on every page load
before ThemeProvider's useEffect runs.

* refactor(ui): grid now uses CSS columns for masonry-style flow [OMN-22]

Checklist #9. Replaced:
  .grid { display: grid; grid-template-columns: 1fr 1fr; }
with:
  .grid { columns: 2; column-gap: var(--row-gap); }
and added break-inside:avoid + margin-bottom:var(--row-gap) to .widget.

CSS columns flows children top-to-bottom within each column, so a
short widget no longer leaves dead vertical space under a tall
row-mate. Previously a 2-col grid with [tall, short] in row 1 pushed
row 2 past the tall widget's bottom edge, leaving a visible gap under
the short one.

* feat(ui): widget kebab menu + flatten sub-card chrome + rename grid class [OMN-22]

Checklist #8, #13, #14, and a follow-up rename:

#14 Kebab menu on every widget: new WidgetChromeContext carries
  {onConfigure, onDelete} from ComponentCell down to ComponentWrapper
  without threading them through widget props. ComponentWrapper renders
  a MoreVertical kebab button in the widget-head when at least one
  handler is provided. Menu items: Configure, Delete (destructive).
  Available in both view and edit modes.

#13 Easier widget removal: Delete lives in the kebab menu, reachable
  with one click from any mode (previously required entering edit
  mode and finding a less-obvious affordance).

#8 Flatten sub-card chrome: DashboardView no longer wraps each grid
  item in its own `<div className="widget">...widget-head...widget-body`
  outer shell. The widget is ComponentCell directly, which delegates
  chrome to the widget's own ComponentWrapper. One card level, not two.
  Also removes the click-widget-to-configure handler — Configure now
  only fires from the kebab menu.

  Prior ComponentCell also rendered an intervening
  `<div data-testid="grid-cell" style={{ overflow: 'hidden' }}>` wrapper.
  That overflow:hidden created a new block formatting context between
  .grid and .widget and prevented CSS columns flow. Normal path now
  skips this wrapper; `grid-cell` testid retained only on the fallback
  "component not available" path.

Follow-up rename: `.grid` → `.dash-grid`. Tailwind's utility layer
generates `.grid { display: grid; }` which loaded after dashboard.css
and overrode our `columns: 2`. Once `display: grid` is set the browser
ignores `columns: N` entirely, which collapsed widgets into a single
column. Renaming makes our custom rule win unambiguously.

* feat(ui): port prototype menu + modal patterns; drop shadcn dropdown/dialog [OMN-22]

Stay-close-to-prototype pass. The prototype's menus and config modal
use hand-authored markup with custom CSS, not a third-party primitive.
Mirroring that pattern lets future design iterations from Claude Design
drop in with minimal translation.

New shared infrastructure:

- src/components/ui/positioned-menu.tsx — small PositionedMenu +
  MenuItem + MenuSeparator components plus a usePositionedMenu() hook
  that captures the trigger's bounding rect on open and manages
  dismiss (outside-click + Escape). Portaled to document.body so it
  isn't clipped by any overflow:hidden ancestor. Uses the prototype's
  .menu / .menu-item / .menu-item.danger / .menu-sep CSS (already
  ported verbatim into dashboard.css).

- src/styles/modals.css additions — RJSF form class-name mappings
  (.form-group, .rjsf-field, .control-label, .form-control, .checkbox)
  styled to match the prototype's .field/.check look. Reset <fieldset>
  user-agent defaults so its content no longer overflows modal-body's
  max-height scroll container.

Config modal:

- ComponentConfigPanel rewritten to use the prototype's
  .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot
  markup verbatim. Cancel + Save changes footer buttons match the
  prototype's pattern. Save commits that placement's draft via a new
  commitDraft store action; Cancel discards. Escape + backdrop click
  also dismiss.

- src/store/configSlice.ts — commitDraft(placementId) writes a
  placement's draft config back to its layout item and clears the
  draft.

Widget kebab menu:

- ComponentWrapper renders a MoreVertical kebab button that opens a
  PositionedMenu with three items matching the prototype: Configure
  Widget, Duplicate, Remove Widget (danger). onDuplicate is a new
  handler supplied via WidgetChromeContext.

- src/store/dashboardSlice.ts — duplicateLayoutItem(itemId) clones a
  placement with a fresh id and deep-copied config, appended to the
  layout.

Sidebar dashboard kebab:

- Replaced Radix DropdownMenu with the shared PositionedMenu pattern.
  Items: Rename, Duplicate, Share Link (no-op placeholder), Delete.

- src/store/dashboardSlice.ts — duplicateDashboard(id) clones a
  dashboard with a fresh id, "(copy)" name suffix, deep-copied
  layout.

Fieldset / #root selector collision:

- The React mount <div id="root"> and RJSF's <fieldset id="root">
  both matched a bare `#root` selector in globals.css, which gave the
  fieldset height:100vh and overflowed the modal. Changed the
  selector to `div#root` so it only targets the React mount.

Cleanup:

- Removed src/components/ui/dropdown-menu.tsx (shadcn Radix shim, no
  remaining consumers) and uninstalled @radix-ui/react-dropdown-menu.
- Removed the briefly-lived src/components/ui/dialog.tsx + uninstalled
  @radix-ui/react-dialog (the modal moved to prototype markup before
  anything else picked it up).
- Deleted src/config/ComponentConfigPanel.css.ts (vanilla-extract
  styling no longer used after the prototype-markup port).
- grid-item testid moved from ComponentWrapper to a
  display:contents wrapper in ComponentCell so tests find it even
  when the widget's LazyComponent is still Suspending. Redundant
  grid-item testid dropped from the legacy DashboardGrid shim.

Reference breadcrumb:

- reference/claude-design/ now holds a local copy of the Claude Design
  prototype (tarball extracted from the share URL) so the files
  survive between sessions. reference/claude-design/ is gitignored
  (large), but reference/README.md is tracked as a breadcrumb
  pointing at the source URL and describing what's in each subdir.

Checklist #10 (config panel modal) + #14 follow-up. 243/243 tests pass.

* fix(widgets): promote primary data labels to --ink + bump tiny label fonts [OMN-22]

Closes ux-polish-checklist #4. Audit found DelegationMetrics and
BaselinesROICard labels were already rendered in --ink (darkest), not
--ink-3 as the dictated note assumed — perceived faintness came from
the 0.6875rem (~11px) font size. Bumped those to 0.75rem (12px).

QualityScorePanel's Mean Score summary row was genuinely secondary
(rendered in --muted-foreground). That row is the widget's only
non-chart content, so promoted to --foreground and bumped 0.75rem →
0.8125rem.

All other --ink-3 / mutedForeground usage is chart-chrome (axes,
loading/empty states, timestamps, search placeholders) and stays
appropriate for those roles.

* feat(theme): add --chart-* tokens + --status-* aliases on :root [OMN-22]

Closes ux-polish-checklist #30. useThemeColors() reads CSS vars from
document.documentElement, but --chart-1..7 only lived on the
.theme-* classes that vanilla-extract applied to <body>. Every chart
color was therefore resolving to the '#888888' fallback (all series
rendered as gray).

Moved --chart-1..7 onto :root with seven distinct hues at matched
lightness/chroma, clear of the brand green (--accent-h=120), plus a
brighter variant for [data-theme="dark"] so series identity is
consistent across theme toggle.

Also added --status-healthy/warning/error aliases pointing at the
prototype's --status-ok/warn/bad, so useThemeColors's status map
finds values too.

* refactor(readiness): convert ReadinessGate from cards to table layout [OMN-22]

Off-checklist request. The card grid (7 columns of status boxes) read
well at desktop widths but wasted vertical space and didn't scale to
more than 7 dimensions. Tabular layout with Status | Dimension |
Detail columns reads the same information more compactly and is
directly extensible to additional dimensions without layout changes.

Status is rendered as a color-outlined pill that picks up
--status-ok/warn/bad from the theme. Header uses the shared prototype
colors (--ink-2 / --panel-2 / --line). Dropped useThemeColors() in
favor of direct CSS vars to match the pattern established by
RoutingDecisionTable.

* feat(grid): drag-and-drop reorder + palette-to-grid insert [OMN-22]

Closes ux-polish-checklist #12.

Two drag sources: palette cards (copy into grid) and existing widgets
(reorder). Drop onto any widget inserts before its position; a
trailing "drop to append" zone appears at the grid tail while
dragging. The empty-state placeholder is replaced by a "drop to add"
slot during drags so a widget can be placed on an empty dashboard.

Store gets two new actions: insertComponentAt(name, version, size,
atIndex) and moveLayoutItem(itemId, toIndex). moveLayoutItem adjusts
the target index when fromIndex < toIndex so callers can pass the
pre-removal target index naturally. Both covered by unit tests.

Chrome context extended with draggable/isDragging/isDropTarget flags
plus HTML5 drag handlers. ComponentWrapper applies them to the
.widget root and adds a GripVertical icon in the head when
draggable. ComponentCell forwards the props through. ComponentPalette
cards become draggable drag sources that also fall back to
click-to-add.

Visual feedback: dragging source drops to 35% opacity; drop target
gets a 3px brand-colored top-edge bar via ::before (new CSS rule).
Append zone uses existing .drop-slot styling with an explicit
column-span so it reaches across the masonry columns.

data-drag-exclude convention added so interactive widget content can
opt out — ComponentWrapper checks the mousedown target's ancestors
for the attribute and cancels dragstart when found. Applied to
RoutingDecisionTable for future-proofing.

* docs(claude): rewrite widget-add recipe to match actual two-track mechanism [OMN-22]

The existing "Add a new widget" recipe described a per-widget
manifest.ts file colocated next to each widget — that file never
existed. In reality widgets live in two tracks:

  A. Local MVP widgets (all seven current widgets): component lives
     under src/components/dashboard/<slug>/, lazy import registered
     in src/components/dashboard/index.ts, manifest entry added to
     MVP_COMPONENTS in scripts/generate-registry.ts.
  B. External @omninode/* package widgets: published as npm packages
     with a dashboardComponents field in package.json pointing at a
     JSON manifest; generate-registry.ts scans node_modules for
     matching packages.

Rewrote the recipe into two clearly-labeled paths and updated "Where
to look" to point at the correct files. No behavior change.

* feat(cost-trend-3d): experimental Tron-style 3D cost visualization widget [OMN-22]

An interactive 3D cost widget that sits alongside the original 2D
CostTrend — same llm_cost topic, different implementation. Built for
exploration; not a replacement for the ECharts-based CostTrend.

Raw three.js (not @react-three/fiber) — started on R3F but hit WebGL
context loss on first render due to three@0.184 / r3f 9.6 skew and
swapped to plain three.js in a useEffect, which has been stable. R3F
was uninstalled after the switch.

Visualization: each bar = one (time-bucket, model) cell. X=time,
Z=model, Y=cost. Translucent cores + bright edge lines on a
near-black background (light theme uses greys + pastels). Grid lines
aligned to cell boundaries. Axis labels projected world→screen as
HTML overlays so they track the camera. Dates dedupe at day
boundaries so hourly buckets don't print MM/DD 24 times in a row.

Camera: faces the strip head-on (azimuth pinned). Horizontal
navigation via a scrollbar below the canvas — no free orbit. Tilt
via mouse-drag Y within a clamped range. Widescreen 200px canvas
height with the scene tuned to fill vertically via FOV=30,
distance=11, target-Y=0.6, Y-offset=-1px (values dialed in via a
temporary slider panel then baked in).

Interaction:
  - Hover: bar under pointer becomes fully opaque (clickability hint)
  - Plain click: cinematic focus animation — camera swoops in on the
    bar, non-focused bars + grid fade to near-transparent, clicked
    bar's opacity ramps to near-solid, stats panel slides in from
    the right with cost/tokens/requests/derived ratios. Escape or
    clicking elsewhere reverses. Mid-flight retargeting retreats to
    default first then re-launches (simpler than pose blending and
    reads as intentional).
  - Shift-click: existing isolate behavior (desaturate other models
    to neutral grey).
  - Model filter legend below the scrollbar — click to toggle
    visibility; filtered-out bars drop to near-zero opacity but stay
    spatially present.

Theme-aware. Two ThemeConfig objects (DARK_THEME = neon Tron,
LIGHT_THEME = greys + pastels on white) selected by a useThemeName
hook that watches data-theme on documentElement.

Fixture generator: _llm_cost_points now emits a full (time × model)
cross product (48 hourly buckets × 4 models = 192 records) so the
3D grid is dense and rectangular. Per-model cost multipliers
(opus > sonnet > qwen > deepseek) make the series visually distinct
rather than uniformly noisy.

Registry + imports + integration tests updated for the 8th widget.
Added three + @types/three as deps.

* docs(checklist): mark polish items done [OMN-22]

Marked #3, #4, #5, #6, #7, #8, #9, #10, #11, #13, #14, #15, #20,
#21, #22, #29, #30 as done, plus the off-checklist ReadinessGate
tabular rework. Updated progress totals (17 done / 10 pending / 2
n/a / 1 keep-as-is). Remaining pending: #7/#8/#10/#13/#14/#15/#21
turned out to already be implemented (confirmed later and marked);
next pass will tackle #12, #16, #19.

* feat(filters): global time range selector + stacked-area Cost Trend [OMN-22]

Adds a dashboard-level time range selector (header, left of the mode
buttons) that all time-series widgets consume for a consistent view
of the same window. Defaults to "Last 24h" on first mount so fresh
dashboards don't show 48h of raw fixture data out of the gate.

Changes by layer:

- ComponentManifest.capabilities gains optional `supports_time_range`.
  True = widget participates in the global range. False = snapshot or
  pre-aggregated summary; the selector is ignored. Existing manifest
  fixtures without the field continue to pass validation.
- TimeRange gains an optional `label` (e.g. "Last 24h") so the
  selector can render "Last 24h" instead of formatting absolute
  timestamps. Absent for custom ranges.
- useTimeRange.ts centralizes resolveTimeRange() + applyTimeRange()
  helpers and the TIME_RANGE_PRESETS constant. Widgets call
  applyTimeRange(data, d => d.<timestamp_field>, resolved) and get a
  filtered slice; no changes required to queryKey (caching stays
  keyed on topic, filtering is post-fetch).
- DateRangeSelector uses the existing PositionedMenu primitive —
  presets (1h/6h/24h/7d/30d) + "All time". Resolves presets to
  absolute ISO timestamps at click-time; window does not auto-advance
  on refetch.

Participating widgets (supports_time_range: true):
  cost-trend-panel, cost-trend-3d, routing-decision-table
Non-participating (false):
  delegation-metrics (summary), baselines-roi-card (snapshot),
  quality-score-panel (summary), readiness-gate (snapshot),
  event-stream (live tail)

CostTrendPanel switches from per-model lines to a stacked area chart
— each model is an additive layer so the top reads as total cost
over the window and each band reads as that model's contribution.
Smoothed curves, no symbols, 55% area opacity, 1px line. Also:
granularity default flipped from "day" to "hour" to match the
fixture's hourly buckets, and x-axis ticks include the hour in
hourly mode so adjacent dates don't collapse to the same label.

Both CostTrend widgets now render a "No cost data in the selected
time range" overlay when the underlying topic has data but the
current window clips it all away — distinct from the all-empty
state.

RoutingDecisionTable's footer "filtered from N" now shows the
in-range count instead of the raw total so the search filter's
denominator reflects the time-windowed set the user is searching
within.

* fix(cost-trend): legend below chart + drop emphasis.focus to fix hover flicker [OMN-22]

Two related changes to CostTrendPanel, landing together because they
both restructure the ECharts option:

1. Hover flicker (task #9). With tooltip.trigger='axis' showing all
   series' values at the hovered x-position AND
   emphasis.focus='series' on each series dimming non-hovered bands,
   the two behaviors fought each other — tooltip appeared, then the
   series emphasis kicked in and dimmed everything else, which read
   as "the line isolated on hover." Removed emphasis.focus; tooltip
   now stays stable across mouse movement with no visual isolation.

2. Legend placement (task #8). Replaced the built-in ECharts top
   legend with a custom HTML legend below the chart, styled like the
   3D Cost Trend's model-chip row (rounded pills with colored dot +
   model name, wraps to new rows if needed). The two Cost Trend
   widgets now read as a matched pair.

Behavioral differences vs 3D:
  - Click toggles visibility only; no isolate-on-click (3D has that;
    2D doesn't need it).
  - No "Clear focus" button.
  - No isolate highlight on the chip.

When a model is toggled off, its series is filtered out of the chart
entirely (not just hidden), so the remaining stack re-fits
vertically. The model list itself is derived from the full (un-
range-filtered) dataset so the legend stays stable as the user
scrubs the time range.

grid.top reduced 34 → 12 now that the built-in legend is gone — the
chart area reclaims the vertical space.

* feat(filters): custom start/end date range picker [OMN-22]

Extends the DateRangeSelector's popover with a "Custom range…" option
that opens an inline calendar + time-input UI. Keeps all existing
preset behavior intact — the custom view is a second view inside the
same popover, reached via a menu item that intentionally doesn't
close the popover on select.

Calendar: react-day-picker v9 in range mode (single month, 30px
cells). Time: two <input type="time"> controls for start/end HH:MM.
Apply builds a TimeRange with no `label` so the selector shows
"MM/DD – MM/DD" instead of a preset name; Cancel returns to the
preset list.

Theming: react-day-picker's --rdp-* CSS variables are bound to the
dashboard's existing tokens (--brand, --brand-soft, --brand-ink, --ink,
--panel-2, --line) on the calendar's wrapper div. Calendar picks up
the active light/dark theme with no extra JS. Compact sizing
(30x30 day cells) so the popover fits cleanly at 320px wide.

Safety: start/end swap if the user ends up with start > end after
combining calendar day + time input; PositionedMenu's outside-click
dismissal is blocked on pointerdown inside the calendar so clicking a
day doesn't close the whole popover.

Adds react-day-picker ^9.14.0. Zero transitive runtime deps in v9.

* fix(range-picker): theming cascade + sensible default times [OMN-22]

Two fixes to the custom-range date picker:

1. Calendar colors. The --rdp-* vars were being set inline on an outer
   wrapper, but react-day-picker's `.rdp-root` declares its own
   defaults at the same specificity — and that stylesheet loads into
   the bundle after the component mounts, so the library's "blue"
   accent was winning. Moved the overrides into CustomRangePicker.css
   which is imported AFTER `react-day-picker/style.css`, targeting
   `.dash-calendar-wrapper .rdp-root` (specificity 0,2,0 vs library's
   0,1,0). Also swapped the range_start/end text color from
   --brand-ink (dark green) to #ffffff — dark-green-on-brand-green
   didn't contrast enough — and range_middle text from --brand-ink to
   --ink so days inside the range read in the neutral dark-ink color
   rather than competing with the brand.

2. Default times. The picker was seeding its start + end time inputs
   from the currently-active range's timestamps, which meant if the
   active preset was "Last 24h" (clicked at 14:37) both inputs
   defaulted to 14:37. Now they default to 00:00 and 23:59 — the
   common case is "whole days between these two dates" and users who
   want a precise minute window can still set it explicitly.

* feat(cost-trend): replace ECharts with three.js 2D stacked-area chart [OMN-22]

Rewrites the 2D Cost Trend chart body in three.js using an orthographic
camera whose frustum matches canvas pixel coordinates (top-left origin,
y-down). ECharts stays as a dependency for other widgets (Quality,
Delegation) — this swap is scoped to CostTrendPanel.

Why: ECharts' default hover model interacted awkwardly with the
stacked-area layout, producing flicker and unintended band-isolation
effects on pointer move. Owning the render loop gives us full control
over hover and tooltip behavior, and matches the technology choice of
the 3D Cost Trend widget so both widgets share a mental model.

Architecture:
  - New StackedAreaChart component owns the three.js renderer/scene/
    camera for the chart body. Re-renders on size / data / theme
    changes only; no rAF loop (preserveDrawingBuffer:true so the canvas
    holds its last frame).
  - Each visible model's band is a BufferGeometry of 2N vertices and
    2(N-1) triangles (top+bottom polyline swept into a filled strip),
    with a sibling Line for the top-edge outline. Horizontal grid
    lines at "nice" y-tick values.
  - CostTrendPanel outer stays unchanged in shape: same data fetch,
    same time-range filter, same in-range-empty overlay, same custom
    legend from task #8 below the chart.

Interaction:
  - Hover: pointermove on the wrapper maps pointer X to the nearest
    bucket index. A vertical guide line (DOM div, not geometry)
    appears at that X; an HTML tooltip follows the cursor showing
    bucket timestamp, per-model cost breakdown in stack order (top to
    bottom), and the cumulative total.
  - No hover emphasis on the bands themselves — fixes the flicker the
    user was seeing with the ECharts implementation.

Color parsing fix: globals.css declares --chart-1..7 as oklch(...)
values, which THREE.Color.setStyle() doesn't understand (it supports
rgb/hsl/hex/named only). Added cssColorToHex() helper that pipes the
CSS value through a 1×1 canvas — Canvas 2D's fillStyle accepts the
full CSS color syntax and gives back a normalized sRGB pixel we can
convert to the numeric hex THREE wants.

Test updated to stub StackedAreaChart (jsdom has no WebGL context);
verifies the outer component plumbs correct bucket + model counts into
the chart child.

* feat(cost-trend): chartType config — stacked area or stacked bar [OMN-22]

Adds a `chartType` option to the 2D Cost Trend widget's manifest
configSchema (enum: 'area' | 'bar', default 'area'). Users pick via
the widget's Configure dialog (kebab menu → Configure Widget), the
value persists with the dashboard layout.

Rendering: same StackedSlice data structure in both modes; only the
geometry builder differs.
  - 'area' (existing): filled bands (triangle strip per model) + top-
    edge polyline outlines. Smooth trend read across time.
  - 'bar' (new): one rectangle per (bucket × visible-model) cell
    stacked on the previous model's cumulative total. Reads as
    discrete per-bucket observations. Bar width = plotWidth /
    bucketCount × 0.72 (with a floor at 2px so dense datasets still
    render). Zero-height cells skip their triangle indices so we
    don't emit degenerate geometry.

File rename: StackedAreaChart.tsx → StackedChart.tsx (via git mv) now
that the component handles both modes. Exported types renamed to
match; the test file's vi.mock path and testid updated accordingly.

Grid + axis labels + hover guide + tooltip are unchanged across modes
— all the chrome lives on the common StackedSlice, not the render
shape.

* fix(cost-trend): bar-mode cell-center x-mapping + suppress zero y-label [OMN-22]

Two visual fixes on the 2D stacked chart, both noticed at "Last 24h"
where the post-filter dataset has only a handful of buckets:

1. Bars overflowed the plot on both sides. The x-scale was
   edge-to-edge (plotLeft..plotRight) regardless of mode. With N=4
   and plotWidth=400 that gave 100px between bar centers and a bar
   width of ~72px, so the leftmost bar's left edge landed 36px inside
   the y-axis label column and the rightmost bar's right edge spilled
   36px past the canvas.

   Moved to a per-mode x-scale:
     - area: edge-to-edge (unchanged). A trend line should span the
       full plot area.
     - bar: cell-center. Each of N cells gets width plotWidth/N and
       its bar is centered. Every bar stays fully inside the plot
       area at any bucket count.

2. The $0 y-axis tick label sat directly above the leftmost x-axis
   date label at narrow widths and read as a single blob. The $0 tick
   is redundant with the x-axis baseline anyway, so the y-label for
   value 0 is now suppressed. The horizontal gridline at the baseline
   still renders; only the text label is dropped.

No test changes — the fixes are rendering-layer, and the chart
component is already stubbed in tests (jsdom has no WebGL context).

* fix(routing-table): tighten narrow columns + shorter timestamp [OMN-22]

Without an explicit <colgroup>, tableLayout:fixed distributed equal
1/6 widths to all six columns. "LLM Conf." held 3 chars ("83%") and
"Cost" held 7 ("$0.0231"), so those got far more space than they
needed while Timestamp ("4/20/2026, 10:40:00 AM" at 22 chars) wrapped
at typical widget widths.

Two-part fix:

1. Added <colgroup> with per-column widths that match content size:
   Timestamp 24%, LLM Agent 21%, Fuzzy Agent 21%, Agreement 13%,
   LLM Conf. 9%, Cost 12%. Totals to 100; tableLayout:fixed respects
   those exact proportions.

2. Shortened the displayed timestamp to "MM/DD HH:MM AM/PM" (dropping
   the year and seconds). Added whiteSpace:nowrap + text-overflow
   ellipsis on the cell and a title attribute carrying the full
   toLocaleString() form so the richer timestamp is still available
   on hover.

* feat(event-stream): search, sticky header, paused indicator, source pills [OMN-22]

First round of professionalism polish. Scope chosen from the suggestion
list: the clear bugs, the straightforward visual upgrades, and the two
highest-leverage interactions. Connection-status dot, click-to-expand,
correlation-id grouping, and live/paused toggle are all deferred.

Bug fixes
  - Dropped `hsl(${colors.border})` on the row border — useThemeColors
    returns a CSS-resolvable string already, so wrapping it in hsl()
    produced invalid CSS and the dividers were not rendering. Now uses
    `var(--line-2)` directly.
  - Gave the scroll container a definite height (SCROLL_HEIGHT = 320).
    The previous `height: 100%` collapsed or grew unpredictably since
    `.widget-body` has no height constraint.

Visual
  - Sticky column header (TIME · EVENT · SOURCE) at the top of the
    scroll region so the semantics of each column are obvious without
    inferring from content.
  - Row padding bumped from 4px to 6px × 10px for breathing room.
  - Timestamp column tightened 140 → 96px; new formatter shows
    HH:MM:SS for events in the last 24h and MM/DD HH:MM for older.
    A title attribute on each row exposes the full event_type,
    source, correlation_id, and ISO timestamp for debugging.
  - Timestamp and event_type both use the mono font stack; source
    moved into a small outlined pill whose hue is derived from a hash
    of the source string — stable per source so the eye can pick one
    out while scanning a fast stream.

Interaction
  - Search input over event_type / source / correlation_id. Mirrors
    the RoutingDecisionTable pattern.
  - Auto-scroll pause is now surfaced: when the user scrolls up, the
    component tracks the arrival count since that moment and shows a
    floating "↑ N new events" button pinned at the top of the scroll
    region. Click to jump back to top and resume auto-scroll. Also
    added an "auto-scroll paused" hint in the footer.
  - Footer shows `filtered / buffered` with a "buffer full" note
    when events have saturated maxEvents.

Internal: `userScrolledUp` promoted from a ref to state so the UI can
react to the paused condition (needed for the new-events button).

* feat(quality): three.js rewrite — pass-rate headline + tier-coloured bars [OMN-22]

Replaces the ECharts-backed Quality Score widget. Two changes in one
pass because the full rewrite touches both the render layer and the
data model of the stat pane.

Why the rewrite:
  - The old widget coloured each of the 5 score buckets with a
    different entry from the chart palette. Buckets are slices of the
    same metric (score), not separate series; giving them unrelated
    hues made the histogram read as five unrelated bars rather than
    one distribution.
  - The mean marker used ECharts' `type: 'average'` which averages
    the Y values (bucket counts), not the weighted score mean that
    the fixture computes. The line was in one place and the label
    claimed a different number. Deleted.
  - "Mean Score: 0.67" was buried in small text above the chart.
    On a glance dashboard the most actionable number ("am I passing?")
    wasn't the biggest thing on screen.

New design:
  - Split layout. Left pane: big pass-rate % in a traffic-light
    colour (green / amber / red based on the value), with mean +
    measurement-count as supporting captions. Right pane: the 3D
    bar chart.
  - 3D chart in three.js. 5 bars along a strip, coloured via a
    red → amber → green hue sweep (hsl 0°..120°) so the shape of
    the distribution also encodes the quality story. MeshStandard
    material + one directional light gives the bars readable
    dimensionality without needing shadows.
  - Translucent vertical "wall" behind the bars at the x-position
    corresponding to the pass threshold, with a bright-outlined edge.
    Spatially communicates "right of the wall = passing".
  - Floating cone marker above the bars pointing down at the mean
    score's x-position, with a dashed drop-line to the ground. Uses
    the brand token.
  - Hover a bar → HTML tooltip with bucket label, count, and % of
    total. Fixed camera, no orbit.
  - Title shortened "Quality Score Distribution" → "Quality Scores".

Config:
  - Added `passThreshold` (default 0.8, range [0, 1]) to the widget
    manifest. Drives both the pass-rate calculation and the wall's
    x-position so a dashboard can calibrate per use case.

Manifest:
  - displayName unchanged ("Quality Scores"), version bumped 1.0.0 →
    1.1.0 to reflect the behaviour + config change.
  - minSize bumped w 3 → 4 so the split layout has room. defaultSize
    and maxSize unchanged.

Shared helper: cssColorToHex extracted from cost-trend/StackedChart
to src/theme/cssColorToHex.ts now that two three.js widgets need it.
Exported through src/theme/index.ts alongside useThemeColors. No
behaviour change to the cost-trend chart; StackedChart just imports
from the new location.

Test: mocks three.js WebGLRenderer with a no-op class (jsdom has no
WebGL context) and shims ResizeObserver. New assertions verify the
pass-rate arithmetic for two thresholds (default 0.8 and a custom
0.6) so the stat pane's headline is locked in.

* fix(quality): Tron-style bars + clearer mean marker [OMN-22]

Bar rendering swapped from MeshStandardMaterial (shaded, semi-opaque)
to the cost-trend-3d Tron recipe: translucent coloured core at 0.55
opacity + bright edge lines on every cube edge at 0.95 opacity. No
lighting (materials are unlit), so dropped the ambient + directional
lights from scene setup. Saturation bumped 0.6 → 0.85 and lightness
0.55 → 0.62 on the red→green tier gradient for the neon feel. The
two three.js widgets now read as siblings visually.

Ground grid switched from a subtle --line colour to cyan (#00e5ff) at
0.35 opacity for the same reason. Threshold plane outline opacity
bumped 0.5 → 0.75 and the wall itself dropped 0.12 → 0.08 so the
outline carries more of the read.

Mean marker reworked. The dashed drop line from the floating cone
down to the ground was getting hidden behind the tallest bar on
common data shapes — one of those "what IS that thing?" visuals.
Dropped it entirely. The cone is slightly larger (radius 0.16 → 0.2,
height 0.3 → 0.42) and now explicitly labelled: we project the
cone's world position to canvas pixels at scene-build time and
anchor an HTML "MEAN 0.XX" chip just above it. The relationship
reads at a glance without needing the line.

* fix(quality): theme-aware contrast for light mode [OMN-22]

The Tron colour recipe I baked in last commit worked against the dark
background but failed in light mode on several specific pairs:

  - Cyan grid (0x00e5ff, opacity 0.35) on the near-white panel — the
    lines were effectively invisible, leaving the ground plane with
    no tick markers.
  - Threshold plane and outline, both white, on a near-white canvas —
    "pass wall" disappeared entirely. Users had no visual cue that
    the scene was partitioned into pass/fail regions.
  - Bar cores at 0.55 opacity on a light background — values washed
    out instead of reading as solid bars.
  - Bar edges at full neon saturation felt garish against the light
    theme's pastel aesthetic.

Introduced a per-theme EffectiveColors block (DARK_EFFECTIVE /
LIGHT_EFFECTIVE) covering grid colour + opacity, threshold plane +
outline colour + opacity, bar core + edge opacity, and bar HSL
saturation + lightness. Light theme swaps:
  grid:      cyan  → slate grey (0x6b7280) at higher opacity
  threshold: white → dark slate (0x334155) on both plane + outline
  bar core:  0.55  → 0.72 opacity so fills read on white
  bar edges: 0.95  → 0.80 opacity (softer against the lighter bg)
  bar hue:   0.85 sat / 0.62 light → 0.60 / 0.50 (pastel, matches
             cost-trend-3d's light theme)
  ground:    one step off canvas-bg in both themes via --panel vs
             --panel-2 so the floor has a visible boundary

Mechanics: extracted the existing useThemeName hook (previously
duplicated inside cost-trend-3d) to src/theme/useThemeName.ts and
exported from the theme barrel. cost-trend-3d updated to import from
the shared location; QualityScorePanel now subscribes and picks the
right EffectiveColors block. Scene rebuilds on theme toggle via the
hook's MutationObserver on data-theme.

* fix(quality): light-mode floor was black — explicit hex + new cssVarToHex helper [OMN-22]

Root cause: the ground plane was built via
`cssColorToHex('var(--panel)', 0x0b0e14)`. Canvas 2D's fillStyle
setter (which cssColorToHex uses for parsing) silently rejects
`var()` syntax and retains the previous value, so the sentinel-check
fell through to the fallback on every call. In dark mode the
fallback 0x0b0e14 was close enough to the actual dark panel colour
that nobody noticed; in light mode a near-black floor against a
near-white canvas stood out as clearly wrong.

Two fixes:

1. Floor set as explicit hex per theme, mirroring the values
   cost-trend-3d ships in DARK_THEME / LIGHT_THEME so the two
   three.js widgets stay visually consistent:
     dark:  0x020812   (near-black, slight blue tint)
     light: 0xf7f8fa   (near-white, barely-there grey tint)

2. Added cssVarToHex(varName, fallback) to src/theme/cssColorToHex.ts
   — reads the computed CSS variable value off documentElement and
   passes the resolved literal to cssColorToHex. The mean cone's
   `var(--brand)` lookup was silently falling through to the same
   fallback for the same reason; switching to cssVarToHex means the
   cone now picks up the actual brand token in both themes.

* feat(cost-by-model): three.js pie widget — cost share per model [OMN-22]

New widget that complements Cost Trend. Where Cost Trend answers
"how has spend moved over time", this answers "who is spending the
money right now". Same llm_cost.v1 topic, client-side aggregation
by model name.

Scene: one pie lying flat on the XZ plane with a small extrusion
depth for 3D read. Each slice is built from THREE.Shape (center →
outer edge → arc → center) + ExtrudeGeometry so a single mesh holds
the whole wedge (top face + sides) and the pointer raycast is
straightforward. Slice colors come from the shared `--chart-*`
palette, indexed by a stable model-to-color map derived from the
full (un-range-filtered) dataset so a model keeps its colour even
if it drops out of the current window and later re-enters.

Interaction: hover pops the hovered slice outward by 0.09 world
units along its mid-angle, with an HTML tooltip showing the model,
cost, and share. Hover-only updates are done in a separate effect
from the slice-build effect so pointer moves don't tear down and
rebuild geometry.

Participates in the dashboard time range (supports_time_range:
true). Legend on the right shows each model with its colour dot,
percentage, and a footer line with the total cost in range.

Theme-aware via the existing PieTheme split (dark = Tron-leaning
with a touch of emissive, light = pastel with no self-glow). Floor
hex values match the cost-trend-3d floor so the two three.js
visualisations stay consistent across themes.

Registry entry `cost-by-model` added (category: visualization,
defaultSize 6×4, minSize 4×3). Both integration tests bumped from
expecting 8 components to 9. Lazy import registered in
src/components/dashboard/index.ts under its implementationKey.

New unit test mocks three.js WebGLRenderer + ResizeObserver and
locks the aggregation math: 4 input rows at $2/$1/$1/$1 across
three models should yield the expected 60% / 20% / 20% legend
shares and a $5.00 total.

* refactor(styles): unify font-family tokens — single source in :root [OMN-22]

Consolidate the font-family token system so a single CSS variable
determines every mono/sans rendering in the app.

- Define --font-sans and --font-mono in globals.css :root as primitives.
  Body and .mono now reference the tokens (previously hardcoded).
- Remove font from the vanilla-extract contract (tokens.css.ts,
  themes.css.ts). Legacy .theme-dark/.theme-light classes no longer
  set 'Inter' or 'JetBrains Mono' which were conflicting with the
  actually-loaded IBM Plex fonts.
- Update CSS files (dashboard.css, library.css, sidebar.css) +
  Header.css.ts + MessageBubble.tsx to use var(--font-mono).
- Simplify widget inline fontFamily from
  'var(--font-mono, "IBM Plex Mono", monospace)' to 'var(--fon…
jonahgabriel added a commit that referenced this pull request Apr 26, 2026
…ct surface (#3)

* feat(fixtures): Python fixture generator [OMN-30] (#1)

* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24] (#2)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path

* feat(data-source): ProtocolSnapshotSource interface [OMN-25] (#4)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26] (#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]

* feat(data-source): HttpSnapshotSource [OMN-28] (#6)

* feat(vite): fixturesMiddleware with testable handler + 5 contract tests [OMN-27] (#7)

* refactor(hooks): useProjectionQuery consumes ProtocolSnapshotSource [OMN-29] (#8)

* feat(hint): starter widget_type -> category preference heuristic [OMN-34] (#9)

* feat(registry): scan node_modules/@omninode/* for dashboardComponents [OMN-31] (#10)

* feat(layout): file-based layout persistence [OMN-32] (#11)

* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test

* feat(config): per-component config panel via @rjsf [OMN-33] (#12)

* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]

* feat(header): New dashboard button with inline create form [OMN-38] (#14)

* fix(registry): call resolveImplementations() eagerly so built-ins show as available [OMN-39] (#15)

* feat(fixtures): emit summary DTOs matching widget contracts; split shared topics [OMN-40] (#16)

* fix(dashboard): Save button now persists layout via layoutPersistence.write [OMN-41] (#17)

* feat(ui): install Tailwind + shadcn utilities + IBM Plex, port OKLCH tokens from Claude Design prototype [OMN-42] (#18)

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView (Phase 2) [OMN-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files

* fix(ci): drop unused waitFor import in Sidebar.test.tsx [OMN-43] (#20)

* feat(ui): verbatim CSS port of prototype; rewrite TSX to use semantic class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.

* chore(audit): scaffold chunked UI fidelity audit folders + README + example chunk [OMN-48]

* chore(audit): generate remaining 38 chunk files across 6 components [OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.

* audit: claim widget-library-06 [OMN-48]

* audit: complete widget-library-06 [OMN-48]

* audit: complete widget-library-01 [OMN-48]

* audit: complete widget-library-02 [OMN-48]

* audit: complete widget-library-03 [OMN-48]

* audit: complete widget-library-04 [OMN-48]

* audit: complete widget-library-05 [OMN-48]

* audit: complete empty-state-01 [OMN-48]

* audit: complete empty-state-02 [OMN-48]

* audit: complete empty-state-03 [OMN-48]

* audit: complete empty-state-04 [OMN-48]

* audit: complete empty-state-05 [OMN-48]

* audit: complete widget-card-01 [OMN-48]

* audit: complete widget-card-02 [OMN-48]

* audit: complete widget-card-03 [OMN-48]

* audit: complete widget-card-04 [OMN-48]

* audit: complete topbar-01 [OMN-48]

* audit: complete topbar-02 [OMN-48]

* audit: complete topbar-03 [OMN-48]

* audit: complete sidebar-01 [OMN-48]

* audit: complete sidebar-02 [OMN-48]

* audit: complete sidebar-03 [OMN-48]

* audit: complete sidebar-04 [OMN-48]

* audit: complete sidebar-05 [OMN-48]

* audit: complete sidebar-06 [OMN-48]

* audit: complete sidebar-07 [OMN-48]

* audit: complete sidebar-08 [OMN-48]

* audit: complete sidebar-09 [OMN-48]

* audit: complete sidebar-10 [OMN-48]

* audit: complete dashboard-01 [OMN-48]

* audit: complete dashboard-02 [OMN-48]

* audit: complete dashboard-03 [OMN-48]

* audit: complete dashboard-04 [OMN-48]

* audit: complete dashboard-05 [OMN-48]

* audit: complete dashboard-06 [OMN-48]

* audit: complete dashboard-07 [OMN-48]

* audit: complete dashboard-08 [OMN-48]

* audit: complete dashboard-09 [OMN-48]

* audit: complete dashboard-10 [OMN-48]

* audit: complete dashboard-11 [OMN-48]

* feat(ui): port EmptyState + WidgetLibrary missing structure [OMN-48]

Addresses audit findings grouped by theme:

- EmptyState (chunks 01-05): new component at src/components/dashboard/EmptyState.tsx
  rebuilds the prototype's dashed-card layout: diagonal stripe overlay,
  48x48 icon tile, "This dashboard is empty" headline, helper copy, and
  "Add first widget" CTA button. Wired in DashboardView in place of the
  prior single-line text fallback. onAdd enters edit mode when not
  already editing.

- WidgetLibrary (chunks 02, 03, 05, 06):
  - Adds close button (X) in .lib-head wired to onClose prop.
  - Adds .lib-search input with local q state; filters catalog by
    displayName + description substring match.
  - Adds .lib-card-thumb with a category-derived lucide icon
    (visualization→LineChart, metrics→Gauge, table→Table,
    status→ShieldCheck, stream→Radio).
  - Adds .lib-foot with "{N} widgets available" + "drag or click" hint.
  - Adds no-results empty-state branch inside .lib-body.
  - Keeps "not implemented" badge text (v2-specific semantic — v2 has
    component status; prototype's "Already on dashboard" semantic is not
    applicable until OMN-44 wires active-widget tracking).

DashboardView wires onClose to handleDiscard so the close button
exits edit mode + drops drafts — nearest equivalent to the prototype's
separate libOpen state.

* audit: mark EmptyState + WidgetLibrary chunks done [OMN-48]

Moves 9 chunks from audited/ to done/ after commit 4145b96:
- empty-state-01 through empty-state-05
- widget-library-02, widget-library-03, widget-library-05, widget-library-06

* feat(ui): port dashboard header + meta row per prototype [OMN-48]

Addresses audit findings for dashboard chunks 02-06:

- Rename title branch (dashboard-03, 04): adds click-to-edit affordance
  on .dash-title with chevron-down glyph + "Click to rename" tooltip.
  Input renders on edit; blur commits via renameDashboard(id, name),
  Enter blurs, Escape cancels.

- Meta row (dashboard-05): .dash-meta now renders the full prototype
  structure — widget count + middle-dot separators + "Timezone: {tz}"
  + "Auto-refresh 30s" with .mono class on numeric spans. Timezone is
  computed from the browser (e.g. "UTC−07:00") rather than hardcoded.

- Header actions (dashboard-06): "Edit" button renamed to "Add Widget"
  (primary variant) with Plus icon to match prototype intent; Save and
  Discard buttons gain Check and X icons respectively. Refresh and
  Share buttons from the prototype are deliberately omitted — they were
  decorative stubs with no v2 backing behavior and would be dead UI.

Tests updated to match new button label (/add widget/i); functionality
preserved. 242/242 passing.

* audit: mark dashboard header + meta chunks done [OMN-48]

* fix(ui): gate widget Live badge on isLive prop [OMN-48]

Addresses widget-card-03 finding: the .widget-live badge was rendered
unconditionally for every widget, falsely advertising real-time behavior
for static summary widgets (BaselinesROI, ReadinessGate, QualityScore).

- Adds isLive?: boolean prop to ComponentWrapper, default false.
- EventStream opts in (matches prototype's real-time-stream semantic).
- Other widgets stay silent by omission; they can opt in individually.

Prototype gates the badge on widget type ∈ {requests,logs,regions};
v2's category-based equivalent is the `stream` category, of which
EventStream is the sole current member.

* audit: close drag-scope chunks (blocked on OMN-44) + live badge fix done [OMN-48]

- 6 chunks moved to blocked/ with blocked_reason set: widget-card-01/02/04
  (drag + grip + kebab) and dashboard-08/09/10 (widget drag wiring,
  drop-slot, DropIndicator). All blocked on OMN-44 (drag-and-drop scope)
  per existing deferrals documented in DashboardView.tsx and
  ComponentWrapper.tsx headers.

- 2 chunks moved to done/:
  - widget-card-03 (Live badge gated in commit 31d988e)
  - dashboard-11 (dead `.library-open .grid .widget` CSS removed from
    src/styles/dashboard.css; v2 uses editMode state not library-open
    class, so the selector could never fire)

* fix(ui): sidebar + topbar polish per audit [OMN-48]

- sidebar-03: workspace label gets the prototype's tiny uppercase tracking
  (fontSize:10, textTransform:uppercase, letterSpacing:0.08em).
- sidebar-04: new-dashboard `+` button gains native `title="New dashboard"`
  tooltip (was aria-label only).
- sidebar-09: empty-state lineHeight 1.6 → 1.5 to match prototype.
- topbar-01: breadcrumb gains leading `<Menu size={16}/>` icon before "Home".

* audit: close remaining 17 chunks — 4 fixed, 13 accepted deviations [OMN-48]

Fixed (commit d15bc4c): sidebar-03, sidebar-04, sidebar-09, topbar-01.

Accepted with Resolution notes:
- Clean-audit chunks: sidebar-01, sidebar-02, sidebar-05 (no changes needed).
- False positives: sidebar-07 (Tailwind ≡ inline style), sidebar-10 (v2.15
  version bump was user-requested).
- Documented v2 architectural deviations: sidebar-06 (RenameInput
  subcomponent), sidebar-08 (shadcn DropdownMenu), topbar-02 (lucide
  icons), topbar-03 (role=button a11y), widget-library-01 (conditional
  render vs class toggle), widget-library-04 (enum-ordered grouping).
- Partially OMN-44 deferred: dashboard-01 (libOpen paddingRight),
  dashboard-07 (DropIndicator fragment — empty branch itself resolved
  in 4145b96).

Final state: todo 0 · audited 0 · blocked 6 (all OMN-44) · done 33.

* fix(ui): widgets added via palette now persist + no longer hidden behind rail

Two bugs introduced by OMN-48's rebuilt WidgetLibrary integration:

1. Clicking widgets in the palette produced only the `.lib-card:active`
   scale animation with no visible result, because `.library` is
   position:fixed (360px wide, covering the right side of the
   viewport). The grid rendered the added widget under the palette.
   Fix: add `paddingRight: 360` to the main content area when
   editMode is active, reserving space for the fixed rail.

2. Closing the palette (X button) destroyed widgets added during the
   session. Root cause: `onClose={handleDiscard}` reverted activeDashboard
   to the edit-start snapshot (empty for newly-created dashboards).
   Fix: `onClose={handleSave}` — closing the library now commits
   changes and exits edit mode, matching the prototype's implicit-save
   semantic. Save button remains the explicit commit; Discard still
   rolls back.

Also: palette moved out of the inline flex wrapper since it manages
its own fixed positioning; the 280px column now belongs solely to the
ConfigComponentPanel.

* docs: add OMN-22 UX polish plan (10 tickets OMN-49..OMN-58) [OMN-22]

Plan file for the post-OMN-48 UX polish round. Triaged 28 user-
surfaced observations into 10 PR-sized tickets under epic OMN-22:

- OMN-49 Task 1: Sidebar footer + workspace cleanup
- OMN-50 Task 2: DashboardView interaction polish
- OMN-51 Task 4: Widget chrome — kebab menu + removal + flatten
- OMN-52 Task 3: Timezone + auto-refresh countdown (blocked by OMN-50)
- OMN-53 Task 5: Widget contrast + chart sizing
- OMN-54 Task 6: Config panel as floating modal (blocked by OMN-51)
- OMN-55 Task 7: Routing decisions table polish
- OMN-56 Task 8: Drag-and-drop (blocked by OMN-51)
- OMN-57 Task 9: Grid reflow (masonry)
- OMN-58 Task 10: Light/dark theme wiring

Already-shipped items (Header cleanup, Refresh button, Agent UI)
documented in the plan's "Already addressed" section.

* fix(ui): rename inputs are now readable and auto-select text [OMN-22]

Sidebar dashboard rename input got no styling — user-agent defaults
rendered black text on white, which is invisible against the dark
sidebar. Added transparent background with a subtle brand-colored
border, dark tinted bg, and explicit sidebar-ink color so the input
reads as a clear edit affordance.

Also fixed two related issues that surfaced while testing:

- Dashboard title rename input (top of DashboardView) inherited color
  via CSS but the inheritance was flaky in some themes. Made the color
  explicit via inline style.

- Text was not auto-selecting when the input mounted from the kebab
  menu — Radix DropdownMenu's onCloseAutoFocus was returning focus to
  the kebab trigger after close, stealing it from the newly-mounted
  input. Fix: added onCloseAutoFocus={e => e.preventDefault()} on the
  dashboard-kebab DropdownMenuContent, and switched RenameInput from
  autoFocus+useEffect to a callback ref + requestAnimationFrame so
  focus+select runs after Radix's focus sequence settles.

Checklist item #1 addressed.

* fix(ui): sidebar rename flow + remove workspace chip [OMN-22]

Two checklist items from the post-OMN-48 UX polish round:

#1 Rename input fix (follow-up): after the initial color/background
  fix, fast-clicking Rename from the kebab menu caused the input to
  flash and revert, and the text didn't highlight. Root cause:
  Radix DropdownMenu's focus-scope teardown dispatches a blur on the
  previously-focused element during its close sequence, which fired
  onBlur and committed the empty value.

  Corrected approach:
    - Defer setRenamingId via queueMicrotask so the input mounts
      after Radix finishes closing.
    - Move focus+select into useLayoutEffect with a stable ref, so
      it runs once after defaultValue is applied.
    - Replace onBlur commit with a document-level pointerdown listener
      that only commits on genuine click-outside.
    - Enter/Escape still commit/cancel via onKeyDown.

  Reference: https://medium.com/@ojasskapre/fixing-input-focus-issues-in-a-shadcn-radix-card-ui-dropdown-menu-in-next-js-7b5931e049c8

#2 Workspace chip removal: dropped the hardcoded "Platform Eng"
  Workspace dropdown from the sidebar. No behavior was wired, no
  workspaces concept exists yet, and it was visual noise. Dropped the
  CSS rules (.workspace, .workspace-chip, .ws-name), the now-unused
  ChevronDown import, and updated the corresponding Sidebar test.

Also adds docs/ux-polish-checklist.md tracking the 28-item list.

* refactor(ui): remove widget count from dash-meta row [OMN-22]

Checklist #20: '0 widgets' / 'N widgets' text under the dashboard
title carried no real value — the library footer already shows the
catalog count, and users can see their widget count by glancing at
the grid. Dropped the span plus its trailing separator.

* refactor(ui): remove sidebar-foot status + version strings [OMN-22]

Checklist #26 + #27: neither the "All systems normal" indicator nor
the hardcoded "v2.15" version string had anything real backing them.
No health monitoring is wired, and the version string was a literal
not sourced from package.json. Dropped both.

Changes:
- Sidebar.tsx: removed the .sidebar-foot <div> (dot, status text, version).
- sidebar.css: dropped .sidebar-foot and .sidebar-foot .dot rules.
- globals.css: moved @keyframes pulse here (was in sidebar.css); still
  used by .widget-live::before in dashboard.css.
- Sidebar.test.tsx: replaced "renders ...normal" assertion with a
  "does NOT render sidebar-foot" assertion.
- FrameLayout.test.tsx: rebased its chrome-smoke assertion onto the
  "an omninode product" tagline (unique, stable).

* refactor(ui): remove Header chrome without backing systems [OMN-22]

Checklist #23 + #24 + #28:

- #23 removed the user chip (avatar "JS", "Jamie Sun", "Platform Eng"):
  there is no user system yet, the chip was static placeholder data.
- #24 removed the Notifications bell and Help-circle icon buttons:
  no notifications system, no help system, clicking them did nothing.
- #28 removed the Menu icon prefixing the breadcrumbs: looked like a
  hamburger menu trigger but had no onClick handler, inviting clicks
  that did nothing.

Header now contains: breadcrumbs, Refresh button (retained), theme
toggle (retained). Test updated to assert the removed elements are
absent.

This reproduces the manual edits that were lost this morning when
shared-worktree branch swapping during the OmniNode workflow cleared
the working tree.

* docs(checklist): mark tasks 20, 23, 24, 26, 27, 28 done [OMN-22]

* feat(ui): library rail smooth transition + close on switch + Escape [OMN-22]

Checklist #7 + #15 + #21, all clustered around widget-library edit-mode
behavior:

#15 Smooth open/close: ComponentPalette now accepts an isOpen prop and
  toggles the .library.open class instead of being conditionally
  mounted. The CSS transition (transform 0.3s cubic-bezier) fires in
  both directions because the element stays in the DOM. aria-hidden
  mirrors isOpen so screen readers ignore the off-screen rail.
  DashboardView renders <ComponentPalette isOpen={editMode} /> once,
  unconditionally.

#7 Close on dashboard switch: a useEffect watches activeDashboard?.id.
  When it changes (using a prevRef to skip the initial mount), it
  calls setEditMode(false), which slides the library out.

#21 Escape closes library: a useEffect binds a keydown listener when
  editMode is true. Pressing Escape calls setEditMode(false) and
  preventsDefault on the event. Listener is torn down when editMode
  goes false.

* fix(ui): explicit fixed height on CostTrend + QualityScore charts [OMN-22]

Checklist #5 + #6. Both charts were sized against parent containers
with no height constraint, so the percentage/flex values resolved to
nothing:

- CostTrendPanel: height:100% fell back to minHeight:200px — half
  what the design called for.
- QualityScorePanel: flex:1 inside a height:100% flex column inside
  an unconstrained .widget-body collapsed the chart to ~7px.

Both now set an explicit height:320px on the ReactECharts element.
QualityScorePanel no longer needs the outer flex column since its
children stack in normal flow. Simplified that wrapper to a plain div.

* fix(theme): expose mutedForeground; widgets no longer render text in bg color [OMN-22]

Checklist #4. Root cause was not per-widget — it was in useThemeColors.
The hook exposed `muted` as `readToken('muted')`, but the `--muted`
shadcn token maps to `var(--panel-2)` — a background tint, not a text
color. Every widget labelled `color: colors.muted` was rendering text
in the same color as its panel surface.

Changes:

- useThemeColors now exposes a new `mutedForeground` field that reads
  `--muted-foreground` (→ `--ink-2`). JSDoc on `muted` warns not to
  use it as a text color.
- DelegationMetrics: promoted "Total Delegations", "Quality Gate Pass
  Rate", "Cost Savings" labels from muted (bg) to foreground (full
  ink) — these sit beside primary metric values and are primary.
- BaselinesROICard: promoted all delta labels, "Recommendations"
  heading, and kv sub-labels to foreground.
- ReadinessGate: "Overall:" promoted to foreground; dimension detail
  and "Checked {time}" bumped to mutedForeground (visible secondary).
- QualityScorePanel: "Mean Score" summary and chart axis labels moved
  to mutedForeground.
- CostTrendPanel: chart axis labels moved to mutedForeground.
- EventStream: timestamp + source columns moved to mutedForeground.

After this change, all widget text renders in the correct ink tone
for its emphasis level in both light and dark themes.

* fix(layout): .main constrained to 100vh so content below fold is scrollable [OMN-22]

Checklist #3. Root cause was in src/styles/topbar.css: .main had
display:flex + flex-direction:column + min-width:0 but no height
constraint. Its parent .app sets height:100vh with display:grid but
default auto rows, so .main grew to content size. When content
(Header + DashboardView) exceeded 100vh, body { overflow:hidden }
in globals.css clipped the excess unreachably.

Fix: add height:100% + min-height:0 + overflow:hidden to .main so it
is pinned to its 100vh grid cell, its flex children can shrink, and
internal scroll happens inside .dash-body (which already has
overflow-y:auto).

Also tracks new task #29 (sidebar kebab menu hover + pointer) in the
checklist for later.

* fix(ui): DropdownMenuItem hover now visible; cursor shows pointer [OMN-22]

Checklist #29. Two issues in the shared shadcn DropdownMenuItem:

- cursor was cursor-default. Changed to cursor-pointer.
- focus:bg-accent did fire on hover (Radix sets focus via pointermove),
  but --accent resolves to oklch(92% 0.08 120) in light mode — a nearly
  white pale green that had no visible contrast against the panel's
  100% white background. Switched highlight to --panel-2, which is the
  prototype's own convention for menu hover (see dashboard.css
  .menu-item:hover). --panel-2 has meaningful contrast in both themes.

Covered three selectors — hover:, focus:, and data-[highlighted]: — to
catch every path Radix uses for highlighting.

Applied at the component level so every DropdownMenu in the app
benefits, not just the sidebar.

* fix(theme): ThemeProvider now drives both data-theme + .theme-* class [OMN-22]

Checklist #22. Root cause: ThemeProvider only toggled the legacy
vanilla-extract `.theme-dark`/`.theme-light` class on <body>. The
prototype-ported chrome (sidebar, topbar, widgets, dashboard) activates
its dark theme via `[data-theme="dark"]` on <html> in globals.css. That
attribute was never being set, so the toggle was a no-op for everything
newer than the vanilla-extract layer.

Fix: ThemeProvider now drives both systems from the same theme state:

- Sets/removes `data-theme="dark"` on document.documentElement (drives
  the globals.css OKLCH tokens used by all prototype-ported chrome).
- Keeps the `.theme-dark`/`.theme-light` body class toggle (drives the
  legacy vanilla-extract consumers like AgentChatPanel).

Also added a synchronous `<script>` in index.html that sets
`data-theme="dark"` before React hydrates. The default theme is dark, so
without this the user sees a brief light-mode flash on every page load
before ThemeProvider's useEffect runs.

* refactor(ui): grid now uses CSS columns for masonry-style flow [OMN-22]

Checklist #9. Replaced:
  .grid { display: grid; grid-template-columns: 1fr 1fr; }
with:
  .grid { columns: 2; column-gap: var(--row-gap); }
and added break-inside:avoid + margin-bottom:var(--row-gap) to .widget.

CSS columns flows children top-to-bottom within each column, so a
short widget no longer leaves dead vertical space under a tall
row-mate. Previously a 2-col grid with [tall, short] in row 1 pushed
row 2 past the tall widget's bottom edge, leaving a visible gap under
the short one.

* feat(ui): widget kebab menu + flatten sub-card chrome + rename grid class [OMN-22]

Checklist #8, #13, #14, and a follow-up rename:

#14 Kebab menu on every widget: new WidgetChromeContext carries
  {onConfigure, onDelete} from ComponentCell down to ComponentWrapper
  without threading them through widget props. ComponentWrapper renders
  a MoreVertical kebab button in the widget-head when at least one
  handler is provided. Menu items: Configure, Delete (destructive).
  Available in both view and edit modes.

#13 Easier widget removal: Delete lives in the kebab menu, reachable
  with one click from any mode (previously required entering edit
  mode and finding a less-obvious affordance).

#8 Flatten sub-card chrome: DashboardView no longer wraps each grid
  item in its own `<div className="widget">...widget-head...widget-body`
  outer shell. The widget is ComponentCell directly, which delegates
  chrome to the widget's own ComponentWrapper. One card level, not two.
  Also removes the click-widget-to-configure handler — Configure now
  only fires from the kebab menu.

  Prior ComponentCell also rendered an intervening
  `<div data-testid="grid-cell" style={{ overflow: 'hidden' }}>` wrapper.
  That overflow:hidden created a new block formatting context between
  .grid and .widget and prevented CSS columns flow. Normal path now
  skips this wrapper; `grid-cell` testid retained only on the fallback
  "component not available" path.

Follow-up rename: `.grid` → `.dash-grid`. Tailwind's utility layer
generates `.grid { display: grid; }` which loaded after dashboard.css
and overrode our `columns: 2`. Once `display: grid` is set the browser
ignores `columns: N` entirely, which collapsed widgets into a single
column. Renaming makes our custom rule win unambiguously.

* feat(ui): port prototype menu + modal patterns; drop shadcn dropdown/dialog [OMN-22]

Stay-close-to-prototype pass. The prototype's menus and config modal
use hand-authored markup with custom CSS, not a third-party primitive.
Mirroring that pattern lets future design iterations from Claude Design
drop in with minimal translation.

New shared infrastructure:

- src/components/ui/positioned-menu.tsx — small PositionedMenu +
  MenuItem + MenuSeparator components plus a usePositionedMenu() hook
  that captures the trigger's bounding rect on open and manages
  dismiss (outside-click + Escape). Portaled to document.body so it
  isn't clipped by any overflow:hidden ancestor. Uses the prototype's
  .menu / .menu-item / .menu-item.danger / .menu-sep CSS (already
  ported verbatim into dashboard.css).

- src/styles/modals.css additions — RJSF form class-name mappings
  (.form-group, .rjsf-field, .control-label, .form-control, .checkbox)
  styled to match the prototype's .field/.check look. Reset <fieldset>
  user-agent defaults so its content no longer overflows modal-body's
  max-height scroll container.

Config modal:

- ComponentConfigPanel rewritten to use the prototype's
  .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot
  markup verbatim. Cancel + Save changes footer buttons match the
  prototype's pattern. Save commits that placement's draft via a new
  commitDraft store action; Cancel discards. Escape + backdrop click
  also dismiss.

- src/store/configSlice.ts — commitDraft(placementId) writes a
  placement's draft config back to its layout item and clears the
  draft.

Widget kebab menu:

- ComponentWrapper renders a MoreVertical kebab button that opens a
  PositionedMenu with three items matching the prototype: Configure
  Widget, Duplicate, Remove Widget (danger). onDuplicate is a new
  handler supplied via WidgetChromeContext.

- src/store/dashboardSlice.ts — duplicateLayoutItem(itemId) clones a
  placement with a fresh id and deep-copied config, appended to the
  layout.

Sidebar dashboard kebab:

- Replaced Radix DropdownMenu with the shared PositionedMenu pattern.
  Items: Rename, Duplicate, Share Link (no-op placeholder), Delete.

- src/store/dashboardSlice.ts — duplicateDashboard(id) clones a
  dashboard with a fresh id, "(copy)" name suffix, deep-copied
  layout.

Fieldset / #root selector collision:

- The React mount <div id="root"> and RJSF's <fieldset id="root">
  both matched a bare `#root` selector in globals.css, which gave the
  fieldset height:100vh and overflowed the modal. Changed the
  selector to `div#root` so it only targets the React mount.

Cleanup:

- Removed src/components/ui/dropdown-menu.tsx (shadcn Radix shim, no
  remaining consumers) and uninstalled @radix-ui/react-dropdown-menu.
- Removed the briefly-lived src/components/ui/dialog.tsx + uninstalled
  @radix-ui/react-dialog (the modal moved to prototype markup before
  anything else picked it up).
- Deleted src/config/ComponentConfigPanel.css.ts (vanilla-extract
  styling no longer used after the prototype-markup port).
- grid-item testid moved from ComponentWrapper to a
  display:contents wrapper in ComponentCell so tests find it even
  when the widget's LazyComponent is still Suspending. Redundant
  grid-item testid dropped from the legacy DashboardGrid shim.

Reference breadcrumb:

- reference/claude-design/ now holds a local copy of the Claude Design
  prototype (tarball extracted from the share URL) so the files
  survive between sessions. reference/claude-design/ is gitignored
  (large), but reference/README.md is tracked as a breadcrumb
  pointing at the source URL and describing what's in each subdir.

Checklist #10 (config panel modal) + #14 follow-up. 243/243 tests pass.

* fix(widgets): promote primary data labels to --ink + bump tiny label fonts [OMN-22]

Closes ux-polish-checklist #4. Audit found DelegationMetrics and
BaselinesROICard labels were already rendered in --ink (darkest), not
--ink-3 as the dictated note assumed — perceived faintness came from
the 0.6875rem (~11px) font size. Bumped those to 0.75rem (12px).

QualityScorePanel's Mean Score summary row was genuinely secondary
(rendered in --muted-foreground). That row is the widget's only
non-chart content, so promoted to --foreground and bumped 0.75rem →
0.8125rem.

All other --ink-3 / mutedForeground usage is chart-chrome (axes,
loading/empty states, timestamps, search placeholders) and stays
appropriate for those roles.

* feat(theme): add --chart-* tokens + --status-* aliases on :root [OMN-22]

Closes ux-polish-checklist #30. useThemeColors() reads CSS vars from
document.documentElement, but --chart-1..7 only lived on the
.theme-* classes that vanilla-extract applied to <body>. Every chart
color was therefore resolving to the '#888888' fallback (all series
rendered as gray).

Moved --chart-1..7 onto :root with seven distinct hues at matched
lightness/chroma, clear of the brand green (--accent-h=120), plus a
brighter variant for [data-theme="dark"] so series identity is
consistent across theme toggle.

Also added --status-healthy/warning/error aliases pointing at the
prototype's --status-ok/warn/bad, so useThemeColors's status map
finds values too.

* refactor(readiness): convert ReadinessGate from cards to table layout [OMN-22]

Off-checklist request. The card grid (7 columns of status boxes) read
well at desktop widths but wasted vertical space and didn't scale to
more than 7 dimensions. Tabular layout with Status | Dimension |
Detail columns reads the same information more compactly and is
directly extensible to additional dimensions without layout changes.

Status is rendered as a color-outlined pill that picks up
--status-ok/warn/bad from the theme. Header uses the shared prototype
colors (--ink-2 / --panel-2 / --line). Dropped useThemeColors() in
favor of direct CSS vars to match the pattern established by
RoutingDecisionTable.

* feat(grid): drag-and-drop reorder + palette-to-grid insert [OMN-22]

Closes ux-polish-checklist #12.

Two drag sources: palette cards (copy into grid) and existing widgets
(reorder). Drop onto any widget inserts before its position; a
trailing "drop to append" zone appears at the grid tail while
dragging. The empty-state placeholder is replaced by a "drop to add"
slot during drags so a widget can be placed on an empty dashboard.

Store gets two new actions: insertComponentAt(name, version, size,
atIndex) and moveLayoutItem(itemId, toIndex). moveLayoutItem adjusts
the target index when fromIndex < toIndex so callers can pass the
pre-removal target index naturally. Both covered by unit tests.

Chrome context extended with draggable/isDragging/isDropTarget flags
plus HTML5 drag handlers. ComponentWrapper applies them to the
.widget root and adds a GripVertical icon in the head when
draggable. ComponentCell forwards the props through. ComponentPalette
cards become draggable drag sources that also fall back to
click-to-add.

Visual feedback: dragging source drops to 35% opacity; drop target
gets a 3px brand-colored top-edge bar via ::before (new CSS rule).
Append zone uses existing .drop-slot styling with an explicit
column-span so it reaches across the masonry columns.

data-drag-exclude convention added so interactive widget content can
opt out — ComponentWrapper checks the mousedown target's ancestors
for the attribute and cancels dragstart when found. Applied to
RoutingDecisionTable for future-proofing.

* docs(claude): rewrite widget-add recipe to match actual two-track mechanism [OMN-22]

The existing "Add a new widget" recipe described a per-widget
manifest.ts file colocated next to each widget — that file never
existed. In reality widgets live in two tracks:

  A. Local MVP widgets (all seven current widgets): component lives
     under src/components/dashboard/<slug>/, lazy import registered
     in src/components/dashboard/index.ts, manifest entry added to
     MVP_COMPONENTS in scripts/generate-registry.ts.
  B. External @omninode/* package widgets: published as npm packages
     with a dashboardComponents field in package.json pointing at a
     JSON manifest; generate-registry.ts scans node_modules for
     matching packages.

Rewrote the recipe into two clearly-labeled paths and updated "Where
to look" to point at the correct files. No behavior change.

* feat(cost-trend-3d): experimental Tron-style 3D cost visualization widget [OMN-22]

An interactive 3D cost widget that sits alongside the original 2D
CostTrend — same llm_cost topic, different implementation. Built for
exploration; not a replacement for the ECharts-based CostTrend.

Raw three.js (not @react-three/fiber) — started on R3F but hit WebGL
context loss on first render due to three@0.184 / r3f 9.6 skew and
swapped to plain three.js in a useEffect, which has been stable. R3F
was uninstalled after the switch.

Visualization: each bar = one (time-bucket, model) cell. X=time,
Z=model, Y=cost. Translucent cores + bright edge lines on a
near-black background (light theme uses greys + pastels). Grid lines
aligned to cell boundaries. Axis labels projected world→screen as
HTML overlays so they track the camera. Dates dedupe at day
boundaries so hourly buckets don't print MM/DD 24 times in a row.

Camera: faces the strip head-on (azimuth pinned). Horizontal
navigation via a scrollbar below the canvas — no free orbit. Tilt
via mouse-drag Y within a clamped range. Widescreen 200px canvas
height with the scene tuned to fill vertically via FOV=30,
distance=11, target-Y=0.6, Y-offset=-1px (values dialed in via a
temporary slider panel then baked in).

Interaction:
  - Hover: bar under pointer becomes fully opaque (clickability hint)
  - Plain click: cinematic focus animation — camera swoops in on the
    bar, non-focused bars + grid fade to near-transparent, clicked
    bar's opacity ramps to near-solid, stats panel slides in from
    the right with cost/tokens/requests/derived ratios. Escape or
    clicking elsewhere reverses. Mid-flight retargeting retreats to
    default first then re-launches (simpler than pose blending and
    reads as intentional).
  - Shift-click: existing isolate behavior (desaturate other models
    to neutral grey).
  - Model filter legend below the scrollbar — click to toggle
    visibility; filtered-out bars drop to near-zero opacity but stay
    spatially present.

Theme-aware. Two ThemeConfig objects (DARK_THEME = neon Tron,
LIGHT_THEME = greys + pastels on white) selected by a useThemeName
hook that watches data-theme on documentElement.

Fixture generator: _llm_cost_points now emits a full (time × model)
cross product (48 hourly buckets × 4 models = 192 records) so the
3D grid is dense and rectangular. Per-model cost multipliers
(opus > sonnet > qwen > deepseek) make the series visually distinct
rather than uniformly noisy.

Registry + imports + integration tests updated for the 8th widget.
Added three + @types/three as deps.

* docs(checklist): mark polish items done [OMN-22]

Marked #3, #4, #5, #6, #7, #8, #9, #10, #11, #13, #14, #15, #20,
#21, #22, #29, #30 as done, plus the off-checklist ReadinessGate
tabular rework. Updated progress totals (17 done / 10 pending / 2
n/a / 1 keep-as-is). Remaining pending: #7/#8/#10/#13/#14/#15/#21
turned out to already be implemented (confirmed later and marked);
next pass will tackle #12, #16, #19.

* feat(filters): global time range selector + stacked-area Cost Trend [OMN-22]

Adds a dashboard-level time range selector (header, left of the mode
buttons) that all time-series widgets consume for a consistent view
of the same window. Defaults to "Last 24h" on first mount so fresh
dashboards don't show 48h of raw fixture data out of the gate.

Changes by layer:

- ComponentManifest.capabilities gains optional `supports_time_range`.
  True = widget participates in the global range. False = snapshot or
  pre-aggregated summary; the selector is ignored. Existing manifest
  fixtures without the field continue to pass validation.
- TimeRange gains an optional `label` (e.g. "Last 24h") so the
  selector can render "Last 24h" instead of formatting absolute
  timestamps. Absent for custom ranges.
- useTimeRange.ts centralizes resolveTimeRange() + applyTimeRange()
  helpers and the TIME_RANGE_PRESETS constant. Widgets call
  applyTimeRange(data, d => d.<timestamp_field>, resolved) and get a
  filtered slice; no changes required to queryKey (caching stays
  keyed on topic, filtering is post-fetch).
- DateRangeSelector uses the existing PositionedMenu primitive —
  presets (1h/6h/24h/7d/30d) + "All time". Resolves presets to
  absolute ISO timestamps at click-time; window does not auto-advance
  on refetch.

Participating widgets (supports_time_range: true):
  cost-trend-panel, cost-trend-3d, routing-decision-table
Non-participating (false):
  delegation-metrics (summary), baselines-roi-card (snapshot),
  quality-score-panel (summary), readiness-gate (snapshot),
  event-stream (live tail)

CostTrendPanel switches from per-model lines to a stacked area chart
— each model is an additive layer so the top reads as total cost
over the window and each band reads as that model's contribution.
Smoothed curves, no symbols, 55% area opacity, 1px line. Also:
granularity default flipped from "day" to "hour" to match the
fixture's hourly buckets, and x-axis ticks include the hour in
hourly mode so adjacent dates don't collapse to the same label.

Both CostTrend widgets now render a "No cost data in the selected
time range" overlay when the underlying topic has data but the
current window clips it all away — distinct from the all-empty
state.

RoutingDecisionTable's footer "filtered from N" now shows the
in-range count instead of the raw total so the search filter's
denominator reflects the time-windowed set the user is searching
within.

* fix(cost-trend): legend below chart + drop emphasis.focus to fix hover flicker [OMN-22]

Two related changes to CostTrendPanel, landing together because they
both restructure the ECharts option:

1. Hover flicker (task #9). With tooltip.trigger='axis' showing all
   series' values at the hovered x-position AND
   emphasis.focus='series' on each series dimming non-hovered bands,
   the two behaviors fought each other — tooltip appeared, then the
   series emphasis kicked in and dimmed everything else, which read
   as "the line isolated on hover." Removed emphasis.focus; tooltip
   now stays stable across mouse movement with no visual isolation.

2. Legend placement (task #8). Replaced the built-in ECharts top
   legend with a custom HTML legend below the chart, styled like the
   3D Cost Trend's model-chip row (rounded pills with colored dot +
   model name, wraps to new rows if needed). The two Cost Trend
   widgets now read as a matched pair.

Behavioral differences vs 3D:
  - Click toggles visibility only; no isolate-on-click (3D has that;
    2D doesn't need it).
  - No "Clear focus" button.
  - No isolate highlight on the chip.

When a model is toggled off, its series is filtered out of the chart
entirely (not just hidden), so the remaining stack re-fits
vertically. The model list itself is derived from the full (un-
range-filtered) dataset so the legend stays stable as the user
scrubs the time range.

grid.top reduced 34 → 12 now that the built-in legend is gone — the
chart area reclaims the vertical space.

* feat(filters): custom start/end date range picker [OMN-22]

Extends the DateRangeSelector's popover with a "Custom range…" option
that opens an inline calendar + time-input UI. Keeps all existing
preset behavior intact — the custom view is a second view inside the
same popover, reached via a menu item that intentionally doesn't
close the popover on select.

Calendar: react-day-picker v9 in range mode (single month, 30px
cells). Time: two <input type="time"> controls for start/end HH:MM.
Apply builds a TimeRange with no `label` so the selector shows
"MM/DD – MM/DD" instead of a preset name; Cancel returns to the
preset list.

Theming: react-day-picker's --rdp-* CSS variables are bound to the
dashboard's existing tokens (--brand, --brand-soft, --brand-ink, --ink,
--panel-2, --line) on the calendar's wrapper div. Calendar picks up
the active light/dark theme with no extra JS. Compact sizing
(30x30 day cells) so the popover fits cleanly at 320px wide.

Safety: start/end swap if the user ends up with start > end after
combining calendar day + time input; PositionedMenu's outside-click
dismissal is blocked on pointerdown inside the calendar so clicking a
day doesn't close the whole popover.

Adds react-day-picker ^9.14.0. Zero transitive runtime deps in v9.

* fix(range-picker): theming cascade + sensible default times [OMN-22]

Two fixes to the custom-range date picker:

1. Calendar colors. The --rdp-* vars were being set inline on an outer
   wrapper, but react-day-picker's `.rdp-root` declares its own
   defaults at the same specificity — and that stylesheet loads into
   the bundle after the component mounts, so the library's "blue"
   accent was winning. Moved the overrides into CustomRangePicker.css
   which is imported AFTER `react-day-picker/style.css`, targeting
   `.dash-calendar-wrapper .rdp-root` (specificity 0,2,0 vs library's
   0,1,0). Also swapped the range_start/end text color from
   --brand-ink (dark green) to #ffffff — dark-green-on-brand-green
   didn't contrast enough — and range_middle text from --brand-ink to
   --ink so days inside the range read in the neutral dark-ink color
   rather than competing with the brand.

2. Default times. The picker was seeding its start + end time inputs
   from the currently-active range's timestamps, which meant if the
   active preset was "Last 24h" (clicked at 14:37) both inputs
   defaulted to 14:37. Now they default to 00:00 and 23:59 — the
   common case is "whole days between these two dates" and users who
   want a precise minute window can still set it explicitly.

* feat(cost-trend): replace ECharts with three.js 2D stacked-area chart [OMN-22]

Rewrites the 2D Cost Trend chart body in three.js using an orthographic
camera whose frustum matches canvas pixel coordinates (top-left origin,
y-down). ECharts stays as a dependency for other widgets (Quality,
Delegation) — this swap is scoped to CostTrendPanel.

Why: ECharts' default hover model interacted awkwardly with the
stacked-area layout, producing flicker and unintended band-isolation
effects on pointer move. Owning the render loop gives us full control
over hover and tooltip behavior, and matches the technology choice of
the 3D Cost Trend widget so both widgets share a mental model.

Architecture:
  - New StackedAreaChart component owns the three.js renderer/scene/
    camera for the chart body. Re-renders on size / data / theme
    changes only; no rAF loop (preserveDrawingBuffer:true so the canvas
    holds its last frame).
  - Each visible model's band is a BufferGeometry of 2N vertices and
    2(N-1) triangles (top+bottom polyline swept into a filled strip),
    with a sibling Line for the top-edge outline. Horizontal grid
    lines at "nice" y-tick values.
  - CostTrendPanel outer stays unchanged in shape: same data fetch,
    same time-range filter, same in-range-empty overlay, same custom
    legend from task #8 below the chart.

Interaction:
  - Hover: pointermove on the wrapper maps pointer X to the nearest
    bucket index. A vertical guide line (DOM div, not geometry)
    appears at that X; an HTML tooltip follows the cursor showing
    bucket timestamp, per-model cost breakdown in stack order (top to
    bottom), and the cumulative total.
  - No hover emphasis on the bands themselves — fixes the flicker the
    user was seeing with the ECharts implementation.

Color parsing fix: globals.css declares --chart-1..7 as oklch(...)
values, which THREE.Color.setStyle() doesn't understand (it supports
rgb/hsl/hex/named only). Added cssColorToHex() helper that pipes the
CSS value through a 1×1 canvas — Canvas 2D's fillStyle accepts the
full CSS color syntax and gives back a normalized sRGB pixel we can
convert to the numeric hex THREE wants.

Test updated to stub StackedAreaChart (jsdom has no WebGL context);
verifies the outer component plumbs correct bucket + model counts into
the chart child.

* feat(cost-trend): chartType config — stacked area or stacked bar [OMN-22]

Adds a `chartType` option to the 2D Cost Trend widget's manifest
configSchema (enum: 'area' | 'bar', default 'area'). Users pick via
the widget's Configure dialog (kebab menu → Configure Widget), the
value persists with the dashboard layout.

Rendering: same StackedSlice data structure in both modes; only the
geometry builder differs.
  - 'area' (existing): filled bands (triangle strip per model) + top-
    edge polyline outlines. Smooth trend read across time.
  - 'bar' (new): one rectangle per (bucket × visible-model) cell
    stacked on the previous model's cumulative total. Reads as
    discrete per-bucket observations. Bar width = plotWidth /
    bucketCount × 0.72 (with a floor at 2px so dense datasets still
    render). Zero-height cells skip their triangle indices so we
    don't emit degenerate geometry.

File rename: StackedAreaChart.tsx → StackedChart.tsx (via git mv) now
that the component handles both modes. Exported types renamed to
match; the test file's vi.mock path and testid updated accordingly.

Grid + axis labels + hover guide + tooltip are unchanged across modes
— all the chrome lives on the common StackedSlice, not the render
shape.

* fix(cost-trend): bar-mode cell-center x-mapping + suppress zero y-label [OMN-22]

Two visual fixes on the 2D stacked chart, both noticed at "Last 24h"
where the post-filter dataset has only a handful of buckets:

1. Bars overflowed the plot on both sides. The x-scale was
   edge-to-edge (plotLeft..plotRight) regardless of mode. With N=4
   and plotWidth=400 that gave 100px between bar centers and a bar
   width of ~72px, so the leftmost bar's left edge landed 36px inside
   the y-axis label column and the rightmost bar's right edge spilled
   36px past the canvas.

   Moved to a per-mode x-scale:
     - area: edge-to-edge (unchanged). A trend line should span the
       full plot area.
     - bar: cell-center. Each of N cells gets width plotWidth/N and
       its bar is centered. Every bar stays fully inside the plot
       area at any bucket count.

2. The $0 y-axis tick label sat directly above the leftmost x-axis
   date label at narrow widths and read as a single blob. The $0 tick
   is redundant with the x-axis baseline anyway, so the y-label for
   value 0 is now suppressed. The horizontal gridline at the baseline
   still renders; only the text label is dropped.

No test changes — the fixes are rendering-layer, and the chart
component is already stubbed in tests (jsdom has no WebGL context).

* fix(routing-table): tighten narrow columns + shorter timestamp [OMN-22]

Without an explicit <colgroup>, tableLayout:fixed distributed equal
1/6 widths to all six columns. "LLM Conf." held 3 chars ("83%") and
"Cost" held 7 ("$0.0231"), so those got far more space than they
needed while Timestamp ("4/20/2026, 10:40:00 AM" at 22 chars) wrapped
at typical widget widths.

Two-part fix:

1. Added <colgroup> with per-column widths that match content size:
   Timestamp 24%, LLM Agent 21%, Fuzzy Agent 21%, Agreement 13%,
   LLM Conf. 9%, Cost 12%. Totals to 100; tableLayout:fixed respects
   those exact proportions.

2. Shortened the displayed timestamp to "MM/DD HH:MM AM/PM" (dropping
   the year and seconds). Added whiteSpace:nowrap + text-overflow
   ellipsis on the cell and a title attribute carrying the full
   toLocaleString() form so the richer timestamp is still available
   on hover.

* feat(event-stream): search, sticky header, paused indicator, source pills [OMN-22]

First round of professionalism polish. Scope chosen from the suggestion
list: the clear bugs, the straightforward visual upgrades, and the two
highest-leverage interactions. Connection-status dot, click-to-expand,
correlation-id grouping, and live/paused toggle are all deferred.

Bug fixes
  - Dropped `hsl(${colors.border})` on the row border — useThemeColors
    returns a CSS-resolvable string already, so wrapping it in hsl()
    produced invalid CSS and the dividers were not rendering. Now uses
    `var(--line-2)` directly.
  - Gave the scroll container a definite height (SCROLL_HEIGHT = 320).
    The previous `height: 100%` collapsed or grew unpredictably since
    `.widget-body` has no height constraint.

Visual
  - Sticky column header (TIME · EVENT · SOURCE) at the top of the
    scroll region so the semantics of each column are obvious without
    inferring from content.
  - Row padding bumped from 4px to 6px × 10px for breathing room.
  - Timestamp column tightened 140 → 96px; new formatter shows
    HH:MM:SS for events in the last 24h and MM/DD HH:MM for older.
    A title attribute on each row exposes the full event_type,
    source, correlation_id, and ISO timestamp for debugging.
  - Timestamp and event_type both use the mono font stack; source
    moved into a small outlined pill whose hue is derived from a hash
    of the source string — stable per source so the eye can pick one
    out while scanning a fast stream.

Interaction
  - Search input over event_type / source / correlation_id. Mirrors
    the RoutingDecisionTable pattern.
  - Auto-scroll pause is now surfaced: when the user scrolls up, the
    component tracks the arrival count since that moment and shows a
    floating "↑ N new events" button pinned at the top of the scroll
    region. Click to jump back to top and resume auto-scroll. Also
    added an "auto-scroll paused" hint in the footer.
  - Footer shows `filtered / buffered` with a "buffer full" note
    when events have saturated maxEvents.

Internal: `userScrolledUp` promoted from a ref to state so the UI can
react to the paused condition (needed for the new-events button).

* feat(quality): three.js rewrite — pass-rate headline + tier-coloured bars [OMN-22]

Replaces the ECharts-backed Quality Score widget. Two changes in one
pass because the full rewrite touches both the render layer and the
data model of the stat pane.

Why the rewrite:
  - The old widget coloured each of the 5 score buckets with a
    different entry from the chart palette. Buckets are slices of the
    same metric (score), not separate series; giving them unrelated
    hues made the histogram read as five unrelated bars rather than
    one distribution.
  - The mean marker used ECharts' `type: 'average'` which averages
    the Y values (bucket counts), not the weighted score mean that
    the fixture computes. The line was in one place and the label
    claimed a different number. Deleted.
  - "Mean Score: 0.67" was buried in small text above the chart.
    On a glance dashboard the most actionable number ("am I passing?")
    wasn't the biggest thing on screen.

New design:
  - Split layout. Left pane: big pass-rate % in a traffic-light
    colour (green / amber / red based on the value), with mean +
    measurement-count as supporting captions. Right pane: the 3D
    bar chart.
  - 3D chart in three.js. 5 bars along a strip, coloured via a
    red → amber → green hue sweep (hsl 0°..120°) so the shape of
    the distribution also encodes the quality story. MeshStandard
    material + one directional light gives the bars readable
    dimensionality without needing shadows.
  - Translucent vertical "wall" behind the bars at the x-position
    corresponding to the pass threshold, with a bright-outlined edge.
    Spatially communicates "right of the wall = passing".
  - Floating cone marker above the bars pointing down at the mean
    score's x-position, with a dashed drop-line to the ground. Uses
    the brand token.
  - Hover a bar → HTML tooltip with bucket label, count, and % of
    total. Fixed camera, no orbit.
  - Title shortened "Quality Score Distribution" → "Quality Scores".

Config:
  - Added `passThreshold` (default 0.8, range [0, 1]) to the widget
    manifest. Drives both the pass-rate calculation and the wall's
    x-position so a dashboard can calibrate per use case.

Manifest:
  - displayName unchanged ("Quality Scores"), version bumped 1.0.0 →
    1.1.0 to reflect the behaviour + config change.
  - minSize bumped w 3 → 4 so the split layout has room. defaultSize
    and maxSize unchanged.

Shared helper: cssColorToHex extracted from cost-trend/StackedChart
to src/theme/cssColorToHex.ts now that two three.js widgets need it.
Exported through src/theme/index.ts alongside useThemeColors. No
behaviour change to the cost-trend chart; StackedChart just imports
from the new location.

Test: mocks three.js WebGLRenderer with a no-op class (jsdom has no
WebGL context) and shims ResizeObserver. New assertions verify the
pass-rate arithmetic for two thresholds (default 0.8 and a custom
0.6) so the stat pane's headline is locked in.

* fix(quality): Tron-style bars + clearer mean marker [OMN-22]

Bar rendering swapped from MeshStandardMaterial (shaded, semi-opaque)
to the cost-trend-3d Tron recipe: translucent coloured core at 0.55
opacity + bright edge lines on every cube edge at 0.95 opacity. No
lighting (materials are unlit), so dropped the ambient + directional
lights from scene setup. Saturation bumped 0.6 → 0.85 and lightness
0.55 → 0.62 on the red→green tier gradient for the neon feel. The
two three.js widgets now read as siblings visually.

Ground grid switched from a subtle --line colour to cyan (#00e5ff) at
0.35 opacity for the same reason. Threshold plane outline opacity
bumped 0.5 → 0.75 and the wall itself dropped 0.12 → 0.08 so the
outline carries more of the read.

Mean marker reworked. The dashed drop line from the floating cone
down to the ground was getting hidden behind the tallest bar on
common data shapes — one of those "what IS that thing?" visuals.
Dropped it entirely. The cone is slightly larger (radius 0.16 → 0.2,
height 0.3 → 0.42) and now explicitly labelled: we project the
cone's world position to canvas pixels at scene-build time and
anchor an HTML "MEAN 0.XX" chip just above it. The relationship
reads at a glance without needing the line.

* fix(quality): theme-aware contrast for light mode [OMN-22]

The Tron colour recipe I baked in last commit worked against the dark
background but failed in light mode on several specific pairs:

  - Cyan grid (0x00e5ff, opacity 0.35) on the near-white panel — the
    lines were effectively invisible, leaving the ground plane with
    no tick markers.
  - Threshold plane and outline, both white, on a near-white canvas —
    "pass wall" disappeared entirely. Users had no visual cue that
    the scene was partitioned into pass/fail regions.
  - Bar cores at 0.55 opacity on a light background — values washed
    out instead of reading as solid bars.
  - Bar edges at full neon saturation felt garish against the light
    theme's pastel aesthetic.

Introduced a per-theme EffectiveColors block (DARK_EFFECTIVE /
LIGHT_EFFECTIVE) covering grid colour + opacity, threshold plane +
outline colour + opacity, bar core + edge opacity, and bar HSL
saturation + lightness. Light theme swaps:
  grid:      cyan  → slate grey (0x6b7280) at higher opacity
  threshold: white → dark slate (0x334155) on both plane + outline
  bar core:  0.55  → 0.72 opacity so fills read on white
  bar edges: 0.95  → 0.80 opacity (softer against the lighter bg)
  bar hue:   0.85 sat / 0.62 light → 0.60 / 0.50 (pastel, matches
             cost-trend-3d's light theme)
  ground:    one step off canvas-bg in both themes via --panel vs
             --panel-2 so the floor has a visible boundary

Mechanics: extracted the existing useThemeName hook (previously
duplicated inside cost-trend-3d) to src/theme/useThemeName.ts and
exported from the theme barrel. cost-trend-3d updated to import from
the shared location; QualityScorePanel now subscribes and picks the
right EffectiveColors block. Scene rebuilds on theme toggle via the
hook's MutationObserver on data-theme.

* fix(quality): light-mode floor was black — explicit hex + new cssVarToHex helper [OMN-22]

Root cause: the ground plane was built via
`cssColorToHex('var(--panel)', 0x0b0e14)`. Canvas 2D's fillStyle
setter (which cssColorToHex uses for parsing) silently rejects
`var()` syntax and retains the previous value, so the sentinel-check
fell through to the fallback on every call. In dark mode the
fallback 0x0b0e14 was close enough to the actual dark panel colour
that nobody noticed; in light mode a near-black floor against a
near-white canvas stood out as clearly wrong.

Two fixes:

1. Floor set as explicit hex per theme, mirroring the values
   cost-trend-3d ships in DARK_THEME / LIGHT_THEME so the two
   three.js widgets stay visually consistent:
     dark:  0x020812   (near-black, slight blue tint)
     light: 0xf7f8fa   (near-white, barely-there grey tint)

2. Added cssVarToHex(varName, fallback) to src/theme/cssColorToHex.ts
   — reads the computed CSS variable value off documentElement and
   passes the resolved literal to cssColorToHex. The mean cone's
   `var(--brand)` lookup was silently falling through to the same
   fallback for the same reason; switching to cssVarToHex means the
   cone now picks up the actual brand token in both themes.

* feat(cost-by-model): three.js pie widget — cost share per model [OMN-22]

New widget that complements Cost Trend. Where Cost Trend answers
"how has spend moved over time", this answers "who is spending the
money right now". Same llm_cost.v1 topic, client-side aggregation
by model name.

Scene: one pie lying flat on the XZ plane with a small extrusion
depth for 3D read. Each slice is built from THREE.Shape (center →
outer edge → arc → center) + ExtrudeGeometry so a single mesh holds
the whole wedge (top face + sides) and the pointer raycast is
straightforward. Slice colors come from the shared `--chart-*`
palette, indexed by a stable model-to-color map derived from the
full (un-range-filtered) dataset so a model keeps its colour even
if it drops out of the current window and later re-enters.

Interaction: hover pops the hovered slice outward by 0.09 world
units along its mid-angle, with an HTML tooltip showing the model,
cost, and share. Hover-only updates are done in a separate effect
from the slice-build effect so pointer moves don't tear down and
rebuild geometry.

Participates in the dashboard time range (supports_time_range:
true). Legend on the right shows each model with its colour dot,
percentage, and a footer line with the total cost in range.

Theme-aware via the existing PieTheme split (dark = Tron-leaning
with a touch of emissive, light = pastel with no self-glow). Floor
hex values match the cost-trend-3d floor so the two three.js
visualisations stay consistent across themes.

Registry entry `cost-by-model` added (category: visualization,
defaultSize 6×4, minSize 4×3). Both integration tests bumped from
expecting 8 components to 9. Lazy import registered in
src/components/dashboard/index.ts under its implementationKey.

New unit test mocks three.js WebGLRenderer + ResizeObserver and
locks the aggregation math: 4 input rows at $2/$1/$1/$1 across
three models should yield the expected 60% / 20% / 20% legend
shares and a $5.00 total.

* refactor(styles): unify font-family tokens — single source in :root [OMN-22]

Consolidate the font-family token system so a single CSS variable
determines every mono/sans rendering in the app.

- Define --font-sans and --font-mono in globals.css :root as primitives.
  Body and .mono now reference the tokens (previously hardcoded).
- Remove font from the vanilla-extract contract (tokens.css.ts,
  themes.css.ts). Legacy .theme-dark/.theme-light classes no longer
  set 'Inter' or 'JetBrains Mono' which were conflicting with the
  actually-loaded IBM Plex fonts.
- Update CSS files (dashboard.css, library.css, sidebar.css) +
  Header.css.ts + MessageBubble.tsx to use var(--font-mono).
- Simplify widget inline fontFamily from
  'var(--font-mono, "IBM Plex Mono", monos…
jonahgabriel added a commit that referenced this pull request Apr 26, 2026
* feat(fixtures): Python fixture generator [OMN-30] (#1)

* feat(fixtures): Python fixture generator for 5 projections [OMN-30]

* fix(fixtures): ruff I001 import sort + T201 noqa on print statements [OMN-30]

* docs: local dev mode rules in CLAUDE.md [OMN-23] (#3)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24] (#2)

* feat(types): generate TS types from Part 1 Pydantic models [OMN-24]

- Install json-schema-to-typescript as devDependency
- Add scripts/run-types-generate.sh: runs omnibase_core emit_ts_types.py,
  hoists nested $defs to top level, pipes through json2ts
- Add npm run types:generate script entry
- Commit generated src/shared/types/generated/onex-models.ts and
  enum-dashboard-widget-type.ts (ModelDashboardHint, ModelProjectorContract, etc.)

* fix(types): add build/ to .gitignore and barrel index for enum canonical export [OMN-24]

Gate review findings:
- build/ (intermediate JSON schema) was missing from .gitignore (plan spec acceptance #8)
- EnumDashboardWidgetType emitted as both string union (onex-models.ts) and TS enum
  (enum-dashboard-widget-type.ts) — add barrel index.ts canonicalising the enum form
  so consumers have a single unambiguous import path

* feat(data-source): ProtocolSnapshotSource interface [OMN-25] (#4)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26] (#5)

* feat(data-source): FileSnapshotSource (middleware in Task 4b) [OMN-26]

* fix(data-source): guard against non-array manifest response in FileSnapshotSource [OMN-26]

* feat(data-source): HttpSnapshotSource [OMN-28] (#6)

* feat(vite): fixturesMiddleware with testable handler + 5 contract tests [OMN-27] (#7)

* refactor(hooks): useProjectionQuery consumes ProtocolSnapshotSource [OMN-29] (#8)

* feat(hint): starter widget_type -> category preference heuristic [OMN-34] (#9)

* feat(registry): scan node_modules/@omninode/* for dashboardComponents [OMN-31] (#10)

* feat(layout): file-based layout persistence [OMN-32] (#11)

* feat(layout): file-based layout persistence via /_layouts middleware [OMN-32]

* fix(layout): path traversal guard + stale closure fix from hostile review [OMN-32]

- layoutsMiddleware: reject names containing path separators or dot-only
  segments (guards against /../ traversal outside dashboard-layouts/)
- DashboardBuilder.useEffect: read live store state instead of stale
  closure to avoid clobbering an already-active dashboard
- layouts-middleware.test: remove unused responseBody var; add path
  traversal rejection test

* feat(config): per-component config panel via @rjsf [OMN-33] (#12)

* feat(config): per-component config panel via @rjsf [OMN-33]

* fix(config): hostile-reviewer pass-1 fixes [OMN-33]

- Strengthen validation-error test: assert hasValidationErrors===true (was typeof guard)
- Extract inline styles to vanilla-extract ComponentConfigPanel.css.ts
- Type configSchema as JSONSchema7 in ComponentManifest (was Record<string,unknown>)
- Remove now-unnecessary `as Record<string,unknown>` cast in ComponentConfigPanel

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35] (#13)

* test(proof-of-life): scripted end-to-end verification + pipeline sanity [OMN-35]

* test(proof-of-life): skip fixtures check in CI; fixtures are gitignored [OMN-35]

* feat(header): New dashboard button with inline create form [OMN-38] (#14)

* fix(registry): call resolveImplementations() eagerly so built-ins show as available [OMN-39] (#15)

* feat(fixtures): emit summary DTOs matching widget contracts; split shared topics [OMN-40] (#16)

* fix(dashboard): Save button now persists layout via layoutPersistence.write [OMN-41] (#17)

* feat(ui): install Tailwind + shadcn utilities + IBM Plex, port OKLCH tokens from Claude Design prototype [OMN-42] (#18)

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView (Phase 2) [OMN-43] (#19)

* feat(ui): install shadcn components + harden token layer for shadcn compat [OMN-43 WIP]

* feat(ui): rebuild Frame, Header, Sidebar, DashboardView per Claude Design prototype [OMN-43]

- Extend dashboardSlice with dashboards[], createDashboard, renameDashboard,
  deleteDashboard, setActiveDashboardById; persist list + lastActiveId to localStorage
- New Sidebar.tsx: brand block, workspace chip, dash-list with inline rename,
  shadcn DropdownMenu kebab (Rename/Delete), + button, pulse-dot footer
- Rewrite FrameLayout.tsx: 2-column grid (240px sidebar, 1fr main) via Tailwind
- Rewrite Header.tsx: 52px Topbar with breadcrumbs + icon cluster; remove old
  inline new-dashboard form (flow moved to Sidebar)
- New DashboardView.tsx (from DashboardBuilder): strict 2-column widget grid,
  no react-grid-layout; Edit/Save/Discard + layoutPersistence.write retained
- DashboardBuilder.tsx → shim re-export of DashboardView for backward compat
- DashboardGrid.tsx rewritten without react-grid-layout
- Remove react-grid-layout + @types/react-grid-layout from package.json
- Delete 65 stale compiled .js / 42 .test.js artifact files that shadowed .tsx sources
- Update Header, FrameLayout, DashboardBuilder, dashboardSlice, App tests
- Add Sidebar.test.tsx (6 tests: create, switch, rename marker, footer, workspace)
- SOURCE headers + traceability doc rows for all 4 rebuilt UI files

* fix(ci): drop unused waitFor import in Sidebar.test.tsx [OMN-43] (#20)

* feat(ui): verbatim CSS port of prototype; rewrite TSX to use semantic class names [OMN-47] (#22)

* chore(plans): add plan for OMN-47 verbatim CSS port

* feat(ui): verbatim CSS port of prototype styles; rewrite TSX to use semantic class names [OMN-47]

* fix(ui): move @import statements before @tailwind directives (CSS spec) [OMN-47]

* fix(ui): rewrite ComponentPalette + ComponentWrapper to use prototype class names [OMN-47]

* fix(ui): remove vanilla-extract body color/bg overrides; bump version [OMN-47]

* fix(ui): drop unused globalStyle import [OMN-47]

* fix(ui): revert loading text to ASCII ellipsis for test compat [OMN-47]

Tests across 6 widget suites assert screen.getByText('Loading...'). The
verbatim port had swapped it to a Unicode ellipsis (U+2026), which broke
them. Loading state is a v2-only addition with no prototype fidelity
requirement, so reverting the string is the minimal fix.

* chore(audit): scaffold chunked UI fidelity audit folders + README + example chunk [OMN-48]

* chore(audit): generate remaining 38 chunk files across 6 components [OMN-48]

Chunks cover the full v2 UI surface against the Claude Design prototype:

- Sidebar: 9 new chunks (02-10) covering brand-name, workspace chip,
  nav section header, dashboard list item internals, rename input,
  kebab menu, empty state fallback, sidebar foot
- Topbar: 3 chunks covering root+breadcrumbs, right icon cluster, user chip
- DashboardView: 11 chunks covering dash-body, header/title, meta row,
  header actions, empty-vs-grid conditional, widget card wiring,
  drop-slot append, DropIndicator component, library-open transition
- EmptyState: 5 chunks covering outer container, diagonal stripe overlay,
  icon tile, headline+helper, CTA button (all inline styles)
- WidgetCard: 4 chunks covering outer+drag state, head+grip, title+live
  badge, kebab+body
- WidgetLibrary: 6 chunks covering root+open state, head, search input
  (user-flagged missing), body+group title, lib-card+thumb (user-flagged
  missing), added state badge + foot

Total 39 todo chunks ready for audit dispatch.

* audit: claim widget-library-06 [OMN-48]

* audit: complete widget-library-06 [OMN-48]

* audit: complete widget-library-01 [OMN-48]

* audit: complete widget-library-02 [OMN-48]

* audit: complete widget-library-03 [OMN-48]

* audit: complete widget-library-04 [OMN-48]

* audit: complete widget-library-05 [OMN-48]

* audit: complete empty-state-01 [OMN-48]

* audit: complete empty-state-02 [OMN-48]

* audit: complete empty-state-03 [OMN-48]

* audit: complete empty-state-04 [OMN-48]

* audit: complete empty-state-05 [OMN-48]

* audit: complete widget-card-01 [OMN-48]

* audit: complete widget-card-02 [OMN-48]

* audit: complete widget-card-03 [OMN-48]

* audit: complete widget-card-04 [OMN-48]

* audit: complete topbar-01 [OMN-48]

* audit: complete topbar-02 [OMN-48]

* audit: complete topbar-03 [OMN-48]

* audit: complete sidebar-01 [OMN-48]

* audit: complete sidebar-02 [OMN-48]

* audit: complete sidebar-03 [OMN-48]

* audit: complete sidebar-04 [OMN-48]

* audit: complete sidebar-05 [OMN-48]

* audit: complete sidebar-06 [OMN-48]

* audit: complete sidebar-07 [OMN-48]

* audit: complete sidebar-08 [OMN-48]

* audit: complete sidebar-09 [OMN-48]

* audit: complete sidebar-10 [OMN-48]

* audit: complete dashboard-01 [OMN-48]

* audit: complete dashboard-02 [OMN-48]

* audit: complete dashboard-03 [OMN-48]

* audit: complete dashboard-04 [OMN-48]

* audit: complete dashboard-05 [OMN-48]

* audit: complete dashboard-06 [OMN-48]

* audit: complete dashboard-07 [OMN-48]

* audit: complete dashboard-08 [OMN-48]

* audit: complete dashboard-09 [OMN-48]

* audit: complete dashboard-10 [OMN-48]

* audit: complete dashboard-11 [OMN-48]

* feat(ui): port EmptyState + WidgetLibrary missing structure [OMN-48]

Addresses audit findings grouped by theme:

- EmptyState (chunks 01-05): new component at src/components/dashboard/EmptyState.tsx
  rebuilds the prototype's dashed-card layout: diagonal stripe overlay,
  48x48 icon tile, "This dashboard is empty" headline, helper copy, and
  "Add first widget" CTA button. Wired in DashboardView in place of the
  prior single-line text fallback. onAdd enters edit mode when not
  already editing.

- WidgetLibrary (chunks 02, 03, 05, 06):
  - Adds close button (X) in .lib-head wired to onClose prop.
  - Adds .lib-search input with local q state; filters catalog by
    displayName + description substring match.
  - Adds .lib-card-thumb with a category-derived lucide icon
    (visualization→LineChart, metrics→Gauge, table→Table,
    status→ShieldCheck, stream→Radio).
  - Adds .lib-foot with "{N} widgets available" + "drag or click" hint.
  - Adds no-results empty-state branch inside .lib-body.
  - Keeps "not implemented" badge text (v2-specific semantic — v2 has
    component status; prototype's "Already on dashboard" semantic is not
    applicable until OMN-44 wires active-widget tracking).

DashboardView wires onClose to handleDiscard so the close button
exits edit mode + drops drafts — nearest equivalent to the prototype's
separate libOpen state.

* audit: mark EmptyState + WidgetLibrary chunks done [OMN-48]

Moves 9 chunks from audited/ to done/ after commit 4145b96:
- empty-state-01 through empty-state-05
- widget-library-02, widget-library-03, widget-library-05, widget-library-06

* feat(ui): port dashboard header + meta row per prototype [OMN-48]

Addresses audit findings for dashboard chunks 02-06:

- Rename title branch (dashboard-03, 04): adds click-to-edit affordance
  on .dash-title with chevron-down glyph + "Click to rename" tooltip.
  Input renders on edit; blur commits via renameDashboard(id, name),
  Enter blurs, Escape cancels.

- Meta row (dashboard-05): .dash-meta now renders the full prototype
  structure — widget count + middle-dot separators + "Timezone: {tz}"
  + "Auto-refresh 30s" with .mono class on numeric spans. Timezone is
  computed from the browser (e.g. "UTC−07:00") rather than hardcoded.

- Header actions (dashboard-06): "Edit" button renamed to "Add Widget"
  (primary variant) with Plus icon to match prototype intent; Save and
  Discard buttons gain Check and X icons respectively. Refresh and
  Share buttons from the prototype are deliberately omitted — they were
  decorative stubs with no v2 backing behavior and would be dead UI.

Tests updated to match new button label (/add widget/i); functionality
preserved. 242/242 passing.

* audit: mark dashboard header + meta chunks done [OMN-48]

* fix(ui): gate widget Live badge on isLive prop [OMN-48]

Addresses widget-card-03 finding: the .widget-live badge was rendered
unconditionally for every widget, falsely advertising real-time behavior
for static summary widgets (BaselinesROI, ReadinessGate, QualityScore).

- Adds isLive?: boolean prop to ComponentWrapper, default false.
- EventStream opts in (matches prototype's real-time-stream semantic).
- Other widgets stay silent by omission; they can opt in individually.

Prototype gates the badge on widget type ∈ {requests,logs,regions};
v2's category-based equivalent is the `stream` category, of which
EventStream is the sole current member.

* audit: close drag-scope chunks (blocked on OMN-44) + live badge fix done [OMN-48]

- 6 chunks moved to blocked/ with blocked_reason set: widget-card-01/02/04
  (drag + grip + kebab) and dashboard-08/09/10 (widget drag wiring,
  drop-slot, DropIndicator). All blocked on OMN-44 (drag-and-drop scope)
  per existing deferrals documented in DashboardView.tsx and
  ComponentWrapper.tsx headers.

- 2 chunks moved to done/:
  - widget-card-03 (Live badge gated in commit 31d988e)
  - dashboard-11 (dead `.library-open .grid .widget` CSS removed from
    src/styles/dashboard.css; v2 uses editMode state not library-open
    class, so the selector could never fire)

* fix(ui): sidebar + topbar polish per audit [OMN-48]

- sidebar-03: workspace label gets the prototype's tiny uppercase tracking
  (fontSize:10, textTransform:uppercase, letterSpacing:0.08em).
- sidebar-04: new-dashboard `+` button gains native `title="New dashboard"`
  tooltip (was aria-label only).
- sidebar-09: empty-state lineHeight 1.6 → 1.5 to match prototype.
- topbar-01: breadcrumb gains leading `<Menu size={16}/>` icon before "Home".

* audit: close remaining 17 chunks — 4 fixed, 13 accepted deviations [OMN-48]

Fixed (commit d15bc4c): sidebar-03, sidebar-04, sidebar-09, topbar-01.

Accepted with Resolution notes:
- Clean-audit chunks: sidebar-01, sidebar-02, sidebar-05 (no changes needed).
- False positives: sidebar-07 (Tailwind ≡ inline style), sidebar-10 (v2.15
  version bump was user-requested).
- Documented v2 architectural deviations: sidebar-06 (RenameInput
  subcomponent), sidebar-08 (shadcn DropdownMenu), topbar-02 (lucide
  icons), topbar-03 (role=button a11y), widget-library-01 (conditional
  render vs class toggle), widget-library-04 (enum-ordered grouping).
- Partially OMN-44 deferred: dashboard-01 (libOpen paddingRight),
  dashboard-07 (DropIndicator fragment — empty branch itself resolved
  in 4145b96).

Final state: todo 0 · audited 0 · blocked 6 (all OMN-44) · done 33.

* fix(ui): widgets added via palette now persist + no longer hidden behind rail

Two bugs introduced by OMN-48's rebuilt WidgetLibrary integration:

1. Clicking widgets in the palette produced only the `.lib-card:active`
   scale animation with no visible result, because `.library` is
   position:fixed (360px wide, covering the right side of the
   viewport). The grid rendered the added widget under the palette.
   Fix: add `paddingRight: 360` to the main content area when
   editMode is active, reserving space for the fixed rail.

2. Closing the palette (X button) destroyed widgets added during the
   session. Root cause: `onClose={handleDiscard}` reverted activeDashboard
   to the edit-start snapshot (empty for newly-created dashboards).
   Fix: `onClose={handleSave}` — closing the library now commits
   changes and exits edit mode, matching the prototype's implicit-save
   semantic. Save button remains the explicit commit; Discard still
   rolls back.

Also: palette moved out of the inline flex wrapper since it manages
its own fixed positioning; the 280px column now belongs solely to the
ConfigComponentPanel.

* docs: add OMN-22 UX polish plan (10 tickets OMN-49..OMN-58) [OMN-22]

Plan file for the post-OMN-48 UX polish round. Triaged 28 user-
surfaced observations into 10 PR-sized tickets under epic OMN-22:

- OMN-49 Task 1: Sidebar footer + workspace cleanup
- OMN-50 Task 2: DashboardView interaction polish
- OMN-51 Task 4: Widget chrome — kebab menu + removal + flatten
- OMN-52 Task 3: Timezone + auto-refresh countdown (blocked by OMN-50)
- OMN-53 Task 5: Widget contrast + chart sizing
- OMN-54 Task 6: Config panel as floating modal (blocked by OMN-51)
- OMN-55 Task 7: Routing decisions table polish
- OMN-56 Task 8: Drag-and-drop (blocked by OMN-51)
- OMN-57 Task 9: Grid reflow (masonry)
- OMN-58 Task 10: Light/dark theme wiring

Already-shipped items (Header cleanup, Refresh button, Agent UI)
documented in the plan's "Already addressed" section.

* fix(ui): rename inputs are now readable and auto-select text [OMN-22]

Sidebar dashboard rename input got no styling — user-agent defaults
rendered black text on white, which is invisible against the dark
sidebar. Added transparent background with a subtle brand-colored
border, dark tinted bg, and explicit sidebar-ink color so the input
reads as a clear edit affordance.

Also fixed two related issues that surfaced while testing:

- Dashboard title rename input (top of DashboardView) inherited color
  via CSS but the inheritance was flaky in some themes. Made the color
  explicit via inline style.

- Text was not auto-selecting when the input mounted from the kebab
  menu — Radix DropdownMenu's onCloseAutoFocus was returning focus to
  the kebab trigger after close, stealing it from the newly-mounted
  input. Fix: added onCloseAutoFocus={e => e.preventDefault()} on the
  dashboard-kebab DropdownMenuContent, and switched RenameInput from
  autoFocus+useEffect to a callback ref + requestAnimationFrame so
  focus+select runs after Radix's focus sequence settles.

Checklist item #1 addressed.

* fix(ui): sidebar rename flow + remove workspace chip [OMN-22]

Two checklist items from the post-OMN-48 UX polish round:

#1 Rename input fix (follow-up): after the initial color/background
  fix, fast-clicking Rename from the kebab menu caused the input to
  flash and revert, and the text didn't highlight. Root cause:
  Radix DropdownMenu's focus-scope teardown dispatches a blur on the
  previously-focused element during its close sequence, which fired
  onBlur and committed the empty value.

  Corrected approach:
    - Defer setRenamingId via queueMicrotask so the input mounts
      after Radix finishes closing.
    - Move focus+select into useLayoutEffect with a stable ref, so
      it runs once after defaultValue is applied.
    - Replace onBlur commit with a document-level pointerdown listener
      that only commits on genuine click-outside.
    - Enter/Escape still commit/cancel via onKeyDown.

  Reference: https://medium.com/@ojasskapre/fixing-input-focus-issues-in-a-shadcn-radix-card-ui-dropdown-menu-in-next-js-7b5931e049c8

#2 Workspace chip removal: dropped the hardcoded "Platform Eng"
  Workspace dropdown from the sidebar. No behavior was wired, no
  workspaces concept exists yet, and it was visual noise. Dropped the
  CSS rules (.workspace, .workspace-chip, .ws-name), the now-unused
  ChevronDown import, and updated the corresponding Sidebar test.

Also adds docs/ux-polish-checklist.md tracking the 28-item list.

* refactor(ui): remove widget count from dash-meta row [OMN-22]

Checklist #20: '0 widgets' / 'N widgets' text under the dashboard
title carried no real value — the library footer already shows the
catalog count, and users can see their widget count by glancing at
the grid. Dropped the span plus its trailing separator.

* refactor(ui): remove sidebar-foot status + version strings [OMN-22]

Checklist #26 + #27: neither the "All systems normal" indicator nor
the hardcoded "v2.15" version string had anything real backing them.
No health monitoring is wired, and the version string was a literal
not sourced from package.json. Dropped both.

Changes:
- Sidebar.tsx: removed the .sidebar-foot <div> (dot, status text, version).
- sidebar.css: dropped .sidebar-foot and .sidebar-foot .dot rules.
- globals.css: moved @keyframes pulse here (was in sidebar.css); still
  used by .widget-live::before in dashboard.css.
- Sidebar.test.tsx: replaced "renders ...normal" assertion with a
  "does NOT render sidebar-foot" assertion.
- FrameLayout.test.tsx: rebased its chrome-smoke assertion onto the
  "an omninode product" tagline (unique, stable).

* refactor(ui): remove Header chrome without backing systems [OMN-22]

Checklist #23 + #24 + #28:

- #23 removed the user chip (avatar "JS", "Jamie Sun", "Platform Eng"):
  there is no user system yet, the chip was static placeholder data.
- #24 removed the Notifications bell and Help-circle icon buttons:
  no notifications system, no help system, clicking them did nothing.
- #28 removed the Menu icon prefixing the breadcrumbs: looked like a
  hamburger menu trigger but had no onClick handler, inviting clicks
  that did nothing.

Header now contains: breadcrumbs, Refresh button (retained), theme
toggle (retained). Test updated to assert the removed elements are
absent.

This reproduces the manual edits that were lost this morning when
shared-worktree branch swapping during the OmniNode workflow cleared
the working tree.

* docs(checklist): mark tasks 20, 23, 24, 26, 27, 28 done [OMN-22]

* feat(ui): library rail smooth transition + close on switch + Escape [OMN-22]

Checklist #7 + #15 + #21, all clustered around widget-library edit-mode
behavior:

#15 Smooth open/close: ComponentPalette now accepts an isOpen prop and
  toggles the .library.open class instead of being conditionally
  mounted. The CSS transition (transform 0.3s cubic-bezier) fires in
  both directions because the element stays in the DOM. aria-hidden
  mirrors isOpen so screen readers ignore the off-screen rail.
  DashboardView renders <ComponentPalette isOpen={editMode} /> once,
  unconditionally.

#7 Close on dashboard switch: a useEffect watches activeDashboard?.id.
  When it changes (using a prevRef to skip the initial mount), it
  calls setEditMode(false), which slides the library out.

#21 Escape closes library: a useEffect binds a keydown listener when
  editMode is true. Pressing Escape calls setEditMode(false) and
  preventsDefault on the event. Listener is torn down when editMode
  goes false.

* fix(ui): explicit fixed height on CostTrend + QualityScore charts [OMN-22]

Checklist #5 + #6. Both charts were sized against parent containers
with no height constraint, so the percentage/flex values resolved to
nothing:

- CostTrendPanel: height:100% fell back to minHeight:200px — half
  what the design called for.
- QualityScorePanel: flex:1 inside a height:100% flex column inside
  an unconstrained .widget-body collapsed the chart to ~7px.

Both now set an explicit height:320px on the ReactECharts element.
QualityScorePanel no longer needs the outer flex column since its
children stack in normal flow. Simplified that wrapper to a plain div.

* fix(theme): expose mutedForeground; widgets no longer render text in bg color [OMN-22]

Checklist #4. Root cause was not per-widget — it was in useThemeColors.
The hook exposed `muted` as `readToken('muted')`, but the `--muted`
shadcn token maps to `var(--panel-2)` — a background tint, not a text
color. Every widget labelled `color: colors.muted` was rendering text
in the same color as its panel surface.

Changes:

- useThemeColors now exposes a new `mutedForeground` field that reads
  `--muted-foreground` (→ `--ink-2`). JSDoc on `muted` warns not to
  use it as a text color.
- DelegationMetrics: promoted "Total Delegations", "Quality Gate Pass
  Rate", "Cost Savings" labels from muted (bg) to foreground (full
  ink) — these sit beside primary metric values and are primary.
- BaselinesROICard: promoted all delta labels, "Recommendations"
  heading, and kv sub-labels to foreground.
- ReadinessGate: "Overall:" promoted to foreground; dimension detail
  and "Checked {time}" bumped to mutedForeground (visible secondary).
- QualityScorePanel: "Mean Score" summary and chart axis labels moved
  to mutedForeground.
- CostTrendPanel: chart axis labels moved to mutedForeground.
- EventStream: timestamp + source columns moved to mutedForeground.

After this change, all widget text renders in the correct ink tone
for its emphasis level in both light and dark themes.

* fix(layout): .main constrained to 100vh so content below fold is scrollable [OMN-22]

Checklist #3. Root cause was in src/styles/topbar.css: .main had
display:flex + flex-direction:column + min-width:0 but no height
constraint. Its parent .app sets height:100vh with display:grid but
default auto rows, so .main grew to content size. When content
(Header + DashboardView) exceeded 100vh, body { overflow:hidden }
in globals.css clipped the excess unreachably.

Fix: add height:100% + min-height:0 + overflow:hidden to .main so it
is pinned to its 100vh grid cell, its flex children can shrink, and
internal scroll happens inside .dash-body (which already has
overflow-y:auto).

Also tracks new task #29 (sidebar kebab menu hover + pointer) in the
checklist for later.

* fix(ui): DropdownMenuItem hover now visible; cursor shows pointer [OMN-22]

Checklist #29. Two issues in the shared shadcn DropdownMenuItem:

- cursor was cursor-default. Changed to cursor-pointer.
- focus:bg-accent did fire on hover (Radix sets focus via pointermove),
  but --accent resolves to oklch(92% 0.08 120) in light mode — a nearly
  white pale green that had no visible contrast against the panel's
  100% white background. Switched highlight to --panel-2, which is the
  prototype's own convention for menu hover (see dashboard.css
  .menu-item:hover). --panel-2 has meaningful contrast in both themes.

Covered three selectors — hover:, focus:, and data-[highlighted]: — to
catch every path Radix uses for highlighting.

Applied at the component level so every DropdownMenu in the app
benefits, not just the sidebar.

* fix(theme): ThemeProvider now drives both data-theme + .theme-* class [OMN-22]

Checklist #22. Root cause: ThemeProvider only toggled the legacy
vanilla-extract `.theme-dark`/`.theme-light` class on <body>. The
prototype-ported chrome (sidebar, topbar, widgets, dashboard) activates
its dark theme via `[data-theme="dark"]` on <html> in globals.css. That
attribute was never being set, so the toggle was a no-op for everything
newer than the vanilla-extract layer.

Fix: ThemeProvider now drives both systems from the same theme state:

- Sets/removes `data-theme="dark"` on document.documentElement (drives
  the globals.css OKLCH tokens used by all prototype-ported chrome).
- Keeps the `.theme-dark`/`.theme-light` body class toggle (drives the
  legacy vanilla-extract consumers like AgentChatPanel).

Also added a synchronous `<script>` in index.html that sets
`data-theme="dark"` before React hydrates. The default theme is dark, so
without this the user sees a brief light-mode flash on every page load
before ThemeProvider's useEffect runs.

* refactor(ui): grid now uses CSS columns for masonry-style flow [OMN-22]

Checklist #9. Replaced:
  .grid { display: grid; grid-template-columns: 1fr 1fr; }
with:
  .grid { columns: 2; column-gap: var(--row-gap); }
and added break-inside:avoid + margin-bottom:var(--row-gap) to .widget.

CSS columns flows children top-to-bottom within each column, so a
short widget no longer leaves dead vertical space under a tall
row-mate. Previously a 2-col grid with [tall, short] in row 1 pushed
row 2 past the tall widget's bottom edge, leaving a visible gap under
the short one.

* feat(ui): widget kebab menu + flatten sub-card chrome + rename grid class [OMN-22]

Checklist #8, #13, #14, and a follow-up rename:

#14 Kebab menu on every widget: new WidgetChromeContext carries
  {onConfigure, onDelete} from ComponentCell down to ComponentWrapper
  without threading them through widget props. ComponentWrapper renders
  a MoreVertical kebab button in the widget-head when at least one
  handler is provided. Menu items: Configure, Delete (destructive).
  Available in both view and edit modes.

#13 Easier widget removal: Delete lives in the kebab menu, reachable
  with one click from any mode (previously required entering edit
  mode and finding a less-obvious affordance).

#8 Flatten sub-card chrome: DashboardView no longer wraps each grid
  item in its own `<div className="widget">...widget-head...widget-body`
  outer shell. The widget is ComponentCell directly, which delegates
  chrome to the widget's own ComponentWrapper. One card level, not two.
  Also removes the click-widget-to-configure handler — Configure now
  only fires from the kebab menu.

  Prior ComponentCell also rendered an intervening
  `<div data-testid="grid-cell" style={{ overflow: 'hidden' }}>` wrapper.
  That overflow:hidden created a new block formatting context between
  .grid and .widget and prevented CSS columns flow. Normal path now
  skips this wrapper; `grid-cell` testid retained only on the fallback
  "component not available" path.

Follow-up rename: `.grid` → `.dash-grid`. Tailwind's utility layer
generates `.grid { display: grid; }` which loaded after dashboard.css
and overrode our `columns: 2`. Once `display: grid` is set the browser
ignores `columns: N` entirely, which collapsed widgets into a single
column. Renaming makes our custom rule win unambiguously.

* feat(ui): port prototype menu + modal patterns; drop shadcn dropdown/dialog [OMN-22]

Stay-close-to-prototype pass. The prototype's menus and config modal
use hand-authored markup with custom CSS, not a third-party primitive.
Mirroring that pattern lets future design iterations from Claude Design
drop in with minimal translation.

New shared infrastructure:

- src/components/ui/positioned-menu.tsx — small PositionedMenu +
  MenuItem + MenuSeparator components plus a usePositionedMenu() hook
  that captures the trigger's bounding rect on open and manages
  dismiss (outside-click + Escape). Portaled to document.body so it
  isn't clipped by any overflow:hidden ancestor. Uses the prototype's
  .menu / .menu-item / .menu-item.danger / .menu-sep CSS (already
  ported verbatim into dashboard.css).

- src/styles/modals.css additions — RJSF form class-name mappings
  (.form-group, .rjsf-field, .control-label, .form-control, .checkbox)
  styled to match the prototype's .field/.check look. Reset <fieldset>
  user-agent defaults so its content no longer overflows modal-body's
  max-height scroll container.

Config modal:

- ComponentConfigPanel rewritten to use the prototype's
  .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot
  markup verbatim. Cancel + Save changes footer buttons match the
  prototype's pattern. Save commits that placement's draft via a new
  commitDraft store action; Cancel discards. Escape + backdrop click
  also dismiss.

- src/store/configSlice.ts — commitDraft(placementId) writes a
  placement's draft config back to its layout item and clears the
  draft.

Widget kebab menu:

- ComponentWrapper renders a MoreVertical kebab button that opens a
  PositionedMenu with three items matching the prototype: Configure
  Widget, Duplicate, Remove Widget (danger). onDuplicate is a new
  handler supplied via WidgetChromeContext.

- src/store/dashboardSlice.ts — duplicateLayoutItem(itemId) clones a
  placement with a fresh id and deep-copied config, appended to the
  layout.

Sidebar dashboard kebab:

- Replaced Radix DropdownMenu with the shared PositionedMenu pattern.
  Items: Rename, Duplicate, Share Link (no-op placeholder), Delete.

- src/store/dashboardSlice.ts — duplicateDashboard(id) clones a
  dashboard with a fresh id, "(copy)" name suffix, deep-copied
  layout.

Fieldset / #root selector collision:

- The React mount <div id="root"> and RJSF's <fieldset id="root">
  both matched a bare `#root` selector in globals.css, which gave the
  fieldset height:100vh and overflowed the modal. Changed the
  selector to `div#root` so it only targets the React mount.

Cleanup:

- Removed src/components/ui/dropdown-menu.tsx (shadcn Radix shim, no
  remaining consumers) and uninstalled @radix-ui/react-dropdown-menu.
- Removed the briefly-lived src/components/ui/dialog.tsx + uninstalled
  @radix-ui/react-dialog (the modal moved to prototype markup before
  anything else picked it up).
- Deleted src/config/ComponentConfigPanel.css.ts (vanilla-extract
  styling no longer used after the prototype-markup port).
- grid-item testid moved from ComponentWrapper to a
  display:contents wrapper in ComponentCell so tests find it even
  when the widget's LazyComponent is still Suspending. Redundant
  grid-item testid dropped from the legacy DashboardGrid shim.

Reference breadcrumb:

- reference/claude-design/ now holds a local copy of the Claude Design
  prototype (tarball extracted from the share URL) so the files
  survive between sessions. reference/claude-design/ is gitignored
  (large), but reference/README.md is tracked as a breadcrumb
  pointing at the source URL and describing what's in each subdir.

Checklist #10 (config panel modal) + #14 follow-up. 243/243 tests pass.

* fix(widgets): promote primary data labels to --ink + bump tiny label fonts [OMN-22]

Closes ux-polish-checklist #4. Audit found DelegationMetrics and
BaselinesROICard labels were already rendered in --ink (darkest), not
--ink-3 as the dictated note assumed — perceived faintness came from
the 0.6875rem (~11px) font size. Bumped those to 0.75rem (12px).

QualityScorePanel's Mean Score summary row was genuinely secondary
(rendered in --muted-foreground). That row is the widget's only
non-chart content, so promoted to --foreground and bumped 0.75rem →
0.8125rem.

All other --ink-3 / mutedForeground usage is chart-chrome (axes,
loading/empty states, timestamps, search placeholders) and stays
appropriate for those roles.

* feat(theme): add --chart-* tokens + --status-* aliases on :root [OMN-22]

Closes ux-polish-checklist #30. useThemeColors() reads CSS vars from
document.documentElement, but --chart-1..7 only lived on the
.theme-* classes that vanilla-extract applied to <body>. Every chart
color was therefore resolving to the '#888888' fallback (all series
rendered as gray).

Moved --chart-1..7 onto :root with seven distinct hues at matched
lightness/chroma, clear of the brand green (--accent-h=120), plus a
brighter variant for [data-theme="dark"] so series identity is
consistent across theme toggle.

Also added --status-healthy/warning/error aliases pointing at the
prototype's --status-ok/warn/bad, so useThemeColors's status map
finds values too.

* refactor(readiness): convert ReadinessGate from cards to table layout [OMN-22]

Off-checklist request. The card grid (7 columns of status boxes) read
well at desktop widths but wasted vertical space and didn't scale to
more than 7 dimensions. Tabular layout with Status | Dimension |
Detail columns reads the same information more compactly and is
directly extensible to additional dimensions without layout changes.

Status is rendered as a color-outlined pill that picks up
--status-ok/warn/bad from the theme. Header uses the shared prototype
colors (--ink-2 / --panel-2 / --line). Dropped useThemeColors() in
favor of direct CSS vars to match the pattern established by
RoutingDecisionTable.

* feat(grid): drag-and-drop reorder + palette-to-grid insert [OMN-22]

Closes ux-polish-checklist #12.

Two drag sources: palette cards (copy into grid) and existing widgets
(reorder). Drop onto any widget inserts before its position; a
trailing "drop to append" zone appears at the grid tail while
dragging. The empty-state placeholder is replaced by a "drop to add"
slot during drags so a widget can be placed on an empty dashboard.

Store gets two new actions: insertComponentAt(name, version, size,
atIndex) and moveLayoutItem(itemId, toIndex). moveLayoutItem adjusts
the target index when fromIndex < toIndex so callers can pass the
pre-removal target index naturally. Both covered by unit tests.

Chrome context extended with draggable/isDragging/isDropTarget flags
plus HTML5 drag handlers. ComponentWrapper applies them to the
.widget root and adds a GripVertical icon in the head when
draggable. ComponentCell forwards the props through. ComponentPalette
cards become draggable drag sources that also fall back to
click-to-add.

Visual feedback: dragging source drops to 35% opacity; drop target
gets a 3px brand-colored top-edge bar via ::before (new CSS rule).
Append zone uses existing .drop-slot styling with an explicit
column-span so it reaches across the masonry columns.

data-drag-exclude convention added so interactive widget content can
opt out — ComponentWrapper checks the mousedown target's ancestors
for the attribute and cancels dragstart when found. Applied to
RoutingDecisionTable for future-proofing.

* docs(claude): rewrite widget-add recipe to match actual two-track mechanism [OMN-22]

The existing "Add a new widget" recipe described a per-widget
manifest.ts file colocated next to each widget — that file never
existed. In reality widgets live in two tracks:

  A. Local MVP widgets (all seven current widgets): component lives
     under src/components/dashboard/<slug>/, lazy import registered
     in src/components/dashboard/index.ts, manifest entry added to
     MVP_COMPONENTS in scripts/generate-registry.ts.
  B. External @omninode/* package widgets: published as npm packages
     with a dashboardComponents field in package.json pointing at a
     JSON manifest; generate-registry.ts scans node_modules for
     matching packages.

Rewrote the recipe into two clearly-labeled paths and updated "Where
to look" to point at the correct files. No behavior change.

* feat(cost-trend-3d): experimental Tron-style 3D cost visualization widget [OMN-22]

An interactive 3D cost widget that sits alongside the original 2D
CostTrend — same llm_cost topic, different implementation. Built for
exploration; not a replacement for the ECharts-based CostTrend.

Raw three.js (not @react-three/fiber) — started on R3F but hit WebGL
context loss on first render due to three@0.184 / r3f 9.6 skew and
swapped to plain three.js in a useEffect, which has been stable. R3F
was uninstalled after the switch.

Visualization: each bar = one (time-bucket, model) cell. X=time,
Z=model, Y=cost. Translucent cores + bright edge lines on a
near-black background (light theme uses greys + pastels). Grid lines
aligned to cell boundaries. Axis labels projected world→screen as
HTML overlays so they track the camera. Dates dedupe at day
boundaries so hourly buckets don't print MM/DD 24 times in a row.

Camera: faces the strip head-on (azimuth pinned). Horizontal
navigation via a scrollbar below the canvas — no free orbit. Tilt
via mouse-drag Y within a clamped range. Widescreen 200px canvas
height with the scene tuned to fill vertically via FOV=30,
distance=11, target-Y=0.6, Y-offset=-1px (values dialed in via a
temporary slider panel then baked in).

Interaction:
  - Hover: bar under pointer becomes fully opaque (clickability hint)
  - Plain click: cinematic focus animation — camera swoops in on the
    bar, non-focused bars + grid fade to near-transparent, clicked
    bar's opacity ramps to near-solid, stats panel slides in from
    the right with cost/tokens/requests/derived ratios. Escape or
    clicking elsewhere reverses. Mid-flight retargeting retreats to
    default first then re-launches (simpler than pose blending and
    reads as intentional).
  - Shift-click: existing isolate behavior (desaturate other models
    to neutral grey).
  - Model filter legend below the scrollbar — click to toggle
    visibility; filtered-out bars drop to near-zero opacity but stay
    spatially present.

Theme-aware. Two ThemeConfig objects (DARK_THEME = neon Tron,
LIGHT_THEME = greys + pastels on white) selected by a useThemeName
hook that watches data-theme on documentElement.

Fixture generator: _llm_cost_points now emits a full (time × model)
cross product (48 hourly buckets × 4 models = 192 records) so the
3D grid is dense and rectangular. Per-model cost multipliers
(opus > sonnet > qwen > deepseek) make the series visually distinct
rather than uniformly noisy.

Registry + imports + integration tests updated for the 8th widget.
Added three + @types/three as deps.

* docs(checklist): mark polish items done [OMN-22]

Marked #3, #4, #5, #6, #7, #8, #9, #10, #11, #13, #14, #15, #20,
#21, #22, #29, #30 as done, plus the off-checklist ReadinessGate
tabular rework. Updated progress totals (17 done / 10 pending / 2
n/a / 1 keep-as-is). Remaining pending: #7/#8/#10/#13/#14/#15/#21
turned out to already be implemented (confirmed later and marked);
next pass will tackle #12, #16, #19.

* feat(filters): global time range selector + stacked-area Cost Trend [OMN-22]

Adds a dashboard-level time range selector (header, left of the mode
buttons) that all time-series widgets consume for a consistent view
of the same window. Defaults to "Last 24h" on first mount so fresh
dashboards don't show 48h of raw fixture data out of the gate.

Changes by layer:

- ComponentManifest.capabilities gains optional `supports_time_range`.
  True = widget participates in the global range. False = snapshot or
  pre-aggregated summary; the selector is ignored. Existing manifest
  fixtures without the field continue to pass validation.
- TimeRange gains an optional `label` (e.g. "Last 24h") so the
  selector can render "Last 24h" instead of formatting absolute
  timestamps. Absent for custom ranges.
- useTimeRange.ts centralizes resolveTimeRange() + applyTimeRange()
  helpers and the TIME_RANGE_PRESETS constant. Widgets call
  applyTimeRange(data, d => d.<timestamp_field>, resolved) and get a
  filtered slice; no changes required to queryKey (caching stays
  keyed on topic, filtering is post-fetch).
- DateRangeSelector uses the existing PositionedMenu primitive —
  presets (1h/6h/24h/7d/30d) + "All time". Resolves presets to
  absolute ISO timestamps at click-time; window does not auto-advance
  on refetch.

Participating widgets (supports_time_range: true):
  cost-trend-panel, cost-trend-3d, routing-decision-table
Non-participating (false):
  delegation-metrics (summary), baselines-roi-card (snapshot),
  quality-score-panel (summary), readiness-gate (snapshot),
  event-stream (live tail)

CostTrendPanel switches from per-model lines to a stacked area chart
— each model is an additive layer so the top reads as total cost
over the window and each band reads as that model's contribution.
Smoothed curves, no symbols, 55% area opacity, 1px line. Also:
granularity default flipped from "day" to "hour" to match the
fixture's hourly buckets, and x-axis ticks include the hour in
hourly mode so adjacent dates don't collapse to the same label.

Both CostTrend widgets now render a "No cost data in the selected
time range" overlay when the underlying topic has data but the
current window clips it all away — distinct from the all-empty
state.

RoutingDecisionTable's footer "filtered from N" now shows the
in-range count instead of the raw total so the search filter's
denominator reflects the time-windowed set the user is searching
within.

* fix(cost-trend): legend below chart + drop emphasis.focus to fix hover flicker [OMN-22]

Two related changes to CostTrendPanel, landing together because they
both restructure the ECharts option:

1. Hover flicker (task #9). With tooltip.trigger='axis' showing all
   series' values at the hovered x-position AND
   emphasis.focus='series' on each series dimming non-hovered bands,
   the two behaviors fought each other — tooltip appeared, then the
   series emphasis kicked in and dimmed everything else, which read
   as "the line isolated on hover." Removed emphasis.focus; tooltip
   now stays stable across mouse movement with no visual isolation.

2. Legend placement (task #8). Replaced the built-in ECharts top
   legend with a custom HTML legend below the chart, styled like the
   3D Cost Trend's model-chip row (rounded pills with colored dot +
   model name, wraps to new rows if needed). The two Cost Trend
   widgets now read as a matched pair.

Behavioral differences vs 3D:
  - Click toggles visibility only; no isolate-on-click (3D has that;
    2D doesn't need it).
  - No "Clear focus" button.
  - No isolate highlight on the chip.

When a model is toggled off, its series is filtered out of the chart
entirely (not just hidden), so the remaining stack re-fits
vertically. The model list itself is derived from the full (un-
range-filtered) dataset so the legend stays stable as the user
scrubs the time range.

grid.top reduced 34 → 12 now that the built-in legend is gone — the
chart area reclaims the vertical space.

* feat(filters): custom start/end date range picker [OMN-22]

Extends the DateRangeSelector's popover with a "Custom range…" option
that opens an inline calendar + time-input UI. Keeps all existing
preset behavior intact — the custom view is a second view inside the
same popover, reached via a menu item that intentionally doesn't
close the popover on select.

Calendar: react-day-picker v9 in range mode (single month, 30px
cells). Time: two <input type="time"> controls for start/end HH:MM.
Apply builds a TimeRange with no `label` so the selector shows
"MM/DD – MM/DD" instead of a preset name; Cancel returns to the
preset list.

Theming: react-day-picker's --rdp-* CSS variables are bound to the
dashboard's existing tokens (--brand, --brand-soft, --brand-ink, --ink,
--panel-2, --line) on the calendar's wrapper div. Calendar picks up
the active light/dark theme with no extra JS. Compact sizing
(30x30 day cells) so the popover fits cleanly at 320px wide.

Safety: start/end swap if the user ends up with start > end after
combining calendar day + time input; PositionedMenu's outside-click
dismissal is blocked on pointerdown inside the calendar so clicking a
day doesn't close the whole popover.

Adds react-day-picker ^9.14.0. Zero transitive runtime deps in v9.

* fix(range-picker): theming cascade + sensible default times [OMN-22]

Two fixes to the custom-range date picker:

1. Calendar colors. The --rdp-* vars were being set inline on an outer
   wrapper, but react-day-picker's `.rdp-root` declares its own
   defaults at the same specificity — and that stylesheet loads into
   the bundle after the component mounts, so the library's "blue"
   accent was winning. Moved the overrides into CustomRangePicker.css
   which is imported AFTER `react-day-picker/style.css`, targeting
   `.dash-calendar-wrapper .rdp-root` (specificity 0,2,0 vs library's
   0,1,0). Also swapped the range_start/end text color from
   --brand-ink (dark green) to #ffffff — dark-green-on-brand-green
   didn't contrast enough — and range_middle text from --brand-ink to
   --ink so days inside the range read in the neutral dark-ink color
   rather than competing with the brand.

2. Default times. The picker was seeding its start + end time inputs
   from the currently-active range's timestamps, which meant if the
   active preset was "Last 24h" (clicked at 14:37) both inputs
   defaulted to 14:37. Now they default to 00:00 and 23:59 — the
   common case is "whole days between these two dates" and users who
   want a precise minute window can still set it explicitly.

* feat(cost-trend): replace ECharts with three.js 2D stacked-area chart [OMN-22]

Rewrites the 2D Cost Trend chart body in three.js using an orthographic
camera whose frustum matches canvas pixel coordinates (top-left origin,
y-down). ECharts stays as a dependency for other widgets (Quality,
Delegation) — this swap is scoped to CostTrendPanel.

Why: ECharts' default hover model interacted awkwardly with the
stacked-area layout, producing flicker and unintended band-isolation
effects on pointer move. Owning the render loop gives us full control
over hover and tooltip behavior, and matches the technology choice of
the 3D Cost Trend widget so both widgets share a mental model.

Architecture:
  - New StackedAreaChart component owns the three.js renderer/scene/
    camera for the chart body. Re-renders on size / data / theme
    changes only; no rAF loop (preserveDrawingBuffer:true so the canvas
    holds its last frame).
  - Each visible model's band is a BufferGeometry of 2N vertices and
    2(N-1) triangles (top+bottom polyline swept into a filled strip),
    with a sibling Line for the top-edge outline. Horizontal grid
    lines at "nice" y-tick values.
  - CostTrendPanel outer stays unchanged in shape: same data fetch,
    same time-range filter, same in-range-empty overlay, same custom
    legend from task #8 below the chart.

Interaction:
  - Hover: pointermove on the wrapper maps pointer X to the nearest
    bucket index. A vertical guide line (DOM div, not geometry)
    appears at that X; an HTML tooltip follows the cursor showing
    bucket timestamp, per-model cost breakdown in stack order (top to
    bottom), and the cumulative total.
  - No hover emphasis on the bands themselves — fixes the flicker the
    user was seeing with the ECharts implementation.

Color parsing fix: globals.css declares --chart-1..7 as oklch(...)
values, which THREE.Color.setStyle() doesn't understand (it supports
rgb/hsl/hex/named only). Added cssColorToHex() helper that pipes the
CSS value through a 1×1 canvas — Canvas 2D's fillStyle accepts the
full CSS color syntax and gives back a normalized sRGB pixel we can
convert to the numeric hex THREE wants.

Test updated to stub StackedAreaChart (jsdom has no WebGL context);
verifies the outer component plumbs correct bucket + model counts into
the chart child.

* feat(cost-trend): chartType config — stacked area or stacked bar [OMN-22]

Adds a `chartType` option to the 2D Cost Trend widget's manifest
configSchema (enum: 'area' | 'bar', default 'area'). Users pick via
the widget's Configure dialog (kebab menu → Configure Widget), the
value persists with the dashboard layout.

Rendering: same StackedSlice data structure in both modes; only the
geometry builder differs.
  - 'area' (existing): filled bands (triangle strip per model) + top-
    edge polyline outlines. Smooth trend read across time.
  - 'bar' (new): one rectangle per (bucket × visible-model) cell
    stacked on the previous model's cumulative total. Reads as
    discrete per-bucket observations. Bar width = plotWidth /
    bucketCount × 0.72 (with a floor at 2px so dense datasets still
    render). Zero-height cells skip their triangle indices so we
    don't emit degenerate geometry.

File rename: StackedAreaChart.tsx → StackedChart.tsx (via git mv) now
that the component handles both modes. Exported types renamed to
match; the test file's vi.mock path and testid updated accordingly.

Grid + axis labels + hover guide + tooltip are unchanged across modes
— all the chrome lives on the common StackedSlice, not the render
shape.

* fix(cost-trend): bar-mode cell-center x-mapping + suppress zero y-label [OMN-22]

Two visual fixes on the 2D stacked chart, both noticed at "Last 24h"
where the post-filter dataset has only a handful of buckets:

1. Bars overflowed the plot on both sides. The x-scale was
   edge-to-edge (plotLeft..plotRight) regardless of mode. With N=4
   and plotWidth=400 that gave 100px between bar centers and a bar
   width of ~72px, so the leftmost bar's left edge landed 36px inside
   the y-axis label column and the rightmost bar's right edge spilled
   36px past the canvas.

   Moved to a per-mode x-scale:
     - area: edge-to-edge (unchanged). A trend line should span the
       full plot area.
     - bar: cell-center. Each of N cells gets width plotWidth/N and
       its bar is centered. Every bar stays fully inside the plot
       area at any bucket count.

2. The $0 y-axis tick label sat directly above the leftmost x-axis
   date label at narrow widths and read as a single blob. The $0 tick
   is redundant with the x-axis baseline anyway, so the y-label for
   value 0 is now suppressed. The horizontal gridline at the baseline
   still renders; only the text label is dropped.

No test changes — the fixes are rendering-layer, and the chart
component is already stubbed in tests (jsdom has no WebGL context).

* fix(routing-table): tighten narrow columns + shorter timestamp [OMN-22]

Without an explicit <colgroup>, tableLayout:fixed distributed equal
1/6 widths to all six columns. "LLM Conf." held 3 chars ("83%") and
"Cost" held 7 ("$0.0231"), so those got far more space than they
needed while Timestamp ("4/20/2026, 10:40:00 AM" at 22 chars) wrapped
at typical widget widths.

Two-part fix:

1. Added <colgroup> with per-column widths that match content size:
   Timestamp 24%, LLM Agent 21%, Fuzzy Agent 21%, Agreement 13%,
   LLM Conf. 9%, Cost 12%. Totals to 100; tableLayout:fixed respects
   those exact proportions.

2. Shortened the displayed timestamp to "MM/DD HH:MM AM/PM" (dropping
   the year and seconds). Added whiteSpace:nowrap + text-overflow
   ellipsis on the cell and a title attribute carrying the full
   toLocaleString() form so the richer timestamp is still available
   on hover.

* feat(event-stream): search, sticky header, paused indicator, source pills [OMN-22]

First round of professionalism polish. Scope chosen from the suggestion
list: the clear bugs, the straightforward visual upgrades, and the two
highest-leverage interactions. Connection-status dot, click-to-expand,
correlation-id grouping, and live/paused toggle are all deferred.

Bug fixes
  - Dropped `hsl(${colors.border})` on the row border — useThemeColors
    returns a CSS-resolvable string already, so wrapping it in hsl()
    produced invalid CSS and the dividers were not rendering. Now uses
    `var(--line-2)` directly.
  - Gave the scroll container a definite height (SCROLL_HEIGHT = 320).
    The previous `height: 100%` collapsed or grew unpredictably since
    `.widget-body` has no height constraint.

Visual
  - Sticky column header (TIME · EVENT · SOURCE) at the top of the
    scroll region so the semantics of each column are obvious without
    inferring from content.
  - Row padding bumped from 4px to 6px × 10px for breathing room.
  - Timestamp column tightened 140 → 96px; new formatter shows
    HH:MM:SS for events in the last 24h and MM/DD HH:MM for older.
    A title attribute on each row exposes the full event_type,
    source, correlation_id, and ISO timestamp for debugging.
  - Timestamp and event_type both use the mono font stack; source
    moved into a small outlined pill whose hue is derived from a hash
    of the source string — stable per source so the eye can pick one
    out while scanning a fast stream.

Interaction
  - Search input over event_type / source / correlation_id. Mirrors
    the RoutingDecisionTable pattern.
  - Auto-scroll pause is now surfaced: when the user scrolls up, the
    component tracks the arrival count since that moment and shows a
    floating "↑ N new events" button pinned at the top of the scroll
    region. Click to jump back to top and resume auto-scroll. Also
    added an "auto-scroll paused" hint in the footer.
  - Footer shows `filtered / buffered` with a "buffer full" note
    when events have saturated maxEvents.

Internal: `userScrolledUp` promoted from a ref to state so the UI can
react to the paused condition (needed for the new-events button).

* feat(quality): three.js rewrite — pass-rate headline + tier-coloured bars [OMN-22]

Replaces the ECharts-backed Quality Score widget. Two changes in one
pass because the full rewrite touches both the render layer and the
data model of the stat pane.

Why the rewrite:
  - The old widget coloured each of the 5 score buckets with a
    different entry from the chart palette. Buckets are slices of the
    same metric (score), not separate series; giving them unrelated
    hues made the histogram read as five unrelated bars rather than
    one distribution.
  - The mean marker used ECharts' `type: 'average'` which averages
    the Y values (bucket counts), not the weighted score mean that
    the fixture computes. The line was in one place and the label
    claimed a different number. Deleted.
  - "Mean Score: 0.67" was buried in small text above the chart.
    On a glance dashboard the most actionable number ("am I passing?")
    wasn't the biggest thing on screen.

New design:
  - Split layout. Left pane: big pass-rate % in a traffic-light
    colour (green / amber / red based on the value), with mean +
    measurement-count as supporting captions. Right pane: the 3D
    bar chart.
  - 3D chart in three.js. 5 bars along a strip, coloured via a
    red → amber → green hue sweep (hsl 0°..120°) so the shape of
    the distribution also encodes the quality story. MeshStandard
    material + one directional light gives the bars readable
    dimensionality without needing shadows.
  - Translucent vertical "wall" behind the bars at the x-position
    corresponding to the pass threshold, with a bright-outlined edge.
    Spatially communicates "right of the wall = passing".
  - Floating cone marker above the bars pointing down at the mean
    score's x-position, with a dashed drop-line to the ground. Uses
    the brand token.
  - Hover a bar → HTML tooltip with bucket label, count, and % of
    total. Fixed camera, no orbit.
  - Title shortened "Quality Score Distribution" → "Quality Scores".

Config:
  - Added `passThreshold` (default 0.8, range [0, 1]) to the widget
    manifest. Drives both the pass-rate calculation and the wall's
    x-position so a dashboard can calibrate per use case.

Manifest:
  - displayName unchanged ("Quality Scores"), version bumped 1.0.0 →
    1.1.0 to reflect the behaviour + config change.
  - minSize bumped w 3 → 4 so the split layout has room. defaultSize
    and maxSize unchanged.

Shared helper: cssColorToHex extracted from cost-trend/StackedChart
to src/theme/cssColorToHex.ts now that two three.js widgets need it.
Exported through src/theme/index.ts alongside useThemeColors. No
behaviour change to the cost-trend chart; StackedChart just imports
from the new location.

Test: mocks three.js WebGLRenderer with a no-op class (jsdom has no
WebGL context) and shims ResizeObserver. New assertions verify the
pass-rate arithmetic for two thresholds (default 0.8 and a custom
0.6) so the stat pane's headline is locked in.

* fix(quality): Tron-style bars + clearer mean marker [OMN-22]

Bar rendering swapped from MeshStandardMaterial (shaded, semi-opaque)
to the cost-trend-3d Tron recipe: translucent coloured core at 0.55
opacity + bright edge lines on every cube edge at 0.95 opacity. No
lighting (materials are unlit), so dropped the ambient + directional
lights from scene setup. Saturation bumped 0.6 → 0.85 and lightness
0.55 → 0.62 on the red→green tier gradient for the neon feel. The
two three.js widgets now read as siblings visually.

Ground grid switched from a subtle --line colour to cyan (#00e5ff) at
0.35 opacity for the same reason. Threshold plane outline opacity
bumped 0.5 → 0.75 and the wall itself dropped 0.12 → 0.08 so the
outline carries more of the read.

Mean marker reworked. The dashed drop line from the floating cone
down to the ground was getting hidden behind the tallest bar on
common data shapes — one of those "what IS that thing?" visuals.
Dropped it entirely. The cone is slightly larger (radius 0.16 → 0.2,
height 0.3 → 0.42) and now explicitly labelled: we project the
cone's world position to canvas pixels at scene-build time and
anchor an HTML "MEAN 0.XX" chip just above it. The relationship
reads at a glance without needing the line.

* fix(quality): theme-aware contrast for light mode [OMN-22]

The Tron colour recipe I baked in last commit worked against the dark
background but failed in light mode on several specific pairs:

  - Cyan grid (0x00e5ff, opacity 0.35) on the near-white panel — the
    lines were effectively invisible, leaving the ground plane with
    no tick markers.
  - Threshold plane and outline, both white, on a near-white canvas —
    "pass wall" disappeared entirely. Users had no visual cue that
    the scene was partitioned into pass/fail regions.
  - Bar cores at 0.55 opacity on a light background — values washed
    out instead of reading as solid bars.
  - Bar edges at full neon saturation felt garish against the light
    theme's pastel aesthetic.

Introduced a per-theme EffectiveColors block (DARK_EFFECTIVE /
LIGHT_EFFECTIVE) covering grid colour + opacity, threshold plane +
outline colour + opacity, bar core + edge opacity, and bar HSL
saturation + lightness. Light theme swaps:
  grid:      cyan  → slate grey (0x6b7280) at higher opacity
  threshold: white → dark slate (0x334155) on both plane + outline
  bar core:  0.55  → 0.72 opacity so fills read on white
  bar edges: 0.95  → 0.80 opacity (softer against the lighter bg)
  bar hue:   0.85 sat / 0.62 light → 0.60 / 0.50 (pastel, matches
             cost-trend-3d's light theme)
  ground:    one step off canvas-bg in both themes via --panel vs
             --panel-2 so the floor has a visible boundary

Mechanics: extracted the existing useThemeName hook (previously
duplicated inside cost-trend-3d) to src/theme/useThemeName.ts and
exported from the theme barrel. cost-trend-3d updated to import from
the shared location; QualityScorePanel now subscribes and picks the
right EffectiveColors block. Scene rebuilds on theme toggle via the
hook's MutationObserver on data-theme.

* fix(quality): light-mode floor was black — explicit hex + new cssVarToHex helper [OMN-22]

Root cause: the ground plane was built via
`cssColorToHex('var(--panel)', 0x0b0e14)`. Canvas 2D's fillStyle
setter (which cssColorToHex uses for parsing) silently rejects
`var()` syntax and retains the previous value, so the sentinel-check
fell through to the fallback on every call. In dark mode the
fallback 0x0b0e14 was close enough to the actual dark panel colour
that nobody noticed; in light mode a near-black floor against a
near-white canvas stood out as clearly wrong.

Two fixes:

1. Floor set as explicit hex per theme, mirroring the values
   cost-trend-3d ships in DARK_THEME / LIGHT_THEME so the two
   three.js widgets stay visually consistent:
     dark:  0x020812   (near-black, slight blue tint)
     light: 0xf7f8fa   (near-white, barely-there grey tint)

2. Added cssVarToHex(varName, fallback) to src/theme/cssColorToHex.ts
   — reads the computed CSS variable value off documentElement and
   passes the resolved literal to cssColorToHex. The mean cone's
   `var(--brand)` lookup was silently falling through to the same
   fallback for the same reason; switching to cssVarToHex means the
   cone now picks up the actual brand token in both themes.

* feat(cost-by-model): three.js pie widget — cost share per model [OMN-22]

New widget that complements Cost Trend. Where Cost Trend answers
"how has spend moved over time", this answers "who is spending the
money right now". Same llm_cost.v1 topic, client-side aggregation
by model name.

Scene: one pie lying flat on the XZ plane with a small extrusion
depth for 3D read. Each slice is built from THREE.Shape (center →
outer edge → arc → center) + ExtrudeGeometry so a single mesh holds
the whole wedge (top face + sides) and the pointer raycast is
straightforward. Slice colors come from the shared `--chart-*`
palette, indexed by a stable model-to-color map derived from the
full (un-range-filtered) dataset so a model keeps its colour even
if it drops out of the current window and later re-enters.

Interaction: hover pops the hovered slice outward by 0.09 world
units along its mid-angle, with an HTML tooltip showing the model,
cost, and share. Hover-only updates are done in a separate effect
from the slice-build effect so pointer moves don't tear down and
rebuild geometry.

Participates in the dashboard time range (supports_time_range:
true). Legend on the right shows each model with its colour dot,
percentage, and a footer line with the total cost in range.

Theme-aware via the existing PieTheme split (dark = Tron-leaning
with a touch of emissive, light = pastel with no self-glow). Floor
hex values match the cost-trend-3d floor so the two three.js
visualisations stay consistent across themes.

Registry entry `cost-by-model` added (category: visualization,
defaultSize 6×4, minSize 4×3). Both integration tests bumped from
expecting 8 components to 9. Lazy import registered in
src/components/dashboard/index.ts under its implementationKey.

New unit test mocks three.js WebGLRenderer + ResizeObserver and
locks the aggregation math: 4 input rows at $2/$1/$1/$1 across
three models should yield the expected 60% / 20% / 20% legend
shares and a $5.00 total.

* refactor(styles): unify font-family tokens — single source in :root [OMN-22]

Consolidate the font-family token system so a single CSS variable
determines every mono/sans rendering in the app.

- Define --font-sans and --font-mono in globals.css :root as primitives.
  Body and .mono now reference the tokens (previously hardcoded).
- Remove font from the vanilla-extract contract (tokens.css.ts,
  themes.css.ts). Legacy .theme-dark/.theme-light classes no longer
  set 'Inter' or 'JetBrains Mono' which were conflicting with the
  actually-loaded IBM Plex fonts.
- Update CSS files (dashboard.css, library.css, sidebar.css) +
  Header.css.ts + MessageBubble.tsx to use var(--font-mono).
- Simplify widget inline fontFamily from
  'var(--font-mono, "IBM Plex Mono", monospace)' to 'var(--f…
jonahgabriel added a commit that referenced this pull request Jun 6, 2026
* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-12069): delegation correlation trace view

Adds the delegation correlation trace view, trigger panel, and dashboard assertion updates needed for CI.

* feat(OMN-12133): add projection query endpoints for log entries and traces (#122)

Adds GET /api/projections/log-entries and GET /api/projections/traces
to the Express server, backed by new queryLogEntries and queryTraces
methods on PostgresProjectionReader. Adds traceExplorer topic constant
to shared/types/topics.ts. All query params are validated; limits are
capped (1000 for entries, 500 for traces). is_running = true when
last_event_at < 60s ago. 17 new unit tests + 2 route-level tests.

* feat(OMN-12089): B6 delegation savings proof pack — printable audit artifact (#123)

Adds DelegationSavingsProofPackPanel as a new 'Savings Proof Pack' tab in
the delegation control plane evidence tabs. Renders an exportable audit
report from live projection data: correlation IDs for every run, routing
decisions (model, rule, confidence), quality gate results (pass/fail +
detail), pricing manifest version, per-run savings, cumulative savings
summary, and a pass-rate totals row.

Print/Save as PDF is handled via window.print() with an injected @media
print stylesheet that isolates the printable region to the proof pack table.
No server-side PDF generation or new backend endpoints required — all data
sourced from DelegationEvidenceSnapshot already in context.

7 tests covering empty state, header rendering, run table, quality gate
labels, routing display, totals row, and print button.

* feat(OMN-12085): add swarm-runs projection topic to PostgresProjectionReader (#124)

Adds onex.snapshot.projection.swarm-runs.v1 case to the Postgres projection
reader. Queries omnidash_analytics.swarm_runs and returns all T4 display
fields: runId, status, subtask/succeeded/failed counts, modelsUsed,
totalCostUsd, cloudEquivalentCostUsd, savingsUsd, parallelismSpeedupRatio,
all latency fields, and createdAt. The swarm_runs table already exists on
stability-test (15436) with 11 rows.

* feat(OMN-12135): add TraceExplorerWidget — two-panel event bus trace explorer (#126)

Adds TraceExplorerWidget to omnidash for event bus observability Phase 2.
The widget consumes onex.snapshot.projection.traces.v1 to render a
left-panel trace list (with running/error indicators) and right-panel
chronological timeline of log entries for the selected trace.

- TraceExplorerWidget.tsx: two-panel layout, trace cards, timeline, search
- TraceExplorerWidget.stories.tsx: Empty, Populated, WithRunningTrace, ErrorTraces, Loading
- TraceExplorerWidget.test.tsx: 5 unit tests (loading, empty, render, filter, select prompt)
- storybook/fixtures/trace-explorer.ts: buildTraceGroups fixture factory
- generate-registry.ts: trace-explorer manifest with projectionSchema + displayContract
- index.ts: lazy import wired under trace-explorer/TraceExplorerWidget
- integration.part2/3: component count bumped 28→29 with trace-explorer

Closes OMN-12135. References OMN-12136, OMN-12137, OMN-12138, OMN-12139.

* feat(OMN-12082): add Context Effectiveness Heatmap widget (B4) (#127)

Implements the context factor effectiveness visualization showing which
context segments help/hurt which models across A3 ablation experiments:

- Segment × model matrix with color-coded cells:
  green (≥75% pass rate), amber (26–74%), red (≤25%)
- Clickable cells open an examples panel showing specific token impact,
  pass/fail counts, and qualitative findings per segment×model combination
- Fixture mode: when no live data exists, shows OMN-11241 research results
  (golden_chain green across all models, claude_md/architecture_patterns
  red for local models, exemplar green for exact-interface tasks,
  local_failures neutral)
- Fixture badge clearly marks when live projection is absent
- Reads from context_experiment_scores table via new
  onex.snapshot.projection.context.experiment-scores.v1 topic
- Adds postgres reader case for context_experiment_scores SELECT
- 10 tests passing (empty state, matrix render, cell selection, fixture
  badge, toggle deselect, footer count)

* feat(OMN-12145): add Express dispatch bridge and Kafka command topic (#128)

- POST /api/dispatch validates body, builds envelope, publishes to
  onex.cmd.omnidash.dispatch-request.v1 via kafkajs producer
- Returns 400 on missing fields, 503 when broker unavailable
- Kafka producer connects on server start, graceful disconnect on SIGTERM/SIGINT
- COMMAND_TOPICS registry in shared/types/command-topics.ts (separate from
  TOPICS to preserve golden-chain onex.snapshot.projection.* invariant)
- 9 unit tests with mocked producer covering validation, 503, and happy path

Closes OMN-12145, OMN-12149

* feat(OMN-12147): add command dispatch UI — trace action buttons + Cmd+K palette (#129)

- New `command-dispatch/` directory with:
  - `useDispatch.ts` hook: POST /api/dispatch, availability probe, error/loading state
  - `DispatchButton.tsx`: icon button (RotateCw/Square) with loading/success/error states
  - `CommandPalette.tsx`: Cmd+K/Ctrl+K overlay with node search, JSON payload editor, dispatch result; includes `useCommandPalette` hook for global keyboard listener
  - `CommandPalette.stories.tsx`: Open, WithResults, Dispatching, Error story exports
- `TraceExplorerWidget.tsx`: adds Stop button on running traces, Re-run button on completed traces
- `App.tsx`: mounts `CommandPalette` at app root via `useCommandPalette` hook

Closes OMN-12147, relates OMN-12148

* fix(OMN-12149): align dispatch command topic to omnimarket namespace (#131)

The node_dispatch_request_handler subscribes to
onex.cmd.omnimarket.dispatch-request.v1 (topic naming lint requires
consumer service namespace). Aligns the Express bridge publisher topic.

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate (#132)

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate

Confirms 298 .js artifacts in src/ and shared/ are stale (all have .ts/.tsx
counterparts; tsconfig emits nothing to src/; build output goes to dist/).
Deletes them from working tree.

Adds scripts/check-no-orphaned-js.sh — fails if any .js file in src/ or shared/
shadows a .ts/.tsx source — and wires it as a CI gate in ci.yml.

Also fixes broken @storybook/test import in CommandPalette.stories.tsx
(package moved to storybook/test in v10), restoring tsc --noEmit clean pass.

* fix(OMN-12171): include command topic in server typecheck

* refactor(OMN-12187): Consolidate DRY violations in omnidash

* refactor(OMN-12187): Consolidate DRY violations in omnidash

Extract shared pure-data helpers (timestampValue, mergeDelegationSessions,
buildCostSavingsOverviewResult) into server/projection-reader-shared.ts.
Both sqlite-projection-reader.ts and postgres-projection-reader.ts delegate
to the shared module, eliminating ~120 lines of duplicated logic.

Add 13 unit tests for the shared module covering edge cases in timestamp
parsing, session merge semantics, and cost overview aggregation.

The integration test files (part2/part3/part4) are distinct suites with
different test content — they are not duplicates and are left intact.

* fix(OMN-12187): remove unused cost overview input

* feat(OMN-12072): Swarm Control Plane widget — decomposition tree, wave view, live counters, savings bar (#125)

* feat(OMN-12072): add Swarm Control Plane widget (B2-T1 through B2-T5)

Implements the full swarm progression UI against the swarm_runs projection:
- B2-T1: SwarmDecompositionTree — parent→subtask tree with aggregate-derived
  per-subtask statuses and model hints; graceful empty state when subtask_count=0
- B2-T2: SwarmSubtaskCard — colored status badge, 3×3 metrics grid, models-used
  pill tags, registry hash footer
- B2-T3: SwarmWaveVisualization — heuristic 60/40 wave split with dependency
  connector; Wave 1 dimmed until run complete; empty state when subtask_count=0
- B2-T4: SwarmLiveCounters — 6 aggregate cards (total runs, subtasks, avg speedup,
  wall time, cost, savings) derived from swarm_runs projection
- B2-T5: SwarmSavingsBar — stacked local+savings bar vs cloud equivalent baseline,
  per-run mini bars (up to 10), zero-cost graceful empty state
- SwarmRunTable: 8-column run list with row selection, speedup/savings coloring
- SwarmControlPlane: tabbed widget (Overview, Decomposition, Run Detail, Wave View,
  Savings) wired to swarm.runs.v1 projection topic via useProjectionQuery
- Adds TOPICS.swarmRuns, swarm-runs.v1 postgres case, GET /api/swarm-runs route
- 10 tests passing

* feat(OMN-12072): add SwarmSubtaskDetailPanel — per-subtask model/tokens/cost/latency table (B5-T3 gap)

Adds SwarmSubtaskDetailPanel showing per-subtask rows with:
- subtask_id (derived: run_id prefix + padded index)
- description (Subtask 01, 02, ...)
- model assigned (cycled from run.models_used)
- status (succeeded/failed/skipped/pending, derived from aggregate counts)
- tokens_input / tokens_output (null — requires node_swarm_subtask_state_reducer)
- cost_usd (evenly distributed from total_cost_usd)
- latency_ms (estimated from dispatch_wall_latency_ms / parallelism)

Panel is clearly marked as aggregate-derived with a fixture notice.
Shows "No subtask data" with reducer name when subtask_count=0.

Wired as new "Subtask Detail" tab in SwarmControlPlane alongside existing
"Run Detail" aggregate card. Uses List icon from lucide-react.

Schema confirmed: swarm_runs has no jsonb subtask column and no separate
swarm_subtasks table exists yet in stability-test. All per-subtask data
requires node_swarm_subtask_state_reducer projection.

15 new tests + 10 existing passing (25 total).

* refactor(OMN-11578/OMN-11596): reduce cyclomatic complexity in ThreeCanvas and parseDashboardDefinition (#137)

OMN-11578: Extract advanceFocusProgress, applyFocusedOpacities,
applyIdleOpacities, and projectAxisLabels from the ThreeCanvas
useEffect god-function (CCN=83). The tick loop is now a thin
orchestrator calling four named helpers via a TickRefs context struct.

OMN-11596: Extract validateTopLevelFields and validateLayoutItem from
parseDashboardDefinition (CCN=31). The parse function is reduced to
an 8-line orchestrator; all conditional branches live in the helpers.

* fix(OMN-12245): silence unused swarm route request (#139)

* feat(OMN-11658): wire SEA demo dashboard projections (#140)

* feat(OMN-11658): wire SEA demo dashboard projections

* fix(OMN-11658): make trace rows toggle independently

* fix(OMN-11658): polish SEA dashboard rendering

* feat(OMN-11658): expose delegation trigger in SEA control plane (#141)

* fix(OMN-11658): improve SEA prompt feedback (#142)

* fix(OMN-11658): let dashboard fill wide viewports (#143)

* fix(OMN-11658): balance dashboard gutters (#144)

* fix(OMN-11658): keep SEA stream newest first (#145)

* fix(OMN-11658): refresh SEA traces after prompt submit (#146)

* fix(OMN-11658): center dashboard shell (#147)

* fix(OMN-11658): extend dashboard body to bottom (#148)

* fix(OMN-11658): repair empty SEA dashboard layout (#149)

* chore(DELEG-002): annotate pricing fallbacks, align fixture model names with bifrost config (#150)

* feat(OMN-12070): wire delegation trigger through omnimarket runtime (#151)

- Add COMMAND_TOPICS.delegateSkill ('onex.cmd.omnimarket.delegate-skill.v1')
  to shared/types/command-topics.ts so the topic is contract-declared rather
  than hardcoded in server/routes.ts
- Declare the delegation trigger command topic in contract.yaml with handler
  node, ingress path, payload shape, and OMNIDASH_RUNTIME_EFFECTS_URL env note
- server/routes.ts uses COMMAND_TOPICS.delegateSkill constant; runtime targets
  stability-test :18085/v1/effects via OMNIDASH_RUNTIME_EFFECTS_URL env var
- DelegationRunContext exposes pendingCorrelationId + setPendingCorrelationId
  so a freshly-dispatched correlation_id propagates to all child panels before
  projection rows materialize
- DelegationRunHeader wires onCorrelationId callback to DelegationTriggerPanel
- DelegationEvidenceTabs auto-navigates to correlation-trace tab on dispatch
  and displays a pending indicator with the correlation_id
- DelegationCorrelationTracePanel falls back to pendingCorrelationId when no
  run is selected and shows appropriate pending vs awaiting-events messages
- Test: setPendingCorrelationId updates context value (DelegationRunContext.test)

* fix(OMN-12243): add 'delegation' short-topic alias to postgres + sqlite projection readers (#152)

GET /projection/delegation was returning rows:[] because the query() switch
had no 'delegation' case — only the full versioned topic name. Adds a
fall-through alias in both PostgresProjectionReader and SqliteProjectionReader
so the short topic name routes to the same decisions.v1 query logic.

Test: new routes.test.ts case seeds a SQLite DB and asserts the short alias
returns the same rows as the full topic name.

* fix(OMN-12287): close fixture gaps and add file-mode banners to dashboard widgets (#153)

- Add fileMode prop to ComponentWrapper: renders a [File Mode] badge in the
  widget header when active, analogous to the [Live] badge, using Text component
  to comply with the no-typography-inline ESLint rule
- Wire fileMode={!isLiveDataSource(mode)} on all 13 widgets that use
  useDataSourceMode: TraceExplorer, LiveEventStream, ContextHeatmap,
  DelegationControlPlane, EventStream, ReadinessGate, DelegationMetrics2D,
  DelegationSavingsWidget, DelegationTokenUsageWidget,
  DelegationModelRoutingWidget, DelegationQualityGateWidget, AbCompareWidget
- Improve emptyHint messages on all updated widgets to include file-mode
  fixture directory paths so developers know exactly where to add test data
- 1124/1124 tests pass

* feat(OMN-12288): deep-link delegation runs to trace explorer (#154)

- Add traceFilter + setTraceFilter to UISlice so a correlation_id can be
  passed to TraceExplorer before navigating there (no URL routing required)
- DelegationRunTable adds a GitCommitHorizontal icon button per row; clicking
  it calls setTraceFilter(correlationId) + setActivePage('trace') and stops
  event propagation so the row-select handler is not also triggered
- TraceExplorerWidget initializes its query state from traceFilter on mount
  and immediately clears the store value so subsequent navigations start fresh
- Tests: DelegationRunTable.test.tsx (7 cases), TraceExplorerWidget deep-link
  test confirms filter pre-fill + store clear (1132 total tests pass)

* feat(T22): add DelegationCostComparisonWidget — per-run cost proof surface (#155)

- New widget DelegationCostComparisonWidget in delegation/ directory reads from
  TOPICS.delegationSavings (same source as DelegationSavingsWidget) and surfaces
  a per-session cost comparison table with columns: Session, Task, Model, Tokens,
  Latency, Local $, Cloud $, Saved, Provenance
- Provenance badges (measured/estimated) on each row per the § 1.3 spec — green
  for measured (llm_call_metrics), amber for estimated (routing-rule inferred)
- Summary KPI bar shows cumulative savings, local/cloud cost, session count,
  total tokens, measured vs estimated breakdown
- Sort controls: date (default), savings descending, tokens descending
- maxRows config cap with overflow notice; showProvenance=false hides source column
- Registered as 'delegation-cost-comparison' in generate-registry.ts and index.ts
- Integration tests updated for count 29 → 30
- 1132/1132 tests pass

* fix(OMN-12402): unify CommandPalette node-type badges and Dispatch button contrast (#156)

Issue 1: kindColor() mixed design tokens (var(--accent), var(--text-tertiary))
with hardcoded hex (#d97706, #7c3aed), so ORCHESTRATOR/COMPUTE badges rendered
as weak plain text while EFFECT/REDUCER read as vivid chips. Drive all four
badges from the design-system node-type role tokens (--<kind>{,-soft,-ink}),
which exist for both themes, so every badge is a consistent filled chip.

Issue 2: the Dispatch button used var(--accent) as its fill (which resolves to
--brand-soft, a pale hover tint) plus literal color:'white' text — invisible in
light mode. Use the brand fill + on-brand text token (--brand /
--primary-foreground) so the CTA stays legible in both themes.

Adds CommandPalette.test.tsx asserting both fixes. Visual proof:
docs/evidence/OMN-12402-{badges,dispatch-light}.png

* fix(OMN-12367): render Correlation Trace tab gracefully in file mode (#159)

In VITE_DATA_SOURCE=file mode the /api/delegation/* proxy is not registered
(it is wired only when VITE_PROJECTION_API_URL is set), so fetchCorrelationTrace
hit the Vite SPA fallback, received index.html, and surfaced a raw
'JSON.parse: unexpected character at line 1 column 1' error.

Detect file mode up front in fetchCorrelationTrace and read a per-correlation
fixture under fixtures/onex.snapshot.projection.delegation.correlation-trace.v1/
so the surface renders a representative event chain without postgres. In live
mode, parse defensively (res.json().catch) so any non-JSON body degrades to an
empty trace and the panel shows its graceful no-events message rather than a raw
parse error.

Adds the delegationCorrelationTrace topic, a representative 2-event fixture for
the file-mode decisions correlation (069e55b5...), and tests covering file-mode
fixture fetch and non-JSON hardening.

* fix(OMN-12399): register Context Effectiveness Heatmap widget in palette (#158)

The widget source (component, hook, fixtures, types, test) existed but was
registered in zero of the three Track A surfaces, so it never appeared in the
widget palette and was unreachable through the supported user flow.

- Add lazy import to componentImports in src/components/dashboard/index.ts
- Add context-effectiveness-heatmap manifest to MVP_COMPONENTS in
  scripts/generate-registry.ts (quality category, contextExperimentScores
  projection, OMN-11241 fixture fallback)
- Regenerate src/registry/component-registry.json (30 -> 31 components)
- Update integration.part2/part3 component-count + name-list assertions

Visual proof (OMN-7093): palette entry + widget rendered on dashboard in
file mode using the OMN-11241 research fixture.

* fix(OMN-12366): give Delegation Control Plane active tab a clear visual indicator (#157)

The 10-tab evidence strip distinguished active from inactive tabs only by
background (var(--panel-2) vs transparent); border, text color, and weight
were identical, so the strip did not read as tabs.

Active tab now uses the --brand accent token: brand border, a 2px brand
bottom-border underline, --brand-soft background, and brand-colored
semibold label text. All via existing design tokens (no hardcoded hex),
verified to read clearly in both light and dark themes.

Visual evidence (OMN-7093):
- docs/evidence/OMN-12366-tab-strip-dark.png
- docs/evidence/OMN-12366-tab-strip-light.png

* feat(OMN-12139): add Trace Explorer fixture for onex.snapshot.projection.traces.v1 (#161)

Wires a _trace_groups() factory into scripts/generate_fixtures.py and emits
five representative TraceGroup records under
fixtures/onex.snapshot.projection.traces.v1/, covering every TraceExplorer
UI state: multi-node completed, single-node completed, running, error, and a
longer multi-node trace. The widget reads each file as one TraceGroup via the
legacy useProjectionQuery readAll path; the file-source middleware
auto-generates index.json.

Shapes mirror the widget's TraceGroup interface exactly (no entity_id); each
record is keyed by its correlation_id so the fixture filename matches.

DoD: VITE_DATA_SOURCE=file dev server renders 5 traces (1 running, 1 error)
in the Trace Explorer in File Mode; Playwright screenshot under
docs/evidence/OMN-12139-trace-explorer-file-mode.png proves populated/running/
error states (OMN-7093). npm run build, lint, and full vitest suite (1172
tests) green.

* fix(OMN-12397): make Savings Proof Pack source line dynamic by data mode (#160)

The printable proof pack hardcoded "Source: OmniNode delegation_events
projection" even in file mode, so a PDF exported from fixture data was
misleading once removed from the parent Fixture banner. Derive the
"Source:" line and reproducibility sentence from useDataSourceMode():

  file     -> Local fixture file (fixtures/.../demo-delegation-snapshot.json)
  sqlite   -> Local SQLite projection
  http     -> OmniNode projection API via Express bridge
  postgres -> OmniNode delegation_events projection

Non-live modes (file/sqlite) state the report is from a local
development data source and is not a live projection.

Renamed fixture live-delegation-demo.json -> demo-delegation-snapshot.json;
the word 'live' inside fixtures/ was second-order misleading. The file is
discovered by directory glob (vite fixturesMiddleware readdirSync), so no
code references the old name.

dod_evidence: rendered_output screenshot
docs/evidence/OMN-12397-proof-pack-file-mode-source.png shows the
file-mode footer with the fixture source string and 15 fixture rows.

* fix(OMN-12392): repoint run-generate-fixtures.sh to local script and correct CLAUDE.md fixture claim (#162)

OMN-12392: `run-generate-fixtures.sh` was looking for `generate_fixtures.py` at
`$OMNIBASE_INFRA_PATH/scripts/generate_fixtures.py`, but that path does not exist in
omnibase_infra. The script lives in this repo at `scripts/generate_fixtures.py`.
Fix: set SCRIPT_PATH to `$REPO/scripts/generate_fixtures.py` while preserving
`--project "$OMNIBASE_INFRA_PATH"` for uv dependency resolution (the script imports
from omnibase_infra). `npm run generate:fixtures` now works without a non-standard
OMNIBASE_INFRA_PATH.

Also fix CLAUDE.md hard rule #6 which claimed "Fixtures and layouts are not committed"
— fixtures/ IS tracked via the OMN-10305 gitignore carve-out (!fixtures/, !fixtures/**).
Correct the claim and add the carve-out explanation.

OMN-12393: add a lineage classification table to CLAUDE.md documenting that
omnidash/ (dev) is CANONICAL_ACTIVE, omnidash-v2/ is SUPERSEDED, and
omnidash-v2-new/ is a TEMP_STAGING clone. Cite verification evidence.

* test(OMN-10947): pin dashboard e2e fixture proof (#163)

* test(OMN-10947): scope dashboard e2e locators (#164)

* fix(OMN-12394): add .env to omnidash .gitignore to prevent credential leak (#165)

The .env file holds OMNIDASH_ANALYTICS_DB_URL with a live Postgres password.
The existing rule only covered .env.local* — .env itself was unprotected.

Adds an explicit .env entry so git never tracks the credential file.

* fix: move omnidash event bus config to contract overlay (#166)

* chore(OMN-12459): add universal managed gitignore block to omnidash (#168)

Appends the onex-managed universal block to .gitignore, adding
.env.*, !.env.example, *.log, .idea/, .vscode/, test-results/, and
playwright-report/ coverage. .env and .DS_Store were already present
individually; the block makes the policy explicit and machine-readable.

No tracked files required untracking.

* ci(OMN-12477): add dev-only main-target-guard (forward-port from main) (#167)

Forward-ports the main-target-guard workflow to dev (the file existed on
main but was absent on dev — main<->dev drift). Adds the dev-only variant
WITHOUT the hotfix/* bypass: main accepts only dev promotion PRs carrying
promotion-receipt: OCC-N. The guard only triggers on base==main, so it is
inert on dev-targeting PRs and takes effect at the next dev->main promotion.

OMN-12477

* feat(OMN-12674): remove reviewdog workflows and configs (#170)

Removes the reviewdog caller workflow from omnidash. No reviewdog
required-status-check bindings existed on main protection, dev
protection, or any repo ruleset, so this is the only active reviewdog
surface in the repo.

dod_evidence: OMN-12674

* fix(OMN-12739): tighten delegation dashboard formatting (#171)

* fix(OMN-12740): add browser-safe dashboard id fallback (#172)

* ci: preserve hotfix main-target allowance for release
jonahgabriel added a commit that referenced this pull request Jun 7, 2026
* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-12069): delegation correlation trace view

Adds the delegation correlation trace view, trigger panel, and dashboard assertion updates needed for CI.

* feat(OMN-12133): add projection query endpoints for log entries and traces (#122)

Adds GET /api/projections/log-entries and GET /api/projections/traces
to the Express server, backed by new queryLogEntries and queryTraces
methods on PostgresProjectionReader. Adds traceExplorer topic constant
to shared/types/topics.ts. All query params are validated; limits are
capped (1000 for entries, 500 for traces). is_running = true when
last_event_at < 60s ago. 17 new unit tests + 2 route-level tests.

* feat(OMN-12089): B6 delegation savings proof pack — printable audit artifact (#123)

Adds DelegationSavingsProofPackPanel as a new 'Savings Proof Pack' tab in
the delegation control plane evidence tabs. Renders an exportable audit
report from live projection data: correlation IDs for every run, routing
decisions (model, rule, confidence), quality gate results (pass/fail +
detail), pricing manifest version, per-run savings, cumulative savings
summary, and a pass-rate totals row.

Print/Save as PDF is handled via window.print() with an injected @media
print stylesheet that isolates the printable region to the proof pack table.
No server-side PDF generation or new backend endpoints required — all data
sourced from DelegationEvidenceSnapshot already in context.

7 tests covering empty state, header rendering, run table, quality gate
labels, routing display, totals row, and print button.

* feat(OMN-12085): add swarm-runs projection topic to PostgresProjectionReader (#124)

Adds onex.snapshot.projection.swarm-runs.v1 case to the Postgres projection
reader. Queries omnidash_analytics.swarm_runs and returns all T4 display
fields: runId, status, subtask/succeeded/failed counts, modelsUsed,
totalCostUsd, cloudEquivalentCostUsd, savingsUsd, parallelismSpeedupRatio,
all latency fields, and createdAt. The swarm_runs table already exists on
stability-test (15436) with 11 rows.

* feat(OMN-12135): add TraceExplorerWidget — two-panel event bus trace explorer (#126)

Adds TraceExplorerWidget to omnidash for event bus observability Phase 2.
The widget consumes onex.snapshot.projection.traces.v1 to render a
left-panel trace list (with running/error indicators) and right-panel
chronological timeline of log entries for the selected trace.

- TraceExplorerWidget.tsx: two-panel layout, trace cards, timeline, search
- TraceExplorerWidget.stories.tsx: Empty, Populated, WithRunningTrace, ErrorTraces, Loading
- TraceExplorerWidget.test.tsx: 5 unit tests (loading, empty, render, filter, select prompt)
- storybook/fixtures/trace-explorer.ts: buildTraceGroups fixture factory
- generate-registry.ts: trace-explorer manifest with projectionSchema + displayContract
- index.ts: lazy import wired under trace-explorer/TraceExplorerWidget
- integration.part2/3: component count bumped 28→29 with trace-explorer

Closes OMN-12135. References OMN-12136, OMN-12137, OMN-12138, OMN-12139.

* feat(OMN-12082): add Context Effectiveness Heatmap widget (B4) (#127)

Implements the context factor effectiveness visualization showing which
context segments help/hurt which models across A3 ablation experiments:

- Segment × model matrix with color-coded cells:
  green (≥75% pass rate), amber (26–74%), red (≤25%)
- Clickable cells open an examples panel showing specific token impact,
  pass/fail counts, and qualitative findings per segment×model combination
- Fixture mode: when no live data exists, shows OMN-11241 research results
  (golden_chain green across all models, claude_md/architecture_patterns
  red for local models, exemplar green for exact-interface tasks,
  local_failures neutral)
- Fixture badge clearly marks when live projection is absent
- Reads from context_experiment_scores table via new
  onex.snapshot.projection.context.experiment-scores.v1 topic
- Adds postgres reader case for context_experiment_scores SELECT
- 10 tests passing (empty state, matrix render, cell selection, fixture
  badge, toggle deselect, footer count)

* feat(OMN-12145): add Express dispatch bridge and Kafka command topic (#128)

- POST /api/dispatch validates body, builds envelope, publishes to
  onex.cmd.omnidash.dispatch-request.v1 via kafkajs producer
- Returns 400 on missing fields, 503 when broker unavailable
- Kafka producer connects on server start, graceful disconnect on SIGTERM/SIGINT
- COMMAND_TOPICS registry in shared/types/command-topics.ts (separate from
  TOPICS to preserve golden-chain onex.snapshot.projection.* invariant)
- 9 unit tests with mocked producer covering validation, 503, and happy path

Closes OMN-12145, OMN-12149

* feat(OMN-12147): add command dispatch UI — trace action buttons + Cmd+K palette (#129)

- New `command-dispatch/` directory with:
  - `useDispatch.ts` hook: POST /api/dispatch, availability probe, error/loading state
  - `DispatchButton.tsx`: icon button (RotateCw/Square) with loading/success/error states
  - `CommandPalette.tsx`: Cmd+K/Ctrl+K overlay with node search, JSON payload editor, dispatch result; includes `useCommandPalette` hook for global keyboard listener
  - `CommandPalette.stories.tsx`: Open, WithResults, Dispatching, Error story exports
- `TraceExplorerWidget.tsx`: adds Stop button on running traces, Re-run button on completed traces
- `App.tsx`: mounts `CommandPalette` at app root via `useCommandPalette` hook

Closes OMN-12147, relates OMN-12148

* fix(OMN-12149): align dispatch command topic to omnimarket namespace (#131)

The node_dispatch_request_handler subscribes to
onex.cmd.omnimarket.dispatch-request.v1 (topic naming lint requires
consumer service namespace). Aligns the Express bridge publisher topic.

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate (#132)

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate

Confirms 298 .js artifacts in src/ and shared/ are stale (all have .ts/.tsx
counterparts; tsconfig emits nothing to src/; build output goes to dist/).
Deletes them from working tree.

Adds scripts/check-no-orphaned-js.sh — fails if any .js file in src/ or shared/
shadows a .ts/.tsx source — and wires it as a CI gate in ci.yml.

Also fixes broken @storybook/test import in CommandPalette.stories.tsx
(package moved to storybook/test in v10), restoring tsc --noEmit clean pass.

* fix(OMN-12171): include command topic in server typecheck

* refactor(OMN-12187): Consolidate DRY violations in omnidash

* refactor(OMN-12187): Consolidate DRY violations in omnidash

Extract shared pure-data helpers (timestampValue, mergeDelegationSessions,
buildCostSavingsOverviewResult) into server/projection-reader-shared.ts.
Both sqlite-projection-reader.ts and postgres-projection-reader.ts delegate
to the shared module, eliminating ~120 lines of duplicated logic.

Add 13 unit tests for the shared module covering edge cases in timestamp
parsing, session merge semantics, and cost overview aggregation.

The integration test files (part2/part3/part4) are distinct suites with
different test content — they are not duplicates and are left intact.

* fix(OMN-12187): remove unused cost overview input

* feat(OMN-12072): Swarm Control Plane widget — decomposition tree, wave view, live counters, savings bar (#125)

* feat(OMN-12072): add Swarm Control Plane widget (B2-T1 through B2-T5)

Implements the full swarm progression UI against the swarm_runs projection:
- B2-T1: SwarmDecompositionTree — parent→subtask tree with aggregate-derived
  per-subtask statuses and model hints; graceful empty state when subtask_count=0
- B2-T2: SwarmSubtaskCard — colored status badge, 3×3 metrics grid, models-used
  pill tags, registry hash footer
- B2-T3: SwarmWaveVisualization — heuristic 60/40 wave split with dependency
  connector; Wave 1 dimmed until run complete; empty state when subtask_count=0
- B2-T4: SwarmLiveCounters — 6 aggregate cards (total runs, subtasks, avg speedup,
  wall time, cost, savings) derived from swarm_runs projection
- B2-T5: SwarmSavingsBar — stacked local+savings bar vs cloud equivalent baseline,
  per-run mini bars (up to 10), zero-cost graceful empty state
- SwarmRunTable: 8-column run list with row selection, speedup/savings coloring
- SwarmControlPlane: tabbed widget (Overview, Decomposition, Run Detail, Wave View,
  Savings) wired to swarm.runs.v1 projection topic via useProjectionQuery
- Adds TOPICS.swarmRuns, swarm-runs.v1 postgres case, GET /api/swarm-runs route
- 10 tests passing

* feat(OMN-12072): add SwarmSubtaskDetailPanel — per-subtask model/tokens/cost/latency table (B5-T3 gap)

Adds SwarmSubtaskDetailPanel showing per-subtask rows with:
- subtask_id (derived: run_id prefix + padded index)
- description (Subtask 01, 02, ...)
- model assigned (cycled from run.models_used)
- status (succeeded/failed/skipped/pending, derived from aggregate counts)
- tokens_input / tokens_output (null — requires node_swarm_subtask_state_reducer)
- cost_usd (evenly distributed from total_cost_usd)
- latency_ms (estimated from dispatch_wall_latency_ms / parallelism)

Panel is clearly marked as aggregate-derived with a fixture notice.
Shows "No subtask data" with reducer name when subtask_count=0.

Wired as new "Subtask Detail" tab in SwarmControlPlane alongside existing
"Run Detail" aggregate card. Uses List icon from lucide-react.

Schema confirmed: swarm_runs has no jsonb subtask column and no separate
swarm_subtasks table exists yet in stability-test. All per-subtask data
requires node_swarm_subtask_state_reducer projection.

15 new tests + 10 existing passing (25 total).

* refactor(OMN-11578/OMN-11596): reduce cyclomatic complexity in ThreeCanvas and parseDashboardDefinition (#137)

OMN-11578: Extract advanceFocusProgress, applyFocusedOpacities,
applyIdleOpacities, and projectAxisLabels from the ThreeCanvas
useEffect god-function (CCN=83). The tick loop is now a thin
orchestrator calling four named helpers via a TickRefs context struct.

OMN-11596: Extract validateTopLevelFields and validateLayoutItem from
parseDashboardDefinition (CCN=31). The parse function is reduced to
an 8-line orchestrator; all conditional branches live in the helpers.

* fix(OMN-12245): silence unused swarm route request (#139)

* feat(OMN-11658): wire SEA demo dashboard projections (#140)

* feat(OMN-11658): wire SEA demo dashboard projections

* fix(OMN-11658): make trace rows toggle independently

* fix(OMN-11658): polish SEA dashboard rendering

* feat(OMN-11658): expose delegation trigger in SEA control plane (#141)

* fix(OMN-11658): improve SEA prompt feedback (#142)

* fix(OMN-11658): let dashboard fill wide viewports (#143)

* fix(OMN-11658): balance dashboard gutters (#144)

* fix(OMN-11658): keep SEA stream newest first (#145)

* fix(OMN-11658): refresh SEA traces after prompt submit (#146)

* fix(OMN-11658): center dashboard shell (#147)

* fix(OMN-11658): extend dashboard body to bottom (#148)

* fix(OMN-11658): repair empty SEA dashboard layout (#149)

* chore(DELEG-002): annotate pricing fallbacks, align fixture model names with bifrost config (#150)

* feat(OMN-12070): wire delegation trigger through omnimarket runtime (#151)

- Add COMMAND_TOPICS.delegateSkill ('onex.cmd.omnimarket.delegate-skill.v1')
  to shared/types/command-topics.ts so the topic is contract-declared rather
  than hardcoded in server/routes.ts
- Declare the delegation trigger command topic in contract.yaml with handler
  node, ingress path, payload shape, and OMNIDASH_RUNTIME_EFFECTS_URL env note
- server/routes.ts uses COMMAND_TOPICS.delegateSkill constant; runtime targets
  stability-test :18085/v1/effects via OMNIDASH_RUNTIME_EFFECTS_URL env var
- DelegationRunContext exposes pendingCorrelationId + setPendingCorrelationId
  so a freshly-dispatched correlation_id propagates to all child panels before
  projection rows materialize
- DelegationRunHeader wires onCorrelationId callback to DelegationTriggerPanel
- DelegationEvidenceTabs auto-navigates to correlation-trace tab on dispatch
  and displays a pending indicator with the correlation_id
- DelegationCorrelationTracePanel falls back to pendingCorrelationId when no
  run is selected and shows appropriate pending vs awaiting-events messages
- Test: setPendingCorrelationId updates context value (DelegationRunContext.test)

* fix(OMN-12243): add 'delegation' short-topic alias to postgres + sqlite projection readers (#152)

GET /projection/delegation was returning rows:[] because the query() switch
had no 'delegation' case — only the full versioned topic name. Adds a
fall-through alias in both PostgresProjectionReader and SqliteProjectionReader
so the short topic name routes to the same decisions.v1 query logic.

Test: new routes.test.ts case seeds a SQLite DB and asserts the short alias
returns the same rows as the full topic name.

* fix(OMN-12287): close fixture gaps and add file-mode banners to dashboard widgets (#153)

- Add fileMode prop to ComponentWrapper: renders a [File Mode] badge in the
  widget header when active, analogous to the [Live] badge, using Text component
  to comply with the no-typography-inline ESLint rule
- Wire fileMode={!isLiveDataSource(mode)} on all 13 widgets that use
  useDataSourceMode: TraceExplorer, LiveEventStream, ContextHeatmap,
  DelegationControlPlane, EventStream, ReadinessGate, DelegationMetrics2D,
  DelegationSavingsWidget, DelegationTokenUsageWidget,
  DelegationModelRoutingWidget, DelegationQualityGateWidget, AbCompareWidget
- Improve emptyHint messages on all updated widgets to include file-mode
  fixture directory paths so developers know exactly where to add test data
- 1124/1124 tests pass

* feat(OMN-12288): deep-link delegation runs to trace explorer (#154)

- Add traceFilter + setTraceFilter to UISlice so a correlation_id can be
  passed to TraceExplorer before navigating there (no URL routing required)
- DelegationRunTable adds a GitCommitHorizontal icon button per row; clicking
  it calls setTraceFilter(correlationId) + setActivePage('trace') and stops
  event propagation so the row-select handler is not also triggered
- TraceExplorerWidget initializes its query state from traceFilter on mount
  and immediately clears the store value so subsequent navigations start fresh
- Tests: DelegationRunTable.test.tsx (7 cases), TraceExplorerWidget deep-link
  test confirms filter pre-fill + store clear (1132 total tests pass)

* feat(T22): add DelegationCostComparisonWidget — per-run cost proof surface (#155)

- New widget DelegationCostComparisonWidget in delegation/ directory reads from
  TOPICS.delegationSavings (same source as DelegationSavingsWidget) and surfaces
  a per-session cost comparison table with columns: Session, Task, Model, Tokens,
  Latency, Local $, Cloud $, Saved, Provenance
- Provenance badges (measured/estimated) on each row per the § 1.3 spec — green
  for measured (llm_call_metrics), amber for estimated (routing-rule inferred)
- Summary KPI bar shows cumulative savings, local/cloud cost, session count,
  total tokens, measured vs estimated breakdown
- Sort controls: date (default), savings descending, tokens descending
- maxRows config cap with overflow notice; showProvenance=false hides source column
- Registered as 'delegation-cost-comparison' in generate-registry.ts and index.ts
- Integration tests updated for count 29 → 30
- 1132/1132 tests pass

* fix(OMN-12402): unify CommandPalette node-type badges and Dispatch button contrast (#156)

Issue 1: kindColor() mixed design tokens (var(--accent), var(--text-tertiary))
with hardcoded hex (#d97706, #7c3aed), so ORCHESTRATOR/COMPUTE badges rendered
as weak plain text while EFFECT/REDUCER read as vivid chips. Drive all four
badges from the design-system node-type role tokens (--<kind>{,-soft,-ink}),
which exist for both themes, so every badge is a consistent filled chip.

Issue 2: the Dispatch button used var(--accent) as its fill (which resolves to
--brand-soft, a pale hover tint) plus literal color:'white' text — invisible in
light mode. Use the brand fill + on-brand text token (--brand /
--primary-foreground) so the CTA stays legible in both themes.

Adds CommandPalette.test.tsx asserting both fixes. Visual proof:
docs/evidence/OMN-12402-{badges,dispatch-light}.png

* fix(OMN-12367): render Correlation Trace tab gracefully in file mode (#159)

In VITE_DATA_SOURCE=file mode the /api/delegation/* proxy is not registered
(it is wired only when VITE_PROJECTION_API_URL is set), so fetchCorrelationTrace
hit the Vite SPA fallback, received index.html, and surfaced a raw
'JSON.parse: unexpected character at line 1 column 1' error.

Detect file mode up front in fetchCorrelationTrace and read a per-correlation
fixture under fixtures/onex.snapshot.projection.delegation.correlation-trace.v1/
so the surface renders a representative event chain without postgres. In live
mode, parse defensively (res.json().catch) so any non-JSON body degrades to an
empty trace and the panel shows its graceful no-events message rather than a raw
parse error.

Adds the delegationCorrelationTrace topic, a representative 2-event fixture for
the file-mode decisions correlation (069e55b5...), and tests covering file-mode
fixture fetch and non-JSON hardening.

* fix(OMN-12399): register Context Effectiveness Heatmap widget in palette (#158)

The widget source (component, hook, fixtures, types, test) existed but was
registered in zero of the three Track A surfaces, so it never appeared in the
widget palette and was unreachable through the supported user flow.

- Add lazy import to componentImports in src/components/dashboard/index.ts
- Add context-effectiveness-heatmap manifest to MVP_COMPONENTS in
  scripts/generate-registry.ts (quality category, contextExperimentScores
  projection, OMN-11241 fixture fallback)
- Regenerate src/registry/component-registry.json (30 -> 31 components)
- Update integration.part2/part3 component-count + name-list assertions

Visual proof (OMN-7093): palette entry + widget rendered on dashboard in
file mode using the OMN-11241 research fixture.

* fix(OMN-12366): give Delegation Control Plane active tab a clear visual indicator (#157)

The 10-tab evidence strip distinguished active from inactive tabs only by
background (var(--panel-2) vs transparent); border, text color, and weight
were identical, so the strip did not read as tabs.

Active tab now uses the --brand accent token: brand border, a 2px brand
bottom-border underline, --brand-soft background, and brand-colored
semibold label text. All via existing design tokens (no hardcoded hex),
verified to read clearly in both light and dark themes.

Visual evidence (OMN-7093):
- docs/evidence/OMN-12366-tab-strip-dark.png
- docs/evidence/OMN-12366-tab-strip-light.png

* feat(OMN-12139): add Trace Explorer fixture for onex.snapshot.projection.traces.v1 (#161)

Wires a _trace_groups() factory into scripts/generate_fixtures.py and emits
five representative TraceGroup records under
fixtures/onex.snapshot.projection.traces.v1/, covering every TraceExplorer
UI state: multi-node completed, single-node completed, running, error, and a
longer multi-node trace. The widget reads each file as one TraceGroup via the
legacy useProjectionQuery readAll path; the file-source middleware
auto-generates index.json.

Shapes mirror the widget's TraceGroup interface exactly (no entity_id); each
record is keyed by its correlation_id so the fixture filename matches.

DoD: VITE_DATA_SOURCE=file dev server renders 5 traces (1 running, 1 error)
in the Trace Explorer in File Mode; Playwright screenshot under
docs/evidence/OMN-12139-trace-explorer-file-mode.png proves populated/running/
error states (OMN-7093). npm run build, lint, and full vitest suite (1172
tests) green.

* fix(OMN-12397): make Savings Proof Pack source line dynamic by data mode (#160)

The printable proof pack hardcoded "Source: OmniNode delegation_events
projection" even in file mode, so a PDF exported from fixture data was
misleading once removed from the parent Fixture banner. Derive the
"Source:" line and reproducibility sentence from useDataSourceMode():

  file     -> Local fixture file (fixtures/.../demo-delegation-snapshot.json)
  sqlite   -> Local SQLite projection
  http     -> OmniNode projection API via Express bridge
  postgres -> OmniNode delegation_events projection

Non-live modes (file/sqlite) state the report is from a local
development data source and is not a live projection.

Renamed fixture live-delegation-demo.json -> demo-delegation-snapshot.json;
the word 'live' inside fixtures/ was second-order misleading. The file is
discovered by directory glob (vite fixturesMiddleware readdirSync), so no
code references the old name.

dod_evidence: rendered_output screenshot
docs/evidence/OMN-12397-proof-pack-file-mode-source.png shows the
file-mode footer with the fixture source string and 15 fixture rows.

* fix(OMN-12392): repoint run-generate-fixtures.sh to local script and correct CLAUDE.md fixture claim (#162)

OMN-12392: `run-generate-fixtures.sh` was looking for `generate_fixtures.py` at
`$OMNIBASE_INFRA_PATH/scripts/generate_fixtures.py`, but that path does not exist in
omnibase_infra. The script lives in this repo at `scripts/generate_fixtures.py`.
Fix: set SCRIPT_PATH to `$REPO/scripts/generate_fixtures.py` while preserving
`--project "$OMNIBASE_INFRA_PATH"` for uv dependency resolution (the script imports
from omnibase_infra). `npm run generate:fixtures` now works without a non-standard
OMNIBASE_INFRA_PATH.

Also fix CLAUDE.md hard rule #6 which claimed "Fixtures and layouts are not committed"
— fixtures/ IS tracked via the OMN-10305 gitignore carve-out (!fixtures/, !fixtures/**).
Correct the claim and add the carve-out explanation.

OMN-12393: add a lineage classification table to CLAUDE.md documenting that
omnidash/ (dev) is CANONICAL_ACTIVE, omnidash-v2/ is SUPERSEDED, and
omnidash-v2-new/ is a TEMP_STAGING clone. Cite verification evidence.

* test(OMN-10947): pin dashboard e2e fixture proof (#163)

* test(OMN-10947): scope dashboard e2e locators (#164)

* fix(OMN-12394): add .env to omnidash .gitignore to prevent credential leak (#165)

The .env file holds OMNIDASH_ANALYTICS_DB_URL with a live Postgres password.
The existing rule only covered .env.local* — .env itself was unprotected.

Adds an explicit .env entry so git never tracks the credential file.

* fix: move omnidash event bus config to contract overlay (#166)

* chore(OMN-12459): add universal managed gitignore block to omnidash (#168)

Appends the onex-managed universal block to .gitignore, adding
.env.*, !.env.example, *.log, .idea/, .vscode/, test-results/, and
playwright-report/ coverage. .env and .DS_Store were already present
individually; the block makes the policy explicit and machine-readable.

No tracked files required untracking.

* ci(OMN-12477): add dev-only main-target-guard (forward-port from main) (#167)

Forward-ports the main-target-guard workflow to dev (the file existed on
main but was absent on dev — main<->dev drift). Adds the dev-only variant
WITHOUT the hotfix/* bypass: main accepts only dev promotion PRs carrying
promotion-receipt: OCC-N. The guard only triggers on base==main, so it is
inert on dev-targeting PRs and takes effect at the next dev->main promotion.

OMN-12477

* feat(OMN-12674): remove reviewdog workflows and configs (#170)

Removes the reviewdog caller workflow from omnidash. No reviewdog
required-status-check bindings existed on main protection, dev
protection, or any repo ruleset, so this is the only active reviewdog
surface in the repo.

dod_evidence: OMN-12674

* fix(OMN-12739): tighten delegation dashboard formatting (#171)

* fix(OMN-12740): add browser-safe dashboard id fallback (#172)

* feat(OMN-12745): add DelegationModelOutputWidget rendering inference generated_text (#177)

Implements the demo-blocking deliverable for OMN-12745: DelegationModelOutputWidget
renders ModelLlmInferenceResponse.generated_text sourced from bus topic
onex.evt.omnibase-infra.inference-response.v1 in fixture mode (offline, no .201
dependency). Live surfacing is gated on the OMN-12746 demo redeploy.

Changes:
- src/components/dashboard/delegation/DelegationModelOutputWidget.tsx — new widget
  rendering latest generated_text, model identity, task type, token count, latency,
  and a recent response history list
- src/components/dashboard/delegation/DelegationModelOutputWidget.test.tsx — 8 unit
  tests (TDD-first: loading, empty, generated_text, model identity, topic label,
  upstream notice, provisioned, token count)
- src/components/dashboard/delegation/DelegationModelOutputWidget.stories.tsx — 6
  stories: Empty, Loading, Populated, FixtureMode, Provisioned, SingleResponse
- shared/types/topics.ts — adds inferenceResponseText topic key
- src/storybook/fixtures/delegation-routing.ts — adds buildInferenceResponseProjection
- src/storybook/fixtures/index.ts — exports new fixture builder
- scripts/generate-registry.ts — adds delegation-model-output MVP manifest entry
- src/registry/component-registry.json — regenerated (32 components)
- src/components/dashboard/index.ts — lazy-import registration
- src/templates/sea-demo.ts — delegation-model-output added to SEA Demo template
- src/templates/templates.test.ts — updated SEA Demo widget order assertions
- src/integration.part2.test.tsx — component count 31 to 32 and name list
- src/integration.part3.test.tsx — component count 31 to 32
- src/storybook-coverage-compliance.test.ts — adds story file to compliance list
- fixtures/onex.snapshot.projection.delegation.inference-response-text.v1/snapshot.json
  — fixture data for file mode (offline demo)

Evidence-Source OCC SHA: c872f8edbabda4306e3dd290dbb8995031da3d7c

* feat(OMN-12748): render delegated prompt/response from contract-declared projection (#178)

The Correlation Trace panel already renders prompt_text/response_text per run,
but fetchCorrelationTrace called a bespoke /api/delegation/correlation-trace/<id>
REST route that the deployed contract-driven projection API does not implement
(404), so the panel always showed 'No events found' and the dashboard could not
surface the actual delegated work.

Read the per-correlation trace from the contract-declared projection instead:
/projection/onex.snapshot.projection.delegation.correlation-trace.v1?correlation_id=<id>
(a read path the projection API already supports). Selecting a run now renders
its prompt + response. File mode still reads the per-correlation fixture.

Pairs with omnimarket OMN-12748, which exposes that projection over
delegation_events. Canonical: client renders a contract-declared projection; no
bespoke REST route.

* chore(release)(OMN-12245): backmerge omnidash v1.1.1 to dev (#176)

* fix(OMN-12756): publish delegation trigger through command bus (#179)

* feat(OMN-12748): render delegated prompt/response from contract-declared projection

The Correlation Trace panel already renders prompt_text/response_text per run,
but fetchCorrelationTrace called a bespoke /api/delegation/correlation-trace/<id>
REST route that the deployed contract-driven projection API does not implement
(404), so the panel always showed 'No events found' and the dashboard could not
surface the actual delegated work.

Read the per-correlation trace from the contract-declared projection instead:
/projection/onex.snapshot.projection.delegation.correlation-trace.v1?correlation_id=<id>
(a read path the projection API already supports). Selecting a run now renders
its prompt + response. File mode still reads the per-correlation fixture.

Pairs with omnimarket OMN-12748, which exposes that projection over
delegation_events. Canonical: client renders a contract-declared projection; no
bespoke REST route.

* fix(OMN-12756): route delegation API to dashboard server

* chore(OMN-12765): backmerge omnidash main to dev (#181)

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities (#120)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities

OMN-11614: Extract shared session-merge and cost-savings-overview aggregation
logic from postgres-projection-reader.ts and sqlite-projection-reader.ts into
server/projection-utils.ts. Removes ~160 lines of duplicated code addressed
by the TODO(OMN-10976) comment. Fixes pre-existing test failures in
postgres-projection-reader.test.ts where string-typed token assertions
contradicted the numeric coercion the reader actually applies.

OMN-11617: Extract manifest loading and QueryClient factory into
src/test-utils/integrationHelpers.ts. Updates integration.part2/3/4.test.tsx
to import TEST_MANIFEST and createTestQueryClient instead of duplicating the
readFileSync + JSON.parse pattern and bare QueryClient constructor.

17 new unit tests for projection-utils.ts cover timestampValue, sessionKey,
mergeDelegationSessions, sessionTokens, and buildCostSavingsOverview.

* fix(OMN-11614, OMN-11617): update delegation widget tests to match OMN-11979 redesign

Tests were written against the old design (KPI tiles with "Pass rate",
"Passed", "Failed" labels; donut panel 420px wide; "QG Pass" column;
"Failure categories" heading; "Recent checks" label). OMN-11979 shipped
a dense terminal-style redesign that changed these rendered strings.

Update assertions to match the current component output:
- DelegationMetrics3D: maxWidth 420→240, canvas height 200→140
- DelegationMetrics2D: "N/M pass" → "N/M local"
- DelegationModelRoutingWidget: column "QG Pass" → "Local Rate"
- DelegationQualityGateWidget: empty msg, check-type labels, section
  headings, recent-checks label all updated to current rendered text

All 1044 tests pass (0 new failures introduced).

* chore: release v1.1.0 (OMN-12245) (#135)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-12069): delegation correlation trace view

Adds the delegation correlation trace view, trigger panel, and dashboard assertion updates needed for CI.

* feat(OMN-12133): add projection query endpoints for log entries and traces (#122)

Adds GET /api/projections/log-entries and GET /api/projections/traces
to the Express server, backed by new queryLogEntries and queryTraces
methods on PostgresProjectionReader. Adds traceExplorer topic constant
to shared/types/topics.ts. All query params are validated; limits are
capped (1000 for entries, 500 for traces). is_running = true when
last_event_at < 60s ago. 17 new unit tests + 2 route-level tests.

* feat(OMN-12089): B6 delegation savings proof pack — printable audit artifact (#123)

Adds DelegationSavingsProofPackPanel as a new 'Savings Proof Pack' tab in
the delegation control plane evidence tabs. Renders an exportable audit
report from live projection data: correlation IDs for every run, routing
decisions (model, rule, confidence), quality gate results (pass/fail +
detail), pricing manifest version, per-run savings, cumulative savings
summary, and a pass-rate totals row.

Print/Save as PDF is handled via window.print() with an injected @media
print stylesheet that isolates the printable region to the proof pack table.
No server-side PDF generation or new backend endpoints required — all data
sourced from DelegationEvidenceSnapshot already in context.

7 tests covering empty state, header rendering, run table, quality gate
labels, routing display, totals row, and print button.

* feat(OMN-12085): add swarm-runs projection topic to PostgresProjectionReader (#124)

Adds onex.snapshot.projection.swarm-runs.v1 case to the Postgres projection
reader. Queries omnidash_analytics.swarm_runs and returns all T4 display
fields: runId, status, subtask/succeeded/failed counts, modelsUsed,
totalCostUsd, cloudEquivalentCostUsd, savingsUsd, parallelismSpeedupRatio,
all latency fields, and createdAt. The swarm_runs table already exists on
stability-test (15436) with 11 rows.

* feat(OMN-12135): add TraceExplorerWidget — two-panel event bus trace explorer (#126)

Adds TraceExplorerWidget to omnidash for event bus observability Phase 2.
The widget consumes onex.snapshot.projection.traces.v1 to render a
left-panel trace list (with running/error indicators) and right-panel
chronological timeline of log entries for the selected trace.

- TraceExplorerWidget.tsx: two-panel layout, trace cards, timeline, search
- TraceExplorerWidget.stories.tsx: Empty, Populated, WithRunningTrace, ErrorTraces, Loading
- TraceExplorerWidget.test.tsx: 5 unit tests (loading, empty, render, filter, select prompt)
- storybook/fixtures/trace-explorer.ts: buildTraceGroups fixture factory
- generate-registry.ts: trace-explorer manifest with projectionSchema + displayContract
- index.ts: lazy import wired under trace-explorer/TraceExplorerWidget
- integration.part2/3: component count bumped 28→29 with trace-explorer

Closes OMN-12135. References OMN-12136, OMN-12137, OMN-12138, OMN-12139.

* feat(OMN-12082): add Context Effectiveness Heatmap widget (B4) (#127)

Implements the context factor effectiveness visualization showing which
context segments help/hurt which models across A3 ablation experiments:

- Segment × model matrix with color-coded cells:
  green (≥75% pass rate), amber (26–74%), red (≤25%)
- Clickable cells open an examples panel showing specific token impact,
  pass/fail counts, and qualitative findings per segment×model combination
- Fixture mode: when no live data exists, shows OMN-11241 research results
  (golden_chain green across all models, claude_md/architecture_patterns
  red for local models, exemplar green for exact-interface tasks,
  local_failures neutral)
- Fixture badge clearly marks when live projection is absent
- Reads from context_experiment_scores table via new
  onex.snapshot.projection.context.experiment-scores.v1 topic
- Adds postgres reader case for context_experiment_scores SELECT
- 10 tests passing (empty state, matrix render, cell selection, fixture
  badge, toggle deselect, footer count)

* feat(OMN-12145): add Express dispatch bridge and Kafka command topic (#128)

- POST /api/dispatch validates body, builds envelope, publishes to
  onex.cmd.omnidash.dispatch-request.v1 via kafkajs producer
- Returns 400 on missing fields, 503 when broker unavailable
- Kafka producer connects on server start, graceful disconnect on SIGTERM/SIGINT
- COMMAND_TOPICS registry in shared/types/command-topics.ts (separate from
  TOPICS to preserve golden-chain onex.snapshot.projection.* invariant)
- 9 unit tests with mocked producer covering validation, 503, and happy path

Closes OMN-12145, OMN-12149

* feat(OMN-12147): add command dispatch UI — trace action buttons + Cmd+K palette (#129)

- New `command-dispatch/` directory with:
  - `useDispatch.ts` hook: POST /api/dispatch, availability probe, error/loading state
  - `DispatchButton.tsx`: icon button (RotateCw/Square) with loading/success/error states
  - `CommandPalette.tsx`: Cmd+K/Ctrl+K overlay with node search, JSON payload editor, dispatch result; includes `useCommandPalette` hook for global keyboard listener
  - `CommandPalette.stories.tsx`: Open, WithResults, Dispatching, Error story exports
- `TraceExplorerWidget.tsx`: adds Stop button on running traces, Re-run button on completed traces
- `App.tsx`: mounts `CommandPalette` at app root via `useCommandPalette` hook

Closes OMN-12147, relates OMN-12148

* fix(OMN-12149): align dispatch command topic to omnimarket namespace (#131)

The node_dispatch_request_handler subscribes to
onex.cmd.omnimarket.dispatch-request.v1 (topic naming lint requires
consumer service namespace). Aligns the Express bridge publisher topic.

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate (#132)

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate

Confirms 298 .js artifacts in src/ and shared/ are stale (all have .ts/.tsx
counterparts; tsconfig emits nothing to src/; build output goes to dist/).
Deletes them from working tree.

Adds scripts/check-no-orphaned-js.sh — fails if any .js file in src/ or shared/
shadows a .ts/.tsx source — and wires it as a CI gate in ci.yml.

Also fixes broken @storybook/test import in CommandPalette.stories.tsx
(package moved to storybook/test in v10), restoring tsc --noEmit clean pass.

* fix(OMN-12171): include command topic in server typecheck

* refactor(OMN-12187): Consolidate DRY violations in omnidash

* refactor(OMN-12187): Consolidate DRY violations in omnidash

Extract shared pure-data helpers (timestampValue, mergeDelegationSessions,
buildCostSavingsOverviewResult) into server/projection-reader-shared.ts.
Both sqlite-projection-reader.ts and postgres-projection-reader.ts delegate
to the shared module, eliminating ~120 lines of duplicated logic.

Add 13 unit tests for the shared module covering edge cases in timestamp
parsing, session merge semantics, and cost overview aggregation.

The integration test files (part2/part3/part4) are distinct suites with
different test content — they are not duplicates and are left intact.

* fix(OMN-12187): remove unused cost overview input

* chore: release v1.1.0

* ci: re-trigger CI for release promotion PR

* feat(OMN-6877): add 4 observability pages — trace explorer, session replay, sandbox monitor, voice sessions (#136)

- TraceExplorerPage: wraps existing TraceExplorerWidget as a full-page view
- SessionReplayPage: step/play/rewind through recorded sessions with state reconstruction and progress bar
- SandboxMonitorPage: real-time allow/deny stream, deny-rate bar, per-policy breakdown table
- VoiceSessionPage: audio timeline, transcript overlay, turn-taking visualization
- Extends AppPage type and App.tsx routing switch for all 4 new pages
- Adds Observability section to Sidebar with GitBranch/RotateCcw/Shield/Mic icons
- Adds sandboxDecisions, voiceSessions, sessionReplay topics to TOPICS constant

* ci(OMN-12243): add main target guard (#138)

* release: promote omnidash dev to main (2026-06-06) (#174)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-12069): delegation correlation trace view

Adds the delegation correlation trace view, trigger panel, and dashboard assertion updates needed for CI.

* feat(OMN-12133): add projection query endpoints for log entries and traces (#122)

Adds GET /api/projections/log-entries and GET /api/projections/traces
to the Express server, backed by new queryLogEntries and queryTraces
methods on PostgresProjectionReader. Adds traceExplorer topic constant
to shared/types/topics.ts. All query params are validated; limits are
capped (1000 for entries, 500 for traces). is_running = true when
last_event_at < 60s ago. 17 new unit tests + 2 route-level tests.

* feat(OMN-12089): B6 delegation savings proof pack — printable audit artifact (#123)

Adds DelegationSavingsProofPackPanel as a new 'Savings Proof Pack' tab in
the delegation control plane evidence tabs. Renders an exportable audit
report from live projection data: correlation IDs for every run, routing
decisions (model, rule, confidence), quality gate results (pass/fail +
detail), pricing manifest version, per-run savings, cumulative savings
summary, and a pass-rate totals row.

Print/Save as PDF is handled via window.print() with an injected @media
print stylesheet that isolates the printable region to the proof pack table.
No server-side PDF generation or new backend endpoints required — all data
sourced from DelegationEvidenceSnapshot already in context.

7 tests covering empty state, header rendering, run table, quality gate
labels, routing display, totals row, and print button.

* feat(OMN-12085): add swarm-runs projection topic to PostgresProjectionReader (#124)

Adds onex.snapshot.projection.swarm-runs.v1 case to the Postgres projection
reader. Queries omnidash_analytics.swarm_runs and returns all T4 display
fields: runId, status, subtask/succeeded/failed counts, modelsUsed,
totalCostUsd, cloudEquivalentCostUsd, savingsUsd, parallelismSpeedupRatio,
all latency fields, and createdAt. The swarm_runs table already exists on
stability-test (15436) with 11 rows.

* feat(OMN-12135): add TraceExplorerWidget — two-panel event bus trace explorer (#126)

Adds TraceExplorerWidget to omnidash for event bus observability Phase 2.
The widget consumes onex.snapshot.projection.traces.v1 to render a
left-panel trace list (with running/error indicators) and right-panel
chronological timeline of log entries for the selected trace.

- TraceExplorerWidget.tsx: two-panel layout, trace cards, timeline, search
- TraceExplorerWidget.stories.tsx: Empty, Populated, WithRunningTrace, ErrorTraces, Loading
- TraceExplorerWidget.test.tsx: 5 unit tests (loading, empty, render, filter, select prompt)
- storybook/fixtures/trace-explorer.ts: buildTraceGroups fixture factory
- generate-registry.ts: trace-explorer manifest with projectionSchema + displayContract
- index.ts: lazy import wired under trace-explorer/TraceExplorerWidget
- integration.part2/3: component count bumped 28→29 with trace-explorer

Closes OMN-12135. References OMN-12136, OMN-12137, OMN-12138, OMN-12139.

* feat(OMN-12082): add Context Effectiveness Heatmap widget (B4) (#127)

Implements the context factor effectiveness visualization showing which
context segments help/hurt which models across A3 ablation experiments:

- Segment × model matrix with color-coded cells:
  green (≥75% pass rate), amber (26–74%), red (≤25%)
- Clickable cells open an examples panel showing specific token impact,
  pass/fail counts, and qualitative findings per segment×model combination
- Fixture mode: when no live data exists, shows OMN-11241 research results
  (golden_chain green across all models, claude_md/architecture_patterns
  red for local models, exemplar green for exact-interface tasks,
  local_failures neutral)
- Fixture badge clearly marks when live projection is absent
- Reads from context_experiment_scores table via new
  onex.snapshot.projection.context.experiment-scores.v1 topic
- Adds postgres reader case for context_experiment_scores SELECT
- 10 tests passing (empty state, matrix render, cell selection, fixture
  badge, toggle deselect, footer count)

* feat(OMN-12145): add Express dispatch bridge and Kafka command topic (#128)

- POST /api/dispatch validates body, builds envelope, publishes to
  onex.cmd.omnidash.dispatch-request.v1 via kafkajs producer
- Returns 400 on missing fields, 503 when broker unavailable
- Kafka producer connects on server start, graceful disconnect on SIGTERM/SIGINT
- COMMAND_TOPICS registry in shared/types/command-topics.ts (separate from
  TOPICS to preserve golden-chain onex.snapshot.projection.* invariant)
- 9 unit tests with mocked producer covering validation, 503, and happy path

Closes OMN-12145, OMN-12149

* feat(OMN-12147): add command dispatch UI — trace action buttons + Cmd+K palette (#129)

- New `command-dispatch/` directory with:
  - `useDispatch.ts` hook: POST /api/dispatch, availability probe, error/loading state
  - `DispatchButton.tsx`: icon button (RotateCw/Square) with loading/success/error states
  - `CommandPalette.tsx`: Cmd+K/Ctrl+K overlay with node search, JSON payload editor, dispatch result; includes `useCommandPalette` hook for global keyboard listener
  - `CommandPalette.stories.tsx`: Open, WithResults, Dispatching, Error story exports
- `TraceExplorerWidget.tsx`: adds Stop button on running traces, Re-run button on completed traces
- `App.tsx`: mounts `CommandPalette` at app root via `useCommandPalette` hook

Closes OMN-12147, relates OMN-12148

* fix(OMN-12149): align dispatch command topic to omnimarket namespace (#131)

The node_dispatch_request_handler subscribes to
onex.cmd.omnimarket.dispatch-request.v1 (topic naming lint requires
consumer service namespace). Aligns the Express bridge publisher topic.

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate (#132)

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate

Confirms 298 .js artifacts in src/ and shared/ are stale (all have .ts/.tsx
counterparts; tsconfig emits nothing to src/; build output goes to dist/).
Deletes them from working tree.

Adds scripts/check-no-orphaned-js.sh — fails if any .js file in src/ or shared/
shadows a .ts/.tsx source — and wires it as a CI gate in ci.yml.

Also fixes broken @storybook/test import in CommandPalette.stories.tsx
(package moved to storybook/test in v10), restoring tsc --noEmit clean pass.

* fix(OMN-12171): include command topic in server typecheck

* refactor(OMN-12187): Consolidate DRY violations in omnidash

* refactor(OMN-12187): Consolidate DRY violations in omnidash

Extract shared pure-data helpers (timestampValue, mergeDelegationSessions,
buildCostSavingsOverviewResult) into server/projection-reader-shared.ts.
Both sqlite-projection-reader.ts and postgres-projection-reader.ts delegate
to the shared module, eliminating ~120 lines of duplicated logic.

Add 13 unit tests for the shared module covering edge cases in timestamp
parsing, session merge semantics, and cost overview aggregation.

The integration test files (part2/part3/part4) are distinct suites with
different test content — they are not duplicates and are left intact.

* fix(OMN-12187): remove unused cost overview input

* feat(OMN-12072): Swarm Control Plane widget — decomposition tree, wave view, live counters, savings bar (#125)

* feat(OMN-12072): add Swarm Control Plane widget (B2-T1 through B2-T5)

Implements the full swarm progression UI against the swarm_runs projection:
- B2-T1: SwarmDecompositionTree — parent→subtask tree with aggregate-derived
  per-subtask statuses and model hints; graceful empty state when subtask_count=0
- B2-T2: SwarmSubtaskCard — colored status badge, 3×3 metrics grid, models-used
  pill tags, registry hash footer
- B2-T3: SwarmWaveVisualization — heuristic 60/40 wave split with dependency
  connector; Wave 1 dimmed until run complete; empty state when subtask_count=0
- B2-T4: SwarmLiveCounters — 6 aggregate cards (total runs, subtasks, avg speedup,
  wall time, cost, savings) derived from swarm_runs projection
- B2-T5: SwarmSavingsBar — stacked local+savings bar vs cloud equivalent baseline,
  per-run mini bars (up to 10), zero-cost graceful empty state
- SwarmRunTable: 8-column run list with row selection, speedup/savings coloring
- SwarmControlPlane: tabbed widget (Overview, Decomposition, Run Detail, Wave View,
  Savings) wired to swarm.runs.v1 projection topic via useProjectionQuery
- Adds TOPICS.swarmRuns, swarm-runs.v1 postgres case, GET /api/swarm-runs route
- 10 tests passing

* feat(OMN-12072): add SwarmSubtaskDetailPanel — per-subtask model/tokens/cost/latency table (B5-T3 gap)

Adds SwarmSubtaskDetailPanel showing per-subtask rows with:
- subtask_id (derived: run_id prefix + padded index)
- description (Subtask 01, 02, ...)
- model assigned (cycled from run.models_used)
- status (succeeded/failed/skipped/pending, derived from aggregate counts)
- tokens_input / tokens_output (null — requires node_swarm_subtask_state_reducer)
- cost_usd (evenly distributed from total_cost_usd)
- latency_ms (estimated from dispatch_wall_latency_ms / parallelism)

Panel is clearly marked as aggregate-derived with a fixture notice.
Shows "No subtask data" with reducer name when subtask_count=0.

Wired as new "Subtask Detail" tab in SwarmControlPlane alongside existing
"Run Detail" aggregate card. Uses List icon from lucide-react.

Schema confirmed: swarm_runs has no jsonb subtask column and no separate
swarm_subtasks table exists yet in stability-test. All per-subtask data
requires node_swarm_subtask_state_reducer projection.

15 new tests + 10 existing passing (25 total).

* refactor(OMN-11578/OMN-11596): reduce cyclomatic complexity in ThreeCanvas and parseDashboardDefinition (#137)

OMN-11578: Extract advanceFocusProgress, applyFocusedOpacities,
applyIdleOpacities, and projectAxisLabels from the ThreeCanvas
useEffect god-function (CCN=83). The tick loop is now a thin
orchestrator calling four named helpers via a TickRefs context struct.

OMN-11596: Extract validateTopLevelFields and validateLayoutItem from
parseDashboardDefinition (CCN=31). The parse function is reduced to
an 8-line orchestrator; all conditional branches live in the helpers.

* fix(OMN-12245): silence unused swarm route request (#139)

* feat(OMN-11658): wire SEA demo dashboard projections (#140)

* feat(OMN-11658): wire SEA demo dashboard projections

* fix(OMN-11658): make trace rows toggle independently

* fix(OMN-11658): polish SEA dashboard rendering

* feat(OMN-11658): expose delegation trigger in SEA control plane (#141)

* fix(OMN-11658): improve SEA prompt feedback (#142)

* fix(OMN-11658): let dashboard fill wide viewports (#143)

* fix(OMN-11658): balance dashboard gutters (#144)

* fix(OMN-11658): keep SEA stream newest first (#145)

* fix(OMN-11658): refresh SEA traces after prompt submit (#146)

* fix(OMN-11658): center dashboard shell (#147)

* fix(OMN-11658): extend dashboard body to bottom (#148)

* fix(OMN-11658): repair empty SEA dashboard layout (#149)

* chore(DELEG-002): annotate pricing fallbacks, align fixture model names with bifrost config (#150)

* feat(OMN-12070): wire delegation trigger through omnimarket runtime (#151)

- Add COMMAND_TOPICS.delegateSkill ('onex.cmd.omnimarket.delegate-skill.v1')
  to shared/types/command-topics.ts so the topic is contract-declared rather
  than hardcoded in server/routes.ts
- Declare the delegation trigger command topic in contract.yaml with handler
  node, ingress path, payload shape, and OMNIDASH_RUNTIME_EFFECTS_URL env note
- server/routes.ts uses COMMAND_TOPICS.delegateSkill constant; runtime targets
  stability-test :18085/v1/effects via OMNIDASH_RUNTIME_EFFECTS_URL env var
- DelegationRunContext exposes pendingCorrelationId + setPendingCorrelationId
  so a freshly-dispatched correlation_id propagates to all child panels before
  projection rows materialize
- DelegationRunHeader wires onCorrelationId callback to DelegationTriggerPanel
- DelegationEvidenceTabs auto-navigates to correlation-trace tab on dispatch
  and displays a pending indicator with the correlation_id
- DelegationCorrelationTracePanel falls back to pendingCorrelationId when no
  run is selected and shows appropriate pending vs awaiting-events messages
- Test: setPendingCorrelationId updates context value (DelegationRunContext.test)

* fix(OMN-12243): add 'delegation' short-topic alias to postgres + sqlite projection readers (#152)

GET /projection/delegation was returning rows:[] because the query() switch
had no 'delegation' case — only the full versioned topic name. Adds a
fall-through alias in both PostgresProjectionReader and SqliteProjectionReader
so the short topic name routes to the same decisions.v1 query logic.

Test: new routes.test.ts case seeds a SQLite DB and asserts the short alias
returns the same rows as the full topic name.

* fix(OMN-12287): close fixture gaps and add file-mode banners to dashboard widgets (#153)

- Add fileMode prop to ComponentWrapper: renders a [File Mode] badge in the
  widget header when active, analogous to the [Live] badge, using Text component
  to comply with the no-typography-inline ESLint rule
- Wire fileMode={!isLiveDataSource(mode)} on all 13 widgets that use
  useDataSourceMode: TraceExplorer, LiveEventStream, ContextHeatmap,
  DelegationControlPlane, EventStream, ReadinessGate, DelegationMetrics2D,
  DelegationSavingsWidget, DelegationTokenUsageWidget,
  DelegationModelRoutingWidget, DelegationQualityGateWidget, AbCompareWidget
- Improve emptyHint messages on all updated widgets to include file-mode
  fixture directory paths so developers know exactly where to add test data
- 1124/1124 tests pass

* feat(OMN-12288): deep-link delegation runs to trace explorer (#154)

- Add traceFilter + setTraceFilter to UISlice so a correlation_id can be
  passed to TraceExplorer before navigating there (no URL routing required)
- DelegationRunTable adds a GitCommitHorizontal icon button per row; clicking
  it calls setTraceFilter(correlationId) + setActivePage('trace') and stops
  event propagation so the row-select handler is not also triggered
- TraceExplorerWidget initializes its query state from traceFilter on mount
  and immediately clears the store value so subsequent navigations start fresh
- Tests: DelegationRunTable.test.tsx (7 cases), TraceExplorerWidget deep-link
  test confirms filter pre-fill + store clear (1132 total tests pass)

* feat(T22): add DelegationCostComparisonWidget — per-run cost proof surface (#155)

- New widget DelegationCostComparisonWidget in delegation/ directory reads from
  TOPICS.delegationSavings (same source as DelegationSavingsWidget) and surfaces
  a per-session cost comparison table with columns: Session, Task, Model, Tokens,
  Latency, Local $, Cloud $, Saved, Provenance
- Provenance badges (measured/estimated) on each row per the § 1.3 spec — green
  for measured (llm_call_metrics), amber for estimated (routing-rule inferred)
- Summary KPI bar shows cumulative savings, local/cloud cost, session count,
  total tokens, measured vs estimated breakdown
- Sort controls: date (default), savings descending, tokens descending
- maxRows config cap with overflow notice; showProvenance=false hides source column
- Registered as 'delegation-cost-comparison' in generate-registry.ts and index.ts
- Integration tests updated for count 29 → 30
- 1132/1132 tests pass

* fix(OMN-12402): unify CommandPalette node-type badges and Dispatch button contrast (#156)

Issue 1: kindColor() mixed design tokens (var(--accent), var(--text-tertiary))
with hardcoded hex (#d97706, #7c3aed), so ORCHESTRATOR/COMPUTE badges rendered
as weak plain text while EFFECT/REDUCER read as vivid chips. Drive all four
badges from the design-system node-type role tokens (--<kind>{,-soft,-ink}),
which exist for both themes, so every badge is a consistent filled chip.

Issue 2: the Dispatch button used var(--accent) as its fill (which resolves to
--brand-soft, a pale hover tint) plus literal color:'white' text — invisible in
light mode. Use the brand fill + on-brand text token (--brand /
--primary-foreground) so the CTA stays legible in both themes.

Adds CommandPalette.test.tsx asserting both fixes. Visual proof:
docs/evidence/OMN-12402-{badges,dispatch-light}.png

* fix(OMN-12367): render Correlation Trace tab gracefully in file mode (#159)

In VITE_DATA_SOURCE=file mode the /api/delegation/* proxy is not registered
(it is wired only when VITE_PROJECTION_API_URL is set), so fetchCorrelationTrace
hit the Vite SPA fallback, received index.html, and surfaced a raw
'JSON.parse: unexpected character at line 1 column 1' error.

Detect file mode up front in fetchCorrelationTrace and read a per-correlation
fixture under fixtures/onex.snapshot.projection.delegation.correlation-trace.v1/
so the surface renders a representative event chain without postgres. In live
mode, parse defensively (res.json().catch) so any non-JSON body degrades to an
empty trace and the panel shows its graceful no-events message rather than a raw
parse error.

Adds the delegationCorrelationTrace topic, a representative 2-event fixture for
the file-mode decisions correlation (069e55b5...), and tests covering file-mode
fixture fetch and non-JSON hardening.

* fix(OMN-12399): register Context Effectiveness Heatmap widget in palette (#158)

The widget source (component, hook, fixtures, types, test) existed but was
registered in zero of the three Track A surfaces, so it never appeared in the
widget palette and was unreachable through the supported user flow.

- Add lazy import to componentImports in src/components/dashboard/index.ts
- Add context-effectiveness-heatmap manifest to MVP_COMPONENTS in
  scripts/generate-registry.ts (quality category, contextExperimentScores
  projection, OMN-11241 fixture fallback)
- Regenerate src/registry/component-registry.json (30 -> 31 components)
- Update integration.part2/part3 component-count + name-list assertions

Visual proof (OMN-7093): palette entry + widget rendered on dashboard in
file mode using the OMN-11241 research fixture.

* fix(OMN-12366): give Delegation Control Plane active tab a clear visual indicator (#157)

The 10-tab evidence strip distinguished active from inactive tabs only by
background (var(--panel-2) vs transparent); border, text color, and weight
were identical, so the strip did not read as tabs.

Active tab now uses the --brand accent token: brand border, a 2px brand
bottom-border underline, --brand-soft background, and brand-colored
semibold label text. All via existing design tokens (no hardcoded hex),
verified to read clearly in both light and dark themes.

Visual evidence (OMN-7093):
- docs/evidence/OMN-12366-tab-strip-dark.png
- docs/evidence/OMN-12366-tab-strip-light.png

* feat(OMN-12139): add Trace Explorer fixture for onex.snapshot.projection.traces.v1 (#161)

Wires a _trace_groups() factory into scripts/generate_fixtures.py and emits
five representative TraceGroup records under
fixtures/onex.snapshot.projection.traces.v1/, covering every TraceExplorer
UI state: multi-node completed, single-node completed, running, error, and a
longer multi-node trace. The widget reads each file as one TraceGroup via the
legacy useProjectionQuery readAll path; the file-source middleware
auto-generates index.json.

Shapes mirror the widget's TraceGroup interface exactly (no entity_id); each
record is keyed by its correlation_id so the fixture filename matches.

DoD: VITE_DATA_SOURCE=file dev server renders 5 traces (1 running, 1 error)
in the Trace Explorer in File Mode; Playwright screenshot under
docs/evidence/OMN-12139-trace-explorer-file-mode.png proves populated/running/
error states (OMN-7093). npm run build, lint, and full vitest suite (1172
tests) green.

* fix(OMN-12397): make Savings Proof Pack source line dynamic by data mode (#160)

The printable proof pack hardcoded "Source: OmniNode delegation_events
projection" even in file mode, so a PDF exported from fixture data was
misleading once removed from the parent Fixture banner. Derive the
"Source:" line and reproducibility sentence from useDataSourceMode():

  file     -> Local fixture file (fixtures/.../demo-delegation-snapshot.json)
  sqlite   -> Local SQLite projection
  http     -> OmniNode projection API via Express bridge
  postgres -> OmniNode delegation_events projection

Non-live modes (file/sqlite) state the report is from a local
development data source and is not a live projection.

Renamed fixture live-delegation-demo.json -> demo-delegation-snapshot.json;
the word 'live' inside fixtures/ was second-order misleading. The file is
discovered by directory glob (vite fixturesMiddleware readdirSync), so no
code references the old name.

dod_evidence: rendered_output screenshot
docs/evidence/OMN-12397-proof-pack-file-mode-source.png shows the
file-mode footer with the fixture source string and 15 fixture rows.

* fix(OMN-12392): repoint run-generate-fixtures.sh to local script and correct CLAUDE.md fixture claim (#162)

OMN-12392: `run-generate-fixtures.sh` was looking for `generate_fixtures.py` at
`$OMNIBASE_INFRA_PATH/scripts/generate_fixtures.py`, but that path does not exist in
omnibase_infra. The script lives in this repo at `scripts/generate_fixtures.py`.
Fix: set SCRIPT_PATH to `$REPO/scripts/generate_fixtures.py` while preserving
`--project "$OMNIBASE_INFRA_PATH"` for uv dependency resolution (the script imports
from omnibase_infra). `npm run generate:fixtures` now works without a non-standard
OMNIBASE_INFRA_PATH.

Also fix CLAUDE.md hard rule #6 which claimed "Fixtures and layouts are not committed"
— fixtures/ IS tracked via the OMN-10305 gitignore carve-out (!fixtures/, !fixtures/**).
Correct the claim and add the carve-out explanation.

OMN-12393: add a lineage classification table to CLAUDE.md documenting that
omnidash/ (dev) is CANONICAL_ACTIVE, omnidash-v2/ is SUPERSEDED, and
omnidash-v2-new/ is a TEMP_STAGING clone. Cite verification evidence.

* test(OMN-10947): pin dashboard e2e fixture proof (#163)

* test(OMN-10947): scope dashboard e2e locators (#164)

* fix(OMN-12394): add .env to omnidash .gitignore to prevent credential leak (#165)

The .env file holds OMNIDASH_ANALYTICS_DB_URL with a live Postgres password.
The existing rule only covered .env.local* — .env itself was unprotected.

Adds an explicit .env entry so git never tracks the credential file.

* fix: move omnidash event bus config to contract overlay (#166)

* chore(OMN-12459): add universal managed gitignore block to omnidash (#168)

Appends the onex-managed universal block to .gitignore, adding
.env.*, !.env.example, *.log, .idea/, .vscode/, test-results/, and
playwright-report/ coverage. .env and .DS_Store were already present
individually; the block makes the policy explicit and machine-readable.

No tracked files required untracking.

* ci(OMN-12477): add dev-only main-target-guard (forward-port from main) (#167)

Forward-ports the main-target-guard workflow to dev (the file existed on
main but was absent on dev — main<->dev drift). Adds the dev-only variant
WITHOUT the hotfix/* bypass: main accepts only dev promotion PRs carrying
promotion-receipt: OCC-N. The guard only triggers on base==main, so it is
inert on dev-targeting PRs and takes effect at the next dev->main promotion.

OMN-12477

* feat(OMN-12674): remove reviewdog workflows and configs (#170)

Removes the reviewdog caller workflow from omnidash. No reviewdog
required-status-check bindings existed on main protection, dev
protection, or any repo ruleset, so this is the only active reviewdog
surface in the repo.

dod_evidence: OMN-12674

* fix(OMN-12739): tighten delegation dashboard formatting (#171)

* fix(OMN-12740): add browser-safe dashboard id fallback (#172)

* ci: preserve hotfix main-target allowance for release

* chore(release)(OMN-12245): bump omnidash to v1.1.1

* chore(OMN-12765): backmerge current main into dash dev lane (#183)

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities (#120)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities

OMN-11614: Extract shared session-merge and cost-savings-overview aggregation
logic from postgres-projection-reader.ts and sqlite-projection-reader.ts into
server/projection-utils.ts. Removes ~160 lines of duplicated code addressed
by the TODO(OMN-10976) comment. Fixes pre-existing test failures in
postgres-projection-reader.test.ts where string-typed token assertions
contradicted the numeric coercion the reader actually applies.

OMN-11617: Extrac…
@jonahgabriel
jonahgabriel deleted the jonah/omn-9834-contract-validation branch June 11, 2026 17:40
andywu42 pushed a commit to andywu42/omnidash that referenced this pull request Jul 16, 2026
…mniNode-ai#201) (OmniNode-ai#202)

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities (#120)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities

OMN-11614: Extract shared session-merge and cost-savings-overview aggregation
logic from postgres-projection-reader.ts and sqlite-projection-reader.ts into
server/projection-utils.ts. Removes ~160 lines of duplicated code addressed
by the TODO(OMN-10976) comment. Fixes pre-existing test failures in
postgres-projection-reader.test.ts where string-typed token assertions
contradicted the numeric coercion the reader actually applies.

OMN-11617: Extract manifest loading and QueryClient factory into
src/test-utils/integrationHelpers.ts. Updates integration.part2/3/4.test.tsx
to import TEST_MANIFEST and createTestQueryClient instead of duplicating the
readFileSync + JSON.parse pattern and bare QueryClient constructor.

17 new unit tests for projection-utils.ts cover timestampValue, sessionKey,
mergeDelegationSessions, sessionTokens, and buildCostSavingsOverview.

* fix(OMN-11614, OMN-11617): update delegation widget tests to match OMN-11979 redesign

Tests were written against the old design (KPI tiles with "Pass rate",
"Passed", "Failed" labels; donut panel 420px wide; "QG Pass" column;
"Failure categories" heading; "Recent checks" label). OMN-11979 shipped
a dense terminal-style redesign that changed these rendered strings.

Update assertions to match the current component output:
- DelegationMetrics3D: maxWidth 420→240, canvas height 200→140
- DelegationMetrics2D: "N/M pass" → "N/M local"
- DelegationModelRoutingWidget: column "QG Pass" → "Local Rate"
- DelegationQualityGateWidget: empty msg, check-type labels, section
  headings, recent-checks label all updated to current rendered text

All 1044 tests pass (0 new failures introduced).

* chore: release v1.1.0 (OMN-12245) (#135)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-12069): delegation correlation trace view

Adds the delegation correlation trace view, trigger panel, and dashboard assertion updates needed for CI.

* feat(OMN-12133): add projection query endpoints for log entries and traces (#122)

Adds GET /api/projections/log-entries and GET /api/projections/traces
to the Express server, backed by new queryLogEntries and queryTraces
methods on PostgresProjectionReader. Adds traceExplorer topic constant
to shared/types/topics.ts. All query params are validated; limits are
capped (1000 for entries, 500 for traces). is_running = true when
last_event_at < 60s ago. 17 new unit tests + 2 route-level tests.

* feat(OMN-12089): B6 delegation savings proof pack — printable audit artifact (#123)

Adds DelegationSavingsProofPackPanel as a new 'Savings Proof Pack' tab in
the delegation control plane evidence tabs. Renders an exportable audit
report from live projection data: correlation IDs for every run, routing
decisions (model, rule, confidence), quality gate results (pass/fail +
detail), pricing manifest version, per-run savings, cumulative savings
summary, and a pass-rate totals row.

Print/Save as PDF is handled via window.print() with an injected @media
print stylesheet that isolates the printable region to the proof pack table.
No server-side PDF generation or new backend endpoints required — all data
sourced from DelegationEvidenceSnapshot already in context.

7 tests covering empty state, header rendering, run table, quality gate
labels, routing display, totals row, and print button.

* feat(OMN-12085): add swarm-runs projection topic to PostgresProjectionReader (#124)

Adds onex.snapshot.projection.swarm-runs.v1 case to the Postgres projection
reader. Queries omnidash_analytics.swarm_runs and returns all T4 display
fields: runId, status, subtask/succeeded/failed counts, modelsUsed,
totalCostUsd, cloudEquivalentCostUsd, savingsUsd, parallelismSpeedupRatio,
all latency fields, and createdAt. The swarm_runs table already exists on
stability-test (15436) with 11 rows.

* feat(OMN-12135): add TraceExplorerWidget — two-panel event bus trace explorer (#126)

Adds TraceExplorerWidget to omnidash for event bus observability Phase 2.
The widget consumes onex.snapshot.projection.traces.v1 to render a
left-panel trace list (with running/error indicators) and right-panel
chronological timeline of log entries for the selected trace.

- TraceExplorerWidget.tsx: two-panel layout, trace cards, timeline, search
- TraceExplorerWidget.stories.tsx: Empty, Populated, WithRunningTrace, ErrorTraces, Loading
- TraceExplorerWidget.test.tsx: 5 unit tests (loading, empty, render, filter, select prompt)
- storybook/fixtures/trace-explorer.ts: buildTraceGroups fixture factory
- generate-registry.ts: trace-explorer manifest with projectionSchema + displayContract
- index.ts: lazy import wired under trace-explorer/TraceExplorerWidget
- integration.part2/3: component count bumped 28→29 with trace-explorer

Closes OMN-12135. References OMN-12136, OMN-12137, OMN-12138, OMN-12139.

* feat(OMN-12082): add Context Effectiveness Heatmap widget (B4) (#127)

Implements the context factor effectiveness visualization showing which
context segments help/hurt which models across A3 ablation experiments:

- Segment × model matrix with color-coded cells:
  green (≥75% pass rate), amber (26–74%), red (≤25%)
- Clickable cells open an examples panel showing specific token impact,
  pass/fail counts, and qualitative findings per segment×model combination
- Fixture mode: when no live data exists, shows OMN-11241 research results
  (golden_chain green across all models, claude_md/architecture_patterns
  red for local models, exemplar green for exact-interface tasks,
  local_failures neutral)
- Fixture badge clearly marks when live projection is absent
- Reads from context_experiment_scores table via new
  onex.snapshot.projection.context.experiment-scores.v1 topic
- Adds postgres reader case for context_experiment_scores SELECT
- 10 tests passing (empty state, matrix render, cell selection, fixture
  badge, toggle deselect, footer count)

* feat(OMN-12145): add Express dispatch bridge and Kafka command topic (#128)

- POST /api/dispatch validates body, builds envelope, publishes to
  onex.cmd.omnidash.dispatch-request.v1 via kafkajs producer
- Returns 400 on missing fields, 503 when broker unavailable
- Kafka producer connects on server start, graceful disconnect on SIGTERM/SIGINT
- COMMAND_TOPICS registry in shared/types/command-topics.ts (separate from
  TOPICS to preserve golden-chain onex.snapshot.projection.* invariant)
- 9 unit tests with mocked producer covering validation, 503, and happy path

Closes OMN-12145, OMN-12149

* feat(OMN-12147): add command dispatch UI — trace action buttons + Cmd+K palette (#129)

- New `command-dispatch/` directory with:
  - `useDispatch.ts` hook: POST /api/dispatch, availability probe, error/loading state
  - `DispatchButton.tsx`: icon button (RotateCw/Square) with loading/success/error states
  - `CommandPalette.tsx`: Cmd+K/Ctrl+K overlay with node search, JSON payload editor, dispatch result; includes `useCommandPalette` hook for global keyboard listener
  - `CommandPalette.stories.tsx`: Open, WithResults, Dispatching, Error story exports
- `TraceExplorerWidget.tsx`: adds Stop button on running traces, Re-run button on completed traces
- `App.tsx`: mounts `CommandPalette` at app root via `useCommandPalette` hook

Closes OMN-12147, relates OMN-12148

* fix(OMN-12149): align dispatch command topic to omnimarket namespace (#131)

The node_dispatch_request_handler subscribes to
onex.cmd.omnimarket.dispatch-request.v1 (topic naming lint requires
consumer service namespace). Aligns the Express bridge publisher topic.

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate (#132)

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate

Confirms 298 .js artifacts in src/ and shared/ are stale (all have .ts/.tsx
counterparts; tsconfig emits nothing to src/; build output goes to dist/).
Deletes them from working tree.

Adds scripts/check-no-orphaned-js.sh — fails if any .js file in src/ or shared/
shadows a .ts/.tsx source — and wires it as a CI gate in ci.yml.

Also fixes broken @storybook/test import in CommandPalette.stories.tsx
(package moved to storybook/test in v10), restoring tsc --noEmit clean pass.

* fix(OMN-12171): include command topic in server typecheck

* refactor(OMN-12187): Consolidate DRY violations in omnidash

* refactor(OMN-12187): Consolidate DRY violations in omnidash

Extract shared pure-data helpers (timestampValue, mergeDelegationSessions,
buildCostSavingsOverviewResult) into server/projection-reader-shared.ts.
Both sqlite-projection-reader.ts and postgres-projection-reader.ts delegate
to the shared module, eliminating ~120 lines of duplicated logic.

Add 13 unit tests for the shared module covering edge cases in timestamp
parsing, session merge semantics, and cost overview aggregation.

The integration test files (part2/part3/part4) are distinct suites with
different test content — they are not duplicates and are left intact.

* fix(OMN-12187): remove unused cost overview input

* chore: release v1.1.0

* ci: re-trigger CI for release promotion PR

* feat(OMN-6877): add 4 observability pages — trace explorer, session replay, sandbox monitor, voice sessions (#136)

- TraceExplorerPage: wraps existing TraceExplorerWidget as a full-page view
- SessionReplayPage: step/play/rewind through recorded sessions with state reconstruction and progress bar
- SandboxMonitorPage: real-time allow/deny stream, deny-rate bar, per-policy breakdown table
- VoiceSessionPage: audio timeline, transcript overlay, turn-taking visualization
- Extends AppPage type and App.tsx routing switch for all 4 new pages
- Adds Observability section to Sidebar with GitBranch/RotateCcw/Shield/Mic icons
- Adds sandboxDecisions, voiceSessions, sessionReplay topics to TOPICS constant

* ci(OMN-12243): add main target guard (#138)

* release: promote omnidash dev to main (2026-06-06) (#174)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-12069): delegation correlation trace view

Adds the delegation correlation trace view, trigger panel, and dashboard assertion updates needed for CI.

* feat(OMN-12133): add projection query endpoints for log entries and traces (#122)

Adds GET /api/projections/log-entries and GET /api/projections/traces
to the Express server, backed by new queryLogEntries and queryTraces
methods on PostgresProjectionReader. Adds traceExplorer topic constant
to shared/types/topics.ts. All query params are validated; limits are
capped (1000 for entries, 500 for traces). is_running = true when
last_event_at < 60s ago. 17 new unit tests + 2 route-level tests.

* feat(OMN-12089): B6 delegation savings proof pack — printable audit artifact (#123)

Adds DelegationSavingsProofPackPanel as a new 'Savings Proof Pack' tab in
the delegation control plane evidence tabs. Renders an exportable audit
report from live projection data: correlation IDs for every run, routing
decisions (model, rule, confidence), quality gate results (pass/fail +
detail), pricing manifest version, per-run savings, cumulative savings
summary, and a pass-rate totals row.

Print/Save as PDF is handled via window.print() with an injected @media
print stylesheet that isolates the printable region to the proof pack table.
No server-side PDF generation or new backend endpoints required — all data
sourced from DelegationEvidenceSnapshot already in context.

7 tests covering empty state, header rendering, run table, quality gate
labels, routing display, totals row, and print button.

* feat(OMN-12085): add swarm-runs projection topic to PostgresProjectionReader (#124)

Adds onex.snapshot.projection.swarm-runs.v1 case to the Postgres projection
reader. Queries omnidash_analytics.swarm_runs and returns all T4 display
fields: runId, status, subtask/succeeded/failed counts, modelsUsed,
totalCostUsd, cloudEquivalentCostUsd, savingsUsd, parallelismSpeedupRatio,
all latency fields, and createdAt. The swarm_runs table already exists on
stability-test (15436) with 11 rows.

* feat(OMN-12135): add TraceExplorerWidget — two-panel event bus trace explorer (#126)

Adds TraceExplorerWidget to omnidash for event bus observability Phase 2.
The widget consumes onex.snapshot.projection.traces.v1 to render a
left-panel trace list (with running/error indicators) and right-panel
chronological timeline of log entries for the selected trace.

- TraceExplorerWidget.tsx: two-panel layout, trace cards, timeline, search
- TraceExplorerWidget.stories.tsx: Empty, Populated, WithRunningTrace, ErrorTraces, Loading
- TraceExplorerWidget.test.tsx: 5 unit tests (loading, empty, render, filter, select prompt)
- storybook/fixtures/trace-explorer.ts: buildTraceGroups fixture factory
- generate-registry.ts: trace-explorer manifest with projectionSchema + displayContract
- index.ts: lazy import wired under trace-explorer/TraceExplorerWidget
- integration.part2/3: component count bumped 28→29 with trace-explorer

Closes OMN-12135. References OMN-12136, OMN-12137, OMN-12138, OMN-12139.

* feat(OMN-12082): add Context Effectiveness Heatmap widget (B4) (#127)

Implements the context factor effectiveness visualization showing which
context segments help/hurt which models across A3 ablation experiments:

- Segment × model matrix with color-coded cells:
  green (≥75% pass rate), amber (26–74%), red (≤25%)
- Clickable cells open an examples panel showing specific token impact,
  pass/fail counts, and qualitative findings per segment×model combination
- Fixture mode: when no live data exists, shows OMN-11241 research results
  (golden_chain green across all models, claude_md/architecture_patterns
  red for local models, exemplar green for exact-interface tasks,
  local_failures neutral)
- Fixture badge clearly marks when live projection is absent
- Reads from context_experiment_scores table via new
  onex.snapshot.projection.context.experiment-scores.v1 topic
- Adds postgres reader case for context_experiment_scores SELECT
- 10 tests passing (empty state, matrix render, cell selection, fixture
  badge, toggle deselect, footer count)

* feat(OMN-12145): add Express dispatch bridge and Kafka command topic (#128)

- POST /api/dispatch validates body, builds envelope, publishes to
  onex.cmd.omnidash.dispatch-request.v1 via kafkajs producer
- Returns 400 on missing fields, 503 when broker unavailable
- Kafka producer connects on server start, graceful disconnect on SIGTERM/SIGINT
- COMMAND_TOPICS registry in shared/types/command-topics.ts (separate from
  TOPICS to preserve golden-chain onex.snapshot.projection.* invariant)
- 9 unit tests with mocked producer covering validation, 503, and happy path

Closes OMN-12145, OMN-12149

* feat(OMN-12147): add command dispatch UI — trace action buttons + Cmd+K palette (#129)

- New `command-dispatch/` directory with:
  - `useDispatch.ts` hook: POST /api/dispatch, availability probe, error/loading state
  - `DispatchButton.tsx`: icon button (RotateCw/Square) with loading/success/error states
  - `CommandPalette.tsx`: Cmd+K/Ctrl+K overlay with node search, JSON payload editor, dispatch result; includes `useCommandPalette` hook for global keyboard listener
  - `CommandPalette.stories.tsx`: Open, WithResults, Dispatching, Error story exports
- `TraceExplorerWidget.tsx`: adds Stop button on running traces, Re-run button on completed traces
- `App.tsx`: mounts `CommandPalette` at app root via `useCommandPalette` hook

Closes OMN-12147, relates OMN-12148

* fix(OMN-12149): align dispatch command topic to omnimarket namespace (#131)

The node_dispatch_request_handler subscribes to
onex.cmd.omnimarket.dispatch-request.v1 (topic naming lint requires
consumer service namespace). Aligns the Express bridge publisher topic.

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate (#132)

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate

Confirms 298 .js artifacts in src/ and shared/ are stale (all have .ts/.tsx
counterparts; tsconfig emits nothing to src/; build output goes to dist/).
Deletes them from working tree.

Adds scripts/check-no-orphaned-js.sh — fails if any .js file in src/ or shared/
shadows a .ts/.tsx source — and wires it as a CI gate in ci.yml.

Also fixes broken @storybook/test import in CommandPalette.stories.tsx
(package moved to storybook/test in v10), restoring tsc --noEmit clean pass.

* fix(OMN-12171): include command topic in server typecheck

* refactor(OMN-12187): Consolidate DRY violations in omnidash

* refactor(OMN-12187): Consolidate DRY violations in omnidash

Extract shared pure-data helpers (timestampValue, mergeDelegationSessions,
buildCostSavingsOverviewResult) into server/projection-reader-shared.ts.
Both sqlite-projection-reader.ts and postgres-projection-reader.ts delegate
to the shared module, eliminating ~120 lines of duplicated logic.

Add 13 unit tests for the shared module covering edge cases in timestamp
parsing, session merge semantics, and cost overview aggregation.

The integration test files (part2/part3/part4) are distinct suites with
different test content — they are not duplicates and are left intact.

* fix(OMN-12187): remove unused cost overview input

* feat(OMN-12072): Swarm Control Plane widget — decomposition tree, wave view, live counters, savings bar (#125)

* feat(OMN-12072): add Swarm Control Plane widget (B2-T1 through B2-T5)

Implements the full swarm progression UI against the swarm_runs projection:
- B2-T1: SwarmDecompositionTree — parent→subtask tree with aggregate-derived
  per-subtask statuses and model hints; graceful empty state when subtask_count=0
- B2-T2: SwarmSubtaskCard — colored status badge, 3×3 metrics grid, models-used
  pill tags, registry hash footer
- B2-T3: SwarmWaveVisualization — heuristic 60/40 wave split with dependency
  connector; Wave 1 dimmed until run complete; empty state when subtask_count=0
- B2-T4: SwarmLiveCounters — 6 aggregate cards (total runs, subtasks, avg speedup,
  wall time, cost, savings) derived from swarm_runs projection
- B2-T5: SwarmSavingsBar — stacked local+savings bar vs cloud equivalent baseline,
  per-run mini bars (up to 10), zero-cost graceful empty state
- SwarmRunTable: 8-column run list with row selection, speedup/savings coloring
- SwarmControlPlane: tabbed widget (Overview, Decomposition, Run Detail, Wave View,
  Savings) wired to swarm.runs.v1 projection topic via useProjectionQuery
- Adds TOPICS.swarmRuns, swarm-runs.v1 postgres case, GET /api/swarm-runs route
- 10 tests passing

* feat(OMN-12072): add SwarmSubtaskDetailPanel — per-subtask model/tokens/cost/latency table (B5-T3 gap)

Adds SwarmSubtaskDetailPanel showing per-subtask rows with:
- subtask_id (derived: run_id prefix + padded index)
- description (Subtask 01, 02, ...)
- model assigned (cycled from run.models_used)
- status (succeeded/failed/skipped/pending, derived from aggregate counts)
- tokens_input / tokens_output (null — requires node_swarm_subtask_state_reducer)
- cost_usd (evenly distributed from total_cost_usd)
- latency_ms (estimated from dispatch_wall_latency_ms / parallelism)

Panel is clearly marked as aggregate-derived with a fixture notice.
Shows "No subtask data" with reducer name when subtask_count=0.

Wired as new "Subtask Detail" tab in SwarmControlPlane alongside existing
"Run Detail" aggregate card. Uses List icon from lucide-react.

Schema confirmed: swarm_runs has no jsonb subtask column and no separate
swarm_subtasks table exists yet in stability-test. All per-subtask data
requires node_swarm_subtask_state_reducer projection.

15 new tests + 10 existing passing (25 total).

* refactor(OMN-11578/OMN-11596): reduce cyclomatic complexity in ThreeCanvas and parseDashboardDefinition (#137)

OMN-11578: Extract advanceFocusProgress, applyFocusedOpacities,
applyIdleOpacities, and projectAxisLabels from the ThreeCanvas
useEffect god-function (CCN=83). The tick loop is now a thin
orchestrator calling four named helpers via a TickRefs context struct.

OMN-11596: Extract validateTopLevelFields and validateLayoutItem from
parseDashboardDefinition (CCN=31). The parse function is reduced to
an 8-line orchestrator; all conditional branches live in the helpers.

* fix(OMN-12245): silence unused swarm route request (#139)

* feat(OMN-11658): wire SEA demo dashboard projections (#140)

* feat(OMN-11658): wire SEA demo dashboard projections

* fix(OMN-11658): make trace rows toggle independently

* fix(OMN-11658): polish SEA dashboard rendering

* feat(OMN-11658): expose delegation trigger in SEA control plane (#141)

* fix(OMN-11658): improve SEA prompt feedback (#142)

* fix(OMN-11658): let dashboard fill wide viewports (#143)

* fix(OMN-11658): balance dashboard gutters (#144)

* fix(OMN-11658): keep SEA stream newest first (#145)

* fix(OMN-11658): refresh SEA traces after prompt submit (#146)

* fix(OMN-11658): center dashboard shell (#147)

* fix(OMN-11658): extend dashboard body to bottom (#148)

* fix(OMN-11658): repair empty SEA dashboard layout (#149)

* chore(DELEG-002): annotate pricing fallbacks, align fixture model names with bifrost config (#150)

* feat(OMN-12070): wire delegation trigger through omnimarket runtime (#151)

- Add COMMAND_TOPICS.delegateSkill ('onex.cmd.omnimarket.delegate-skill.v1')
  to shared/types/command-topics.ts so the topic is contract-declared rather
  than hardcoded in server/routes.ts
- Declare the delegation trigger command topic in contract.yaml with handler
  node, ingress path, payload shape, and OMNIDASH_RUNTIME_EFFECTS_URL env note
- server/routes.ts uses COMMAND_TOPICS.delegateSkill constant; runtime targets
  stability-test :18085/v1/effects via OMNIDASH_RUNTIME_EFFECTS_URL env var
- DelegationRunContext exposes pendingCorrelationId + setPendingCorrelationId
  so a freshly-dispatched correlation_id propagates to all child panels before
  projection rows materialize
- DelegationRunHeader wires onCorrelationId callback to DelegationTriggerPanel
- DelegationEvidenceTabs auto-navigates to correlation-trace tab on dispatch
  and displays a pending indicator with the correlation_id
- DelegationCorrelationTracePanel falls back to pendingCorrelationId when no
  run is selected and shows appropriate pending vs awaiting-events messages
- Test: setPendingCorrelationId updates context value (DelegationRunContext.test)

* fix(OMN-12243): add 'delegation' short-topic alias to postgres + sqlite projection readers (#152)

GET /projection/delegation was returning rows:[] because the query() switch
had no 'delegation' case — only the full versioned topic name. Adds a
fall-through alias in both PostgresProjectionReader and SqliteProjectionReader
so the short topic name routes to the same decisions.v1 query logic.

Test: new routes.test.ts case seeds a SQLite DB and asserts the short alias
returns the same rows as the full topic name.

* fix(OMN-12287): close fixture gaps and add file-mode banners to dashboard widgets (#153)

- Add fileMode prop to ComponentWrapper: renders a [File Mode] badge in the
  widget header when active, analogous to the [Live] badge, using Text component
  to comply with the no-typography-inline ESLint rule
- Wire fileMode={!isLiveDataSource(mode)} on all 13 widgets that use
  useDataSourceMode: TraceExplorer, LiveEventStream, ContextHeatmap,
  DelegationControlPlane, EventStream, ReadinessGate, DelegationMetrics2D,
  DelegationSavingsWidget, DelegationTokenUsageWidget,
  DelegationModelRoutingWidget, DelegationQualityGateWidget, AbCompareWidget
- Improve emptyHint messages on all updated widgets to include file-mode
  fixture directory paths so developers know exactly where to add test data
- 1124/1124 tests pass

* feat(OMN-12288): deep-link delegation runs to trace explorer (#154)

- Add traceFilter + setTraceFilter to UISlice so a correlation_id can be
  passed to TraceExplorer before navigating there (no URL routing required)
- DelegationRunTable adds a GitCommitHorizontal icon button per row; clicking
  it calls setTraceFilter(correlationId) + setActivePage('trace') and stops
  event propagation so the row-select handler is not also triggered
- TraceExplorerWidget initializes its query state from traceFilter on mount
  and immediately clears the store value so subsequent navigations start fresh
- Tests: DelegationRunTable.test.tsx (7 cases), TraceExplorerWidget deep-link
  test confirms filter pre-fill + store clear (1132 total tests pass)

* feat(T22): add DelegationCostComparisonWidget — per-run cost proof surface (#155)

- New widget DelegationCostComparisonWidget in delegation/ directory reads from
  TOPICS.delegationSavings (same source as DelegationSavingsWidget) and surfaces
  a per-session cost comparison table with columns: Session, Task, Model, Tokens,
  Latency, Local $, Cloud $, Saved, Provenance
- Provenance badges (measured/estimated) on each row per the § 1.3 spec — green
  for measured (llm_call_metrics), amber for estimated (routing-rule inferred)
- Summary KPI bar shows cumulative savings, local/cloud cost, session count,
  total tokens, measured vs estimated breakdown
- Sort controls: date (default), savings descending, tokens descending
- maxRows config cap with overflow notice; showProvenance=false hides source column
- Registered as 'delegation-cost-comparison' in generate-registry.ts and index.ts
- Integration tests updated for count 29 → 30
- 1132/1132 tests pass

* fix(OMN-12402): unify CommandPalette node-type badges and Dispatch button contrast (#156)

Issue 1: kindColor() mixed design tokens (var(--accent), var(--text-tertiary))
with hardcoded hex (#d97706, #7c3aed), so ORCHESTRATOR/COMPUTE badges rendered
as weak plain text while EFFECT/REDUCER read as vivid chips. Drive all four
badges from the design-system node-type role tokens (--<kind>{,-soft,-ink}),
which exist for both themes, so every badge is a consistent filled chip.

Issue 2: the Dispatch button used var(--accent) as its fill (which resolves to
--brand-soft, a pale hover tint) plus literal color:'white' text — invisible in
light mode. Use the brand fill + on-brand text token (--brand /
--primary-foreground) so the CTA stays legible in both themes.

Adds CommandPalette.test.tsx asserting both fixes. Visual proof:
docs/evidence/OMN-12402-{badges,dispatch-light}.png

* fix(OMN-12367): render Correlation Trace tab gracefully in file mode (#159)

In VITE_DATA_SOURCE=file mode the /api/delegation/* proxy is not registered
(it is wired only when VITE_PROJECTION_API_URL is set), so fetchCorrelationTrace
hit the Vite SPA fallback, received index.html, and surfaced a raw
'JSON.parse: unexpected character at line 1 column 1' error.

Detect file mode up front in fetchCorrelationTrace and read a per-correlation
fixture under fixtures/onex.snapshot.projection.delegation.correlation-trace.v1/
so the surface renders a representative event chain without postgres. In live
mode, parse defensively (res.json().catch) so any non-JSON body degrades to an
empty trace and the panel shows its graceful no-events message rather than a raw
parse error.

Adds the delegationCorrelationTrace topic, a representative 2-event fixture for
the file-mode decisions correlation (069e55b5...), and tests covering file-mode
fixture fetch and non-JSON hardening.

* fix(OMN-12399): register Context Effectiveness Heatmap widget in palette (#158)

The widget source (component, hook, fixtures, types, test) existed but was
registered in zero of the three Track A surfaces, so it never appeared in the
widget palette and was unreachable through the supported user flow.

- Add lazy import to componentImports in src/components/dashboard/index.ts
- Add context-effectiveness-heatmap manifest to MVP_COMPONENTS in
  scripts/generate-registry.ts (quality category, contextExperimentScores
  projection, OMN-11241 fixture fallback)
- Regenerate src/registry/component-registry.json (30 -> 31 components)
- Update integration.part2/part3 component-count + name-list assertions

Visual proof (OMN-7093): palette entry + widget rendered on dashboard in
file mode using the OMN-11241 research fixture.

* fix(OMN-12366): give Delegation Control Plane active tab a clear visual indicator (#157)

The 10-tab evidence strip distinguished active from inactive tabs only by
background (var(--panel-2) vs transparent); border, text color, and weight
were identical, so the strip did not read as tabs.

Active tab now uses the --brand accent token: brand border, a 2px brand
bottom-border underline, --brand-soft background, and brand-colored
semibold label text. All via existing design tokens (no hardcoded hex),
verified to read clearly in both light and dark themes.

Visual evidence (OMN-7093):
- docs/evidence/OMN-12366-tab-strip-dark.png
- docs/evidence/OMN-12366-tab-strip-light.png

* feat(OMN-12139): add Trace Explorer fixture for onex.snapshot.projection.traces.v1 (#161)

Wires a _trace_groups() factory into scripts/generate_fixtures.py and emits
five representative TraceGroup records under
fixtures/onex.snapshot.projection.traces.v1/, covering every TraceExplorer
UI state: multi-node completed, single-node completed, running, error, and a
longer multi-node trace. The widget reads each file as one TraceGroup via the
legacy useProjectionQuery readAll path; the file-source middleware
auto-generates index.json.

Shapes mirror the widget's TraceGroup interface exactly (no entity_id); each
record is keyed by its correlation_id so the fixture filename matches.

DoD: VITE_DATA_SOURCE=file dev server renders 5 traces (1 running, 1 error)
in the Trace Explorer in File Mode; Playwright screenshot under
docs/evidence/OMN-12139-trace-explorer-file-mode.png proves populated/running/
error states (OMN-7093). npm run build, lint, and full vitest suite (1172
tests) green.

* fix(OMN-12397): make Savings Proof Pack source line dynamic by data mode (#160)

The printable proof pack hardcoded "Source: OmniNode delegation_events
projection" even in file mode, so a PDF exported from fixture data was
misleading once removed from the parent Fixture banner. Derive the
"Source:" line and reproducibility sentence from useDataSourceMode():

  file     -> Local fixture file (fixtures/.../demo-delegation-snapshot.json)
  sqlite   -> Local SQLite projection
  http     -> OmniNode projection API via Express bridge
  postgres -> OmniNode delegation_events projection

Non-live modes (file/sqlite) state the report is from a local
development data source and is not a live projection.

Renamed fixture live-delegation-demo.json -> demo-delegation-snapshot.json;
the word 'live' inside fixtures/ was second-order misleading. The file is
discovered by directory glob (vite fixturesMiddleware readdirSync), so no
code references the old name.

dod_evidence: rendered_output screenshot
docs/evidence/OMN-12397-proof-pack-file-mode-source.png shows the
file-mode footer with the fixture source string and 15 fixture rows.

* fix(OMN-12392): repoint run-generate-fixtures.sh to local script and correct CLAUDE.md fixture claim (#162)

OMN-12392: `run-generate-fixtures.sh` was looking for `generate_fixtures.py` at
`$OMNIBASE_INFRA_PATH/scripts/generate_fixtures.py`, but that path does not exist in
omnibase_infra. The script lives in this repo at `scripts/generate_fixtures.py`.
Fix: set SCRIPT_PATH to `$REPO/scripts/generate_fixtures.py` while preserving
`--project "$OMNIBASE_INFRA_PATH"` for uv dependency resolution (the script imports
from omnibase_infra). `npm run generate:fixtures` now works without a non-standard
OMNIBASE_INFRA_PATH.

Also fix CLAUDE.md hard rule #6 which claimed "Fixtures and layouts are not committed"
— fixtures/ IS tracked via the OMN-10305 gitignore carve-out (!fixtures/, !fixtures/**).
Correct the claim and add the carve-out explanation.

OMN-12393: add a lineage classification table to CLAUDE.md documenting that
omnidash/ (dev) is CANONICAL_ACTIVE, omnidash-v2/ is SUPERSEDED, and
omnidash-v2-new/ is a TEMP_STAGING clone. Cite verification evidence.

* test(OMN-10947): pin dashboard e2e fixture proof (#163)

* test(OMN-10947): scope dashboard e2e locators (#164)

* fix(OMN-12394): add .env to omnidash .gitignore to prevent credential leak (#165)

The .env file holds OMNIDASH_ANALYTICS_DB_URL with a live Postgres password.
The existing rule only covered .env.local* — .env itself was unprotected.

Adds an explicit .env entry so git never tracks the credential file.

* fix: move omnidash event bus config to contract overlay (#166)

* chore(OMN-12459): add universal managed gitignore block to omnidash (#168)

Appends the onex-managed universal block to .gitignore, adding
.env.*, !.env.example, *.log, .idea/, .vscode/, test-results/, and
playwright-report/ coverage. .env and .DS_Store were already present
individually; the block makes the policy explicit and machine-readable.

No tracked files required untracking.

* ci(OMN-12477): add dev-only main-target-guard (forward-port from main) (#167)

Forward-ports the main-target-guard workflow to dev (the file existed on
main but was absent on dev — main<->dev drift). Adds the dev-only variant
WITHOUT the hotfix/* bypass: main accepts only dev promotion PRs carrying
promotion-receipt: OCC-N. The guard only triggers on base==main, so it is
inert on dev-targeting PRs and takes effect at the next dev->main promotion.

OMN-12477

* feat(OMN-12674): remove reviewdog workflows and configs (#170)

Removes the reviewdog caller workflow from omnidash. No reviewdog
required-status-check bindings existed on main protection, dev
protection, or any repo ruleset, so this is the only active reviewdog
surface in the repo.

dod_evidence: OMN-12674

* fix(OMN-12739): tighten delegation dashboard formatting (#171)

* fix(OMN-12740): add browser-safe dashboard id fallback (#172)

* ci: preserve hotfix main-target allowance for release

* chore(release)(OMN-12245): bump omnidash to v1.1.1

* release(OMN-12765): promote dash dev lane to main (#185)

* chore(release)(OMN-12765): bump omnidash to v1.1.2 (#187)

* chore(release)(OMN-12765): bump omnidash to v1.1.2

* chore(release)(OMN-12765): refresh hotfix release gates

* chore(release)(OMN-12765): promote dev integration to main (#190)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-12069): delegation correlation trace view

Adds the delegation correlation trace view, trigger panel, and dashboard assertion updates needed for CI.

* feat(OMN-12133): add projection query endpoints for log entries and traces (#122)

Adds GET /api/projections/log-entries and GET /api/projections/traces
to the Express server, backed by new queryLogEntries and queryTraces
methods on PostgresProjectionReader. Adds traceExplorer topic constant
to shared/types/topics.ts. All query params are validated; limits are
capped (1000 for entries, 500 for traces). is_running = true when
last_event_at < 60s ago. 17 new unit tests + 2 route-level tests.

* feat(OMN-12089): B6 delegation savings proof pack — printable audit artifact (#123)

Adds DelegationSavingsProofPackPanel as a new 'Savings Proof Pack' tab in
the delegation control plane evidence tabs. Renders an exportable audit
report from live projection data: correlation IDs for every run, routing
decisions (model, rule, confidence), quality gate results (pass/fail +
detail), pricing manifest version, per-run savings, cumulative savings
summary, and a pass-rate totals row.

Print/Save as PDF is handled via window.print() with an injected @media
print stylesheet that isolates the printable region to the proof pack table.
No server-side PDF generation or new backend endpoints required — all data
sourced from DelegationEvidenceSnapshot already in context.

7 tests covering empty state, header rendering, run table, quality gate
labels, routing display, totals row, and print button.

* feat(OMN-12085): add swarm-runs projection topic to PostgresProjectionReader (#124)

Adds onex.snapshot.projection.swarm-runs.v1 case to the Postgres projection
reader. Queries omnidash_analytics.swarm_runs and returns all T4 display
fields: runId, status, subtask/succeeded/failed counts, modelsUsed,
totalCostUsd, cloudEquivalentCostUsd, savingsUsd, parallelismSpeedupRatio,
all latency fields, and createdAt. The swarm_runs table already exists on
stability-test (15436) with 11 rows.

* feat(OMN-12135): add TraceExplorerWidget — two-panel event bus trace explorer (#126)

Adds TraceExplorerWidget to omnidash for event bus observability Phase 2.
The widget consumes onex.snapshot.projection.traces.v1 to render a
left-panel trace list (with running/error indicators) and right-panel
chronological timeline of log entries for the selected trace.

- TraceExplorerWidget.tsx: two-panel layout, trace cards, timeline, search
- TraceExplorerWidget.stories.tsx: Empty, Populated, WithRunningTrace, ErrorTraces, Loading
- TraceExplorerWidget.test.tsx: 5 unit tests (loading, empty, render, filter, select prompt)
- storybook/fixtures/trace-explorer.ts: buildTraceGroups fixture factory
- generate-registry.ts: trace-explorer manifest with projectionSchema + displayContract
- index.ts: lazy import wired under trace-explorer/TraceExplorerWidget
- integration.part2/3: component count bumped 28→29 with trace-explorer

Closes OMN-12135. References OMN-12136, OMN-12137, OMN-12138, OMN-12139.

* feat(OMN-12082): add Context Effectiveness Heatmap widget (B4) (#127)

Implements the context factor effectiveness visualization showing which
context segments help/hurt which models across A3 ablation experiments:

- Segment × model matrix with color-coded cells:
  green (≥75% pass rate), amber (26–74%), red (≤25%)
- Clickable cells open an examples panel showing specific token impact,
  pass/fail counts, and qualitative findings per segment×model combination
- Fixture mode: when no live data exists, shows OMN-11241 research results
  (golden_chain green across all models, claude_md/architecture_patterns
  red for local models, exemplar green for exact-interface tasks,
  local_failures neutral)
- Fixture badge clearly marks when live projection is absent
- Reads from context_experiment_scores table via new
  onex.snapshot.projection.context.experiment-scores.v1 topic
- Adds postgres reader case for context_experiment_scores SELECT
- 10 tests passing (empty state, matrix render, cell selection, fixture
  badge, toggle deselect, footer count)

* feat(OMN-12145): add Express dispatch bridge and Kafka command topic (#128)

- POST /api/dispatch validates body, builds envelope, publishes to
  onex.cmd.omnidash.dispatch-request.v1 via kafkajs producer
- Returns 400 on missing fields, 503 when broker unavailable
- Kafka producer connects on server start, graceful disconnect on SIGTERM/SIGINT
- COMMAND_TOPICS registry in shared/types/command-topics.ts (separate from
  TOPICS to preserve golden-chain onex.snapshot.projection.* invariant)
- 9 unit tests with mocked producer covering validation, 503, and happy path

Closes OMN-12145, OMN-12149

* feat(OMN-12147): add command dispatch UI — trace action buttons + Cmd+K palette (#129)

- New `command-dispatch/` directory with:
  - `useDispatch.ts` hook: POST /api/dispatch, availability probe, error/loading state
  - `DispatchButton.tsx`: icon button (RotateCw/Square) with loading/success/error states
  - `CommandPalette.tsx`: Cmd+K/Ctrl+K overlay with node search, JSON payload editor, dispatch result; includes `useCommandPalette` hook for global keyboard listener
  - `CommandPalette.stories.tsx`: Open, WithResults, Dispatching, Error story exports
- `TraceExplorerWidget.tsx`: adds Stop button on running traces, Re-run button on completed traces
- `App.tsx`: mounts `CommandPalette` at app root via `useCommandPalette` hook

Closes OMN-12147, relates OMN-12148

* fix(OMN-12149): align dispatch command topic to omnimarket namespace (#131)

The node_dispatch_request_handler subscribes to
onex.cmd.omnimarket.dispatch-request.v1 (topic naming lint requires
consumer service namespace). Aligns the Express bridge publisher topic.

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate (#132)

* chore(OMN-12171): delete orphaned .js files and add CI enforcement gate

Confirms 298 .js artifacts in src/ and shared/ are stale (all have .ts/.tsx
counterparts; tsconfig emits nothing to src/; build output goes to dist/).
Deletes them from working tree.

Adds scripts/check-no-orphaned-js.sh — fails if any .js file in src/ or shared/
shadows a .ts/.tsx source — and wires it as a CI gate in ci.yml.

Also fixes broken @storybook/test import in CommandPalette.stories.tsx
(package moved to storybook/test in v10), restoring tsc --noEmit clean pass.

* fix(OMN-12171): include command topic in server typecheck

* refactor(OMN-12187): Consolidate DRY violations in omnidash

* refactor(OMN-12187): Consolidate DRY violations in omnidash

Extract shared pure-data helpers (timestampValue, mergeDelegationSessions,
buildCostSavingsOverviewResult) into server/projection-reader-shared.ts.
Both sqlite-projection-reader.ts and postgres-projection-reader.ts delegate
to the shared module, eliminating ~120 lines of duplicated logic.

Add 13 unit tests for the shared module covering edge cases in timestamp
parsing, session merge semantics, and cost overview aggregation.

The integration test files (part2/part3/part4) are distinct suites with
different test content — they are not duplicates and are left intact.

* fix(OMN-12187): remove unused cost overview input

* feat(OMN-12072): Swarm Control Plane widget — decomposition tree, wave view, live counters, savings bar (#125)

* feat(OMN-12072): add Swarm Control Plane widget (B2-T1 through B2-T5)

Implements the full swarm progression UI against the swarm_runs projection:
- B2-T1: SwarmDecompositionTree — parent→subtask tree with aggregate-derived
  per-subtask statuses and model hints; graceful empty state when subtask_count=0
- B2-T2: SwarmSubtaskCard — colored status badge, 3×3 metrics grid, models-used
  pill tags, registry hash footer
- B2-T3: SwarmWaveVisualization — heuristic 60/40 wave split with dependency
  connector; Wave 1 dimmed until run complete; empty state when subtask_count=0
- B2-T4: SwarmLiveCounters — 6 aggregate cards (total runs, subtasks, avg speedup,
  wall time, cost, savings) derived from swarm_runs projection
- B2-T5: SwarmSavingsBar — stacked local+savings bar vs cloud equivalent baseline,
  per-run mini bars (up to 10), zero-cost graceful empty state
- SwarmRunTable: 8-column run list with row selection, speedup/savings coloring
- SwarmControlPlane: tabbed widget (Overview, Decomposition, Run Detail, Wave View,
  Savings) wired to swarm.runs.v1 projection topic via useProjectionQuery
- Adds TOPICS.swarmRuns, swarm-runs.v1 postgres case, GET /api/swarm-runs route
- 10 tests passing

* feat(OMN-12072): add SwarmSubtaskDetailPanel — per-subtask model/tokens/cost/latency table (B5-T3 gap)

Adds SwarmSubtaskDetailPanel showing per-subtask rows with:
- subtask_id (derived: run_id prefix + padded index)
- description (Subtask 01, 02, ...)
- model assigned (cycled from run.models_used)
- status (succeeded/failed/skipped/pending, derived from aggregate counts)
- tokens_input / tokens_output (null — requires node_swarm_subtask_state_reducer)
- cost_usd (evenly distributed from total_cost_usd)
- latency_ms (estimated from dispatch_wall_latency_ms / parallelism)

Panel is clearly marked as aggregate-derived with a fixture notice.
Shows "No subtask data" with reducer name when subtask_count=0.

Wired as new "Subtask Detail" tab in SwarmControlPlane alongside existing
"Run Detail" aggregate card. Uses List icon from lucide-react.

Schema confirmed: swarm_runs has no jsonb subtask column and no separate
swarm_subtasks table exists yet in stability-test. All per-subtask data
requires node_swarm_subtask_state_reducer projection.

15 new tests + 10 existing passing (25 total).

* refactor(OMN-11578/OMN-11596): reduce cyclomatic complexity in ThreeCanvas and parseDashboardDefinition (#137)

OMN-11578: Extract advanceFocusProgress, applyFocusedOpacities,
applyIdleOpacities, and projectAxisLabels from the ThreeCanvas
useEffect god-function (CCN=83). The tick loop is now a thin
orchestrator calling four named helpers via a TickRefs context struct.

OMN-11596: Extract validateTopLevelFields and validateLayoutItem from
parseDashboardDefinition (CCN=31). The parse function is reduced to
an 8-line orchestrator; all conditional branches live in the helpers.

* fix(OMN-12245): silence unused swarm route request (#139)

* feat(OMN-11658): wire SEA demo dashboard projections (#140)

* feat(OMN-11658): wire SEA demo dashboard projections

* fix(OMN-11658): make trace rows toggle independently

* fix(OMN-11658): polish SEA dashboard rendering

* feat(OMN-11658): expose delegation trigger in SEA control plane (#141)

* fix(OMN-11658): improve SEA prompt feedback (#142)

* fix(OMN-11658): let dashboard fill wide viewports (#143)

* fix(OMN-11658): balance dashboard gutters (#144)

* fix(OMN-11658): keep SEA stream newest first (#145)

* fix(OMN-11658): refresh SEA traces after prompt submit (#146)

* fix(OMN-11658): center dashboard shell (#147)

* fix(OMN-11658): extend dashboard body to bottom (#148)

* fix(OMN-11658): repair empty SEA dashboard layout (#149)

* chore(DELEG-002): annotate pricing fallbacks, align fixture model names with bifrost config (#150)

* feat(OMN-12070): wire delegation trigger through omnimarket runtime (#151)

- Add COMMAND_TOPICS.delegateSkill ('onex.cmd.omnimarket.delegate-skill.v1')
  to shared/types/command-topics.ts so the topic is contract-declared rather
  than hardcoded in server/routes.ts
- Declare the delegation trigger command topic in contract.yaml with handler
  node, ingress path, payload shape, and OMNIDASH_RUNTIME_EFFECTS_URL env note
- server/routes.ts uses COMMAND_TOPICS.delegateSkill constant; runtime targets
  stability-test :18085/v1/effects via OMNIDASH_RUNTIME_EFFECTS_URL env var
- DelegationRunContext exposes pendingCorrelationId + setPendingCorrelationId
  so a freshly-dispatched correlation_id propagates to all child panels before
  projection rows materialize
- DelegationRunHeader wires onCorrelationId callback to DelegationTriggerPanel
- DelegationEvidenceTabs auto-navigates to correlation-trace tab on dispatch
  and displays a pending indicator with the correlation_id
- DelegationCorrelationTracePanel falls back to pendingCorrelationId when no
  run is selected and shows appropriate pending vs awaiting-events messages
- Test: setPendingCorrelationId updates context value (DelegationRunContext.test)

* fix(OMN-12243): add 'delegation' short-topic alias to postgres + sqlite projection readers (#152)

GET /projection/delegation was returning rows:[] because the query() switch
had no 'delegation' case — only the full versioned topic name. Adds a
fall-through alias in both PostgresProjectionReader and SqliteProjectionReader
so the short topic name routes to the same decisions.v1 query logic.

Test: new routes.test.ts case seeds a SQLite DB and asserts the short alias
returns the same rows as the full topic name.

* fix(OMN-12287): close fixture gaps and add file-mode banners to dashboard widgets (#153)

- Add fileMode prop to ComponentWrapper: renders a [File Mode] badge in the
  widget header when active, analogous to the [Live] badge, using Text component
  to comply with the no-typography-inline ESLint rule
- Wire fileMode={!isLiveDataSource(mode)} on all 13 widgets that use
  useDataSourceMode: TraceExplorer, LiveEventStream, ContextHeatmap,
  DelegationControlPlane, EventStream, ReadinessGate, DelegationMetrics2D,
  DelegationSavingsWidget, DelegationTokenUsageWidget,
  DelegationModelRoutingWidget, DelegationQualityGateWidget, AbCompareWidget
- Improve emptyHint messages on all updated widgets to include file-mode
  fixture directory paths so developers know exactly where to add test data
- 1124/1124 tests pass

* feat(OMN-12288): deep-link delegation runs to trace explorer (#154)

- Add traceFilter + setTraceFilter to UISlice so a correlation_id can be
  passed to TraceExplorer before navigating there (no URL routing required)
- DelegationRunTable adds a GitCommitHorizontal icon button per row; clicking
  it calls setTraceFilter(correlationId) + setActivePage('trace') and stops
  event propagation so the row-select handler is not also triggered
- TraceExplorerWidget initializes its query state from traceFilter on mount
  and immediately clears the store value so subsequent navigations start fresh
- Tests: DelegationRunTable.test.tsx (7 cases), TraceExplorerWidget deep-link
  test confirms filter pre-fill + store clear (1132 total tests pass)

* feat(T22): add DelegationCostComparisonWidget — per-run cost proof surface (#155)

- New widget DelegationCostComparisonWidget in delegation/ directory reads from
  TOPICS.delegationSavings (same source as DelegationSavingsWidget) and surfaces
  a per-session cost comparison table with columns: Session, Task, Model, Tokens,
  Latency, Local $, Cloud $, Saved, Provenance
- Provenance badges (measured/estimated) on each row per the § 1.3 spec — green
  for measured (llm_call_metrics), amber for estimated (routing-rule inferred)
- Summary KPI bar shows cumulative savings, local/cloud cost, session count,
  total tokens, measured vs estimated breakdown
- Sort controls: date (default), savings descending, tokens descending
- maxRows config cap with overflow notice; showProvenance=false hides source column
- Registered as 'delegation-cost-comparison' in generate-registry.ts and index.ts
- Integration tests updated for count 29 → 30
- 1132/1132 tests pass

* fix(OMN-12402): unify CommandPalette node-type badges and Dispatch button contrast (#156)

Issue 1: kindColor() mixed design tokens (var(--accent), var(--text-tertiary))
with hardcoded hex (#d97706, #7c3aed), so ORCHESTRATOR/COMPUTE badges rendered
as weak plain text while EFFECT/REDUCER read as vivid chips. Drive all four
badges from the design-system node-type role tokens (--<kind>{,-soft,-ink}),
which exist for both themes, so every badge is a consistent filled chip.

Issue 2: the Dispatch button used var(--accent) as its fill (which resolves to
--brand-soft, a pale hover tint) plus literal color:'white' text — invisible in
light mode. Use the brand fill + on-brand text token (--brand /
--primary-foreground) so the CTA stays legible in both themes.

Adds CommandPalette.test.tsx asserting both fixes. Visual proof:
docs/evidence/OMN-12402-{badges,dispatch-light}.png

* fix(OMN-12367): render Correlation Trace tab gracefully in file mode (#159)

In VITE_DATA_SOURCE=file mode the /api/delegation/* proxy is not registered
(it is wired only when VITE_PROJECTION_API_URL is set), so fetchCorrelationTrace
hit the Vite SPA fallback, received index.html, and surfaced a raw
'JSON.parse: unexpected character at line 1 column 1' error.

Detect file mode up front in fetchCorrelationTrace and read a per-correlation
fixture under fixtures/onex.snapshot.projection.delegation.correlation-trace.v1/
so the surface renders a representative event chain without postgres. In live
mode, parse defensively (res.json().catch) so any non-JSON body degrades to an
empty trace and the panel shows its graceful no-events message rather than a raw
parse error.

Adds the delegationCorrelationTrace topic, a representative 2-event fixture for
the file-mode decisions correlation (069e55b5...), and tests covering file-mode
fixture fetch and non-JSON hardening.

* fix(OMN-12399): register Context Effectiveness Heatmap widget in palette (#158)

The widget source (component, hook, fixtures, types, test) existed but was
registered in zero of the three Track A surfaces, so it never appeared in the
widget palette and was unreachable through the supported user flow.

- Add lazy import to componentImports in src/components/dashboard/index.ts
- Add context-effectiveness-heatmap manifest to MVP_COMPONENTS in
  scripts/generate-registry.ts (quality category, contextExperimentScores
  projection, OMN-11241 fixture fallback)
- Regenerate src/registry/component-registry.json (30 -> 31 components)
- Update integration.part2/part3 component-count + name-list assertions

Visual proof (OMN-7093): palette entry + widget rendered on dashboard in
file mode using the OMN-11241 research fixture.

* fix(OMN-12366): give Delegation Control Plane active tab a clear visual indicator (#157)

The 10-tab evidence strip distinguished active from inactive tabs only by
background (var(--panel-2) vs transparent); border, text color, and weight
were identical, so the strip did not read as tabs.

Active tab now uses the --brand accent token: brand border, a 2px brand
bottom-border underline, --brand-soft background, and brand-colored
semibold label text. All via existing design tokens (no hardcoded hex),
verified to read clearly in both light and dark themes.

Visual evidence (OMN-7093):
- docs/evidence/OMN-12366-tab-strip-dark.png
- docs/evidence/OMN-12366-tab-strip-light.png

* feat(OMN-12139): add Trace Explorer fixture for onex.snapshot.projection.traces.v1 (#161)

Wires a _trace_groups() factory into scripts/generate_fixtures.py and emits
five representative TraceGroup records under
fixtures/onex.snapshot.projection.traces.v1/, covering every TraceExplorer
UI state: multi-node completed, single-node completed, running, error, and a
longer multi-node trace. The widget reads each file as one TraceGroup via the
legacy useProjectionQuery readAll path; the file-source middleware
auto-generates index.json.

Shapes mirror the widget's TraceGroup interface exactly (no entity_id); each
record is keyed by its correlation_id so the fixture filename matches.

DoD: VITE_DATA_SOURCE=file dev server renders 5 traces (1 running, 1 error)
in the Trace Explorer in File Mode; Playwright screenshot under
docs/evidence/OMN-12139-trace-explorer-file-mode.png proves populated/running/
error states (OMN-7093). npm run build, lint, and full vitest suite (1172
tests) green.

* fix(OMN-12397): make Savings Proof Pack source line dynamic by data mode (#160)

The printable proof pack hardcoded "Source: OmniNode delegation_events
projection" even in file mode, so a PDF exported from fixture data was
misleading once removed from the parent Fixture banner. Derive the
"Source:" line and reproducibility sentence from useDataSourceMode():

  file     -> Local fixture file (fixtures/.../demo-delegation-snapshot.json)
  sqlite   -> Local SQLite projection
  http     -> OmniNode projection API via Express bridge
  postgres -> OmniNode delegation_events projection

Non-live modes (file/sqlite) state the report is from a local
development data source and is not a live projection.

Renamed fixture live-delegation-demo.json -> demo-delegation-snapshot.json;
the word 'live' inside fixtures/ was second-order misleading. The file is
discovered by directory glob (vite fixturesMiddleware readdirSync), so no
code references the old name.

dod_evidence: rendered_output screenshot
docs/evidence/OMN-12397-proof-pack-file-mode-source.png shows the
file-mode footer with the fixture source string and 15 fixture rows.

* fix(OMN-12392): repoint run-generate-fixtures.sh to local script and correct CLAUDE.md fixture claim (#162)

OMN-12392: `run-generate-fixtures.sh` was looking for `generate_fixtures.py` at
`$OMNIBASE_INFRA_PATH/scripts/generate_fixtures.py`, but that path does not exist in
omnibase_infra. The script lives in this repo at `scripts/generate_fixtures.py`.
Fix: set SCRIPT_PATH to `$REPO/scripts/generate_fixtures.py` while preserving
`--project "$OMNIBASE_INFRA_PATH"` for uv dependency resolution (the script imports
from omnibase_infra). `npm run generate:fixtures` now works without a non-standard
OMNIBASE_INFRA_PATH.

Also fix CLAUDE.md hard rule #6 which claimed "Fixtures and layouts are not committed"
— fixtures/ IS tracked via the OMN-10305 gitignore carve-out (!fixtures/, !fixtures/**).
Correct the claim and add the carve-out explanation.

OMN-12393: add a lineage classification table to CLAUDE.md documenting that
omnidash/ (dev) is CANONICAL_ACTIVE, omnidash-v2/ is SUPERSEDED, and
omnidash-v2-new/ is a TEMP_STAGING clone. Cite verification evidence.

* test(OMN-10947): pin dashboard e2e fixture proof (#163)

* test(OMN-10947): scope dashboard e2e locators (#164)

* fix(OMN-12394): add .env to omnidash .gitignore to prevent credential leak (#165)

The .env file holds OMNIDASH_ANALYTICS_DB_URL with a live Postgres password.
The existing rule only covered .env.local* — .env itself was unprotected.

Adds an explicit .env entry so git never tracks the credential file.

* fix: move omnidash event bus config to contract overlay (#166)

* chore(OMN-12459): add universal managed gitignore block to omnidash (#168)

Appends the onex-managed universal block to .gitignore, adding
.env.*, !.env.example, *.log, .idea/, .vscode/, test-results/, and
playwright-report/ coverage. .env and .DS_Store were already present
individually; the block makes the policy explicit and machine-readable.

No tracked files required untracking.

* ci(OMN-12477): add dev-only main-target-guard (forward-port from main) (#167)

Forward-ports the main-target-guard workflow to dev (the file existed on
main but was absent on dev — main<->dev drift). Adds the dev-only variant
WITHOUT the hotfix/* bypass: main accepts only dev promotion PRs carrying
promotion-receipt: OCC-N. The guard only triggers on base==main, so it is
inert on dev-targeting PRs and takes effect at the next dev->main promotion.

OMN-12477

* feat(OMN-12674): remove reviewdog workflows and configs (#170)

Removes the reviewdog caller workflow from omnidash. No reviewdog
required-status-check bindings existed on main protection, dev
protection, or any repo ruleset, so this is the only active reviewdog
surface in the repo.

dod_evidence: OMN-12674

* fix(OMN-12739): tighten delegation dashboard formatting (#171)

* fix(OMN-12740): add browser-safe dashboard id fallback (#172)

* feat(OMN-12745): add DelegationModelOutputWidget rendering inference generated_text (#177)

Implements the demo-blocking deliverable for OMN-12745: DelegationModelOutputWidget
renders ModelLlmInferenceResponse.generated_text sourced from bus topic
onex.evt.omnibase-infra.inference-response.v1 in fixture mode (offline, no .201
dependency). Live surfacing is gated on the OMN-12746 demo redeploy.

Changes:
- src/components/dashboard/delegation/DelegationModelOutputWidget.tsx — new widget
  rendering latest generated_text, model identity, task type, token count, latency,
  and a recent response history list
- src/components/dashboard/delegation/DelegationModelOutputWidget.test.tsx — 8 unit
  tests (TDD-first: loading, empty, generated_text, model identity, topic label,
  upstream notice, provisioned, token count)
- src/components/dashboard/delegation/DelegationModelOutputWidget.stories.tsx — 6
  stories: Empty, Loading, Populated, FixtureMode, Provisioned, SingleResponse
- shared/types/topics.ts — adds inferenceResponseText topic key
- src/storybook/fixtures/delegation-routing.ts — adds buildInferenceResponseProjection
- src/storybook/fixtures/index.ts — exports new fixture builder
- scripts/generate-registry.ts — adds delegation-model-output MVP manifest entry
- src/registry/component-registry.json — regenerated (32 components)
- src/components/dashboard/index.ts — lazy-import registration
- src/templates/sea-demo.ts — delegation-model-output added to SEA Demo template
- src/templates/templates.test.ts — updated SEA Demo widget order assertions
- src/integration.part2.test.tsx — component count 31 to 32 and name list
- src/integration.part3.test.tsx — component count 31 to 32
- src/storybook-coverage-compliance.test.ts — adds story file to compliance list
- fixtures/onex.snapshot.projection.delegation.inference-response-text.v1/snapshot.json
  — fixture data for file mode (offline demo)

Evidence-Source OCC SHA: c872f8edbabda4306e3dd290dbb8995031da3d7c

* feat(OMN-12748): render delegated prompt/response from contract-declared projection (#178)

The Correlation Trace panel already renders prompt_text/response_text per run,
but fetchCorrelationTrace called a bespoke /api/delegation/correlation-trace/<id>
REST route that the deployed contract-driven projection API does not implement
(404), so the panel always showed 'No events found' and the dashboard could not
surface the actual delegated work.

Read the per-correlation trace from the contract-declared projection instead:
/projection/onex.snapshot.projection.delegation.correlation-trace.v1?correlation_id=<id>
(a read path the projection API already supports). Selecting a run now renders
its prompt + response. File mode still reads the per-correlation fixture.

Pairs with omnimarket OMN-12748, which exposes that projection over
delegation_events. Canonical: client renders a contract-declared projection; no
bespoke REST route.

* chore(release)(OMN-12245): backmerge omnidash v1.1.1 to dev (#176)

* fix(OMN-12756): publish delegation trigger through command bus (#179)

* feat(OMN-12748): render delegated prompt/response from contract-declared projection

The Correlation Trace panel already renders prompt_text/response_text per run,
but fetchCorrelationTrace called a bespoke /api/delegation/correlation-trace/<id>
REST route that the deployed contract-driven projection API does not implement
(404), so the panel always showed 'No events found' and the dashboard could not
surface the actual delegated work.

Read the per-correlation trace from the contract-declared projection instead:
/projection/onex.snapshot.projection.delegation.correlation-trace.v1?correlation_id=<id>
(a read path the projection API already supports). Selecting a run now renders
its prompt + response. File mode still reads the per-correlation fixture.

Pairs with omnimarket OMN-12748, which exposes that projection over
delegation_events. Canonical: client renders a contract-declared projection; no
bespoke REST route.

* fix(OMN-12756): route delegation API to dashboard server

* chore(OMN-12765): backmerge omnidash main to dev (#181)

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities (#120)

* feat(OMN-11979): redesign delegation dashboard — dense metrics, narrative labels, stale .js cleanup (#119)

- Replace donut chart with terminal-style KPI tiles + stacked model bar + task breakdown
- Rename Quality Gate → Escalation Detail, humanize failure categories
- Fix postgres-projection-reader: correct column names, JSONB string coercion
- Add /projection Vite proxy for Express bridge
- Remove 8 stale compiled .js files that shadowed .tsx source via Vite resolution
- "Quality Gate" → "Local Success", "pass/fail" → "local/escalated" throughout

* feat(OMN-11614, OMN-11617): deduplicate projection readers and integration test utilities

OMN-11614: Extract shared session-merge and cost-savings-overview aggregation
logic from postgres-projection-reader.ts and sqlite-projection-reader.ts into
server/projection-utils.ts. Removes ~160 lines o…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants