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