harden terminal ui states and drawer focus handling #16

Open
dirtydishes wants to merge 4 commits from lavender/all-quiet into main
3 changed files with 534 additions and 34 deletions
Showing only changes of commit c57feee976 - Show all commits

View file

@ -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}

View file

@ -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 = {
@ -4894,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);
@ -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 (
<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}>
@ -5052,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)}` : ""}
@ -5117,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}>
@ -5225,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}>
@ -5328,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}>
@ -8856,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) {
@ -9000,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>
@ -9182,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">
@ -9220,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)}
>
@ -9300,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>
@ -9316,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>

File diff suppressed because one or more lines are too long