harden web terminal ui states
This commit is contained in:
parent
6d11abc660
commit
5538f3faa1
5 changed files with 461 additions and 72 deletions
|
|
@ -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-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-dk5","title":"Remove frontend cooker route","description":"Remove the experimental /frontend-cooker page and update repository references that still list it as an available public route.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T13:50:38Z","created_by":"dirtydishes","updated_at":"2026-05-29T13:53:05Z","started_at":"2026-05-29T13:50:48Z","closed_at":"2026-05-29T13:53:05Z","close_reason":"Removed the /frontend-cooker Next.js route, cleaned route/scanner references, documented the work, and validated the web build.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-dk5","title":"Remove frontend cooker route","description":"Remove the experimental /frontend-cooker page and update repository references that still list it as an available public route.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T13:50:38Z","created_by":"dirtydishes","updated_at":"2026-05-29T13:53:05Z","started_at":"2026-05-29T13:50:48Z","closed_at":"2026-05-29T13:53:05Z","close_reason":"Removed the /frontend-cooker Next.js route, cleaned route/scanner references, documented the work, and validated the web build.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-ep2","title":"Configure Impeccable live mode","description":"Initialize the repository's Impeccable live-mode configuration so future design iteration can start without first-time setup.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T08:03:47Z","created_by":"dirtydishes","updated_at":"2026-05-29T08:05:01Z","started_at":"2026-05-29T08:03:52Z","closed_at":"2026-05-29T08:05:01Z","close_reason":"Configured Impeccable live mode and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-ep2","title":"Configure Impeccable live mode","description":"Initialize the repository's Impeccable live-mode configuration so future design iteration can start without first-time setup.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T08:03:47Z","created_by":"dirtydishes","updated_at":"2026-05-29T08:05:01Z","started_at":"2026-05-29T08:03:52Z","closed_at":"2026-05-29T08:05:01Z","close_reason":"Configured Impeccable live mode and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-9en","title":"Install Impeccable skill for Codex","description":"Install the Impeccable skill in the Codex-compatible project locations after the upstream installer selected unused harness folders.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T07:59:10Z","created_by":"dirtydishes","updated_at":"2026-05-29T07:59:22Z","started_at":"2026-05-29T07:59:18Z","closed_at":"2026-05-29T07:59:22Z","close_reason":"Installed Impeccable into .agents and mirrored it into .codex/skills for Codex use.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-9en","title":"Install Impeccable skill for Codex","description":"Install the Impeccable skill in the Codex-compatible project locations after the upstream installer selected unused harness folders.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T07:59:10Z","created_by":"dirtydishes","updated_at":"2026-05-29T07:59:22Z","started_at":"2026-05-29T07:59:18Z","closed_at":"2026-05-29T07:59:22Z","close_reason":"Installed Impeccable into .agents and mirrored it into .codex/skills for Codex use.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -1675,6 +1675,7 @@ h3 {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table-cell-number {
|
.data-table-cell-number {
|
||||||
|
|
@ -2010,11 +2011,16 @@ h3 {
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 76px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
NAV_ITEMS,
|
NAV_ITEMS,
|
||||||
appendHistoryTail,
|
appendHistoryTail,
|
||||||
buildAlertContextPath,
|
buildAlertContextPath,
|
||||||
|
buildTapeStatusAnnouncement,
|
||||||
buildDefaultFlowFilters,
|
buildDefaultFlowFilters,
|
||||||
buildOptionTapeQueryParams,
|
buildOptionTapeQueryParams,
|
||||||
classifierToneForFamily,
|
classifierToneForFamily,
|
||||||
|
|
@ -51,6 +52,34 @@ import {
|
||||||
toggleFilterValue
|
toggleFilterValue
|
||||||
} from "./terminal";
|
} from "./terminal";
|
||||||
|
|
||||||
|
describe("tape status hardening", () => {
|
||||||
|
it("builds a screen-reader announcement with replay state and queued rows", () => {
|
||||||
|
expect(
|
||||||
|
buildTapeStatusAnnouncement({
|
||||||
|
status: "connected",
|
||||||
|
replayTime: null,
|
||||||
|
replayComplete: false,
|
||||||
|
paused: true,
|
||||||
|
dropped: 12,
|
||||||
|
mode: "replay"
|
||||||
|
})
|
||||||
|
).toBe("Replay feed paused, time not available, 12 queued rows");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces stale live feeds without relying on the colored dot", () => {
|
||||||
|
expect(
|
||||||
|
buildTapeStatusAnnouncement({
|
||||||
|
status: "stale",
|
||||||
|
replayTime: null,
|
||||||
|
replayComplete: false,
|
||||||
|
paused: false,
|
||||||
|
dropped: 0,
|
||||||
|
mode: "live"
|
||||||
|
})
|
||||||
|
).toBe("Live feed behind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const makeItem = (traceId: string, seq: number, ts: number) => ({
|
const makeItem = (traceId: string, seq: number, ts: number) => ({
|
||||||
trace_id: traceId,
|
trace_id: traceId,
|
||||||
seq,
|
seq,
|
||||||
|
|
|
||||||
|
|
@ -2145,6 +2145,59 @@ export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode):
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildTapeStatusAnnouncement = ({
|
||||||
|
status,
|
||||||
|
replayTime,
|
||||||
|
replayComplete,
|
||||||
|
paused,
|
||||||
|
dropped,
|
||||||
|
mode
|
||||||
|
}: Pick<TapeStatusProps, "status" | "replayTime" | "replayComplete" | "paused" | "dropped" | "mode">): string => {
|
||||||
|
const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
|
||||||
|
const feedLabel = mode === "live" && label.toLowerCase().startsWith("feed ")
|
||||||
|
? label.toLowerCase()
|
||||||
|
: `feed ${label.toLowerCase()}`;
|
||||||
|
const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`];
|
||||||
|
|
||||||
|
if (mode === "replay") {
|
||||||
|
parts.push(`time ${replayTime ? formatTime(replayTime) : "not available"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paused && dropped > 0) {
|
||||||
|
parts.push(`${dropped} queued rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const DataCell = ({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
title,
|
||||||
|
numeric = false
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
}) => {
|
||||||
|
const classes = ["data-table-cell", numeric ? "data-table-cell-number" : "", className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={classes} role="cell" title={title}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyState = ({ children }: { children: ReactNode }) => (
|
||||||
|
<div className="empty" role="status" aria-live="polite">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
type TapeConfig<T> = {
|
type TapeConfig<T> = {
|
||||||
mode: TapeMode;
|
mode: TapeMode;
|
||||||
wsPath: string;
|
wsPath: string;
|
||||||
|
|
@ -3893,17 +3946,33 @@ const TapeStatus = ({
|
||||||
}: TapeStatusProps) => {
|
}: TapeStatusProps) => {
|
||||||
const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
|
const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
|
||||||
const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : "";
|
const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : "";
|
||||||
|
const announcement = buildTapeStatusAnnouncement({
|
||||||
|
status,
|
||||||
|
replayTime,
|
||||||
|
replayComplete,
|
||||||
|
paused,
|
||||||
|
dropped,
|
||||||
|
mode
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}>
|
<div
|
||||||
<span className="status-dot" />
|
className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={announcement}
|
||||||
|
>
|
||||||
|
<span className="status-dot" aria-hidden="true" />
|
||||||
<span className="status-inline-label">{label}</span>
|
<span className="status-inline-label">{label}</span>
|
||||||
{mode === "replay" ? (
|
{mode === "replay" ? (
|
||||||
<span className="status-inline-meta">
|
<span className="status-inline-meta">
|
||||||
Replay time {replayTime ? formatTime(replayTime) : "—"}
|
Replay time {replayTime ? formatTime(replayTime) : "—"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={`status-inline-counter${pausedLabel ? " status-inline-counter-visible" : ""}`}>
|
<span
|
||||||
|
className={`status-inline-counter${pausedLabel ? " status-inline-counter-visible" : ""}`}
|
||||||
|
aria-hidden={!pausedLabel}
|
||||||
|
>
|
||||||
{pausedLabel || "+000 queued"}
|
{pausedLabel || "+000 queued"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -7532,7 +7601,7 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="empty">
|
<EmptyState>
|
||||||
{state.mode === "live"
|
{state.mode === "live"
|
||||||
? state.options.status === "stale"
|
? state.options.status === "stale"
|
||||||
? "Feed behind. Waiting for fresh option prints."
|
? "Feed behind. Waiting for fresh option prints."
|
||||||
|
|
@ -7544,29 +7613,23 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
||||||
: state.tickerSet.size > 0
|
: state.tickerSet.size > 0
|
||||||
? "No option prints match the current filter."
|
? "No option prints match the current filter."
|
||||||
: "Replay queue empty. Ensure ClickHouse has data."}
|
: "Replay queue empty. Ensure ClickHouse has data."}
|
||||||
</div>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="data-table-wrap">
|
<div className="data-table-wrap">
|
||||||
<div className="data-table data-table-options" role="table" aria-label="Options tape">
|
<div className="data-table data-table-options" role="table" aria-label="Options tape">
|
||||||
<div className="data-table-head" role="row">
|
<div className="data-table-head" role="row">
|
||||||
<span className="data-table-cell">TIME</span>
|
{["TIME", "SYM", "EXP", "STRIKE", "C/P", "SPOT", "DETAILS", "TYPE", "VALUE", "SIDE", "IV", "CLASSIFIER"].map((header) => (
|
||||||
<span className="data-table-cell">SYM</span>
|
<span className="data-table-cell" role="columnheader" key={header}>
|
||||||
<span className="data-table-cell">EXP</span>
|
{header}
|
||||||
<span className="data-table-cell">STRIKE</span>
|
</span>
|
||||||
<span className="data-table-cell">C/P</span>
|
))}
|
||||||
<span className="data-table-cell">SPOT</span>
|
|
||||||
<span className="data-table-cell">DETAILS</span>
|
|
||||||
<span className="data-table-cell">TYPE</span>
|
|
||||||
<span className="data-table-cell">VALUE</span>
|
|
||||||
<span className="data-table-cell">SIDE</span>
|
|
||||||
<span className="data-table-cell">IV</span>
|
|
||||||
<span className="data-table-cell">CLASSIFIER</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="data-table-scroll" ref={state.optionsScroll.setListRef}>
|
<div className="data-table-scroll" ref={state.optionsScroll.setListRef}>
|
||||||
<div
|
<div
|
||||||
className="data-table-body"
|
className="data-table-body"
|
||||||
style={{ height: `${virtual.totalSize}px` }}
|
style={{ height: `${virtual.totalSize}px` }}
|
||||||
aria-hidden={virtual.virtualItems.length === 0}
|
aria-hidden={virtual.virtualItems.length === 0}
|
||||||
|
role="rowgroup"
|
||||||
>
|
>
|
||||||
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => {
|
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => {
|
||||||
const contractId = normalizeContractId(print.option_contract_id);
|
const contractId = normalizeContractId(print.option_contract_id);
|
||||||
|
|
@ -7602,42 +7665,42 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
||||||
};
|
};
|
||||||
const cells = (
|
const cells = (
|
||||||
<>
|
<>
|
||||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
<DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
|
||||||
<span className="data-table-cell">
|
<DataCell title={contractId}>
|
||||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||||
{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}
|
{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</DataCell>
|
||||||
<span className="data-table-cell">
|
<DataCell title={contractDisplay?.expiration ?? parsed?.expiry ?? undefined}>
|
||||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||||
{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}
|
{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">
|
<DataCell numeric title={contractDisplay?.strike ?? undefined}>
|
||||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||||
{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}
|
{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</DataCell>
|
||||||
<span className="data-table-cell">
|
<DataCell>
|
||||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||||
{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}
|
{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">{typeof spot === "number" ? formatPrice(spot) : "--"}</span>
|
<DataCell numeric>{typeof spot === "number" ? formatPrice(spot) : "--"}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">
|
<DataCell numeric title={`${formatSize(print.size)} at ${formatPrice(print.price)}, ${nbboSide ?? "unknown side"}`}>
|
||||||
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
|
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
|
||||||
</span>
|
</DataCell>
|
||||||
<span className="data-table-cell">{print.option_type ?? "--"}</span>
|
<DataCell title={print.option_type ?? undefined}>{print.option_type ?? "--"}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number notional-emphasis">${formatCompactUsd(notional)}</span>
|
<DataCell numeric className="notional-emphasis" title={`$${formatUsd(notional)}`}>${formatCompactUsd(notional)}</DataCell>
|
||||||
<span className="data-table-cell">
|
<DataCell>
|
||||||
{nbboSide ? (
|
{nbboSide ? (
|
||||||
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
|
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
|
||||||
) : (
|
) : (
|
||||||
"--"
|
"--"
|
||||||
)}
|
)}
|
||||||
</span>
|
</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">{typeof iv === "number" ? formatPct(iv) : "--"}</span>
|
<DataCell numeric>{typeof iv === "number" ? formatPct(iv) : "--"}</DataCell>
|
||||||
<span className="data-table-cell">{decor ? humanizeClassifierId(decor.family) : "--"}</span>
|
<DataCell title={decor ? humanizeClassifierId(decor.family) : undefined}>{decor ? humanizeClassifierId(decor.family) : "--"}</DataCell>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -7721,7 +7784,7 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
||||||
>
|
>
|
||||||
<div className="data-table-shell">
|
<div className="data-table-shell">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="empty">
|
<EmptyState>
|
||||||
{state.mode === "live"
|
{state.mode === "live"
|
||||||
? state.equities.status === "stale"
|
? state.equities.status === "stale"
|
||||||
? "Feed behind. Waiting for fresh equity prints."
|
? "Feed behind. Waiting for fresh equity prints."
|
||||||
|
|
@ -7735,23 +7798,23 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
||||||
: state.tickerSet.size > 0
|
: state.tickerSet.size > 0
|
||||||
? "No equity prints match the current filter."
|
? "No equity prints match the current filter."
|
||||||
: "Replay queue empty. Ensure ClickHouse has data."}
|
: "Replay queue empty. Ensure ClickHouse has data."}
|
||||||
</div>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="data-table-wrap">
|
<div className="data-table-wrap">
|
||||||
<div className="data-table data-table-equities" role="table" aria-label="Equity prints">
|
<div className="data-table data-table-equities" role="table" aria-label="Equity prints">
|
||||||
<div className="data-table-head" role="row">
|
<div className="data-table-head" role="row">
|
||||||
<span className="data-table-cell">TIME</span>
|
{["TIME", "SYM", "PRICE", "SIZE", "VENUE", "TAPE"].map((header) => (
|
||||||
<span className="data-table-cell">SYM</span>
|
<span className="data-table-cell" role="columnheader" key={header}>
|
||||||
<span className="data-table-cell">PRICE</span>
|
{header}
|
||||||
<span className="data-table-cell">SIZE</span>
|
</span>
|
||||||
<span className="data-table-cell">VENUE</span>
|
))}
|
||||||
<span className="data-table-cell">TAPE</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="data-table-scroll" ref={state.equitiesScroll.setListRef}>
|
<div className="data-table-scroll" ref={state.equitiesScroll.setListRef}>
|
||||||
<div className="data-table-body" style={{ height: `${virtual.totalSize}px` }}>
|
<div className="data-table-body" role="rowgroup" style={{ height: `${virtual.totalSize}px` }}>
|
||||||
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => (
|
{virtual.virtualItems.map(({ item: print, key, index, start, size }) => (
|
||||||
<div
|
<div
|
||||||
className={`data-table-row data-table-row-equities data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}`}
|
className={`data-table-row data-table-row-equities data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}`}
|
||||||
|
role="row"
|
||||||
key={key}
|
key={key}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
data-row-start={String(start)}
|
data-row-start={String(start)}
|
||||||
|
|
@ -7759,8 +7822,8 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
||||||
data-tape-key={key}
|
data-tape-key={key}
|
||||||
style={{ transform: `translateY(${start}px)` }}
|
style={{ transform: `translateY(${start}px)` }}
|
||||||
>
|
>
|
||||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
<DataCell numeric title={formatDateTime(print.ts)}>{formatTime(print.ts)}</DataCell>
|
||||||
<span className="data-table-cell">
|
<DataCell title={print.underlying_id}>
|
||||||
<button
|
<button
|
||||||
className="instrument-cell-button"
|
className="instrument-cell-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -7768,11 +7831,11 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
||||||
>
|
>
|
||||||
{print.underlying_id}
|
{print.underlying_id}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">${formatPrice(print.price)}</span>
|
<DataCell numeric>${formatPrice(print.price)}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">{formatSize(print.size)}x</span>
|
<DataCell numeric>{formatSize(print.size)}x</DataCell>
|
||||||
<span className="data-table-cell">{print.exchange}</span>
|
<DataCell title={print.exchange}>{print.exchange}</DataCell>
|
||||||
<span className="data-table-cell">{print.offExchangeFlag ? "Off-Ex" : "Lit"}</span>
|
<DataCell>{print.offExchangeFlag ? "Off-Ex" : "Lit"}</DataCell>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -7825,7 +7888,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
||||||
>
|
>
|
||||||
<div className="data-table-shell">
|
<div className="data-table-shell">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="empty">
|
<EmptyState>
|
||||||
{state.tickerSet.size > 0
|
{state.tickerSet.size > 0
|
||||||
? "No flow packets match the current filter."
|
? "No flow packets match the current filter."
|
||||||
: state.mode === "live"
|
: state.mode === "live"
|
||||||
|
|
@ -7833,23 +7896,19 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
||||||
? "Feed behind. Waiting for fresh flow packets."
|
? "Feed behind. Waiting for fresh flow packets."
|
||||||
: "No flow packets yet. Start compute."
|
: "No flow packets yet. Start compute."
|
||||||
: "Replay queue empty. Ensure ClickHouse has data."}
|
: "Replay queue empty. Ensure ClickHouse has data."}
|
||||||
</div>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="data-table-wrap">
|
<div className="data-table-wrap">
|
||||||
<div className="data-table data-table-flow" role="table" aria-label="Flow packets">
|
<div className="data-table data-table-flow" role="table" aria-label="Flow packets">
|
||||||
<div className="data-table-head" role="row">
|
<div className="data-table-head" role="row">
|
||||||
<span className="data-table-cell">TIME</span>
|
{["TIME", "CONTRACT", "PRINTS", "SIZE", "NOTIONAL", "WINDOW", "STRUCTURE", "NBBO", "QUALITY"].map((header) => (
|
||||||
<span className="data-table-cell">CONTRACT</span>
|
<span className="data-table-cell" role="columnheader" key={header}>
|
||||||
<span className="data-table-cell">PRINTS</span>
|
{header}
|
||||||
<span className="data-table-cell">SIZE</span>
|
</span>
|
||||||
<span className="data-table-cell">NOTIONAL</span>
|
))}
|
||||||
<span className="data-table-cell">WINDOW</span>
|
|
||||||
<span className="data-table-cell">STRUCTURE</span>
|
|
||||||
<span className="data-table-cell">NBBO</span>
|
|
||||||
<span className="data-table-cell">QUALITY</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="data-table-scroll" ref={state.flowScroll.setListRef}>
|
<div className="data-table-scroll" ref={state.flowScroll.setListRef}>
|
||||||
<div className="data-table-body" style={{ height: `${virtual.totalSize}px` }}>
|
<div className="data-table-body" role="rowgroup" style={{ height: `${virtual.totalSize}px` }}>
|
||||||
{virtual.virtualItems.map(({ item: packet, key, index, start, size }) => {
|
{virtual.virtualItems.map(({ item: packet, key, index, start, size }) => {
|
||||||
const features = packet.features ?? {};
|
const features = packet.features ?? {};
|
||||||
const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
|
const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
|
||||||
|
|
@ -7904,6 +7963,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`data-table-row data-table-row-flow data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}${nbboStale || nbboMissing ? " data-table-row-warn" : ""}`}
|
className={`data-table-row data-table-row-flow data-table-virtual-row${index % 2 === 1 ? " is-even" : ""}${nbboStale || nbboMissing ? " data-table-row-warn" : ""}`}
|
||||||
|
role="row"
|
||||||
key={key}
|
key={key}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
data-row-start={String(start)}
|
data-row-start={String(start)}
|
||||||
|
|
@ -7911,15 +7971,15 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
||||||
data-tape-key={key}
|
data-tape-key={key}
|
||||||
style={{ transform: `translateY(${start}px)` }}
|
style={{ transform: `translateY(${start}px)` }}
|
||||||
>
|
>
|
||||||
<span className="data-table-cell data-table-cell-number">{formatTime(startTs)} → {formatTime(endTs)}</span>
|
<DataCell numeric title={`${formatDateTime(startTs)} to ${formatDateTime(endTs)}`}>{formatTime(startTs)} → {formatTime(endTs)}</DataCell>
|
||||||
<span className="data-table-cell">{contract}</span>
|
<DataCell title={contract}>{contract}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(count)}</span>
|
<DataCell numeric>{formatFlowMetric(count)}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">{formatFlowMetric(totalSize)}</span>
|
<DataCell numeric>{formatFlowMetric(totalSize)}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">${formatUsd(notional)}</span>
|
<DataCell numeric>${formatUsd(notional)}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</span>
|
<DataCell numeric>{windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"}</DataCell>
|
||||||
<span className="data-table-cell">{structureLabel}</span>
|
<DataCell title={structureLabel !== "--" ? structureLabel : undefined}>{structureLabel}</DataCell>
|
||||||
<span className="data-table-cell data-table-cell-number">{nbboLabel}</span>
|
<DataCell numeric title={nbboLabel !== "--" ? nbboLabel : undefined}>{nbboLabel}</DataCell>
|
||||||
<span className="data-table-cell">{qualityLabel || "--"}</span>
|
<DataCell title={qualityLabel || undefined}>{qualityLabel || "--"}</DataCell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue