From c4cbf91778fc7272b6e61e4b833d427e66fbf26d Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 15:16:27 +0000 Subject: [PATCH 1/5] feat(ui): share one token-backed status notice (v2.19.0) Calendar's missing Naruon projection uses StatusNotice (ADR 0214 / #611). Success and unavailable are a named region so App live-region uniqueness holds; retry stays role=alert. Synthetic fixtures only. --- AGENTS.md | 6 +- CHANGELOG.d/2.19.0-status-notice.md | 7 ++ CLAUDE.md | 1 + docs/adr/0214-token-backed-status-notice.md | 56 ++++++++++++ docs/storybook-inventory.md | 1 + frontend/src/App.css | 91 +++++++++++++++++++ frontend/src/App.test.tsx | 3 + .../src/components/StatusNotice.stories.tsx | 62 +++++++++++++ frontend/src/components/StatusNotice.test.tsx | 89 ++++++++++++++++++ frontend/src/components/StatusNotice.tsx | 74 +++++++++++++++ .../components/WorkspaceCalendar.stories.tsx | 14 ++- .../src/components/WorkspaceCalendar.test.tsx | 4 + frontend/src/components/WorkspaceCalendar.tsx | 16 ++-- frontend/src/i18n.ts | 35 +++++++ 14 files changed, 450 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.d/2.19.0-status-notice.md create mode 100644 docs/adr/0214-token-backed-status-notice.md create mode 100644 frontend/src/components/StatusNotice.stories.tsx create mode 100644 frontend/src/components/StatusNotice.test.tsx create mode 100644 frontend/src/components/StatusNotice.tsx diff --git a/AGENTS.md b/AGENTS.md index 5daf15b45..c3be6d85a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 0214): 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` diff --git a/CHANGELOG.d/2.19.0-status-notice.md b/CHANGELOG.d/2.19.0-status-notice.md new file mode 100644 index 000000000..06dfbc908 --- /dev/null +++ b/CHANGELOG.d/2.19.0-status-notice.md @@ -0,0 +1,7 @@ +## 2.19.0 — token-backed status notice + +Calendar's missing Naruon projection now uses one shared `StatusNotice` +(ADR 0214): 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. diff --git a/CLAUDE.md b/CLAUDE.md index 7cc24d47d..dac7cbb93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 0214), 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 diff --git a/docs/adr/0214-token-backed-status-notice.md b/docs/adr/0214-token-backed-status-notice.md new file mode 100644 index 000000000..00f2573dc --- /dev/null +++ b/docs/adr/0214-token-backed-status-notice.md @@ -0,0 +1,56 @@ +# ADR 0214: 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). +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 diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 8891ba568..45eb70945 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -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` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 78e4d09a9..3e4c13599 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 3d5a3d572..4ce8804bd 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -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(); diff --git a/frontend/src/components/StatusNotice.stories.tsx b/frontend/src/components/StatusNotice.stories.tsx new file mode 100644 index 000000000..867bd821e --- /dev/null +++ b/frontend/src/components/StatusNotice.stories.tsx @@ -0,0 +1,62 @@ +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; + +export default meta; + +type Story = StoryObj; + +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: + "Connect the Naruon calendar projection. Open a commitment below to read that 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("Connect the Naruon calendar projection"); + 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).toHaveTextContent("Retry needed"); + await userEvent.click(canvas.getByRole("button", { name: "Retry" })); + await expect(args.onRetry).toHaveBeenCalledTimes(1); + }, +}; diff --git a/frontend/src/components/StatusNotice.test.tsx b/frontend/src/components/StatusNotice.test.tsx new file mode 100644 index 000000000..e94de98c1 --- /dev/null +++ b/frontend/src/components/StatusNotice.test.tsx @@ -0,0 +1,89 @@ +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("gives success, unavailable, and retry each a distinct accessible name", () => { + const { rerender } = render( + , + ); + const success = screen.getByRole("region").getAttribute("aria-label"); + + rerender(); + const unavailable = screen.getByRole("region").getAttribute("aria-label"); + + rerender(); + const retry = screen.getByRole("alert").getAttribute("aria-label"); + + expect(new Set([success, unavailable, retry]).size).toBe(3); + expect(success).toMatch(/^Ready:/); + expect(unavailable).toMatch(/^Unavailable:/); + expect(retry).toMatch(/^Retry needed:/); + }); + + it("keeps success and unavailable on a named region and retry on role=alert", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("region", { name: /^Unavailable:/ })).toHaveTextContent( + "Unavailable", + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("Retry needed"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("shows the next action without inventing evidence", () => { + render( + , + ); + expect(screen.getByRole("region", { name: /^Unavailable:/ })).toHaveTextContent( + "Connect the Naruon calendar projection", + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("retries only on the retry kind", async () => { + const onRetry = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + + rerender( + , + ); + 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( + , + ); + 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(); + const glyph = screen.getByRole("region", { name: /^Ready:/ }).querySelector(".status-notice-glyph"); + expect(glyph).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/frontend/src/components/StatusNotice.tsx b/frontend/src/components/StatusNotice.tsx new file mode 100644 index 000000000..3101fd95c --- /dev/null +++ b/frontend/src/components/StatusNotice.tsx @@ -0,0 +1,74 @@ +import { t } from "../i18n"; + +/** Shared token-backed next-action notice (ADR 0214). */ +export type StatusNoticeKind = "success" | "unavailable" | "retry"; + +const KIND_GLYPH: Record = { + success: "●", + unavailable: "◌", + retry: "△", +}; + +const KIND_LABEL_KEY: Record = { + success: "Ready", + unavailable: "Unavailable", + retry: "Retry needed", +}; + +const KIND_DESCRIPTION_KEY: Record = { + success: "This evidence is ready to use.", + unavailable: "This evidence is unavailable. Follow the next action.", + retry: "This request failed. Retry the same action.", +}; + +export type StatusNoticeProps = { + kind: StatusNoticeKind; + message: string; + nextAction?: string; + retryLabel?: string; + onRetry?: () => void; +}; + +/** + * One accessible notice for success, unavailable, and retry states. + * + * Color is never the only channel: each kind keeps a distinct glyph and + * visible label. Callers pass already-localized message text and must not + * interpolate provider payloads (ADR 0123). + * + * Success and unavailable are a named region, not `role="status"`, so they + * do not collide with App live-region uniqueness. Retry is `role="alert"`. + */ +export function StatusNotice({ + kind, + message, + nextAction, + retryLabel, + onRetry, +}: StatusNoticeProps) { + const label = t(KIND_LABEL_KEY[kind]); + const description = t(KIND_DESCRIPTION_KEY[kind]); + const showRetry = kind === "retry" && typeof onRetry === "function"; + const isRetry = kind === "retry"; + return ( +
+

