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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,11 @@ pnpm run lint && pnpm run test && pnpm run build
Repeated web objects use `frontend/src/styles/tokens.css`, not inline hex
(ADR 0099 badge/accent tokens, with dark-mode overrides guarded by
`tokens.test.ts`); new stories belong in the inventory at
`docs/storybook-inventory.md`.
`docs/storybook-inventory.md`. Success, unavailable, and retry copy share
`StatusNotice` (ADR 0220): Calendar's missing Naruon projection is the first
migrated flow. Success and unavailable stay a named region (not live
`role="status"`); retry stays `role="alert"`. Do not add a second placeholder
or interpolate provider payloads into that notice.

A run-bearing analysis-run registry empties only after an unrevoked
`analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.d/2.19.0-status-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## 2.19.0 — token-backed status notice

Calendar's missing Naruon projection now uses one shared `StatusNotice`
(ADR 0220): unavailable copy and the next action sit in a named region,
while a retry kind keeps `role="alert"` plus Retry. Success, unavailable,
and retry are named by label and glyph, not color alone. Synthetic
fixtures only.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity
(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233), occupational construct catalog search
(ADR 0257), the text-channel embedding swap and cosine
clamp (ADR 0190), per-edge channel-score persistence (ADR 0195),
token-backed status notices (ADR 0220),
migration replay (ADR 0166), docstring coverage, and the measurement
boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before
changing code, tests, or runtime policy rather than restating anything
Expand Down
8 changes: 7 additions & 1 deletion backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4905,7 +4905,13 @@ def test_calendar_is_empty_before_any_commitment(client, demo_analyst_token, see
assert payload["commitments"] == []
assert payload["events"] == []
assert payload["calendar_sources"]["naruon_available"] is False
assert "Connect the Naruon calendar projection" in payload["calendar_sources"]["naruon_next_action"]
next_action = payload["calendar_sources"]["naruon_next_action"]
assert next_action == (
"Ask your workspace administrator to enable calendar access. "
"Open a commitment below to read its source post."
)
assert "Naruon" not in next_action
assert "projection" not in next_action
assert "caldav_available" not in payload["calendar_sources"]


Expand Down
58 changes: 58 additions & 0 deletions docs/adr/0220-token-backed-status-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ADR 0220: Share one token-backed status notice

- Status: Accepted
- Date: 2026-08-25
- Issue: #611
- Supersedes closed-branch ADR 0134 for current protected `main` only

## Context

Issue #611 decomposes closed PR #490 without replaying that 321-file tree.
Closed-branch ADR 0134 required a shared token-backed exception surface with
success, unavailable, and retry states. Protected `main` already sanitizes
provider failures (ADR 0123) and has ad hoc `role="alert"` / placeholder copy,
but it has no shared accessible notice. Calendar's Naruon fail-closed path
(ADR 0203) currently renders the next action as a second placeholder without
an accessible status.

## Decision

Add one `StatusNotice` component under `frontend/src/components/` that:

1. Accepts only `success`, `unavailable`, or `retry`.
2. Distinguishes those kinds by visible label text and glyph shape, not color
alone (WCAG 1.4.1). Color uses the existing ADR 0099 badge-status tokens.
3. Uses a named `region` (`role="region"` plus `aria-label`) for success
and unavailable so the notice does not collide with App live-region
uniqueness (`getByRole("status")`). Retry uses `role="alert"`.
Unavailable is missing evidence, not a transport failure.
4. Renders caller-supplied message and optional next-action copy. It never
interpolates provider payloads, credentials, or raw HTTP bodies (ADR 0123).
Customer-facing copy names the available capability and next action, never
an internal provider, model, transport, environment variable, or projection.
5. Shows a retry control only on the retry kind when the caller supplies
`onRetry`.

The first migrated product flow is the Calendar Naruon fail-closed path.
Do not copy closed-branch exception classes or Storybook inventories from
PR #490. Later unavailable flows migrate one at a time.

## Consequences

- Calendar names the missing Naruon projection and the next action in one
accessible notice while commitments remain clickable.
- Storybook `Chrome/StatusNotice` covers success, unavailable, and retry.
- New product failures must reuse this component instead of a second
placeholder or inline `role="alert"` with raw hex.

## References — APA 7th

World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines
(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/

World Wide Web Consortium. (2023). *ARIA in HTML* (W3C Recommendation).
https://www.w3.org/TR/html-aria/

National Institute of Standards and Technology. (2020). *Security and privacy
controls for information systems and organizations* (NIST Special Publication
800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ operator-facing control you can click before changing product CSS.
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
| `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` |
| `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` |
| `Chrome/StatusNotice` | Read success, unavailable, or retry copy, then take the named next action. Success and unavailable are a named region (not live `role=status`); Retry is `role=alert` and only on the retry kind. Calendar's missing Naruon projection uses unavailable. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
| `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` |
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1570,3 +1570,94 @@
--color-dashboard-surface: #1f2028;
}
}

.status-notice {
display: grid;
gap: var(--space-control-gap);
margin-top: var(--space-panel-block);
padding: var(--space-panel-block);
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
}

.status-notice-heading {
display: inline-flex;
align-items: center;
gap: var(--space-chip-gap);
margin: 0;
font-size: var(--font-size-badge);
font-weight: 600;
}

.status-notice-glyph {
font-size: 0.7em;
line-height: 1;
}

.status-notice-message,
.status-notice-next-action {
margin: 0;
font-size: 0.85rem;
}

.status-notice-retry {
justify-self: start;
min-height: var(--size-control-min);
}

.ask-agent-form {
display: grid;
gap: var(--space-control-gap);
max-width: 42rem;
}

.ask-agent-field {
display: grid;
gap: 0.35rem;
}

.ask-agent-field textarea,
.ask-agent-field input {
box-sizing: border-box;
width: 100%;
min-height: var(--size-control-min);
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-background);
color: var(--color-text);
font: inherit;
padding: 0.65rem;
}

.ask-agent-checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: var(--size-control-min);
}

.ask-agent-form > .btn-primary {
justify-self: start;
min-height: var(--size-control-min);
}

.status-notice-kind-success {
background: var(--badge-status-success-bg);
color: var(--badge-status-success-text);
}

.status-notice-kind-unavailable {
background: var(--badge-status-pending-bg);
color: var(--badge-status-pending-text);
}

.status-notice-kind-retry {
background: var(--badge-status-danger-bg);
color: var(--badge-status-danger-text);
}

.keyman-select {
background: none;
border: none;
padding: 0;
color: inherit;
5 changes: 4 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ describe("App, authenticated", () => {
calendar_sources: {
naruon_available: false,
naruon_next_action:
"Connect the Naruon calendar projection. Open a commitment below to read that post.",
"Ask your workspace administrator to enable calendar access. Open a commitment below to read its source post.",
},
}),
);
Expand Down Expand Up @@ -4352,6 +4352,9 @@ describe("App, authenticated", () => {
await userEvent.click(await screen.findByRole("button", { name: "달력" }));
expect(screen.getByRole("heading", { name: "달력" })).toBeInTheDocument();
expect(screen.getByText("이 범위의 일정을 아직 받을 수 없습니다")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: /^Unavailable:/ }),
).toHaveTextContent("이 범위의 일정을 아직 받을 수 없습니다");
expect(screen.getByRole("heading", { name: "Observed calendar events" })).toBeInTheDocument();
expect(screen.queryByText(/CalDAV/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Buyer|Cubee/i)).not.toBeInTheDocument();
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/components/StatusNotice.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { StatusNotice } from "./StatusNotice";
import "../App.css";

const meta = {
title: "Chrome/StatusNotice",
component: StatusNotice,
parameters: { layout: "padded" },
} satisfies Meta<typeof StatusNotice>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Success: Story = {
args: {
kind: "success",
message: "Observed calendar events are ready. Open a commitment to read that post.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const notice = canvas.getByRole("region", { name: /^Ready:/ });
await expect(notice).toHaveTextContent("Ready");
await expect(notice.getAttribute("aria-label")).toMatch(/ready to use/i);
await expect(canvas.queryByRole("button")).toBeNull();
await expect(canvas.queryByRole("status")).toBeNull();
},
};

export const Unavailable: Story = {
args: {
kind: "unavailable",
message: "이 범위의 일정을 아직 받을 수 없습니다",
nextAction:
"Ask your workspace administrator to enable calendar access. Open a commitment below to read its source post.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const notice = canvas.getByRole("region", { name: /^Unavailable:/ });
await expect(notice).toHaveTextContent("Unavailable");
await expect(notice).toHaveTextContent("이 범위의 일정을 아직 받을 수 없습니다");
await expect(notice).toHaveTextContent("enable calendar access");
await expect(notice).not.toHaveTextContent(/Naruon|provider|model|transport|environment/i);
await expect(canvas.queryByRole("alert")).toBeNull();
await expect(canvas.queryByRole("status")).toBeNull();
},
};

export const Retry: Story = {
args: {
kind: "retry",
message: "Dashboard 근거를 불러오지 못했습니다.",
onRetry: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const notice = canvas.getByRole("alert");
await expect(notice).not.toHaveAttribute("aria-label");
await expect(notice).toHaveTextContent("Retry needed");
await expect(notice).toHaveTextContent("Dashboard 근거를 불러오지 못했습니다.");
await userEvent.click(canvas.getByRole("button", { name: "Retry" }));
await expect(args.onRetry).toHaveBeenCalledTimes(1);
},
};
94 changes: 94 additions & 0 deletions frontend/src/components/StatusNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StatusNotice } from "./StatusNotice";
import { setLocale } from "../i18n";

describe("StatusNotice", () => {
afterEach(() => {
setLocale("en");
});

it("names passive regions and leaves retry alert content exposed", () => {
const { rerender } = render(
<StatusNotice kind="success" message="Observed events loaded." />,
);
const success = screen.getByRole("region").getAttribute("aria-label");

rerender(<StatusNotice kind="unavailable" message="Calendar projection is missing." />);
const unavailable = screen.getByRole("region").getAttribute("aria-label");

rerender(<StatusNotice kind="retry" message="Dashboard evidence did not load." />);
const retry = screen.getByRole("alert");

expect(new Set([success, unavailable]).size).toBe(2);
expect(success).toMatch(/^Ready:/);
expect(unavailable).toMatch(/^Unavailable:/);
expect(retry).not.toHaveAttribute("aria-label");
expect(retry).toHaveTextContent("Retry needed");
expect(retry).toHaveTextContent("Dashboard evidence did not load.");
});

it("keeps success and unavailable on a named region and retry on role=alert", () => {
const { rerender } = render(
<StatusNotice kind="unavailable" message="이 범위의 일정을 아직 받을 수 없습니다" />,
);
expect(screen.getByRole("region", { name: /^Unavailable:/ })).toHaveTextContent(
"Unavailable",
);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();

rerender(<StatusNotice kind="retry" message="Dashboard 근거를 불러오지 못했습니다." />);
expect(screen.getByRole("alert")).toHaveTextContent("Retry needed");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

it("shows the next action without inventing evidence", () => {
render(
<StatusNotice
kind="unavailable"
message="이 범위의 일정을 아직 받을 수 없습니다"
nextAction="Ask your workspace administrator to enable calendar access. Open a commitment below to read its source post."
/>,
);
expect(screen.getByRole("region", { name: /^Unavailable:/ })).toHaveTextContent(
"enable calendar access",
);
expect(screen.getByRole("region", { name: /^Unavailable:/ })).not.toHaveTextContent(
/Naruon|provider|model|transport|environment/i,
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("retries only on the retry kind", async () => {
const onRetry = vi.fn();
const { rerender } = render(
<StatusNotice kind="unavailable" message="Missing projection." onRetry={onRetry} />,
);
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();

rerender(
<StatusNotice kind="retry" message="Dashboard 근거를 불러오지 못했습니다." onRetry={onRetry} />,
);
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetry).toHaveBeenCalledTimes(1);
});

it("localizes the kind label and keeps caller message text", () => {
setLocale("ko");
render(
<StatusNotice kind="unavailable" message="이 범위의 일정을 아직 받을 수 없습니다" />,
);
const notice = screen.getByRole("region", { name: /^사용할 수 없음:/ });
expect(notice).toHaveTextContent("사용할 수 없음");
expect(notice).toHaveTextContent("이 범위의 일정을 아직 받을 수 없습니다");
expect(notice.getAttribute("aria-label")).toContain("다음 조치");
});

it("hides the decorative glyph from assistive tech", () => {
render(<StatusNotice kind="success" message="Observed events loaded." />);
const glyph = screen.getByRole("region", { name: /^Ready:/ }).querySelector(".status-notice-glyph");
expect(glyph).toHaveAttribute("aria-hidden", "true");
});
});
Loading
Loading