harden terminal ui states and drawer focus handling #16

Open
dirtydishes wants to merge 4 commits from lavender/all-quiet into main
8 changed files with 2139 additions and 125 deletions

View file

@ -24,6 +24,10 @@
{"_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-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-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-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}
{"_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-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-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} {"_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}

View file

@ -10,6 +10,7 @@
--text: oklch(0.93 0.014 250); --text: oklch(0.93 0.014 250);
--text-dim: oklch(0.74 0.018 250); --text-dim: oklch(0.74 0.018 250);
--text-faint: oklch(0.59 0.016 250); --text-faint: oklch(0.59 0.016 250);
--text-muted: var(--text-dim);
--accent: oklch(0.78 0.12 74); --accent: oklch(0.78 0.12 74);
--accent-soft: oklch(0.78 0.12 74 / 0.1); --accent-soft: oklch(0.78 0.12 74 / 0.1);
--green: oklch(0.74 0.13 151); --green: oklch(0.74 0.13 151);
@ -661,6 +662,7 @@ h3 {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.35; line-height: 1.35;
overflow-wrap: anywhere;
} }
.flow-filter-checkbox-grid, .flow-filter-checkbox-grid,
@ -1249,6 +1251,12 @@ h3 {
color: var(--text-dim); color: var(--text-dim);
} }
.drawer-note,
.drawer-empty,
.note {
overflow-wrap: anywhere;
}
.chart-surface { .chart-surface {
width: 100%; width: 100%;
height: 460px; height: 460px;
@ -1457,9 +1465,11 @@ h3 {
color: oklch(0.91 0.08 72); color: oklch(0.91 0.08 72);
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.35; line-height: 1.35;
overflow-wrap: anywhere;
} }
.data-table-wrap { .data-table-wrap {
position: relative;
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@ -1675,6 +1685,7 @@ h3 {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 0.72rem; font-size: 0.72rem;
unicode-bidi: plaintext;
} }
.data-table-cell-number { .data-table-cell-number {
@ -2010,11 +2021,16 @@ h3 {
} }
.empty { .empty {
display: flex;
align-items: center;
min-height: 76px;
padding: 18px; padding: 18px;
border-radius: 12px; border-radius: 12px;
border: 1px dashed var(--border); border: 1px dashed var(--border);
background: var(--bg-soft); background: var(--bg-soft);
color: var(--text-dim); color: var(--text-dim);
line-height: 1.4;
overflow-wrap: anywhere;
} }
.drawer { .drawer {
@ -2285,6 +2301,7 @@ h3 {
.synthetic-control-error { .synthetic-control-error {
color: var(--red); color: var(--red);
overflow-wrap: anywhere;
} }
.drawer-header { .drawer-header {
@ -2473,6 +2490,10 @@ h3 {
min-height: 0; min-height: 0;
} }
.page-grid-options > .terminal-pane {
height: clamp(430px, 68svh, 720px);
}
.command-deck-grid { .command-deck-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-areas: grid-template-areas:
@ -2547,7 +2568,7 @@ h3 {
} }
.terminal-content { .terminal-content {
padding: 16px 10px 22px; padding: 18px 10px calc(22px + env(safe-area-inset-bottom));
} }
.page-shell { .page-shell {
@ -2599,11 +2620,19 @@ h3 {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 30; 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 { .terminal-topbar-leading {
width: 100%; width: auto;
min-width: 0;
grid-column: 1;
grid-row: 1;
} }
.terminal-button, .terminal-button,
@ -2622,30 +2651,50 @@ h3 {
.terminal-topbar-actions, .terminal-topbar-actions,
.terminal-topbar-controls, .terminal-topbar-controls,
.terminal-topbar-mode { .terminal-topbar-mode {
width: 100%; min-width: 0;
justify-content: flex-start; justify-content: flex-start;
} }
.terminal-topbar-actions, .terminal-topbar-actions {
display: contents;
}
.terminal-topbar-controls { .terminal-topbar-controls {
flex-direction: column; width: 100%;
align-items: stretch; 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 { .terminal-menu-trigger {
width: 100%; width: auto;
justify-content: center; justify-content: center;
} }
.terminal-topbar-mode .terminal-button, .terminal-topbar-mode .terminal-button,
.terminal-topbar-controls > .terminal-button,
.terminal-topbar-leading > .terminal-button, .terminal-topbar-leading > .terminal-button,
.page-actions > .terminal-button, .page-actions > .terminal-button,
.page-actions > .flow-filter-popover { .page-actions > .flow-filter-popover {
width: 100%; width: 100%;
} }
.terminal-topbar-controls > .terminal-button {
width: auto;
min-width: 76px;
}
.instrument-focus-chip { .instrument-focus-chip {
grid-column: 1 / -1;
max-width: none; max-width: none;
min-height: 44px; min-height: 44px;
justify-content: space-between; justify-content: space-between;
@ -2669,6 +2718,10 @@ h3 {
border-radius: 12px; border-radius: 12px;
} }
.page-grid-options > .terminal-pane {
height: clamp(390px, 62svh, 620px);
}
.terminal-pane-head, .terminal-pane-head,
.terminal-pane-body { .terminal-pane-body {
padding: 14px 12px; padding: 14px 12px;
@ -2700,6 +2753,7 @@ h3 {
width: 100%; width: 100%;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
margin-top: 2px;
} }
.flow-filter-popover { .flow-filter-popover {
@ -2737,6 +2791,19 @@ h3 {
margin-inline: -12px; margin-inline: -12px;
border-radius: 0; border-radius: 0;
scroll-snap-type: x proximity; 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 { .data-table {
@ -2754,6 +2821,39 @@ h3 {
padding-inline: 8px; 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-options,
.data-table-row-equities { .data-table-row-equities {
height: 40px; height: 40px;
@ -2816,6 +2916,22 @@ h3 {
} }
@media (max-width: 420px) { @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 { .terminal-content {
padding-inline: 8px; padding-inline: 8px;
} }

View file

@ -4,6 +4,7 @@ import {
NAV_ITEMS, NAV_ITEMS,
appendHistoryTail, appendHistoryTail,
buildAlertContextPath, buildAlertContextPath,
buildTapeStatusAnnouncement,
buildDefaultFlowFilters, buildDefaultFlowFilters,
buildOptionTapeQueryParams, buildOptionTapeQueryParams,
classifierToneForFamily, classifierToneForFamily,
@ -13,6 +14,7 @@ import {
countActiveFlowFilterGroups, countActiveFlowFilterGroups,
filterOptionTapeItems, filterOptionTapeItems,
findAnchorRestoreIndex, findAnchorRestoreIndex,
formatUiErrorMessage,
formatCompactUsd, formatCompactUsd,
formatOptionContractLabel, formatOptionContractLabel,
flushPausableTapeData, flushPausableTapeData,
@ -51,6 +53,53 @@ import {
toggleFilterValue toggleFilterValue
} from "./terminal"; } 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");
});
});
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) => ({ const makeItem = (traceId: string, seq: number, ts: number) => ({
trace_id: traceId, trace_id: traceId,
seq, seq,

View file

@ -16,6 +16,7 @@ import {
type CSSProperties, type CSSProperties,
type Dispatch, type Dispatch,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
type RefObject,
type ReactNode, type ReactNode,
type SetStateAction type SetStateAction
} from "react"; } from "react";
@ -391,6 +392,115 @@ const EMPTY_SMART_MONEY_EVENTS: SmartMoneyEvent[] = [];
const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = []; const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];
const EMPTY_NEWS_STORIES: NewsStory[] = []; 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<HTMLElement>(TABBABLE_SELECTOR)).filter(isElementTabbable);
};
const useModalFocusTrap = (
active: boolean,
rootRef: RefObject<HTMLElement | null>,
onClose: () => void,
restoreFocusRef?: RefObject<HTMLElement | null>
) => {
const fallbackFocusRef = useRef<HTMLElement | null>(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<IChartApi["addCandlestickSeries"]>; type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;
type EquityOverlayPoint = { type EquityOverlayPoint = {
@ -507,6 +617,20 @@ const sampleToLimit = <T,>(items: T[], limit: number): T[] => {
return sampled; 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 readErrorDetail = async (response: Response): Promise<string> => {
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
const text = await response.text(); const text = await response.text();
@ -530,9 +654,9 @@ const readErrorDetail = async (response: Response): Promise<string> => {
error?: string; error?: string;
message?: string; message?: string;
}; };
return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`; return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`);
} catch { } catch {
return `${statusLabel}: ${truncated}`; return formatUiErrorMessage(`${statusLabel}: ${truncated}`);
} }
}; };
@ -2145,6 +2269,59 @@ export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode):
} }
}; };
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}`];
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 (
<span className={classes} role="cell" title={title}>
{children}
</span>
);
};
const EmptyState = ({ children }: { children: ReactNode }) => (
<div className="empty" role="status" aria-live="polite">
{children}
</div>
);
type TapeConfig<T> = { type TapeConfig<T> = {
mode: TapeMode; mode: TapeMode;
wsPath: string; wsPath: string;
@ -3893,17 +4070,33 @@ const TapeStatus = ({
}: TapeStatusProps) => { }: TapeStatusProps) => {
const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : ""; const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : "";
const announcement = buildTapeStatusAnnouncement({
status,
replayTime,
replayComplete,
paused,
dropped,
mode
});
return ( return (
<div className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}> <div
<span className="status-dot" /> 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" />
<span className="status-inline-label">{label}</span> <span className="status-inline-label">{label}</span>
{mode === "replay" ? ( {mode === "replay" ? (
<span className="status-inline-meta"> <span className="status-inline-meta">
Replay time {replayTime ? formatTime(replayTime) : "—"} Replay time {replayTime ? formatTime(replayTime) : "—"}
</span> </span>
) : null} ) : null}
<span className={`status-inline-counter${pausedLabel ? " status-inline-counter-visible" : ""}`}> <span
className={`status-inline-counter${pausedLabel ? " status-inline-counter-visible" : ""}`}
aria-hidden={!pausedLabel}
>
{pausedLabel || "+000 queued"} {pausedLabel || "+000 queued"}
</span> </span>
</div> </div>
@ -4410,7 +4603,7 @@ const CandleChart = ({
if (!active) { if (!active) {
return; return;
} }
setError(error instanceof Error ? error.message : String(error)); setError(formatUiErrorMessage(error));
setStatus("disconnected"); setStatus("disconnected");
setHasData(false); setHasData(false);
} }
@ -4811,6 +5004,8 @@ const formatOptionalMs = (value: unknown): string | null => {
}; };
const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => { const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const primary = alert.hits[0]; const primary = alert.hits[0];
const direction = deriveAlertDirection(alert); const direction = deriveAlertDirection(alert);
const severity = normalizeAlertSeverity(alert); const severity = normalizeAlertSeverity(alert);
@ -4818,13 +5013,14 @@ const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: Al
const unknownCount = evidence.filter((item) => item.kind === "unknown").length; const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading; const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;
const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : []; const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];
useModalFocusTrap(true, drawerRef, onClose);
return ( return (
<aside className="drawer"> <aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header"> <div className="drawer-header">
<div> <div>
<p className="drawer-eyebrow">Alert details</p> <p className="drawer-eyebrow">Alert details</p>
<h3>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3> <h3 id={titleId}>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3>
<p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p> <p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p>
</div> </div>
<button className="drawer-close" type="button" onClick={onClose}> <button className="drawer-close" type="button" onClick={onClose}>
@ -4969,14 +5165,17 @@ type NewsDrawerProps = {
}; };
const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => { const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const body = sanitizeNewsHtml(story.content_html); const body = sanitizeNewsHtml(story.content_html);
useModalFocusTrap(true, drawerRef, onClose);
return ( return (
<aside className="drawer"> <aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header"> <div className="drawer-header">
<div> <div>
<p className="drawer-eyebrow">News wire</p> <p className="drawer-eyebrow">News wire</p>
<h3>{story.headline}</h3> <h3 id={titleId}>{story.headline}</h3>
<p className="drawer-subtitle"> <p className="drawer-subtitle">
{story.source} · Published {formatDateTime(story.published_ts)} {story.source} · Published {formatDateTime(story.published_ts)}
{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""} {story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}
@ -5034,16 +5233,19 @@ type ClassifierHitDrawerProps = {
}; };
const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => { const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const direction = normalizeDirection(hit.direction); const direction = normalizeDirection(hit.direction);
const evidencePrints = evidence.filter((item) => item.kind === "print"); const evidencePrints = evidence.filter((item) => item.kind === "print");
const unknownCount = evidence.filter((item) => item.kind === "unknown").length; const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
useModalFocusTrap(true, drawerRef, onClose);
return ( return (
<aside className="drawer"> <aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header"> <div className="drawer-header">
<div> <div>
<p className="drawer-eyebrow">Classifier hit</p> <p className="drawer-eyebrow">Classifier hit</p>
<h3>{humanizeClassifierId(hit.classifier_id)}</h3> <h3 id={titleId}>{humanizeClassifierId(hit.classifier_id)}</h3>
<p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p> <p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p>
</div> </div>
<button className="drawer-close" type="button" onClick={onClose}> <button className="drawer-close" type="button" onClick={onClose}>
@ -5142,19 +5344,22 @@ type SmartMoneyDrawerProps = {
}; };
const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => { const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const primaryScore = const primaryScore =
event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ?? event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??
event.profile_scores[0]; event.profile_scores[0];
const direction = normalizeDirection(event.primary_direction); const direction = normalizeDirection(event.primary_direction);
const evidencePrints = evidence.filter((item) => item.kind === "print"); const evidencePrints = evidence.filter((item) => item.kind === "print");
const unknownCount = evidence.filter((item) => item.kind === "unknown").length; const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
useModalFocusTrap(true, drawerRef, onClose);
return ( return (
<aside className="drawer"> <aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header"> <div className="drawer-header">
<div> <div>
<p className="drawer-eyebrow">Smart money profile</p> <p className="drawer-eyebrow">Smart money profile</p>
<h3>{smartMoneyProfileLabel(event.primary_profile_id)}</h3> <h3 id={titleId}>{smartMoneyProfileLabel(event.primary_profile_id)}</h3>
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p> <p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
</div> </div>
<button className="drawer-close" type="button" onClick={onClose}> <button className="drawer-close" type="button" onClick={onClose}>
@ -5245,19 +5450,22 @@ type DarkDrawerProps = {
}; };
const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => { const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const joinEvidence = evidence.filter( const joinEvidence = evidence.filter(
(item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join" (item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join"
); );
const unknownCount = evidence.filter((item) => item.kind === "unknown").length; const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
const traceRefs = event.evidence_refs.slice(0, 6); const traceRefs = event.evidence_refs.slice(0, 6);
const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length); const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);
useModalFocusTrap(true, drawerRef, onClose);
return ( return (
<aside className="drawer"> <aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header"> <div className="drawer-header">
<div> <div>
<p className="drawer-eyebrow">Inferred dark</p> <p className="drawer-eyebrow">Inferred dark</p>
<h3>{humanizeClassifierId(event.type)}</h3> <h3 id={titleId}>{humanizeClassifierId(event.type)}</h3>
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p> <p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
</div> </div>
<button className="drawer-close" type="button" onClick={onClose}> <button className="drawer-close" type="button" onClick={onClose}>
@ -7527,12 +7735,12 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
> >
<div className="data-table-shell"> <div className="data-table-shell">
{state.mode === "live" && optionHistoryError ? ( {state.mode === "live" && optionHistoryError ? (
<div className="history-load-warning" role="status"> <div className="history-load-warning" role="status" aria-live="polite">
Older option history failed to load: {optionHistoryError} Older option history failed to load: {formatUiErrorMessage(optionHistoryError)}
</div> </div>
) : null} ) : null}
{items.length === 0 ? ( {items.length === 0 ? (
<div className="empty"> <EmptyState>
{state.mode === "live" {state.mode === "live"
? state.options.status === "stale" ? state.options.status === "stale"
? "Feed behind. Waiting for fresh option prints." ? "Feed behind. Waiting for fresh option prints."
@ -7544,29 +7752,23 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
: state.tickerSet.size > 0 : state.tickerSet.size > 0
? "No option prints match the current filter." ? "No option prints match the current filter."
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </EmptyState>
) : ( ) : (
<div className="data-table-wrap"> <div className="data-table-wrap">
<div className="data-table data-table-options" role="table" aria-label="Options tape"> <div className="data-table data-table-options" role="table" aria-label="Options tape">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> {["TIME", "SYM", "EXP", "STRIKE", "C/P", "SPOT", "DETAILS", "TYPE", "VALUE", "SIDE", "IV", "CLASSIFIER"].map((header) => (
<span className="data-table-cell">SYM</span> <span className="data-table-cell" role="columnheader" key={header}>
<span className="data-table-cell">EXP</span> {header}
<span className="data-table-cell">STRIKE</span> </span>
<span className="data-table-cell">C/P</span> ))}
<span className="data-table-cell">SPOT</span>
<span className="data-table-cell">DETAILS</span>
<span className="data-table-cell">TYPE</span>
<span className="data-table-cell">VALUE</span>
<span className="data-table-cell">SIDE</span>
<span className="data-table-cell">IV</span>
<span className="data-table-cell">CLASSIFIER</span>
</div> </div>
<div className="data-table-scroll" ref={state.optionsScroll.setListRef}> <div className="data-table-scroll" ref={state.optionsScroll.setListRef}>
<div <div
className="data-table-body" className="data-table-body"
style={{ height: `${virtual.totalSize}px` }} style={{ height: `${virtual.totalSize}px` }}
aria-hidden={virtual.virtualItems.length === 0} aria-hidden={virtual.virtualItems.length === 0}
role="rowgroup"
> >
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => { {virtual.virtualItems.map(({ item: print, key, index, start, size }) => {
const contractId = normalizeContractId(print.option_contract_id); const contractId = normalizeContractId(print.option_contract_id);
@ -7602,42 +7804,42 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
}; };
const cells = ( const cells = (
<> <>
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span> <DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
<span className="data-table-cell"> <DataCell title={contractId}>
<button className="instrument-cell-button" type="button" onClick={focusContract}> <button className="instrument-cell-button" type="button" onClick={focusContract}>
{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)} {contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}
</button> </button>
</span> </DataCell>
<span className="data-table-cell"> <DataCell title={contractDisplay?.expiration ?? parsed?.expiry ?? undefined}>
<button className="instrument-cell-button" type="button" onClick={focusContract}> <button className="instrument-cell-button" type="button" onClick={focusContract}>
{contractDisplay?.expiration ?? parsed?.expiry ?? "--"} {contractDisplay?.expiration ?? parsed?.expiry ?? "--"}
</button> </button>
</span> </DataCell>
<span className="data-table-cell data-table-cell-number"> <DataCell numeric title={contractDisplay?.strike ?? undefined}>
<button className="instrument-cell-button" type="button" onClick={focusContract}> <button className="instrument-cell-button" type="button" onClick={focusContract}>
{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"} {contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}
</button> </button>
</span> </DataCell>
<span className="data-table-cell"> <DataCell>
<button className="instrument-cell-button" type="button" onClick={focusContract}> <button className="instrument-cell-button" type="button" onClick={focusContract}>
{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"} {parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}
</button> </button>
</span> </DataCell>
<span className="data-table-cell data-table-cell-number">{typeof spot === "number" ? formatPrice(spot) : "--"}</span> <DataCell numeric>{typeof spot === "number" ? formatPrice(spot) : "--"}</DataCell>
<span className="data-table-cell data-table-cell-number"> <DataCell numeric title={`${formatSize(print.size)} at ${formatPrice(print.price)}, ${nbboSide ?? "unknown side"}`}>
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"} {formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
</span> </DataCell>
<span className="data-table-cell">{print.option_type ?? "--"}</span> <DataCell title={print.option_type ?? undefined}>{print.option_type ?? "--"}</DataCell>
<span className="data-table-cell data-table-cell-number notional-emphasis">${formatCompactUsd(notional)}</span> <DataCell numeric className="notional-emphasis" title={`$${formatUsd(notional)}`}>${formatCompactUsd(notional)}</DataCell>
<span className="data-table-cell"> <DataCell>
{nbboSide ? ( {nbboSide ? (
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span> <span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
) : ( ) : (
"--" "--"
)} )}
</span> </DataCell>
<span className="data-table-cell data-table-cell-number">{typeof iv === "number" ? formatPct(iv) : "--"}</span> <DataCell numeric>{typeof iv === "number" ? formatPct(iv) : "--"}</DataCell>
<span className="data-table-cell">{decor ? humanizeClassifierId(decor.family) : "--"}</span> <DataCell title={decor ? humanizeClassifierId(decor.family) : undefined}>{decor ? humanizeClassifierId(decor.family) : "--"}</DataCell>
</> </>
); );
@ -7721,7 +7923,7 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
> >
<div className="data-table-shell"> <div className="data-table-shell">
{items.length === 0 ? ( {items.length === 0 ? (
<div className="empty"> <EmptyState>
{state.mode === "live" {state.mode === "live"
? state.equities.status === "stale" ? state.equities.status === "stale"
? "Feed behind. Waiting for fresh equity prints." ? "Feed behind. Waiting for fresh equity prints."
@ -7735,23 +7937,23 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
: state.tickerSet.size > 0 : state.tickerSet.size > 0
? "No equity prints match the current filter." ? "No equity prints match the current filter."
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </EmptyState>
) : ( ) : (
<div className="data-table-wrap"> <div className="data-table-wrap">
<div className="data-table data-table-equities" role="table" aria-label="Equity prints"> <div className="data-table data-table-equities" role="table" aria-label="Equity prints">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> {["TIME", "SYM", "PRICE", "SIZE", "VENUE", "TAPE"].map((header) => (
<span className="data-table-cell">SYM</span> <span className="data-table-cell" role="columnheader" key={header}>
<span className="data-table-cell">PRICE</span> {header}
<span className="data-table-cell">SIZE</span> </span>
<span className="data-table-cell">VENUE</span> ))}
<span className="data-table-cell">TAPE</span>
</div> </div>
<div className="data-table-scroll" ref={state.equitiesScroll.setListRef}> <div className="data-table-scroll" ref={state.equitiesScroll.setListRef}>
<div className="data-table-body" style={{ height: `${virtual.totalSize}px` }}> <div className="data-table-body" role="rowgroup" style={{ height: `${virtual.totalSize}px` }}>
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => ( {virtual.virtualItems.map(({ item: print, key, index, start, size }) => (
<div <div
className={`data-table-row data-table-row-equities data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}`} className={`data-table-row data-table-row-equities data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}`}
role="row"
key={key} key={key}
data-index={index} data-index={index}
data-row-start={String(start)} data-row-start={String(start)}
@ -7759,8 +7961,8 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
data-tape-key={key} data-tape-key={key}
style={{ transform: `translateY(${start}px)` }} style={{ transform: `translateY(${start}px)` }}
> >
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span> <DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
<span className="data-table-cell"> <DataCell title={print.underlying_id}>
<button <button
className="instrument-cell-button" className="instrument-cell-button"
type="button" type="button"
@ -7768,11 +7970,11 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
> >
{print.underlying_id} {print.underlying_id}
</button> </button>
</span> </DataCell>
<span className="data-table-cell data-table-cell-number">${formatPrice(print.price)}</span> <DataCell numeric>${formatPrice(print.price)}</DataCell>
<span className="data-table-cell data-table-cell-number">{formatSize(print.size)}x</span> <DataCell numeric>{formatSize(print.size)}x</DataCell>
<span className="data-table-cell">{print.exchange}</span> <DataCell title={print.exchange}>{print.exchange}</DataCell>
<span className="data-table-cell">{print.offExchangeFlag ? "Off-Ex" : "Lit"}</span> <DataCell>{print.offExchangeFlag ? "Off-Ex" : "Lit"}</DataCell>
</div> </div>
))} ))}
</div> </div>
@ -7825,7 +8027,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
> >
<div className="data-table-shell"> <div className="data-table-shell">
{items.length === 0 ? ( {items.length === 0 ? (
<div className="empty"> <EmptyState>
{state.tickerSet.size > 0 {state.tickerSet.size > 0
? "No flow packets match the current filter." ? "No flow packets match the current filter."
: state.mode === "live" : state.mode === "live"
@ -7833,23 +8035,19 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
? "Feed behind. Waiting for fresh flow packets." ? "Feed behind. Waiting for fresh flow packets."
: "No flow packets yet. Start compute." : "No flow packets yet. Start compute."
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </EmptyState>
) : ( ) : (
<div className="data-table-wrap"> <div className="data-table-wrap">
<div className="data-table data-table-flow" role="table" aria-label="Flow packets"> <div className="data-table data-table-flow" role="table" aria-label="Flow packets">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> {["TIME", "CONTRACT", "PRINTS", "SIZE", "NOTIONAL", "WINDOW", "STRUCTURE", "NBBO", "QUALITY"].map((header) => (
<span className="data-table-cell">CONTRACT</span> <span className="data-table-cell" role="columnheader" key={header}>
<span className="data-table-cell">PRINTS</span> {header}
<span className="data-table-cell">SIZE</span> </span>
<span className="data-table-cell">NOTIONAL</span> ))}
<span className="data-table-cell">WINDOW</span>
<span className="data-table-cell">STRUCTURE</span>
<span className="data-table-cell">NBBO</span>
<span className="data-table-cell">QUALITY</span>
</div> </div>
<div className="data-table-scroll" ref={state.flowScroll.setListRef}> <div className="data-table-scroll" ref={state.flowScroll.setListRef}>
<div className="data-table-body" style={{ height: `${virtual.totalSize}px` }}> <div className="data-table-body" role="rowgroup" style={{ height: `${virtual.totalSize}px` }}>
{virtual.virtualItems.map(({ item: packet, key, index, start, size }) => { {virtual.virtualItems.map(({ item: packet, key, index, start, size }) => {
const features = packet.features ?? {}; const features = packet.features ?? {};
const contract = String(features.option_contract_id ?? packet.id ?? "unknown"); const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
@ -7904,6 +8102,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
return ( return (
<div <div
className={`data-table-row data-table-row-flow data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}${nbboStale || nbboMissing ? " data-table-row-warn" : ""}`} className={`data-table-row data-table-row-flow data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}${nbboStale || nbboMissing ? " data-table-row-warn" : ""}`}
role="row"
key={key} key={key}
data-index={index} data-index={index}
data-row-start={String(start)} data-row-start={String(start)}
@ -7911,15 +8110,15 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
data-tape-key={key} data-tape-key={key}
style={{ transform: `translateY(${start}px)` }} style={{ transform: `translateY(${start}px)` }}
> >
<span className="data-table-cell data-table-cell-number">{formatTime(startTs)} {formatTime(endTs)}</span> <DataCell numeric title={`${formatDateTime(startTs)} to ${formatDateTime(endTs)}`}>{formatTime(startTs)} {formatTime(endTs)}</DataCell>
<span className="data-table-cell">{contract}</span> <DataCell title={contract}>{contract}</DataCell>
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(count)}</span> <DataCell numeric>{formatFlowMetric(count)}</DataCell>
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(totalSize)}</span> <DataCell numeric>{formatFlowMetric(totalSize)}</DataCell>
<span className="data-table-cell data-table-cell-number">${formatUsd(notional)}</span> <DataCell numeric>${formatUsd(notional)}</DataCell>
<span className="data-table-cell data-table-cell-number">{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</span> <DataCell numeric>{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</DataCell>
<span className="data-table-cell">{structureLabel}</span> <DataCell title={structureLabel !== "--" ? structureLabel : undefined}>{structureLabel}</DataCell>
<span className="data-table-cell data-table-cell-number">{nbboLabel}</span> <DataCell numeric title={nbboLabel !== "--" ? nbboLabel : undefined}>{nbboLabel}</DataCell>
<span className="data-table-cell">{qualityLabel || "--"}</span> <DataCell title={qualityLabel || undefined}>{qualityLabel || "--"}</DataCell>
</div> </div>
); );
})} })}
@ -8782,6 +8981,15 @@ function SyntheticControlDock() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
const savedRef = useRef<SyntheticControlState | null>(null); const savedRef = useRef<SyntheticControlState | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const closeDrawer = useCallback(() => {
setOpen(false);
}, []);
useModalFocusTrap(open, drawerRef, closeDrawer, triggerRef);
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
@ -8809,6 +9017,9 @@ function SyntheticControlDock() {
setLoading(false); setLoading(false);
return; return;
} }
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
const nextStatus = (await response.json()) as SyntheticAdminStatusResponse; const nextStatus = (await response.json()) as SyntheticAdminStatusResponse;
setStatus(nextStatus); setStatus(nextStatus);
if (!dirtyRef.current) { if (!dirtyRef.current) {
@ -8819,7 +9030,7 @@ function SyntheticControlDock() {
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) { if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : String(loadError)); setError(formatUiErrorMessage(loadError, "Synthetic status could not be loaded"));
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@ -8857,8 +9068,7 @@ function SyntheticControlDock() {
}) })
.then(async (response) => { .then(async (response) => {
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => null); throw new Error(await readErrorDetail(response));
throw new Error(body?.detail ?? body?.error ?? "Synthetic control update failed");
} }
return (await response.json()) as SyntheticAdminControlResponse; return (await response.json()) as SyntheticAdminControlResponse;
}) })
@ -8879,7 +9089,7 @@ function SyntheticControlDock() {
}) })
.catch((updateError) => { .catch((updateError) => {
dirtyRef.current = false; dirtyRef.current = false;
setError(updateError instanceof Error ? updateError.message : String(updateError)); setError(formatUiErrorMessage(updateError, "Synthetic control update failed"));
setDraft(savedRef.current); setDraft(savedRef.current);
}) })
.finally(() => { .finally(() => {
@ -8924,22 +9134,31 @@ function SyntheticControlDock() {
<> <>
<button <button
aria-expanded={open} aria-expanded={open}
aria-haspopup="dialog"
aria-label="Synthetic control" aria-label="Synthetic control"
className={`synthetic-control-gear${open ? " is-open" : ""}`} className={`synthetic-control-gear${open ? " is-open" : ""}`}
onClick={() => setOpen((current) => !current)} onClick={() => setOpen((current) => !current)}
ref={triggerRef}
type="button" type="button"
> >
<span className="synthetic-control-gear-mark">+</span> <span className="synthetic-control-gear-mark">+</span>
</button> </button>
{open ? ( {open ? (
<aside className="synthetic-control-drawer" aria-label="Synthetic control drawer"> <aside
aria-labelledby={titleId}
aria-modal="true"
className="synthetic-control-drawer"
ref={drawerRef}
role="dialog"
tabIndex={-1}
>
<div className="synthetic-control-header"> <div className="synthetic-control-header">
<div> <div>
<p className="synthetic-control-kicker">Synthetic Control</p> <p className="synthetic-control-kicker">Synthetic Control</p>
<h3>Hosted tape operator rail</h3> <h3 id={titleId}>Hosted tape operator rail</h3>
</div> </div>
<button className="drawer-close" onClick={() => setOpen(false)} type="button"> <button className="drawer-close" onClick={closeDrawer} type="button">
Close Close
</button> </button>
</div> </div>
@ -9087,7 +9306,11 @@ function SyntheticControlDock() {
</div> </div>
</section> </section>
{error ? <p className="drawer-note synthetic-control-error">{error}</p> : null} {error ? (
<p className="drawer-note synthetic-control-error" role="alert">
{error}
</p>
) : null}
</> </>
)} )}
</aside> </aside>
@ -9102,29 +9325,20 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const tickerFieldId = useId(); const tickerFieldId = useId();
const tickerHintId = useId(); const tickerHintId = useId();
const navTriggerRef = useRef<HTMLButtonElement | null>(null);
const navDrawerRef = useRef<HTMLElement | null>(null);
const navDrawerTitleId = useId();
const activeNavHref = getTerminalNavCurrentHref(pathname); const activeNavHref = getTerminalNavCurrentHref(pathname);
const closeNavDrawer = useCallback(() => {
setDrawerOpen(false);
}, []);
useModalFocusTrap(drawerOpen, navDrawerRef, closeNavDrawer, navTriggerRef);
useEffect(() => { useEffect(() => {
setDrawerOpen(false); setDrawerOpen(false);
}, [pathname]); }, [pathname]);
useEffect(() => {
if (!drawerOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setDrawerOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [drawerOpen]);
return ( return (
<TerminalContext.Provider value={state}> <TerminalContext.Provider value={state}>
<div className="terminal-shell"> <div className="terminal-shell">
@ -9140,6 +9354,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
aria-expanded={drawerOpen} aria-expanded={drawerOpen}
aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"} aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}
className="terminal-button terminal-menu-trigger" className="terminal-button terminal-menu-trigger"
ref={navTriggerRef}
type="button" type="button"
onClick={() => setDrawerOpen((current) => !current)} onClick={() => setDrawerOpen((current) => !current)}
> >
@ -9220,15 +9435,19 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
aria-label="Close navigation drawer" aria-label="Close navigation drawer"
className="terminal-drawer-backdrop" className="terminal-drawer-backdrop"
type="button" type="button"
onClick={() => setDrawerOpen(false)} onClick={closeNavDrawer}
/> />
<aside <aside
aria-label="Primary navigation" aria-labelledby={navDrawerTitleId}
aria-modal="true"
className="terminal-nav-drawer" className="terminal-nav-drawer"
id="terminal-nav-drawer" id="terminal-nav-drawer"
ref={navDrawerRef}
role="dialog"
tabIndex={-1}
> >
<div className="terminal-drawer-head"> <div className="terminal-drawer-head">
<div className="terminal-brand"> <div className="terminal-brand" id={navDrawerTitleId}>
<span className="terminal-brand-kicker">IF</span> <span className="terminal-brand-kicker">IF</span>
<span className="terminal-brand-name">islandflow</span> <span className="terminal-brand-name">islandflow</span>
</div> </div>
@ -9236,7 +9455,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
aria-label="Close navigation drawer" aria-label="Close navigation drawer"
className="terminal-button terminal-drawer-close" className="terminal-button terminal-drawer-close"
type="button" type="button"
onClick={() => setDrawerOpen(false)} onClick={closeNavDrawer}
> >
Close Close
</button> </button>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,293 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Harden Web Terminal UI States</title>
<style>
:root {
color-scheme: dark;
--bg: #06080b;
--panel: #101720;
--panel-2: #0b1118;
--line: rgba(230, 237, 244, 0.14);
--text: #e6edf4;
--muted: #9aabba;
--faint: #718093;
--accent: #f5a623;
--blue: #4da3ff;
--green: #25c17a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: linear-gradient(180deg, #0b1016 0%, var(--bg) 100%);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(1120px, calc(100vw - 32px));
margin: 0 auto;
padding: 48px 0 64px;
}
header {
display: grid;
gap: 12px;
padding-bottom: 28px;
border-bottom: 1px solid var(--line);
}
h1, h2 {
margin: 0;
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
h1 {
font-size: 2rem;
line-height: 1.15;
}
h2 {
margin-top: 34px;
font-size: 1rem;
color: var(--accent);
text-transform: uppercase;
}
p { max-width: 74ch; }
.summary {
max-width: 78ch;
color: var(--muted);
font-size: 1.02rem;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 9px;
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.78rem;
}
section {
padding-top: 2px;
}
ul {
padding-left: 20px;
}
li + li {
margin-top: 8px;
}
code, pre {
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre {
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
background: var(--panel-2);
color: #dce7f2;
}
.diff-note {
color: var(--faint);
font-size: 0.86rem;
}
.callout {
border: 1px solid rgba(77, 163, 255, 0.28);
border-radius: 12px;
padding: 14px 16px;
background: rgba(77, 163, 255, 0.08);
}
a { color: var(--blue); }
</style>
</head>
<body>
<main>
<header>
<div class="meta">
<span class="chip">2026-05-29 18:04 EDT</span>
<span class="chip">Beads: islandflow-ggm</span>
<span class="chip">Web terminal hardening</span>
</div>
<h1>Harden Web Terminal UI States</h1>
<p class="summary">
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.
</p>
</header>
<section>
<h2>Summary</h2>
<p>
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.
</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Added <code>buildTapeStatusAnnouncement</code> so live and replay feed states have complete screen-reader labels instead of relying on colored dots or terse visible labels.</li>
<li>Added reusable <code>DataCell</code> and <code>EmptyState</code> helpers for terminal panes.</li>
<li>Updated Options, Equities, and Flow panes with semantic column headers, rowgroups, cells, and useful <code>title</code> fallbacks for clipped values.</li>
<li>Improved empty-state layout so long messages wrap cleanly without collapsing the pane.</li>
<li>Added <code>unicode-bidi: plaintext</code> to table cells so mixed-direction symbols, ticker text, and unusual copied values are less likely to reorder confusingly.</li>
<li>Added focused tests for the new status-announcement helper.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>
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.
</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li><code>TapeStatus</code> now exposes a polite status region with an <code>aria-label</code> such as <code>Live feed behind</code> or <code>Replay feed paused, time not available, 12 queued rows</code>.</li>
<li>The visible status dot is marked <code>aria-hidden</code>, keeping color as a visual cue rather than the only status carrier.</li>
<li>Table headers are generated from arrays to keep repeated header markup consistent.</li>
<li>Clipped values such as option contracts, exact timestamps, full notional values, NBBO quality strings, and venue labels now expose fuller details through <code>title</code> where useful.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p class="diff-note">
Diff snippets are presented in the format expected by diffs.com-style unified diff rendering. <code>@pierre/diffs</code> is installed in this repo, but it does not expose a CLI binary, so the relevant unified snippets are embedded directly.
</p>
<pre><code>diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx
@@
+export const buildTapeStatusAnnouncement = ({
+ status,
+ replayTime,
+ replayComplete,
+ paused,
+ dropped,
+ mode
+}: Pick&lt;TapeStatusProps, "status" | "replayTime" | "replayComplete" | "paused" | "dropped" | "mode"&gt;): string =&gt; {
+ const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
+ const feedLabel = mode === "live" &amp;&amp; label.toLowerCase().startsWith("feed ")
+ ? label.toLowerCase()
+ : `feed ${label.toLowerCase()}`;
+ const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`];
+ ...
+};
+
+const EmptyState = ({ children }: { children: ReactNode }) =&gt; (
+ &lt;div className="empty" role="status" aria-live="polite"&gt;
+ {children}
+ &lt;/div&gt;
+);
@@
- &lt;div className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}&gt;
- &lt;span className="status-dot" /&gt;
+ &lt;div
+ className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}
+ role="status"
+ aria-live="polite"
+ aria-label={announcement}
+ &gt;
+ &lt;span className="status-dot" aria-hidden="true" /&gt;</code></pre>
<pre><code>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;
}</code></pre>
<pre><code>diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts
@@
+describe("tape status hardening", () =&gt; {
+ it("announces stale live feeds without relying on the colored dot", () =&gt; {
+ expect(
+ buildTapeStatusAnnouncement({
+ status: "stale",
+ replayTime: null,
+ replayComplete: false,
+ paused: false,
+ dropped: 0,
+ mode: "live"
+ })
+ ).toBe("Live feed behind");
+ });
+});</code></pre>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>
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.
</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li><code>bun test apps/web/app/terminal.test.ts</code>: 76 passing tests.</li>
<li><code>bun --cwd=apps/web run build</code>: production build completed successfully.</li>
<li>Browser verification at <code>http://localhost:3000/options</code>: confirmed status regions, table semantics, column headers, rowgroup, and cells are present in the rendered page.</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>The Options pane can still be wider than a narrow viewport by design; the table remains inside its horizontal scroll container.</li>
<li>Alert, classifier, dark-event, and news panes still have some older one-off markup. This task hardened the highest-traffic tape panes first.</li>
<li>The browser check observed a history-load warning because backend history was unavailable locally. That state rendered cleanly and was not a build blocker.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<div class="callout">
<p>
No additional Beads issue was required during this turn. A sensible future pass would extend the same <code>DataCell</code> and <code>EmptyState</code> treatment to Alerts, Smart Money, Dark Events, and the chart evidence lists.
</p>
</div>
</section>
</main>
</body>
</html>

File diff suppressed because one or more lines are too long