diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 58e5b6b..7552b8d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a454a20..1c1a5cc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -10,6 +10,7 @@ --text: oklch(0.93 0.014 250); --text-dim: oklch(0.74 0.018 250); --text-faint: oklch(0.59 0.016 250); + --text-muted: var(--text-dim); --accent: oklch(0.78 0.12 74); --accent-soft: oklch(0.78 0.12 74 / 0.1); --green: oklch(0.74 0.13 151); @@ -661,6 +662,7 @@ h3 { color: var(--text-muted); font-size: 0.78rem; line-height: 1.35; + overflow-wrap: anywhere; } .flow-filter-checkbox-grid, @@ -1249,6 +1251,12 @@ h3 { color: var(--text-dim); } +.drawer-note, +.drawer-empty, +.note { + overflow-wrap: anywhere; +} + .chart-surface { width: 100%; height: 460px; @@ -1457,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; } diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index e6ed106..1c9dc6c 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -4,6 +4,7 @@ import { NAV_ITEMS, appendHistoryTail, buildAlertContextPath, + buildTapeStatusAnnouncement, buildDefaultFlowFilters, buildOptionTapeQueryParams, classifierToneForFamily, @@ -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, diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 5375688..694a353 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -16,6 +16,7 @@ import { type CSSProperties, type Dispatch, type MouseEvent as ReactMouseEvent, + type RefObject, type ReactNode, type SetStateAction } from "react"; @@ -391,6 +392,115 @@ const EMPTY_SMART_MONEY_EVENTS: SmartMoneyEvent[] = []; const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = []; const EMPTY_NEWS_STORIES: NewsStory[] = []; +const TABBABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled]):not([type='hidden'])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])" +].join(","); + +export const isElementTabbable = (element: HTMLElement): boolean => { + if (element.hasAttribute("disabled") || element.getAttribute("aria-hidden") === "true") { + return false; + } + + const tabIndex = element.getAttribute("tabindex"); + if (tabIndex && Number(tabIndex) < 0) { + return false; + } + + return Boolean(element.offsetParent || element.getClientRects().length > 0); +}; + +export const getTabbableElements = (root: HTMLElement): HTMLElement[] => { + return Array.from(root.querySelectorAll(TABBABLE_SELECTOR)).filter(isElementTabbable); +}; + +const useModalFocusTrap = ( + active: boolean, + rootRef: RefObject, + onClose: () => void, + restoreFocusRef?: RefObject +) => { + const fallbackFocusRef = useRef(null); + + useLayoutEffect(() => { + if (!active) { + return; + } + + fallbackFocusRef.current = + restoreFocusRef?.current ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null); + const root = rootRef.current; + if (!root) { + return; + } + + const focusTarget = getTabbableElements(root)[0] ?? root; + focusTarget.focus({ preventScroll: true }); + + return () => { + const restoreTarget = restoreFocusRef?.current ?? fallbackFocusRef.current; + if (restoreTarget?.isConnected) { + restoreTarget.focus({ preventScroll: true }); + } + fallbackFocusRef.current = null; + }; + }, [active, restoreFocusRef, rootRef]); + + useEffect(() => { + if (!active) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + const root = rootRef.current; + if (!root) { + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const tabbable = getTabbableElements(root); + if (tabbable.length === 0) { + event.preventDefault(); + root.focus({ preventScroll: true }); + return; + } + + const first = tabbable[0]; + const last = tabbable[tabbable.length - 1]; + const activeElement = document.activeElement; + + if (event.shiftKey && activeElement === first) { + event.preventDefault(); + last.focus({ preventScroll: true }); + } else if (!event.shiftKey && activeElement === last) { + event.preventDefault(); + first.focus({ preventScroll: true }); + } else if (!root.contains(activeElement)) { + event.preventDefault(); + first.focus({ preventScroll: true }); + } + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [active, onClose, rootRef]); +}; + type CandlestickSeries = ReturnType; type EquityOverlayPoint = { @@ -507,6 +617,20 @@ const sampleToLimit = (items: T[], limit: number): T[] => { return sampled; }; +export const formatUiErrorMessage = (message: unknown, fallback = "Request failed"): string => { + const raw = + message instanceof Error + ? message.message + : typeof message === "string" + ? message + : String(message ?? ""); + const normalized = raw.replace(/\s+/g, " ").trim(); + if (!normalized) { + return fallback; + } + return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized; +}; + const readErrorDetail = async (response: Response): Promise => { const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; const text = await response.text(); @@ -530,9 +654,9 @@ const readErrorDetail = async (response: Response): Promise => { error?: string; message?: string; }; - return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`; + return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`); } catch { - return `${statusLabel}: ${truncated}`; + return formatUiErrorMessage(`${statusLabel}: ${truncated}`); } }; @@ -2145,6 +2269,59 @@ export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): } }; +export const buildTapeStatusAnnouncement = ({ + status, + replayTime, + replayComplete, + paused, + dropped, + mode +}: Pick): string => { + const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); + const feedLabel = mode === "live" && label.toLowerCase().startsWith("feed ") + ? label.toLowerCase() + : `feed ${label.toLowerCase()}`; + const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`]; + + if (mode === "replay") { + parts.push(`time ${replayTime ? formatTime(replayTime) : "not available"}`); + } + + if (paused && dropped > 0) { + parts.push(`${dropped} queued rows`); + } + + return parts.join(", "); +}; + +const DataCell = ({ + children, + className = "", + title, + numeric = false +}: { + children: ReactNode; + className?: string; + title?: string; + numeric?: boolean; +}) => { + const classes = ["data-table-cell", numeric ? "data-table-cell-number" : "", className] + .filter(Boolean) + .join(" "); + + return ( + + {children} + + ); +}; + +const EmptyState = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); + type TapeConfig = { mode: TapeMode; wsPath: string; @@ -3893,17 +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 ( -
- +
+
@@ -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(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 ( -