From 5538f3faa13ec620eca7c56e0ccf4c3fa950e5c8 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Fri, 29 May 2026 18:06:18 -0400 Subject: [PATCH 1/4] harden web terminal ui states --- .beads/issues.jsonl | 1 + apps/web/app/globals.css | 6 + apps/web/app/terminal.test.ts | 29 ++ apps/web/app/terminal.tsx | 204 +++++++----- ...6-05-29-harden-web-terminal-ui-states.html | 293 ++++++++++++++++++ 5 files changed, 461 insertions(+), 72 deletions(-) create mode 100644 docs/turns/2026-05-29-harden-web-terminal-ui-states.html diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 58e5b6b..570dd9a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,6 +24,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-dk5","title":"Remove frontend cooker route","description":"Remove the experimental /frontend-cooker page and update repository references that still list it as an available public route.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T13:50:38Z","created_by":"dirtydishes","updated_at":"2026-05-29T13:53:05Z","started_at":"2026-05-29T13:50:48Z","closed_at":"2026-05-29T13:53:05Z","close_reason":"Removed the /frontend-cooker Next.js route, cleaned route/scanner references, documented the work, and validated the web build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ep2","title":"Configure Impeccable live mode","description":"Initialize the repository's Impeccable live-mode configuration so future design iteration can start without first-time setup.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T08:03:47Z","created_by":"dirtydishes","updated_at":"2026-05-29T08:05:01Z","started_at":"2026-05-29T08:03:52Z","closed_at":"2026-05-29T08:05:01Z","close_reason":"Configured Impeccable live mode and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-9en","title":"Install Impeccable skill for Codex","description":"Install the Impeccable skill in the Codex-compatible project locations after the upstream installer selected unused harness folders.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T07:59:10Z","created_by":"dirtydishes","updated_at":"2026-05-29T07:59:22Z","started_at":"2026-05-29T07:59:18Z","closed_at":"2026-05-29T07:59:22Z","close_reason":"Installed Impeccable into .agents and mirrored it into .codex/skills for Codex use.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a454a20..7cbc952 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1675,6 +1675,7 @@ h3 { text-overflow: ellipsis; white-space: nowrap; font-size: 0.72rem; + unicode-bidi: plaintext; } .data-table-cell-number { @@ -2010,11 +2011,16 @@ h3 { } .empty { + display: flex; + align-items: center; + min-height: 76px; padding: 18px; border-radius: 12px; border: 1px dashed var(--border); background: var(--bg-soft); color: var(--text-dim); + line-height: 1.4; + overflow-wrap: anywhere; } .drawer { diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index e6ed106..80d727f 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -4,6 +4,7 @@ import { NAV_ITEMS, appendHistoryTail, buildAlertContextPath, + buildTapeStatusAnnouncement, buildDefaultFlowFilters, buildOptionTapeQueryParams, classifierToneForFamily, @@ -51,6 +52,34 @@ import { toggleFilterValue } from "./terminal"; +describe("tape status hardening", () => { + it("builds a screen-reader announcement with replay state and queued rows", () => { + expect( + buildTapeStatusAnnouncement({ + status: "connected", + replayTime: null, + replayComplete: false, + paused: true, + dropped: 12, + mode: "replay" + }) + ).toBe("Replay feed paused, time not available, 12 queued rows"); + }); + + it("announces stale live feeds without relying on the colored dot", () => { + expect( + buildTapeStatusAnnouncement({ + status: "stale", + replayTime: null, + replayComplete: false, + paused: false, + dropped: 0, + mode: "live" + }) + ).toBe("Live feed behind"); + }); +}); + const makeItem = (traceId: string, seq: number, ts: number) => ({ trace_id: traceId, seq, diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 5375688..2826266 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -2145,6 +2145,59 @@ export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): } }; +export const buildTapeStatusAnnouncement = ({ + status, + replayTime, + replayComplete, + paused, + dropped, + mode +}: Pick): string => { + const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); + const feedLabel = mode === "live" && label.toLowerCase().startsWith("feed ") + ? label.toLowerCase() + : `feed ${label.toLowerCase()}`; + const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`]; + + if (mode === "replay") { + parts.push(`time ${replayTime ? formatTime(replayTime) : "not available"}`); + } + + if (paused && dropped > 0) { + parts.push(`${dropped} queued rows`); + } + + return parts.join(", "); +}; + +const DataCell = ({ + children, + className = "", + title, + numeric = false +}: { + children: ReactNode; + className?: string; + title?: string; + numeric?: boolean; +}) => { + const classes = ["data-table-cell", numeric ? "data-table-cell-number" : "", className] + .filter(Boolean) + .join(" "); + + return ( + + {children} + + ); +}; + +const EmptyState = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); + type TapeConfig = { mode: TapeMode; wsPath: string; @@ -3893,17 +3946,33 @@ const TapeStatus = ({ }: TapeStatusProps) => { const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : ""; + const announcement = buildTapeStatusAnnouncement({ + status, + replayTime, + replayComplete, + paused, + dropped, + mode + }); return ( -
- +
+
@@ -7532,7 +7601,7 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
) : null} {items.length === 0 ? ( -
+ {state.mode === "live" ? state.options.status === "stale" ? "Feed behind. Waiting for fresh option prints." @@ -7544,29 +7613,23 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => { : state.tickerSet.size > 0 ? "No option prints match the current filter." : "Replay queue empty. Ensure ClickHouse has data."} -
+ ) : (
- TIME - SYM - EXP - STRIKE - C/P - SPOT - DETAILS - TYPE - VALUE - SIDE - IV - CLASSIFIER + {["TIME", "SYM", "EXP", "STRIKE", "C/P", "SPOT", "DETAILS", "TYPE", "VALUE", "SIDE", "IV", "CLASSIFIER"].map((header) => ( + + {header} + + ))}
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => { const contractId = normalizeContractId(print.option_contract_id); @@ -7602,42 +7665,42 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => { }; const cells = ( <> - {formatTime(print.ts)} - + {formatTime(print.ts)} + - - + + - - + + - - + + - - {typeof spot === "number" ? formatPrice(spot) : "--"} - + + {typeof spot === "number" ? formatPrice(spot) : "--"} + {formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"} - - {print.option_type ?? "--"} - ${formatCompactUsd(notional)} - + + {print.option_type ?? "--"} + ${formatCompactUsd(notional)} + {nbboSide ? ( {nbboSide} ) : ( "--" )} - - {typeof iv === "number" ? formatPct(iv) : "--"} - {decor ? humanizeClassifierId(decor.family) : "--"} + + {typeof iv === "number" ? formatPct(iv) : "--"} + {decor ? humanizeClassifierId(decor.family) : "--"} ); @@ -7721,7 +7784,7 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => { >
{items.length === 0 ? ( -
+ {state.mode === "live" ? state.equities.status === "stale" ? "Feed behind. Waiting for fresh equity prints." @@ -7735,23 +7798,23 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => { : state.tickerSet.size > 0 ? "No equity prints match the current filter." : "Replay queue empty. Ensure ClickHouse has data."} -
+ ) : (
- TIME - SYM - PRICE - SIZE - VENUE - TAPE + {["TIME", "SYM", "PRICE", "SIZE", "VENUE", "TAPE"].map((header) => ( + + {header} + + ))}
-
+
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => (
{ data-tape-key={key} style={{ transform: `translateY(${start}px)` }} > - {formatTime(print.ts)} - + {formatTime(print.ts)} + - - ${formatPrice(print.price)} - {formatSize(print.size)}x - {print.exchange} - {print.offExchangeFlag ? "Off-Ex" : "Lit"} + + ${formatPrice(print.price)} + {formatSize(print.size)}x + {print.exchange} + {print.offExchangeFlag ? "Off-Ex" : "Lit"}
))}
@@ -7825,7 +7888,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => { >
{items.length === 0 ? ( -
+ {state.tickerSet.size > 0 ? "No flow packets match the current filter." : state.mode === "live" @@ -7833,23 +7896,19 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => { ? "Feed behind. Waiting for fresh flow packets." : "No flow packets yet. Start compute." : "Replay queue empty. Ensure ClickHouse has data."} -
+ ) : (
- TIME - CONTRACT - PRINTS - SIZE - NOTIONAL - WINDOW - STRUCTURE - NBBO - QUALITY + {["TIME", "CONTRACT", "PRINTS", "SIZE", "NOTIONAL", "WINDOW", "STRUCTURE", "NBBO", "QUALITY"].map((header) => ( + + {header} + + ))}
-
+
{virtual.virtualItems.map(({ item: packet, key, index, start, size }) => { const features = packet.features ?? {}; const contract = String(features.option_contract_id ?? packet.id ?? "unknown"); @@ -7904,6 +7963,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => { return (
{ data-tape-key={key} style={{ transform: `translateY(${start}px)` }} > - {formatTime(startTs)} โ†’ {formatTime(endTs)} - {contract} - {formatFlowMetric(count)} - {formatFlowMetric(totalSize)} - ${formatUsd(notional)} - {windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"} - {structureLabel} - {nbboLabel} - {qualityLabel || "--"} + {formatTime(startTs)} โ†’ {formatTime(endTs)} + {contract} + {formatFlowMetric(count)} + {formatFlowMetric(totalSize)} + ${formatUsd(notional)} + {windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"} + {structureLabel} + {nbboLabel} + {qualityLabel || "--"}
); })} diff --git a/docs/turns/2026-05-29-harden-web-terminal-ui-states.html b/docs/turns/2026-05-29-harden-web-terminal-ui-states.html new file mode 100644 index 0000000..a303937 --- /dev/null +++ b/docs/turns/2026-05-29-harden-web-terminal-ui-states.html @@ -0,0 +1,293 @@ + + + + + + Harden Web Terminal UI States + + + +
+
+
+ 2026-05-29 18:04 EDT + Beads: islandflow-ggm + Web terminal hardening +
+

Harden Web Terminal UI States

+

+ The terminal UI now handles live and replay status, empty panes, clipped market data, and table semantics more reliably for real users, assistive technology, and unusual input values. +

+
+ +
+

Summary

+

+ I hardened the main web terminal surface by adding accessible feed announcements, reusable empty-state markup, safer data cells for clipped values, and stronger table semantics on the busiest tape panes. +

+
+ +
+

Changes Made

+
    +
  • Added buildTapeStatusAnnouncement so live and replay feed states have complete screen-reader labels instead of relying on colored dots or terse visible labels.
  • +
  • Added reusable DataCell and EmptyState helpers for terminal panes.
  • +
  • Updated Options, Equities, and Flow panes with semantic column headers, rowgroups, cells, and useful title fallbacks for clipped values.
  • +
  • Improved empty-state layout so long messages wrap cleanly without collapsing the pane.
  • +
  • Added unicode-bidi: plaintext to table cells so mixed-direction symbols, ticker text, and unusual copied values are less likely to reorder confusingly.
  • +
  • Added focused tests for the new status-announcement helper.
  • +
+
+ +
+

Context

+

+ Islandflow is an evidence console for live market investigation. The UI has to remain useful when feeds are stale, paused, empty, or carrying long contract identifiers and numeric values. Hardening here focused on making the existing dense terminal more robust without changing its visual identity. +

+
+ +
+

Important Implementation Details

+
    +
  • TapeStatus now exposes a polite status region with an aria-label such as Live feed behind or Replay feed paused, time not available, 12 queued rows.
  • +
  • The visible status dot is marked aria-hidden, keeping color as a visual cue rather than the only status carrier.
  • +
  • Table headers are generated from arrays to keep repeated header markup consistent.
  • +
  • Clipped values such as option contracts, exact timestamps, full notional values, NBBO quality strings, and venue labels now expose fuller details through title where useful.
  • +
+
+ +
+

Relevant Diff Snippets

+

+ Diff snippets are presented in the format expected by diffs.com-style unified diff rendering. @pierre/diffs is installed in this repo, but it does not expose a CLI binary, so the relevant unified snippets are embedded directly. +

+
diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx
+@@
++export const buildTapeStatusAnnouncement = ({
++  status,
++  replayTime,
++  replayComplete,
++  paused,
++  dropped,
++  mode
++}: Pick<TapeStatusProps, "status" | "replayTime" | "replayComplete" | "paused" | "dropped" | "mode">): string => {
++  const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
++  const feedLabel = mode === "live" && label.toLowerCase().startsWith("feed ")
++    ? label.toLowerCase()
++    : `feed ${label.toLowerCase()}`;
++  const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`];
++  ...
++};
++
++const EmptyState = ({ children }: { children: ReactNode }) => (
++  <div className="empty" role="status" aria-live="polite">
++    {children}
++  </div>
++);
+
+@@
+-    <div className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}>
+-      <span className="status-dot" />
++    <div
++      className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}
++      role="status"
++      aria-live="polite"
++      aria-label={announcement}
++    >
++      <span className="status-dot" aria-hidden="true" />
+ +
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
+@@
+ .data-table-cell {
+   min-width: 0;
+   overflow: hidden;
+   text-overflow: ellipsis;
+   white-space: nowrap;
+   font-size: 0.72rem;
++  unicode-bidi: plaintext;
+ }
+
+ .empty {
++  display: flex;
++  align-items: center;
++  min-height: 76px;
+   padding: 18px;
+   border-radius: 12px;
+   border: 1px dashed var(--border);
+   background: var(--bg-soft);
+   color: var(--text-dim);
++  line-height: 1.4;
++  overflow-wrap: anywhere;
+ }
+ +
diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts
+@@
++describe("tape status hardening", () => {
++  it("announces stale live feeds without relying on the colored dot", () => {
++    expect(
++      buildTapeStatusAnnouncement({
++        status: "stale",
++        replayTime: null,
++        replayComplete: false,
++        paused: false,
++        dropped: 0,
++        mode: "live"
++      })
++    ).toBe("Live feed behind");
++  });
++});
+
+ +
+

Expected Impact for End-Users

+

+ Traders and researchers should get a steadier terminal under imperfect feed conditions. Screen-reader users get explicit live and replay status changes, empty panes announce themselves clearly, and clipped market values are easier to inspect without widening the layout. +

+
+ +
+

Validation

+
    +
  • bun test apps/web/app/terminal.test.ts: 76 passing tests.
  • +
  • bun --cwd=apps/web run build: production build completed successfully.
  • +
  • Browser verification at http://localhost:3000/options: confirmed status regions, table semantics, column headers, rowgroup, and cells are present in the rendered page.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • The Options pane can still be wider than a narrow viewport by design; the table remains inside its horizontal scroll container.
  • +
  • Alert, classifier, dark-event, and news panes still have some older one-off markup. This task hardened the highest-traffic tape panes first.
  • +
  • The browser check observed a history-load warning because backend history was unavailable locally. That state rendered cleanly and was not a build blocker.
  • +
+
+ +
+

Follow-up Work

+
+

+ No additional Beads issue was required during this turn. A sensible future pass would extend the same DataCell and EmptyState treatment to Alerts, Smart Money, Dark Events, and the chart evidence lists. +

+
+
+
+ + -- 2.49.1 From 7835342cd3a8184cbfadd1dec71c3191dc3188d0 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Fri, 29 May 2026 18:16:09 -0400 Subject: [PATCH 2/4] harden terminal ui error states --- .beads/issues.jsonl | 1 + apps/web/app/globals.css | 10 + apps/web/app/terminal.test.ts | 20 + apps/web/app/terminal.tsx | 40 +- .../2026-05-29-harden-terminal-ui-errors.html | 614 ++++++++++++++++++ 5 files changed, 675 insertions(+), 10 deletions(-) create mode 100644 docs/turns/2026-05-29-harden-terminal-ui-errors.html diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 570dd9a..fb7d590 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,6 +24,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-aq9","title":"Harden terminal UI error and overflow states","description":"Harden the web terminal against oversized API errors, non-JSON synthetic admin failures, and long status text so live trading panes remain stable under bad network/backend responses.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:10:16Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:13:37Z","closed_at":"2026-05-29T22:13:37Z","close_reason":"Hardened terminal UI error rendering, synthetic admin failure parsing, long-message wrapping, and added focused tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-dk5","title":"Remove frontend cooker route","description":"Remove the experimental /frontend-cooker page and update repository references that still list it as an available public route.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T13:50:38Z","created_by":"dirtydishes","updated_at":"2026-05-29T13:53:05Z","started_at":"2026-05-29T13:50:48Z","closed_at":"2026-05-29T13:53:05Z","close_reason":"Removed the /frontend-cooker Next.js route, cleaned route/scanner references, documented the work, and validated the web build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ep2","title":"Configure Impeccable live mode","description":"Initialize the repository's Impeccable live-mode configuration so future design iteration can start without first-time setup.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T08:03:47Z","created_by":"dirtydishes","updated_at":"2026-05-29T08:05:01Z","started_at":"2026-05-29T08:03:52Z","closed_at":"2026-05-29T08:05:01Z","close_reason":"Configured Impeccable live mode and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7cbc952..092961a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -10,6 +10,7 @@ --text: oklch(0.93 0.014 250); --text-dim: oklch(0.74 0.018 250); --text-faint: oklch(0.59 0.016 250); + --text-muted: var(--text-dim); --accent: oklch(0.78 0.12 74); --accent-soft: oklch(0.78 0.12 74 / 0.1); --green: oklch(0.74 0.13 151); @@ -661,6 +662,7 @@ h3 { color: var(--text-muted); font-size: 0.78rem; line-height: 1.35; + overflow-wrap: anywhere; } .flow-filter-checkbox-grid, @@ -1249,6 +1251,12 @@ h3 { color: var(--text-dim); } +.drawer-note, +.drawer-empty, +.note { + overflow-wrap: anywhere; +} + .chart-surface { width: 100%; height: 460px; @@ -1457,6 +1465,7 @@ h3 { color: oklch(0.91 0.08 72); font-size: 0.78rem; line-height: 1.35; + overflow-wrap: anywhere; } .data-table-wrap { @@ -2291,6 +2300,7 @@ h3 { .synthetic-control-error { color: var(--red); + overflow-wrap: anywhere; } .drawer-header { diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index 80d727f..1c9dc6c 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -14,6 +14,7 @@ import { countActiveFlowFilterGroups, filterOptionTapeItems, findAnchorRestoreIndex, + formatUiErrorMessage, formatCompactUsd, formatOptionContractLabel, flushPausableTapeData, @@ -80,6 +81,25 @@ describe("tape status hardening", () => { }); }); +describe("terminal error message hardening", () => { + it("normalizes whitespace and clamps oversized messages before rendering", () => { + const longMessage = `API failed\n\n${"x".repeat(320)}`; + + const formatted = formatUiErrorMessage(longMessage); + + expect(formatted).toHaveLength(240); + expect(formatted).toStartWith("API failed x"); + expect(formatted).toEndWith("..."); + expect(formatted).not.toContain("\n"); + }); + + it("uses a fallback when an error payload is empty", () => { + expect(formatUiErrorMessage(" ", "Synthetic status could not be loaded")).toBe( + "Synthetic status could not be loaded" + ); + }); +}); + const makeItem = (traceId: string, seq: number, ts: number) => ({ trace_id: traceId, seq, diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 2826266..10dfd0b 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -507,6 +507,20 @@ const sampleToLimit = (items: T[], limit: number): T[] => { return sampled; }; +export const formatUiErrorMessage = (message: unknown, fallback = "Request failed"): string => { + const raw = + message instanceof Error + ? message.message + : typeof message === "string" + ? message + : String(message ?? ""); + const normalized = raw.replace(/\s+/g, " ").trim(); + if (!normalized) { + return fallback; + } + return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized; +}; + const readErrorDetail = async (response: Response): Promise => { const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; const text = await response.text(); @@ -530,9 +544,9 @@ const readErrorDetail = async (response: Response): Promise => { error?: string; message?: string; }; - return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`; + return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`); } catch { - return `${statusLabel}: ${truncated}`; + return formatUiErrorMessage(`${statusLabel}: ${truncated}`); } }; @@ -4479,7 +4493,7 @@ const CandleChart = ({ if (!active) { return; } - setError(error instanceof Error ? error.message : String(error)); + setError(formatUiErrorMessage(error)); setStatus("disconnected"); setHasData(false); } @@ -7596,8 +7610,8 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => { >
{state.mode === "live" && optionHistoryError ? ( -
- Older option history failed to load: {optionHistoryError} +
+ Older option history failed to load: {formatUiErrorMessage(optionHistoryError)}
) : null} {items.length === 0 ? ( @@ -8869,6 +8883,9 @@ function SyntheticControlDock() { setLoading(false); return; } + if (!response.ok) { + throw new Error(await readErrorDetail(response)); + } const nextStatus = (await response.json()) as SyntheticAdminStatusResponse; setStatus(nextStatus); if (!dirtyRef.current) { @@ -8879,7 +8896,7 @@ function SyntheticControlDock() { } } catch (loadError) { if (!cancelled) { - setError(loadError instanceof Error ? loadError.message : String(loadError)); + setError(formatUiErrorMessage(loadError, "Synthetic status could not be loaded")); } } finally { if (!cancelled) { @@ -8917,8 +8934,7 @@ function SyntheticControlDock() { }) .then(async (response) => { if (!response.ok) { - const body = await response.json().catch(() => null); - throw new Error(body?.detail ?? body?.error ?? "Synthetic control update failed"); + throw new Error(await readErrorDetail(response)); } return (await response.json()) as SyntheticAdminControlResponse; }) @@ -8939,7 +8955,7 @@ function SyntheticControlDock() { }) .catch((updateError) => { dirtyRef.current = false; - setError(updateError instanceof Error ? updateError.message : String(updateError)); + setError(formatUiErrorMessage(updateError, "Synthetic control update failed")); setDraft(savedRef.current); }) .finally(() => { @@ -9147,7 +9163,11 @@ function SyntheticControlDock() {
- {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} )} diff --git a/docs/turns/2026-05-29-harden-terminal-ui-errors.html b/docs/turns/2026-05-29-harden-terminal-ui-errors.html new file mode 100644 index 0000000..0f1b353 --- /dev/null +++ b/docs/turns/2026-05-29-harden-terminal-ui-errors.html @@ -0,0 +1,614 @@ + + + + + + Harden Terminal UI Error States + + + +
+
+

Turn document ยท 2026-05-29 18:12 EDT

+

Harden Terminal UI Error States

+

+ This pass made the Islandflow web terminal more stable when backend or network responses are + messy: oversized error payloads are clamped, non-JSON admin failures are handled explicitly, + and long warning text now wraps inside terminal surfaces. +

+
+ Beads: islandflow-aq9 + Surface: apps/web + Command: impeccable harden +
+
+ +
+

Summary

+

+ The terminal now sanitizes UI-facing error strings before rendering them, handles failed + synthetic-admin status responses before parsing JSON, and gives long warnings, drawer notes, + and filter copy safe wrapping behavior. +

+
+ +
+

Changes Made

+
    +
  • + Added formatUiErrorMessage in apps/web/app/terminal.tsx to normalize + whitespace, provide fallback text, and clamp rendered error messages to 240 characters. +
  • +
  • + Routed chart, option-history, and synthetic-control error displays through the formatter so + server payloads cannot flood or distort the interface. +
  • +
  • + Updated synthetic admin status loading to check response.ok before JSON parsing, + reusing the existing response-detail reader for JSON, HTML, and plain-text failures. +
  • +
  • + Added aria-live and role="alert" where warning/error text changes need + to be announced by assistive technology. +
  • +
  • + Added wrapping containment for drawer notes, empty states, flow-filter copy, synthetic errors, + and history-load warnings. +
  • +
+
+ +
+

Context

+

+ Islandflow is a real-time market-data terminal. During live trading or replay investigation, + backend failures should be clear and recoverable without breaking table lanes, drawers, charts, + or operator controls. The hardened paths are intentionally small and utility-first so the + product keeps its dense instrument-panel character. +

+
+ +
+

Important Implementation Details

+
    +
  • + The formatter accepts unknown, so callers can safely pass native errors, strings, + empty payloads, or unexpected values without branching at every render site. +
  • +
  • + readErrorDetail still preserves useful HTTP status context, but now returns a + UI-safe string even when the backend sends HTML, a huge plain-text body, or malformed JSON. +
  • +
  • + The CSS keeps the terminal layout dense. It wraps only message-like text surfaces, not the + horizontally scrollable market-data tables. +
  • +
  • + The dev-server smoke test showed desktop /options had no visible-overflow + offenders. Mobile still reports a document width wider than the viewport because the options + data table intentionally keeps a minimum width and scrolls horizontally. +
  • +
+
+ +
+

Relevant Diff Snippets

+

+ The snippets below were rendered with @pierre/diffs/ssr. They show the core UI error formatter, synthetic-admin failure handling, CSS wrapping guardrails, and regression coverage. +

+
apps/web/app/terminal.tsx
-2+16
506 unmodified lines
507
508
509
510
511
512
17 unmodified lines
530
531
532
533
534
535
536
537
506 unmodified lines
return sampled;
};
+
const readErrorDetail = async (response: Response): Promise<string> => {
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
const text = await response.text();
17 unmodified lines
error?: string;
message?: string;
};
return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`;
} catch {
return `${statusLabel}: ${truncated}`;
}
};
506 unmodified lines
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
17 unmodified lines
544
545
546
547
548
549
550
551
506 unmodified lines
return sampled;
};
+
export const formatUiErrorMessage = (message: unknown, fallback = "Request failed"): string => {
const raw =
message instanceof Error
? message.message
: typeof message === "string"
? message
: String(message ?? "");
const normalized = raw.replace(/\s+/g, " ").trim();
if (!normalized) {
return fallback;
}
return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized;
};
+
const readErrorDetail = async (response: Response): Promise<string> => {
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
const text = await response.text();
17 unmodified lines
error?: string;
message?: string;
};
return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`);
} catch {
return formatUiErrorMessage(`${statusLabel}: ${truncated}`);
}
};
+
apps/web/app/terminal.tsx
-2+4
8882 unmodified lines
8869
8870
8871
8872
8873
8874
42 unmodified lines
8917
8918
8919
8920
8921
8922
8923
8924
8882 unmodified lines
setLoading(false);
return;
}
const nextStatus = (await response.json()) as SyntheticAdminStatusResponse;
setStatus(nextStatus);
if (!dirtyRef.current) {
42 unmodified lines
})
.then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.detail ?? body?.error ?? "Synthetic control update failed");
}
return (await response.json()) as SyntheticAdminControlResponse;
})
8882 unmodified lines
8883
8884
8885
8886
8887
8888
8889
8890
8891
42 unmodified lines
8934
8935
8936
8937
8938
8939
8940
8882 unmodified lines
setLoading(false);
return;
}
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
const nextStatus = (await response.json()) as SyntheticAdminStatusResponse;
setStatus(nextStatus);
if (!dirtyRef.current) {
42 unmodified lines
})
.then(async (response) => {
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
return (await response.json()) as SyntheticAdminControlResponse;
})
+
apps/web/app/globals.css
+7
9 unmodified lines
10
11
12
13
14
15
1234 unmodified lines
1249
1250
1251
1252
1253
1254
9 unmodified lines
--text: oklch(0.93 0.014 250);
--text-dim: oklch(0.74 0.018 250);
--text-faint: oklch(0.59 0.016 250);
--accent: oklch(0.78 0.12 74);
--accent-soft: oklch(0.78 0.12 74 / 0.1);
--green: oklch(0.74 0.13 151);
1234 unmodified lines
color: var(--text-dim);
}
+
.chart-surface {
width: 100%;
height: 460px;
9 unmodified lines
10
11
12
13
14
15
16
1234 unmodified lines
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
9 unmodified lines
--text: oklch(0.93 0.014 250);
--text-dim: oklch(0.74 0.018 250);
--text-faint: oklch(0.59 0.016 250);
--text-muted: var(--text-dim);
--accent: oklch(0.78 0.12 74);
--accent-soft: oklch(0.78 0.12 74 / 0.1);
--green: oklch(0.74 0.13 151);
1234 unmodified lines
color: var(--text-dim);
}
+
.drawer-note,
.drawer-empty,
.note {
overflow-wrap: anywhere;
}
+
.chart-surface {
width: 100%;
height: 460px;
+
apps/web/app/terminal.test.ts
+19
80 unmodified lines
80
81
82
83
84
85
80 unmodified lines
});
});
+
const makeItem = (traceId: string, seq: number, ts: number) => ({
trace_id: traceId,
seq,
80 unmodified lines
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
80 unmodified lines
});
});
+
describe("terminal error message hardening", () => {
it("normalizes whitespace and clamps oversized messages before rendering", () => {
const longMessage = `API failed\n\n${"x".repeat(320)}`;
+
const formatted = formatUiErrorMessage(longMessage);
+
expect(formatted).toHaveLength(240);
expect(formatted).toStartWith("API failed x");
expect(formatted).toEndWith("...");
expect(formatted).not.toContain("\n");
});
+
it("uses a fallback when an error payload is empty", () => {
expect(formatUiErrorMessage(" ", "Synthetic status could not be loaded")).toBe(
"Synthetic status could not be loaded"
);
});
});
+
const makeItem = (traceId: string, seq: number, ts: number) => ({
trace_id: traceId,
seq,
+
+ +
+

Expected Impact for End-Users

+

+ Users should see shorter, more useful failure messages when API routes, charts, history loading, + or synthetic-admin controls fail. The terminal should remain readable even when an upstream + service returns a long stack trace, an HTML error page, or text with unusual spacing. +

+
+ +
+

Validation

+
    +
  • Passed: bun test apps/web/app/terminal.test.ts
  • +
  • Passed: bun --cwd=apps/web run build
  • +
  • + Passed: Playwright smoke loaded + http://localhost:3000/options at desktop and mobile widths; desktop scan found no + visible-overflow offenders. +
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • + This hardens UI rendering of failures. It does not change backend retry behavior or websocket + reconnection policy. +
  • +
  • + Mobile /options keeps a wider-than-viewport table by design. The mitigation is the + existing horizontal table scroll, which preserves data-column legibility. +
  • +
+
+ +
+

Follow-up Work

+
    +
  • + islandflow-3by: add browser or DOM coverage for the shared terminal navigation + drawer interactions. +
  • +
  • + Consider adding a small DOM-level test for synthetic-admin non-JSON failure rendering if the + project adds a React component test harness. +
  • +
+
+
+ + -- 2.49.1 From 96b179243c7f570e689490f8fdd8d938d9360183 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Fri, 29 May 2026 18:37:04 -0400 Subject: [PATCH 3/4] improve narrow options table behavior --- .beads/issues.jsonl | 1 + apps/web/app/globals.css | 118 +++++- ...26-05-29-improve-narrow-options-table.html | 359 ++++++++++++++++++ 3 files changed, 469 insertions(+), 9 deletions(-) create mode 100644 docs/turns/2026-05-29-improve-narrow-options-table.html diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fb7d590..8dda90e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,6 +24,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-833","title":"Improve narrow options table responsiveness","description":"Adapt the Options route for narrow screens so dense tape tables remain contained in their panes, preserve row identity while horizontally panning, and keep the mobile ticker/filter controls readable.","acceptance_criteria":"Options tape panes have bounded heights on narrow screens; table body scrolls internally; first table column remains visible while panning; mobile topbar and filter controls have adequate spacing; web production build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:34:05Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:36:20Z","started_at":"2026-05-29T22:34:24Z","closed_at":"2026-05-29T22:36:20Z","close_reason":"Implemented narrow-screen options pane containment, sticky row context, touch-scroll affordances, and mobile control spacing. Validated with web build and in-browser narrow viewport checks.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-aq9","title":"Harden terminal UI error and overflow states","description":"Harden the web terminal against oversized API errors, non-JSON synthetic admin failures, and long status text so live trading panes remain stable under bad network/backend responses.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:10:16Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:13:37Z","closed_at":"2026-05-29T22:13:37Z","close_reason":"Hardened terminal UI error rendering, synthetic admin failure parsing, long-message wrapping, and added focused tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-dk5","title":"Remove frontend cooker route","description":"Remove the experimental /frontend-cooker page and update repository references that still list it as an available public route.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T13:50:38Z","created_by":"dirtydishes","updated_at":"2026-05-29T13:53:05Z","started_at":"2026-05-29T13:50:48Z","closed_at":"2026-05-29T13:53:05Z","close_reason":"Removed the /frontend-cooker Next.js route, cleaned route/scanner references, documented the work, and validated the web build.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 092961a..1c1a5cc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1469,6 +1469,7 @@ h3 { } .data-table-wrap { + position: relative; display: flex; flex: 1 1 auto; min-height: 0; @@ -2489,6 +2490,10 @@ h3 { min-height: 0; } + .page-grid-options > .terminal-pane { + height: clamp(430px, 68svh, 720px); + } + .command-deck-grid { grid-template-columns: minmax(0, 1fr); grid-template-areas: @@ -2563,7 +2568,7 @@ h3 { } .terminal-content { - padding: 16px 10px 22px; + padding: 18px 10px calc(22px + env(safe-area-inset-bottom)); } .page-shell { @@ -2615,11 +2620,19 @@ h3 { position: sticky; top: 0; z-index: 30; - padding: 12px 10px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + column-gap: 10px; + row-gap: 16px; + padding: 10px 10px 12px; } .terminal-topbar-leading { - width: 100%; + width: auto; + min-width: 0; + grid-column: 1; + grid-row: 1; } .terminal-button, @@ -2638,30 +2651,50 @@ h3 { .terminal-topbar-actions, .terminal-topbar-controls, .terminal-topbar-mode { - width: 100%; + min-width: 0; justify-content: flex-start; } - .terminal-topbar-actions, + .terminal-topbar-actions { + display: contents; + } + .terminal-topbar-controls { - flex-direction: column; - align-items: stretch; + width: 100%; + grid-column: 1 / -1; + grid-row: 2; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; + } + + .terminal-topbar-mode { + grid-column: 2; + grid-row: 1; + width: auto; + justify-content: flex-end; } .terminal-menu-trigger { - width: 100%; + width: auto; justify-content: center; } .terminal-topbar-mode .terminal-button, - .terminal-topbar-controls > .terminal-button, .terminal-topbar-leading > .terminal-button, .page-actions > .terminal-button, .page-actions > .flow-filter-popover { width: 100%; } + .terminal-topbar-controls > .terminal-button { + width: auto; + min-width: 76px; + } + .instrument-focus-chip { + grid-column: 1 / -1; max-width: none; min-height: 44px; justify-content: space-between; @@ -2685,6 +2718,10 @@ h3 { border-radius: 12px; } + .page-grid-options > .terminal-pane { + height: clamp(390px, 62svh, 620px); + } + .terminal-pane-head, .terminal-pane-body { padding: 14px 12px; @@ -2716,6 +2753,7 @@ h3 { width: 100%; flex-direction: column; align-items: stretch; + margin-top: 2px; } .flow-filter-popover { @@ -2753,6 +2791,19 @@ h3 { margin-inline: -12px; border-radius: 0; scroll-snap-type: x proximity; + scrollbar-gutter: stable; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + } + + .data-table-wrap::after { + content: ""; + position: sticky; + right: 0; + z-index: 5; + flex: 0 0 18px; + pointer-events: none; + background: linear-gradient(90deg, transparent, oklch(0.12 0.01 250 / 0.92)); } .data-table { @@ -2770,6 +2821,39 @@ h3 { padding-inline: 8px; } + .data-table-head .data-table-cell:first-child, + .data-table-row .data-table-cell:first-child { + position: sticky; + left: 0; + z-index: 4; + margin-left: -8px; + padding-left: 8px; + background: oklch(0.13 0.01 250); + box-shadow: + 1px 0 0 oklch(0.72 0.012 250 / 0.14), + 14px 0 18px oklch(0.06 0.01 250 / 0.42); + } + + .data-table-head .data-table-cell:first-child { + z-index: 6; + background: oklch(0.15 0.012 250); + } + + .data-table-row.is-even .data-table-cell:first-child { + background: oklch(0.145 0.011 250); + } + + .data-table-row:hover .data-table-cell:first-child, + .data-table-row:focus-visible .data-table-cell:first-child { + background: oklch(0.18 0.025 74); + } + + .data-table-row-classified .data-table-cell:first-child { + background: + linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.04 + var(--classifier-intensity, 0) * 0.1)), transparent 90%), + oklch(0.13 0.01 250); + } + .data-table-row-options, .data-table-row-equities { height: 40px; @@ -2832,6 +2916,22 @@ h3 { } @media (max-width: 420px) { + .terminal-topbar { + column-gap: 8px; + row-gap: 14px; + padding-inline: 8px; + } + + .terminal-menu-trigger { + min-width: 92px; + padding-inline: 8px; + } + + .terminal-topbar-mode .terminal-button { + min-width: 82px; + padding-inline: 8px; + } + .terminal-content { padding-inline: 8px; } diff --git a/docs/turns/2026-05-29-improve-narrow-options-table.html b/docs/turns/2026-05-29-improve-narrow-options-table.html new file mode 100644 index 0000000..c4119bf --- /dev/null +++ b/docs/turns/2026-05-29-improve-narrow-options-table.html @@ -0,0 +1,359 @@ + + + + + + Improve Narrow Options Table Responsiveness + + + +
+
+

Improve Narrow Options Table Responsiveness

+

+ Adapted the Options route for narrow screens so dense tape tables stay inside bounded panes, + keep row identity visible while panning horizontally, and give the mobile ticker/filter controls + more room to breathe. +

+
+ Completed: 2026-05-29 18:34 EDT + Issue: islandflow-833 + Surface: apps/web Options route +
+
+ +
+

Summary

+

+ The Options tape now behaves like a contained terminal pane on phone-sized screens instead of + stretching down the page with the full virtual table height. The first table column remains pinned + during horizontal panning, and the mobile topbar spacing is less compressed around the ticker field. +

+
+ +
+

Changes Made

+
    +
  • Added bounded viewport-based heights for Options route panes at tablet and phone breakpoints.
  • +
  • Kept the first table column sticky on narrow screens so row context remains visible while panning.
  • +
  • Added a subtle right-edge affordance and touch scrolling refinements for horizontally wide tables.
  • +
  • Improved mobile topbar and page-action spacing around the Menu, Ticker, Contract Filter, and Filter controls.
  • +
+
+ +
+

Context

+

+ The Options route contains high-density virtualized market data. On desktop, panes have bounded heights + and table bodies scroll internally. At narrow breakpoints, the previous CSS changed those panes to + automatic height, which made the table read as an endless page rather than an isolated tape viewport. +

+
+ +
+

Important Implementation Details

+
    +
  • The fix stays CSS-only and preserves the existing virtualized row markup and row-height assumptions.
  • +
  • The pane heights use svh so mobile browser chrome is handled better than with classic viewport units.
  • +
  • The sticky first column is limited to the narrow-screen breakpoint where horizontal panning is expected.
  • +
  • No backend URL or private environment configuration was added to committed source.
  • +
+
+ +
+

Relevant Diff Snippets

+

+ Rendered with @pierre/diffs/ssr using preloadPatchDiff against the real + apps/web/app/globals.css patch. The SSR output is embedded directly below. +

+
+
apps/web/app/globals.css
-9+109
1469
1470
1471
1472
1473
1474
2489
2490
2491
2492
2493
2494
2563
2564
2565
2566
2567
2568
2569
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2685
2686
2687
2688
2689
2690
2716
2717
2718
2719
2720
2721
2753
2754
2755
2756
2757
2758
2770
2771
2772
2773
2774
2775
2832
2833
2834
2835
2836
2837
}
+
.data-table-wrap {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-height: 0;
}
+
.command-deck-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
}
+
.terminal-content {
padding: 16px 10px 22px;
}
+
.page-shell {
position: sticky;
top: 0;
z-index: 30;
padding: 12px 10px;
}
+
.terminal-topbar-leading {
width: 100%;
}
+
.terminal-button,
.terminal-topbar-actions,
.terminal-topbar-controls,
.terminal-topbar-mode {
width: 100%;
justify-content: flex-start;
}
+
.terminal-topbar-actions,
.terminal-topbar-controls {
flex-direction: column;
align-items: stretch;
}
+
.terminal-menu-trigger {
width: 100%;
justify-content: center;
}
+
.terminal-topbar-mode .terminal-button,
.terminal-topbar-controls > .terminal-button,
.terminal-topbar-leading > .terminal-button,
.page-actions > .terminal-button,
.page-actions > .flow-filter-popover {
width: 100%;
}
+
.instrument-focus-chip {
max-width: none;
min-height: 44px;
justify-content: space-between;
border-radius: 12px;
}
+
.terminal-pane-head,
.terminal-pane-body {
padding: 14px 12px;
width: 100%;
flex-direction: column;
align-items: stretch;
}
+
.flow-filter-popover {
margin-inline: -12px;
border-radius: 0;
scroll-snap-type: x proximity;
}
+
.data-table {
padding-inline: 8px;
}
+
.data-table-row-options,
.data-table-row-equities {
height: 40px;
}
+
@media (max-width: 420px) {
.terminal-content {
padding-inline: 8px;
}
1469
1470
1471
1472
1473
1474
1475
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2568
2569
2570
2571
2572
2573
2574
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2753
2754
2755
2756
2757
2758
2759
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
}
+
.data-table-wrap {
position: relative;
display: flex;
flex: 1 1 auto;
min-height: 0;
min-height: 0;
}
+
.page-grid-options > .terminal-pane {
height: clamp(430px, 68svh, 720px);
}
+
.command-deck-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
}
+
.terminal-content {
padding: 18px 10px calc(22px + env(safe-area-inset-bottom));
}
+
.page-shell {
position: sticky;
top: 0;
z-index: 30;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
column-gap: 10px;
row-gap: 16px;
padding: 10px 10px 12px;
}
+
.terminal-topbar-leading {
width: auto;
min-width: 0;
grid-column: 1;
grid-row: 1;
}
+
.terminal-button,
.terminal-topbar-actions,
.terminal-topbar-controls,
.terminal-topbar-mode {
min-width: 0;
justify-content: flex-start;
}
+
.terminal-topbar-actions {
display: contents;
}
+
.terminal-topbar-controls {
width: 100%;
grid-column: 1 / -1;
grid-row: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 10px;
}
+
.terminal-topbar-mode {
grid-column: 2;
grid-row: 1;
width: auto;
justify-content: flex-end;
}
+
.terminal-menu-trigger {
width: auto;
justify-content: center;
}
+
.terminal-topbar-mode .terminal-button,
.terminal-topbar-leading > .terminal-button,
.page-actions > .terminal-button,
.page-actions > .flow-filter-popover {
width: 100%;
}
+
.terminal-topbar-controls > .terminal-button {
width: auto;
min-width: 76px;
}
+
.instrument-focus-chip {
grid-column: 1 / -1;
max-width: none;
min-height: 44px;
justify-content: space-between;
border-radius: 12px;
}
+
.page-grid-options > .terminal-pane {
height: clamp(390px, 62svh, 620px);
}
+
.terminal-pane-head,
.terminal-pane-body {
padding: 14px 12px;
width: 100%;
flex-direction: column;
align-items: stretch;
margin-top: 2px;
}
+
.flow-filter-popover {
margin-inline: -12px;
border-radius: 0;
scroll-snap-type: x proximity;
scrollbar-gutter: stable;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
+
.data-table-wrap::after {
content: "";
position: sticky;
right: 0;
z-index: 5;
flex: 0 0 18px;
pointer-events: none;
background: linear-gradient(90deg, transparent, oklch(0.12 0.01 250 / 0.92));
}
+
.data-table {
padding-inline: 8px;
}
+
.data-table-head .data-table-cell:first-child,
.data-table-row .data-table-cell:first-child {
position: sticky;
left: 0;
z-index: 4;
margin-left: -8px;
padding-left: 8px;
background: oklch(0.13 0.01 250);
box-shadow:
1px 0 0 oklch(0.72 0.012 250 / 0.14),
14px 0 18px oklch(0.06 0.01 250 / 0.42);
}
+
.data-table-head .data-table-cell:first-child {
z-index: 6;
background: oklch(0.15 0.012 250);
}
+
.data-table-row.is-even .data-table-cell:first-child {
background: oklch(0.145 0.011 250);
}
+
.data-table-row:hover .data-table-cell:first-child,
.data-table-row:focus-visible .data-table-cell:first-child {
background: oklch(0.18 0.025 74);
}
+
.data-table-row-classified .data-table-cell:first-child {
background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.04 + var(--classifier-intensity, 0) * 0.1)), transparent 90%),
oklch(0.13 0.01 250);
}
+
.data-table-row-options,
.data-table-row-equities {
height: 40px;
}
+
@media (max-width: 420px) {
.terminal-topbar {
column-gap: 8px;
row-gap: 14px;
padding-inline: 8px;
}
+
.terminal-menu-trigger {
min-width: 92px;
padding-inline: 8px;
}
+
.terminal-topbar-mode .terminal-button {
min-width: 82px;
padding-inline: 8px;
}
+
.terminal-content {
padding-inline: 8px;
}
+
+
+ +
+

Expected Impact for End-Users

+

+ On phones and narrow browser windows, traders can scan the Options tape inside a stable pane instead of + losing the rest of the page to an unbounded table. Horizontal panning now keeps time context visible, + and the top controls are easier to distinguish and tap. +

+
+ +
+

Validation

+
    +
  • Passed: bun --cwd=apps/web run build.
  • +
  • Passed: Browser check at 390px wide on http://localhost:3000/options.
  • +
  • Measured a contained pane height of 523px and internal table viewport of 317px with a larger internal scroll height.
  • +
  • Confirmed no page-level horizontal overflow and confirmed live tape status showed Connected.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • The narrow-screen table is still a dense data table, not a separate card/list representation.
  • +
  • The sticky first column mitigates lost context without changing virtualization behavior or row heights.
  • +
  • The page remains vertically scrollable between stacked panes, but each tape now owns its internal scroll.
  • +
+
+ +
+

Follow-up Work

+

+ No required follow-up is left for islandflow-833. A future enhancement could add a dedicated compact row + layout for phone screens if the product wants less horizontal panning than the current dense terminal table. +

+
+
+ + -- 2.49.1 From c57feee976a8d7bfb465ebd04f22e1ec22458a3c Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Fri, 29 May 2026 19:10:08 -0400 Subject: [PATCH 4/4] harden drawer dialog focus behavior --- .beads/issues.jsonl | 1 + apps/web/app/terminal.tsx | 207 ++++++++-- ...2026-05-29-harden-drawer-dialog-focus.html | 360 ++++++++++++++++++ 3 files changed, 534 insertions(+), 34 deletions(-) create mode 100644 docs/turns/2026-05-29-harden-drawer-dialog-focus.html diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8dda90e..7552b8d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,6 +24,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-wtg","title":"Harden drawer dialog focus behavior","description":"Fix terminal drawers so they expose modal dialog semantics, trap keyboard focus while open, and restore focus to the invoking control after close.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:55:25Z","created_by":"dirtydishes","updated_at":"2026-05-29T23:09:45Z","started_at":"2026-05-29T22:56:22Z","closed_at":"2026-05-29T23:09:45Z","close_reason":"Implemented modal dialog semantics, focus trapping, Escape dismissal, focus restoration, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-833","title":"Improve narrow options table responsiveness","description":"Adapt the Options route for narrow screens so dense tape tables remain contained in their panes, preserve row identity while horizontally panning, and keep the mobile ticker/filter controls readable.","acceptance_criteria":"Options tape panes have bounded heights on narrow screens; table body scrolls internally; first table column remains visible while panning; mobile topbar and filter controls have adequate spacing; web production build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:34:05Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:36:20Z","started_at":"2026-05-29T22:34:24Z","closed_at":"2026-05-29T22:36:20Z","close_reason":"Implemented narrow-screen options pane containment, sticky row context, touch-scroll affordances, and mobile control spacing. Validated with web build and in-browser narrow viewport checks.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-aq9","title":"Harden terminal UI error and overflow states","description":"Harden the web terminal against oversized API errors, non-JSON synthetic admin failures, and long status text so live trading panes remain stable under bad network/backend responses.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:10:16Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:13:37Z","closed_at":"2026-05-29T22:13:37Z","close_reason":"Hardened terminal UI error rendering, synthetic admin failure parsing, long-message wrapping, and added focused tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 10dfd0b..694a353 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -16,6 +16,7 @@ import { type CSSProperties, type Dispatch, type MouseEvent as ReactMouseEvent, + type RefObject, type ReactNode, type SetStateAction } from "react"; @@ -391,6 +392,115 @@ const EMPTY_SMART_MONEY_EVENTS: SmartMoneyEvent[] = []; const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = []; const EMPTY_NEWS_STORIES: NewsStory[] = []; +const TABBABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled]):not([type='hidden'])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])" +].join(","); + +export const isElementTabbable = (element: HTMLElement): boolean => { + if (element.hasAttribute("disabled") || element.getAttribute("aria-hidden") === "true") { + return false; + } + + const tabIndex = element.getAttribute("tabindex"); + if (tabIndex && Number(tabIndex) < 0) { + return false; + } + + return Boolean(element.offsetParent || element.getClientRects().length > 0); +}; + +export const getTabbableElements = (root: HTMLElement): HTMLElement[] => { + return Array.from(root.querySelectorAll(TABBABLE_SELECTOR)).filter(isElementTabbable); +}; + +const useModalFocusTrap = ( + active: boolean, + rootRef: RefObject, + onClose: () => void, + restoreFocusRef?: RefObject +) => { + const fallbackFocusRef = useRef(null); + + useLayoutEffect(() => { + if (!active) { + return; + } + + fallbackFocusRef.current = + restoreFocusRef?.current ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null); + const root = rootRef.current; + if (!root) { + return; + } + + const focusTarget = getTabbableElements(root)[0] ?? root; + focusTarget.focus({ preventScroll: true }); + + return () => { + const restoreTarget = restoreFocusRef?.current ?? fallbackFocusRef.current; + if (restoreTarget?.isConnected) { + restoreTarget.focus({ preventScroll: true }); + } + fallbackFocusRef.current = null; + }; + }, [active, restoreFocusRef, rootRef]); + + useEffect(() => { + if (!active) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + const root = rootRef.current; + if (!root) { + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const tabbable = getTabbableElements(root); + if (tabbable.length === 0) { + event.preventDefault(); + root.focus({ preventScroll: true }); + return; + } + + const first = tabbable[0]; + const last = tabbable[tabbable.length - 1]; + const activeElement = document.activeElement; + + if (event.shiftKey && activeElement === first) { + event.preventDefault(); + last.focus({ preventScroll: true }); + } else if (!event.shiftKey && activeElement === last) { + event.preventDefault(); + first.focus({ preventScroll: true }); + } else if (!root.contains(activeElement)) { + event.preventDefault(); + first.focus({ preventScroll: true }); + } + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [active, onClose, rootRef]); +}; + type CandlestickSeries = ReturnType; type EquityOverlayPoint = { @@ -4894,6 +5004,8 @@ const formatOptionalMs = (value: unknown): string | null => { }; const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => { + const drawerRef = useRef(null); + const titleId = useId(); const primary = alert.hits[0]; const direction = deriveAlertDirection(alert); const severity = normalizeAlertSeverity(alert); @@ -4901,13 +5013,14 @@ const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: Al const unknownCount = evidence.filter((item) => item.kind === "unknown").length; const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading; const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : []; + useModalFocusTrap(true, drawerRef, onClose); return ( -