Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/web/app/detections/[detectionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,11 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] })
title="No evidence available"
/>
) : (
<ol className="timeline-list">
{evidenceItems.map((item) => (
<ol
aria-label="Evidence timeline ordered oldest to newest"
className="timeline-list"
>
{evidenceItems.map((item, index) => (
<li
className={`timeline-item ${evidenceTypeClass(item.evidence_type)}`}
key={item.evidence_id}
Expand All @@ -231,6 +234,10 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] })
</div>
<time dateTime={item.timestamp}>{formatTimestamp(item.timestamp)}</time>
</div>
<p className="sr-only">
Evidence item {index + 1} of {evidenceItems.length}, ordered
by timestamp.
</p>
<p>{item.description}</p>
<dl className="evidence-meta">
<div>
Expand Down
81 changes: 75 additions & 6 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ a {
min-height: 100vh;
}

.skip-link {
position: absolute;
z-index: 20;
top: 10px;
left: 10px;
transform: translateY(-140%);
border: 2px solid var(--accent-strong);
border-radius: 7px;
background: var(--surface);
color: var(--accent-strong);
font-weight: 760;
padding: 10px 12px;
}

.skip-link:focus-visible {
transform: translateY(0);
outline: 3px solid #cfe7db;
}

.site-header {
border-bottom: 1px solid var(--border);
background: rgb(255 255 255 / 92%);
Expand All @@ -59,6 +78,12 @@ a {
.brand {
display: grid;
gap: 2px;
border-radius: 7px;
}

.brand:focus-visible {
outline: 3px solid #cfe7db;
outline-offset: 4px;
}

.brand-name {
Expand Down Expand Up @@ -93,7 +118,8 @@ a {
.nav-link:focus-visible {
border-color: var(--border);
background: var(--surface-muted);
outline: none;
outline: 3px solid #cfe7db;
outline-offset: 2px;
}

.page-shell {
Expand All @@ -102,6 +128,23 @@ a {
padding: 28px 0 48px;
}

.page-shell:focus-visible {
outline: 3px solid #cfe7db;
outline-offset: 6px;
}

.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
padding: 0;
}

.hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr);
Expand Down Expand Up @@ -339,7 +382,8 @@ h3 {
.primary-action:hover,
.primary-action:focus-visible {
background: var(--accent-strong);
outline: none;
outline: 3px solid #cfe7db;
outline-offset: 2px;
}

.secondary-action {
Expand All @@ -350,7 +394,8 @@ h3 {
.secondary-action:hover,
.secondary-action:focus-visible {
background: #edf6f1;
outline: none;
outline: 3px solid #cfe7db;
outline-offset: 2px;
}

.overview-grid {
Expand Down Expand Up @@ -547,7 +592,8 @@ h3 {
.back-link:hover,
.back-link:focus-visible {
color: var(--accent-strong);
outline: none;
outline: 3px solid #cfe7db;
outline-offset: 3px;
}

.content-panel {
Expand Down Expand Up @@ -899,6 +945,13 @@ h3 {
gap: 14px;
}

.form-help {
margin-bottom: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
}

.review-form div {
display: grid;
gap: 7px;
Expand Down Expand Up @@ -926,7 +979,8 @@ h3 {
.review-form input:focus,
.review-form textarea:focus {
border-color: var(--accent);
outline: 3px solid #dceee6;
outline: 3px solid #cfe7db;
outline-offset: 1px;
}

.review-actions {
Expand Down Expand Up @@ -963,7 +1017,22 @@ h3 {
}

.review-actions button:focus-visible {
outline: 3px solid #dceee6;
outline: 3px solid #cfe7db;
outline-offset: 2px;
}

.submit-status {
min-height: 1.25em;
color: var(--muted);
font-size: 0.9rem;
font-weight: 650;
line-height: 1.4;
}

.draft-copy button:focus-visible,
.review-actions button:focus-visible {
outline: 3px solid #cfe7db;
outline-offset: 2px;
}

.decision-result {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<html lang="en">
<body>
<div className="shell">
<a className="skip-link" href="#main-content">
Skip to main content
</a>
<header className="site-header">
<div className="header-inner">
<Link className="brand" href="/">
Expand All @@ -38,7 +41,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<DemoDataBadge />
</div>
</header>
<main className="page-shell">{children}</main>
<main className="page-shell" id="main-content" tabIndex={-1}>
{children}
</main>
</div>
</body>
</html>
Expand Down
28 changes: 27 additions & 1 deletion apps/web/app/recommendations/recommendation-review-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function RecommendationReviewPanel({

const canSubmit =
reviewer.trim().length > 0 && reason.trim().length > 0 && submittingAction === null;
const isSubmitting = submittingAction !== null;

async function submitDecision(action: DecisionAction) {
setSubmittingAction(action);
Expand Down Expand Up @@ -96,10 +97,20 @@ export function RecommendationReviewPanel({
<dd>{formatEvidenceIds(recommendation.evidence_ids)}</dd>
</div>
</dl>
<form className="review-form" onSubmit={handleSubmit}>
<form
aria-busy={isSubmitting}
aria-describedby="decision-form-help decision-submit-status"
className="review-form"
onSubmit={handleSubmit}
>
<p className="form-help" id="decision-form-help">
Enter a reviewer and decision reason before approving, rejecting, or
deferring this simulator-backed recommendation.
</p>
<div>
<label htmlFor="reviewer">Reviewer name</label>
<input
aria-describedby="decision-form-help"
id="reviewer"
name="reviewer"
onChange={(event) => setReviewer(event.target.value)}
Expand All @@ -111,6 +122,7 @@ export function RecommendationReviewPanel({
<div>
<label htmlFor="reason">Decision reason</label>
<textarea
aria-describedby="decision-form-help"
id="reason"
name="reason"
onChange={(event) => setReason(event.target.value)}
Expand All @@ -121,27 +133,41 @@ export function RecommendationReviewPanel({
</div>
<div className="review-actions" aria-label="Recommendation decision actions">
<button
aria-describedby="decision-submit-status"
disabled={!canSubmit}
onClick={() => submitDecision("approve")}
type="button"
>
{submittingAction === "approve" ? "Approving..." : "Approve"}
</button>
<button
aria-describedby="decision-submit-status"
disabled={!canSubmit}
onClick={() => submitDecision("reject")}
type="button"
>
{submittingAction === "reject" ? "Rejecting..." : "Reject"}
</button>
<button
aria-describedby="decision-submit-status"
disabled={!canSubmit}
onClick={() => submitDecision("defer")}
type="button"
>
{submittingAction === "defer" ? "Deferring..." : "Defer"}
</button>
</div>
<div
className="submit-status"
id="decision-submit-status"
role="status"
>
{isSubmitting
? `Recording ${submittingAction} decision...`
: canSubmit
? "Decision actions are available."
: "Decision actions are disabled until reviewer and reason are entered."}
</div>
</form>
{errorMessage !== null ? (
<div className="state-panel error-panel" role="alert">
Expand Down
14 changes: 12 additions & 2 deletions apps/web/e2e/operations-workbench-demo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ test("walks the simulator-backed Operations Workbench demo path", async ({ page
resetDemoState();

await page.goto("/");
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: "Skip to main content" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#main-content")).toBeFocused();
await expect(page.getByText("Simulator-backed demo data").first()).toBeVisible();
await expect(page.getByText("Synthetic local scenario; not real plant data.").first()).toBeVisible();
await expect(page.getByRole("region", { name: "Local API connection state" })).toBeVisible();
Expand Down Expand Up @@ -76,13 +80,19 @@ async function recordDecision(
action: "Approve" | "Reject" | "Defer",
expectedDecision: "approved" | "rejected" | "deferred",
) {
await expect(page.getByRole("button", { name: action })).toBeDisabled();
await page.getByLabel("Reviewer name").fill(`quality_engineer_${expectedDecision}`);
await page
.getByLabel("Decision reason")
.fill(`Playwright smoke test recorded a ${expectedDecision} demo decision.`);
await page.getByRole("button", { name: action }).click();
await expect(page.getByRole("button", { name: action })).toBeEnabled();
await expect(page.locator("#decision-submit-status")).toContainText(
"Decision actions are available.",
);
await page.getByRole("button", { name: action }).focus();
await page.keyboard.press("Enter");

const feedback = page.getByRole("status");
const feedback = page.locator(".decision-result");
await expect(feedback).toContainText(`Demo audit feedback: ${expectedDecision}`);
await expect(feedback).toContainText(`Reviewer: quality_engineer_${expectedDecision}`);
await expect(feedback).toContainText(`Updated status: ${expectedDecision}`);
Expand Down
Loading
Loading