+ + {label} +

+

{message}

+ {nextAction ?

{nextAction}

: null} + {showRetry ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/components/WorkspaceCalendar.stories.tsx b/frontend/src/components/WorkspaceCalendar.stories.tsx index 60f864a5f..752296622 100644 --- a/frontend/src/components/WorkspaceCalendar.stories.tsx +++ b/frontend/src/components/WorkspaceCalendar.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { WorkspaceCalendar } from "./WorkspaceCalendar"; import type { CalendarResponse } from "../api"; +import "../App.css"; const unavailable: CalendarResponse = { events: [], @@ -63,7 +65,17 @@ export default meta; type Story = StoryObj; -export const NaruonUnavailable: Story = {}; +export const NaruonUnavailable: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const notice = canvas.getByRole("region", { name: /^Unavailable:/ }); + await expect(notice).toHaveTextContent("이 범위의 일정을 아직 받을 수 없습니다"); + await expect(notice).toHaveTextContent("Connect the Naruon calendar projection"); + await expect( + canvas.getByRole("button", { name: /open commitment for: public post/i }), + ).toBeVisible(); + }, +}; export const ObservedOccurrence: Story = { args: { calendar: observed }, diff --git a/frontend/src/components/WorkspaceCalendar.test.tsx b/frontend/src/components/WorkspaceCalendar.test.tsx index 3b7a5eca0..aaa7f2fde 100644 --- a/frontend/src/components/WorkspaceCalendar.test.tsx +++ b/frontend/src/components/WorkspaceCalendar.test.tsx @@ -43,6 +43,10 @@ describe("WorkspaceCalendar", () => { expect(screen.getByRole("heading", { name: "달력" })).toBeInTheDocument(); expect(screen.getByText(CALENDAR_CONSUME_UNAVAILABLE)).toBeInTheDocument(); + const notice = screen.getByRole("region", { name: /^Unavailable:/ }); + expect(notice).toHaveTextContent(CALENDAR_CONSUME_UNAVAILABLE); + expect(notice).toHaveTextContent("Connect the Naruon calendar projection"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); expect(screen.queryByText(/CalDAV/i)).not.toBeInTheDocument(); await userEvent.click( screen.getByRole("button", { name: /open commitment for: public post/i }), diff --git a/frontend/src/components/WorkspaceCalendar.tsx b/frontend/src/components/WorkspaceCalendar.tsx index 0b37ae2dc..8fd814f73 100644 --- a/frontend/src/components/WorkspaceCalendar.tsx +++ b/frontend/src/components/WorkspaceCalendar.tsx @@ -1,4 +1,5 @@ import { EvidenceStatusMark } from "./EvidenceStatusMark"; +import { StatusNotice } from "./StatusNotice"; import { CALENDAR_CONSUME_UNAVAILABLE } from "../gnbChrome"; import type { CalendarResponse, NaruonCalendarEvent } from "../api"; import { t } from "../i18n"; @@ -32,11 +33,15 @@ export function WorkspaceCalendar({

{heading}

{t("Observed calendar events")}

- {events.length === 0 ? ( + {!naruonAvailable ? ( + + ) : events.length === 0 ? (

- {naruonAvailable - ? t("No observed calendar events are available.") - : failClosedCopy} + {t("No observed calendar events are available.")}

) : (
    @@ -45,9 +50,6 @@ export function WorkspaceCalendar({ ))}
)} - {!naruonAvailable && naruonNextAction ? ( -

{naruonNextAction}

- ) : null}

{t("Upcoming commitments")}

diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 2302fea56..ae3b870a3 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -75,6 +75,15 @@ const TRANSLATIONS: Partial>> = { "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": "예정된 약속이 없습니다. 글에서 약속을 찾거나 기한이 있는 티켓을 만드세요.", "Open commitment for:": "약속 열기:", + Ready: "준비됨", + Unavailable: "사용할 수 없음", + "Retry needed": "다시 시도 필요", + "This evidence is ready to use.": "이 근거를 사용할 수 있습니다.", + "This evidence is unavailable. Follow the next action.": + "이 근거를 아직 사용할 수 없습니다. 다음 조치를 진행하세요.", + "This request failed. Retry the same action.": + "요청이 실패했습니다. 같은 조치를 다시 시도하세요.", + Retry: "다시 시도", "Advanced review tools": "고급 검토 도구", "Evidence operations": "증거 처리", "Evidence provenance": "근거 출처", @@ -634,6 +643,14 @@ const TRANSLATIONS: Partial>> = { "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": "没有即将到来的承诺。请从文章中查找承诺,或创建带截止日期的工单。", "Open commitment for:": "打开承诺:", + Ready: "已就绪", + Unavailable: "不可用", + "Retry needed": "需要重试", + "This evidence is ready to use.": "可以使用该证据。", + "This evidence is unavailable. Follow the next action.": + "该证据尚不可用。请按下一步操作继续。", + "This request failed. Retry the same action.": "请求失败。请重试同一操作。", + Retry: "重试", "Advanced review tools": "高级审查工具", "Evidence operations": "证据操作", "Evidence provenance": "证据来源", @@ -1209,6 +1226,15 @@ const TRANSLATIONS: Partial>> = { "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": "今後のコミットメントはありません。投稿から検索するか、期限付きのチケットを作成してください。", "Open commitment for:": "コミットメントを開く:", + Ready: "利用可能", + Unavailable: "利用できません", + "Retry needed": "再試行が必要", + "This evidence is ready to use.": "この根拠を利用できます。", + "This evidence is unavailable. Follow the next action.": + "この根拠はまだ利用できません。次の操作に進んでください。", + "This request failed. Retry the same action.": + "要求が失敗しました。同じ操作を再試行してください。", + Retry: "再試行", "Advanced review tools": "高度なレビュー ツール", "Evidence operations": "証拠操作", "Evidence provenance": "証拠の出所", @@ -1763,6 +1789,15 @@ const TRANSLATIONS: Partial>> = { "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": "Không có cam kết sắp tới. Hãy tìm cam kết từ bài viết hoặc tạo phiếu có hạn hoàn thành.", "Open commitment for:": "Mở cam kết:", + Ready: "Sẵn sàng", + Unavailable: "Không khả dụng", + "Retry needed": "Cần thử lại", + "This evidence is ready to use.": "Có thể dùng bằng chứng này.", + "This evidence is unavailable. Follow the next action.": + "Bằng chứng này chưa khả dụng. Hãy làm bước tiếp theo.", + "This request failed. Retry the same action.": + "Yêu cầu thất bại. Hãy thử lại cùng thao tác.", + Retry: "Thử lại", "Advanced review tools": "Công cụ rà soát nâng cao", "Evidence operations": "Thao tác bằng chứng", "Evidence provenance": "Nguồn gốc bằng chứng", From a56e6918c5a71e8266fa5db713174232ccd05837 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:52:13 +0900 Subject: [PATCH 2/5] docs: reserve status notice ADR 0220 --- AGENTS.md | 2 +- CHANGELOG.d/2.19.0-status-notice.md | 2 +- CLAUDE.md | 2 +- ...cked-status-notice.md => 0220-token-backed-status-notice.md} | 2 +- frontend/src/components/StatusNotice.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename docs/adr/{0214-token-backed-status-notice.md => 0220-token-backed-status-notice.md} (98%) diff --git a/AGENTS.md b/AGENTS.md index c3be6d85a..8ef2bc751 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,7 +328,7 @@ 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`. Success, unavailable, and retry copy share -`StatusNotice` (ADR 0214): Calendar's missing Naruon projection is the first +`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. diff --git a/CHANGELOG.d/2.19.0-status-notice.md b/CHANGELOG.d/2.19.0-status-notice.md index 06dfbc908..da0a3abd7 100644 --- a/CHANGELOG.d/2.19.0-status-notice.md +++ b/CHANGELOG.d/2.19.0-status-notice.md @@ -1,7 +1,7 @@ ## 2.19.0 — token-backed status notice Calendar's missing Naruon projection now uses one shared `StatusNotice` -(ADR 0214): unavailable copy and the next action sit in a named region, +(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. diff --git a/CLAUDE.md b/CLAUDE.md index dac7cbb93..84d7f4095 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +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 0214), +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 diff --git a/docs/adr/0214-token-backed-status-notice.md b/docs/adr/0220-token-backed-status-notice.md similarity index 98% rename from docs/adr/0214-token-backed-status-notice.md rename to docs/adr/0220-token-backed-status-notice.md index 00f2573dc..486af387d 100644 --- a/docs/adr/0214-token-backed-status-notice.md +++ b/docs/adr/0220-token-backed-status-notice.md @@ -1,4 +1,4 @@ -# ADR 0214: Share one token-backed status notice +# ADR 0220: Share one token-backed status notice - Status: Accepted - Date: 2026-08-25 diff --git a/frontend/src/components/StatusNotice.tsx b/frontend/src/components/StatusNotice.tsx index 3101fd95c..9e6754776 100644 --- a/frontend/src/components/StatusNotice.tsx +++ b/frontend/src/components/StatusNotice.tsx @@ -1,6 +1,6 @@ import { t } from "../i18n"; -/** Shared token-backed next-action notice (ADR 0214). */ +/** Shared token-backed next-action notice (ADR 0220). */ export type StatusNoticeKind = "success" | "unavailable" | "retry"; const KIND_GLYPH: Record = { From 08f4bae5c8ac28d3a86811964c9d31394771074d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 04:11:53 +0900 Subject: [PATCH 3/5] fix(a11y): expose retry alert message --- frontend/src/components/StatusNotice.stories.tsx | 2 ++ frontend/src/components/StatusNotice.test.tsx | 10 ++++++---- frontend/src/components/StatusNotice.tsx | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/StatusNotice.stories.tsx b/frontend/src/components/StatusNotice.stories.tsx index 867bd821e..8a9b7a470 100644 --- a/frontend/src/components/StatusNotice.stories.tsx +++ b/frontend/src/components/StatusNotice.stories.tsx @@ -55,7 +55,9 @@ export const Retry: Story = { 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); }, diff --git a/frontend/src/components/StatusNotice.test.tsx b/frontend/src/components/StatusNotice.test.tsx index e94de98c1..a0322e514 100644 --- a/frontend/src/components/StatusNotice.test.tsx +++ b/frontend/src/components/StatusNotice.test.tsx @@ -9,7 +9,7 @@ describe("StatusNotice", () => { setLocale("en"); }); - it("gives success, unavailable, and retry each a distinct accessible name", () => { + it("names passive regions and leaves retry alert content exposed", () => { const { rerender } = render( , ); @@ -19,12 +19,14 @@ describe("StatusNotice", () => { const unavailable = screen.getByRole("region").getAttribute("aria-label"); rerender(); - const retry = screen.getByRole("alert").getAttribute("aria-label"); + const retry = screen.getByRole("alert"); - expect(new Set([success, unavailable, retry]).size).toBe(3); + expect(new Set([success, unavailable]).size).toBe(2); expect(success).toMatch(/^Ready:/); expect(unavailable).toMatch(/^Unavailable:/); - expect(retry).toMatch(/^Retry needed:/); + 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", () => { diff --git a/frontend/src/components/StatusNotice.tsx b/frontend/src/components/StatusNotice.tsx index 9e6754776..fa0d99b79 100644 --- a/frontend/src/components/StatusNotice.tsx +++ b/frontend/src/components/StatusNotice.tsx @@ -54,7 +54,7 @@ export function StatusNotice({