Skip to content

ci(OMN-9867): add --squash flag to auto-merge workflow - #16

Merged
github-actions[bot] merged 1 commit into
mainfrom
jonah/omn-9867-add-squash-flag-v2
Apr 26, 2026
Merged

github-actions[bot] merged 1 commit into
mainfrom
jonah/omn-9867-add-squash-flag-v2

Conversation

@jonahgabriel

Copy link
Copy Markdown
Contributor

Closes OMN-9867.

Root cause

gh pr merge --auto without an explicit method flag fails non-interactively on repos without a merge queue (exit 1). This workflow was lifted verbatim from omnidash v1 in OMN-9832 (PR #1). v1 works today because its merge queue overrides the method; v2 has no merge queue yet — causing every PR's auto-merge step to exit 1.

Fix

Added --squash to the gh pr merge --auto call on line 87 of auto-merge.yml. Companion fix landed in omnidash v1 at https://github.com/OmniNode-ai/omnidash/pull/629.

dod_evidence

  • grep "gh pr merge.*--squash" .github/workflows/auto-merge.yml returns the patched line
  • Pre-commit hooks pass including TypeScript typecheck (1 file changed, 1 insertion, 1 deletion)

Without an explicit method flag, gh pr merge --auto fails non-interactively
on repos without a merge queue. This was lifted verbatim from v1 in OMN-9832
(PR #1); adding --squash explicitly fixes the exit-1 failure on every v2 PR.
@linear

linear Bot commented Apr 26, 2026

Copy link
Copy Markdown

OMN-9867

@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 6 minutes and 47 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 6 minutes and 47 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: c28e90ac-f095-4423-a196-4e5a4dda6f83

📥 Commits

Reviewing files that changed from the base of the PR and between ed4d522 and cc99fcf.

📒 Files selected for processing (1)
  • .github/workflows/auto-merge.yml

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

@github-actions
github-actions Bot merged commit 0719e81 into main Apr 26, 2026
14 of 17 checks passed
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…
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.

1 participant