harden terminal ui states and drawer focus handling #16
8 changed files with 2139 additions and 125 deletions
|
|
@ -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-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}
|
||||
{"_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}
|
||||
|
|
|
|||
|
|
@ -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,9 +1465,11 @@ h3 {
|
|||
color: oklch(0.91 0.08 72);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.data-table-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
|
@ -1675,6 +1685,7 @@ h3 {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.72rem;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.data-table-cell-number {
|
||||
|
|
@ -2010,11 +2021,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 {
|
||||
|
|
@ -2285,6 +2301,7 @@ h3 {
|
|||
|
||||
.synthetic-control-error {
|
||||
color: var(--red);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
|
|
@ -2473,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:
|
||||
|
|
@ -2547,7 +2568,7 @@ h3 {
|
|||
}
|
||||
|
||||
.terminal-content {
|
||||
padding: 16px 10px 22px;
|
||||
padding: 18px 10px calc(22px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
|
|
@ -2599,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,
|
||||
|
|
@ -2622,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;
|
||||
|
|
@ -2669,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;
|
||||
|
|
@ -2700,6 +2753,7 @@ h3 {
|
|||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.flow-filter-popover {
|
||||
|
|
@ -2737,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 {
|
||||
|
|
@ -2754,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;
|
||||
|
|
@ -2816,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
NAV_ITEMS,
|
||||
appendHistoryTail,
|
||||
buildAlertContextPath,
|
||||
buildTapeStatusAnnouncement,
|
||||
buildDefaultFlowFilters,
|
||||
buildOptionTapeQueryParams,
|
||||
classifierToneForFamily,
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
countActiveFlowFilterGroups,
|
||||
filterOptionTapeItems,
|
||||
findAnchorRestoreIndex,
|
||||
formatUiErrorMessage,
|
||||
formatCompactUsd,
|
||||
formatOptionContractLabel,
|
||||
flushPausableTapeData,
|
||||
|
|
@ -51,6 +53,53 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<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 EquityOverlayPoint = {
|
||||
|
|
@ -507,6 +617,20 @@ const sampleToLimit = <T,>(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<string> => {
|
||||
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
|
||||
const text = await response.text();
|
||||
|
|
@ -530,9 +654,9 @@ const readErrorDetail = async (response: Response): Promise<string> => {
|
|||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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> = {
|
||||
mode: TapeMode;
|
||||
wsPath: string;
|
||||
|
|
@ -3893,17 +4070,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 (
|
||||
<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" />
|
||||
<span className="status-inline-label">{label}</span>
|
||||
{mode === "replay" ? (
|
||||
<span className="status-inline-meta">
|
||||
Replay time {replayTime ? formatTime(replayTime) : "—"}
|
||||
</span>
|
||||
) : 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"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -4410,7 +4603,7 @@ const CandleChart = ({
|
|||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setError(formatUiErrorMessage(error));
|
||||
setStatus("disconnected");
|
||||
setHasData(false);
|
||||
}
|
||||
|
|
@ -4811,6 +5004,8 @@ const formatOptionalMs = (value: unknown): string | null => {
|
|||
};
|
||||
|
||||
const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {
|
||||
const drawerRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const primary = alert.hits[0];
|
||||
const direction = deriveAlertDirection(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 isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;
|
||||
const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];
|
||||
useModalFocusTrap(true, drawerRef, onClose);
|
||||
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||
<div className="drawer-header">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<button className="drawer-close" type="button" onClick={onClose}>
|
||||
|
|
@ -4969,14 +5165,17 @@ type NewsDrawerProps = {
|
|||
};
|
||||
|
||||
const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {
|
||||
const drawerRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const body = sanitizeNewsHtml(story.content_html);
|
||||
useModalFocusTrap(true, drawerRef, onClose);
|
||||
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||
<div className="drawer-header">
|
||||
<div>
|
||||
<p className="drawer-eyebrow">News wire</p>
|
||||
<h3>{story.headline}</h3>
|
||||
<h3 id={titleId}>{story.headline}</h3>
|
||||
<p className="drawer-subtitle">
|
||||
{story.source} · Published {formatDateTime(story.published_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 drawerRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const direction = normalizeDirection(hit.direction);
|
||||
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||
useModalFocusTrap(true, drawerRef, onClose);
|
||||
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||
<div className="drawer-header">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<button className="drawer-close" type="button" onClick={onClose}>
|
||||
|
|
@ -5142,19 +5344,22 @@ type SmartMoneyDrawerProps = {
|
|||
};
|
||||
|
||||
const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {
|
||||
const drawerRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const primaryScore =
|
||||
event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??
|
||||
event.profile_scores[0];
|
||||
const direction = normalizeDirection(event.primary_direction);
|
||||
const evidencePrints = evidence.filter((item) => item.kind === "print");
|
||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||
useModalFocusTrap(true, drawerRef, onClose);
|
||||
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||
<div className="drawer-header">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<button className="drawer-close" type="button" onClick={onClose}>
|
||||
|
|
@ -5245,19 +5450,22 @@ type DarkDrawerProps = {
|
|||
};
|
||||
|
||||
const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {
|
||||
const drawerRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const joinEvidence = evidence.filter(
|
||||
(item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join"
|
||||
);
|
||||
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
|
||||
const traceRefs = event.evidence_refs.slice(0, 6);
|
||||
const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);
|
||||
useModalFocusTrap(true, drawerRef, onClose);
|
||||
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
|
||||
<div className="drawer-header">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<button className="drawer-close" type="button" onClick={onClose}>
|
||||
|
|
@ -7527,12 +7735,12 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
|||
>
|
||||
<div className="data-table-shell">
|
||||
{state.mode === "live" && optionHistoryError ? (
|
||||
<div className="history-load-warning" role="status">
|
||||
Older option history failed to load: {optionHistoryError}
|
||||
<div className="history-load-warning" role="status" aria-live="polite">
|
||||
Older option history failed to load: {formatUiErrorMessage(optionHistoryError)}
|
||||
</div>
|
||||
) : null}
|
||||
{items.length === 0 ? (
|
||||
<div className="empty">
|
||||
<EmptyState>
|
||||
{state.mode === "live"
|
||||
? state.options.status === "stale"
|
||||
? "Feed behind. Waiting for fresh option prints."
|
||||
|
|
@ -7544,29 +7752,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."}
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<div className="data-table data-table-options" role="table" aria-label="Options tape">
|
||||
<div className="data-table-head" role="row">
|
||||
<span className="data-table-cell">TIME</span>
|
||||
<span className="data-table-cell">SYM</span>
|
||||
<span className="data-table-cell">EXP</span>
|
||||
<span className="data-table-cell">STRIKE</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>
|
||||
{["TIME", "SYM", "EXP", "STRIKE", "C/P", "SPOT", "DETAILS", "TYPE", "VALUE", "SIDE", "IV", "CLASSIFIER"].map((header) => (
|
||||
<span className="data-table-cell" role="columnheader" key={header}>
|
||||
{header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="data-table-scroll" ref={state.optionsScroll.setListRef}>
|
||||
<div
|
||||
className="data-table-body"
|
||||
style={{ height: `${virtual.totalSize}px` }}
|
||||
aria-hidden={virtual.virtualItems.length === 0}
|
||||
role="rowgroup"
|
||||
>
|
||||
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => {
|
||||
const contractId = normalizeContractId(print.option_contract_id);
|
||||
|
|
@ -7602,42 +7804,42 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
|||
};
|
||||
const cells = (
|
||||
<>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
||||
<span className="data-table-cell">
|
||||
<DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
|
||||
<DataCell title={contractId}>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell">
|
||||
</DataCell>
|
||||
<DataCell title={contractDisplay?.expiration ?? parsed?.expiry ?? undefined}>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">
|
||||
</DataCell>
|
||||
<DataCell numeric title={contractDisplay?.strike ?? undefined}>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell">
|
||||
</DataCell>
|
||||
<DataCell>
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">{typeof spot === "number" ? formatPrice(spot) : "--"}</span>
|
||||
<span className="data-table-cell data-table-cell-number">
|
||||
</DataCell>
|
||||
<DataCell numeric>{typeof spot === "number" ? formatPrice(spot) : "--"}</DataCell>
|
||||
<DataCell numeric title={`${formatSize(print.size)} at ${formatPrice(print.price)}, ${nbboSide ?? "unknown side"}`}>
|
||||
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
|
||||
</span>
|
||||
<span className="data-table-cell">{print.option_type ?? "--"}</span>
|
||||
<span className="data-table-cell data-table-cell-number notional-emphasis">${formatCompactUsd(notional)}</span>
|
||||
<span className="data-table-cell">
|
||||
</DataCell>
|
||||
<DataCell title={print.option_type ?? undefined}>{print.option_type ?? "--"}</DataCell>
|
||||
<DataCell numeric className="notional-emphasis" title={`$${formatUsd(notional)}`}>${formatCompactUsd(notional)}</DataCell>
|
||||
<DataCell>
|
||||
{nbboSide ? (
|
||||
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">{typeof iv === "number" ? formatPct(iv) : "--"}</span>
|
||||
<span className="data-table-cell">{decor ? humanizeClassifierId(decor.family) : "--"}</span>
|
||||
</DataCell>
|
||||
<DataCell numeric>{typeof iv === "number" ? formatPct(iv) : "--"}</DataCell>
|
||||
<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">
|
||||
{items.length === 0 ? (
|
||||
<div className="empty">
|
||||
<EmptyState>
|
||||
{state.mode === "live"
|
||||
? state.equities.status === "stale"
|
||||
? "Feed behind. Waiting for fresh equity prints."
|
||||
|
|
@ -7735,23 +7937,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."}
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<div className="data-table data-table-equities" role="table" aria-label="Equity prints">
|
||||
<div className="data-table-head" role="row">
|
||||
<span className="data-table-cell">TIME</span>
|
||||
<span className="data-table-cell">SYM</span>
|
||||
<span className="data-table-cell">PRICE</span>
|
||||
<span className="data-table-cell">SIZE</span>
|
||||
<span className="data-table-cell">VENUE</span>
|
||||
<span className="data-table-cell">TAPE</span>
|
||||
{["TIME", "SYM", "PRICE", "SIZE", "VENUE", "TAPE"].map((header) => (
|
||||
<span className="data-table-cell" role="columnheader" key={header}>
|
||||
{header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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 }) => (
|
||||
<div
|
||||
className={`data-table-row data-table-row-equities data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}`}
|
||||
role="row"
|
||||
key={key}
|
||||
data-index={index}
|
||||
data-row-start={String(start)}
|
||||
|
|
@ -7759,8 +7961,8 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
|||
data-tape-key={key}
|
||||
style={{ transform: `translateY(${start}px)` }}
|
||||
>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
||||
<span className="data-table-cell">
|
||||
<DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
|
||||
<DataCell title={print.underlying_id}>
|
||||
<button
|
||||
className="instrument-cell-button"
|
||||
type="button"
|
||||
|
|
@ -7768,11 +7970,11 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
|||
>
|
||||
{print.underlying_id}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">${formatPrice(print.price)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{formatSize(print.size)}x</span>
|
||||
<span className="data-table-cell">{print.exchange}</span>
|
||||
<span className="data-table-cell">{print.offExchangeFlag ? "Off-Ex" : "Lit"}</span>
|
||||
</DataCell>
|
||||
<DataCell numeric>${formatPrice(print.price)}</DataCell>
|
||||
<DataCell numeric>{formatSize(print.size)}x</DataCell>
|
||||
<DataCell title={print.exchange}>{print.exchange}</DataCell>
|
||||
<DataCell>{print.offExchangeFlag ? "Off-Ex" : "Lit"}</DataCell>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -7825,7 +8027,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
|||
>
|
||||
<div className="data-table-shell">
|
||||
{items.length === 0 ? (
|
||||
<div className="empty">
|
||||
<EmptyState>
|
||||
{state.tickerSet.size > 0
|
||||
? "No flow packets match the current filter."
|
||||
: state.mode === "live"
|
||||
|
|
@ -7833,23 +8035,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."}
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<div className="data-table data-table-flow" role="table" aria-label="Flow packets">
|
||||
<div className="data-table-head" role="row">
|
||||
<span className="data-table-cell">TIME</span>
|
||||
<span className="data-table-cell">CONTRACT</span>
|
||||
<span className="data-table-cell">PRINTS</span>
|
||||
<span className="data-table-cell">SIZE</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>
|
||||
{["TIME", "CONTRACT", "PRINTS", "SIZE", "NOTIONAL", "WINDOW", "STRUCTURE", "NBBO", "QUALITY"].map((header) => (
|
||||
<span className="data-table-cell" role="columnheader" key={header}>
|
||||
{header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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 }) => {
|
||||
const features = packet.features ?? {};
|
||||
const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
|
||||
|
|
@ -7904,6 +8102,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
|||
return (
|
||||
<div
|
||||
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}
|
||||
data-index={index}
|
||||
data-row-start={String(start)}
|
||||
|
|
@ -7911,15 +8110,15 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
|||
data-tape-key={key}
|
||||
style={{ transform: `translateY(${start}px)` }}
|
||||
>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(startTs)} → {formatTime(endTs)}</span>
|
||||
<span className="data-table-cell">{contract}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(count)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(totalSize)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">${formatUsd(notional)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</span>
|
||||
<span className="data-table-cell">{structureLabel}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{nbboLabel}</span>
|
||||
<span className="data-table-cell">{qualityLabel || "--"}</span>
|
||||
<DataCell numeric title={`${formatDateTime(startTs)} to ${formatDateTime(endTs)}`}>{formatTime(startTs)} → {formatTime(endTs)}</DataCell>
|
||||
<DataCell title={contract}>{contract}</DataCell>
|
||||
<DataCell numeric>{formatFlowMetric(count)}</DataCell>
|
||||
<DataCell numeric>{formatFlowMetric(totalSize)}</DataCell>
|
||||
<DataCell numeric>${formatUsd(notional)}</DataCell>
|
||||
<DataCell numeric>{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</DataCell>
|
||||
<DataCell title={structureLabel !== "--" ? structureLabel : undefined}>{structureLabel}</DataCell>
|
||||
<DataCell numeric title={nbboLabel !== "--" ? nbboLabel : undefined}>{nbboLabel}</DataCell>
|
||||
<DataCell title={qualityLabel || undefined}>{qualityLabel || "--"}</DataCell>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -8782,6 +8981,15 @@ function SyntheticControlDock() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
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(() => {
|
||||
if (!visible) {
|
||||
|
|
@ -8809,6 +9017,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) {
|
||||
|
|
@ -8819,7 +9030,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) {
|
||||
|
|
@ -8857,8 +9068,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;
|
||||
})
|
||||
|
|
@ -8879,7 +9089,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(() => {
|
||||
|
|
@ -8924,22 +9134,31 @@ function SyntheticControlDock() {
|
|||
<>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Synthetic control"
|
||||
className={`synthetic-control-gear${open ? " is-open" : ""}`}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
>
|
||||
<span className="synthetic-control-gear-mark">+</span>
|
||||
</button>
|
||||
|
||||
{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>
|
||||
<p className="synthetic-control-kicker">Synthetic Control</p>
|
||||
<h3>Hosted tape operator rail</h3>
|
||||
<h3 id={titleId}>Hosted tape operator rail</h3>
|
||||
</div>
|
||||
<button className="drawer-close" onClick={() => setOpen(false)} type="button">
|
||||
<button className="drawer-close" onClick={closeDrawer} type="button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -9087,7 +9306,11 @@ function SyntheticControlDock() {
|
|||
</div>
|
||||
</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>
|
||||
|
|
@ -9102,29 +9325,20 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const tickerFieldId = useId();
|
||||
const tickerHintId = useId();
|
||||
const navTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const navDrawerRef = useRef<HTMLElement | null>(null);
|
||||
const navDrawerTitleId = useId();
|
||||
const activeNavHref = getTerminalNavCurrentHref(pathname);
|
||||
const closeNavDrawer = useCallback(() => {
|
||||
setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
useModalFocusTrap(drawerOpen, navDrawerRef, closeNavDrawer, navTriggerRef);
|
||||
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false);
|
||||
}, [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 (
|
||||
<TerminalContext.Provider value={state}>
|
||||
<div className="terminal-shell">
|
||||
|
|
@ -9140,6 +9354,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
aria-expanded={drawerOpen}
|
||||
aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||
className="terminal-button terminal-menu-trigger"
|
||||
ref={navTriggerRef}
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen((current) => !current)}
|
||||
>
|
||||
|
|
@ -9220,15 +9435,19 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
aria-label="Close navigation drawer"
|
||||
className="terminal-drawer-backdrop"
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
onClick={closeNavDrawer}
|
||||
/>
|
||||
<aside
|
||||
aria-label="Primary navigation"
|
||||
aria-labelledby={navDrawerTitleId}
|
||||
aria-modal="true"
|
||||
className="terminal-nav-drawer"
|
||||
id="terminal-nav-drawer"
|
||||
ref={navDrawerRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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-name">islandflow</span>
|
||||
</div>
|
||||
|
|
@ -9236,7 +9455,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
aria-label="Close navigation drawer"
|
||||
className="terminal-button terminal-drawer-close"
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
onClick={closeNavDrawer}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
|
|
|||
360
docs/turns/2026-05-29-harden-drawer-dialog-focus.html
Normal file
360
docs/turns/2026-05-29-harden-drawer-dialog-focus.html
Normal file
File diff suppressed because one or more lines are too long
614
docs/turns/2026-05-29-harden-terminal-ui-errors.html
Normal file
614
docs/turns/2026-05-29-harden-terminal-ui-errors.html
Normal file
File diff suppressed because one or more lines are too long
293
docs/turns/2026-05-29-harden-web-terminal-ui-states.html
Normal file
293
docs/turns/2026-05-29-harden-web-terminal-ui-states.html
Normal 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<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" /></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", () => {
|
||||
+ 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");
|
||||
+ });
|
||||
+});</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>
|
||||
359
docs/turns/2026-05-29-improve-narrow-options-table.html
Normal file
359
docs/turns/2026-05-29-improve-narrow-options-table.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue