diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index efede9e..5fcfafa 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -53,9 +53,9 @@ jobs: run: | corepack enable corepack pnpm install --frozen-lockfile - - name: Install Chromium + - name: Install Playwright browsers if: ${{ matrix.browser }} - run: corepack pnpm exec playwright install --with-deps chromium + run: corepack pnpm exec playwright install --with-deps chromium firefox webkit - name: Run blocking gate run: corepack pnpm ci:gate -- ${{ matrix.gate }} - name: Upload gate evidence @@ -91,9 +91,9 @@ jobs: run: | corepack enable corepack pnpm install --frozen-lockfile - - name: Install Chromium + - name: Install Playwright browsers if: ${{ matrix.browser }} - run: corepack pnpm exec playwright install --with-deps chromium + run: corepack pnpm exec playwright install --with-deps chromium firefox webkit - name: Run blocking gate run: corepack pnpm ci:gate -- ${{ matrix.gate }} - name: Upload gate evidence diff --git a/README.md b/README.md index 2d4dac6..43bcad9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,27 @@ corepack pnpm dev Runtime-public settings live in `public/config.json` and are validated before the product tree mounts. Client secrets are forbidden. +## Included starter experience + +The default build mounts a domain-neutral application shell with a header, +responsive sidebar, route focus management, session integration status, and a +persistent `system` / `light` / `dark` theme selector. + +| Route | Purpose | +| --- | --- | +| `/` | implementation readiness and starter links | +| `/examples/ui` | buttons, fields, cards, alerts, badges, modal, and tokens | +| `/examples/states` | loading, refresh, empty, error, auth, forbidden, and not-found states | +| `/examples/auth` | reactive external-auth integration seam | +| `/sample/resources` | protected, domain-neutral integration route | + +`AUTH_MODE=demo` is credential-free and accepted only in local/development +environments. Deployments use `AUTH_MODE=external` and provide the opaque auth +owner described in +[`docs/architecture/starter-experience.md`](docs/architecture/starter-experience.md). +The client route policy is user experience only; server authorization remains +authoritative. + ## Architecture Dependencies point inward: @@ -27,9 +48,11 @@ bootstrap composes concrete adapters contracts own cross-cutting registries ``` -See `docs/architecture/overview.md` and `docs/architecture/layers.md`. The -removable sample slice is under `src/sample/contract-fixture`; product code is -not allowed to import it. +See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and +`docs/architecture/starter-experience.md`. The removable sample slice is under +`src/sample/contract-fixture`; product code is not allowed to import it. The +visible starter routes do not depend on that fixture and continue to build +after it is removed. ## Verification @@ -52,9 +75,17 @@ corepack pnpm drill:runbooks corepack pnpm check:ci ``` +Install the pinned Playwright browser engines before the first cross-browser +run: + +```bash +corepack pnpm exec playwright install --with-deps chromium firefox webkit +``` + Two gates intentionally need external evidence: -- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review. +- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review + for all six registered routes. - `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum eligible-sample threshold and 28 days of production data exist. diff --git a/artifacts/tests/a11y-manual/EXAMPLES_AUTH.md b/artifacts/tests/a11y-manual/EXAMPLES_AUTH.md new file mode 100644 index 0000000..8c6efa2 --- /dev/null +++ b/artifacts/tests/a11y-manual/EXAMPLES_AUTH.md @@ -0,0 +1,18 @@ +# EXAMPLES_AUTH accessibility review + +Status: pending-manual-review +Route ID: EXAMPLES_AUTH +Release ID: +Reviewer: +Reviewed at: +Signature: +Attestation: pending +M1 Keyboard: pending +M2 Visible focus: pending +M3 Route focus: pending +M4 Modal focus: not-applicable (no modal on this route) +M5 Error association: not-applicable (no form error on this route) +M6 Color signal: pending +M7 Reduced motion: pending +Screen reader: pending +Notes: Review session state announcements and unavailable external-integration behavior. diff --git a/artifacts/tests/a11y-manual/EXAMPLES_STATES.md b/artifacts/tests/a11y-manual/EXAMPLES_STATES.md new file mode 100644 index 0000000..b071073 --- /dev/null +++ b/artifacts/tests/a11y-manual/EXAMPLES_STATES.md @@ -0,0 +1,18 @@ +# EXAMPLES_STATES accessibility review + +Status: pending-manual-review +Route ID: EXAMPLES_STATES +Release ID: +Reviewer: +Reviewed at: +Signature: +Attestation: pending +M1 Keyboard: pending +M2 Visible focus: pending +M3 Route focus: pending +M4 Modal focus: not-applicable (no modal on this route) +M5 Error association: not-applicable (no form error on this route) +M6 Color signal: pending +M7 Reduced motion: pending +Screen reader: pending +Notes: Review loading, refresh, empty, error, authentication, forbidden, and not-found announcements. diff --git a/artifacts/tests/a11y-manual/EXAMPLES_UI.md b/artifacts/tests/a11y-manual/EXAMPLES_UI.md new file mode 100644 index 0000000..b82c1cf --- /dev/null +++ b/artifacts/tests/a11y-manual/EXAMPLES_UI.md @@ -0,0 +1,18 @@ +# EXAMPLES_UI accessibility review + +Status: pending-manual-review +Route ID: EXAMPLES_UI +Release ID: +Reviewer: +Reviewed at: +Signature: +Attestation: pending +M1 Keyboard: pending +M2 Visible focus: pending +M3 Route focus: pending +M4 Modal focus: pending +M5 Error association: pending +M6 Color signal: pending +M7 Reduced motion: pending +Screen reader: pending +Notes: Review the text-field error association and modal focus containment/restoration. diff --git a/config/ci/gates.json b/config/ci/gates.json index ff7edba..2de046d 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -125,6 +125,9 @@ "evidence": [ "artifacts/tests/a11y.json", "artifacts/tests/a11y-manual/APP_HOME.md", + "artifacts/tests/a11y-manual/EXAMPLES_UI.md", + "artifacts/tests/a11y-manual/EXAMPLES_STATES.md", + "artifacts/tests/a11y-manual/EXAMPLES_AUTH.md", "artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md", "artifacts/tests/a11y-manual/NOT_FOUND.md", "artifacts/tests/a11y-manual/report.json" diff --git a/docs/accessibility/manual-checklist.md b/docs/accessibility/manual-checklist.md index 9ef5f1d..3ebfe3a 100644 --- a/docs/accessibility/manual-checklist.md +++ b/docs/accessibility/manual-checklist.md @@ -1,10 +1,12 @@ # Manual accessibility review checklist Automated axe checks do not establish WCAG conformance. A human reviewer must -review all three route records in `artifacts/tests/a11y-manual/` against one -release candidate and sign them. Copy the template fields exactly; the gate -rejects blank identity/timestamp/signature fields, pending verdicts, mismatched -release IDs, or missing routes. +review all six route records in `artifacts/tests/a11y-manual/` against one +release candidate and sign them. The required scope is derived from the route +registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, +`SAMPLE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the +gate rejects blank identity/timestamp/signature fields, pending verdicts, +mismatched release IDs, or missing routes. Allowed item verdicts: @@ -43,5 +45,7 @@ The reviewer must verify: - M7: non-essential motion is suppressed with reduced-motion preference - Screen reader: headings, live regions, errors, and actions are announced once -Passing automated evidence means only that tested pages had no critical or -serious axe findings under the recorded browser run. +`EXAMPLES_UI` requires real M4 modal and M5 field-error review; those items must +not be marked not-applicable on that route. Passing automated evidence means +only that tested pages had no critical or serious axe findings under the +recorded Chromium, Firefox, and WebKit runs. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 9feeb9e..ff860a0 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -8,6 +8,8 @@ in `review-ledger.json`, not automatically to edits in this file. flowchart LR Bootstrap[bootstrap / composition root] --> Presentation[presentation] Bootstrap --> Adapters[adapters] + Presentation --> Shell[app shell and route surfaces] + Shell --> Providers[session and theme providers] Presentation --> Application[application] Adapters --> Application Application --> Domain[domain] @@ -21,3 +23,9 @@ implement application ports, and only the composition root selects concrete adapters. Contract registries are the single named source for routes, API operations, environment values, storage keys, errors, queries, telemetry, and release tokens. + +The executable route tree is mounted only after runtime configuration and +release-manifest coherence pass. It receives the composed query client, +credential-opaque session port, storage port, telemetry port, and immutable +build ID. Visible starter pages depend on those ports and contracts, never on a +concrete adapter or the removable sample fixture. diff --git a/docs/architecture/starter-experience.md b/docs/architecture/starter-experience.md new file mode 100644 index 0000000..b45a51f --- /dev/null +++ b/docs/architecture/starter-experience.md @@ -0,0 +1,69 @@ +# Starter experience contract + +The repository provides a runnable, domain-neutral application rather than +only infrastructure contracts. The starter experience is intentionally +replaceable at the page level while the shell, providers, boundaries, +primitives, and state surfaces remain reusable. + +## Runtime composition + +```text +validated config + coherent release manifest + -> concrete adapters + -> QueryClientProvider + -> ThemeProvider + -> SessionProvider + -> BrowserRouter + -> AppShell + -> lazy route boundary +``` + +Boot stops before product mount when configuration or release coherence fails. +Each lazy page renders inside Suspense and a telemetry-aware route boundary. +Route changes move focus to the new page heading; the skip link and landmarks +remain stable in the shell. + +## Registered routes + +| ID | Path | Access | Surface | +| --- | --- | --- | --- | +| `APP_HOME` | `/` | public | readiness dashboard | +| `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens | +| `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix | +| `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls | +| `SAMPLE_RESOURCE_LIST` | `/sample/resources` | integration-defined | protected integration seam | +| `NOT_FOUND` | `*` | public | safe navigation recovery | + +Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not +maintain a second route list. The client access decision never claims to be +authorization. + +## Authentication seam + +The session port exposes state subscription, sign-in start, sign-out, +credential attachment, recovery, and unauthenticated notification. Credentials +remain opaque to the application and presentation layers. + +- `demo`: local/development-only state transition with no credentials +- `external`: delegates to `globalThis.__CA_FRONTEND_AUTH_OWNER__` +- missing/invalid external owner: fails closed as `integration-failed` + +An external owner implements `readState`, `subscribe`, `beginSignIn`, +`signOut`, `attachCredential`, `recoverSession`, and +`notifyUnauthenticated`. It owns token acquisition and storage. + +## Extending the starter + +1. Register the route path, access hint, title, chunk, loading surface, error + surface, and optional navigation metadata in `src/contracts/routes.js`. +2. Add a lazy page in `src/presentation/` and render it through `RouteSurface`. +3. Use application ports or use cases; do not import concrete adapters. +4. Use the semantic tokens, UI primitives, and state surfaces before adding a + project-specific variant. +5. Add component behavior, all-engine E2E, automated axe, and signed manual + route evidence. +6. Run `test:sample-removal` to prove the visible starter still builds without + `src/sample/contract-fixture`. + +Theme preference is the public `COLOR_SCHEME` storage contract. Authentication +tokens and other secrets remain forbidden storage keys. diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md index 447f5b3..e3c82dd 100644 --- a/docs/operations/ci-quality-gates.md +++ b/docs/operations/ci-quality-gates.md @@ -30,6 +30,11 @@ registry: merge evidence through the PR decision, coherent release evidence through the next release promotion, drill evidence through the next production promotion, and field evidence through aggregation. +Browser-backed merge gates install and execute the pinned Chromium, Firefox, +and WebKit engines. This makes route behavior, reflow, native dialog semantics, +theme persistence, and automated accessibility a cross-engine contract rather +than a Chromium-only smoke check. + Repository variables required by higher tiers: - `HOSTING_BASE_URL` for live header verification diff --git a/docs/styling/design-tokens.md b/docs/styling/design-tokens.md index 2de05b5..178d367 100644 --- a/docs/styling/design-tokens.md +++ b/docs/styling/design-tokens.md @@ -1,8 +1,32 @@ # Design-token styling contract `src/presentation/styles/theme.css` is the styling SSOT. Components consume -semantic color, spacing, typography, and radius tokens through static Tailwind -classes. +semantic color, spacing, typography, status, and radius tokens through static +classes. `/examples/ui` is the executable token and primitive gallery. + +## Theme contract + +The supported public preference is the closed set `system`, `light`, and +`dark`. `ThemeProvider` reads and writes `COLOR_SCHEME` through the application +storage port; it does not access a raw storage key. `system` subscribes to +`prefers-color-scheme` changes. Bootstrap applies the persisted preference +before React paints. + +Components use semantic tokens such as `--color-panel`, `--color-content`, +`--color-border`, `--color-action`, and status surface/content/border triples. +They must not hard-code a light-only panel or text color. Both light and dark +surfaces are included in automated axe checks. + +## Included primitives + +- `Button`: primary, secondary, danger, and ghost intent +- `TextField`: label, help text, required state, and associated validation error +- `Card`: labelled surface with optional footer +- `Alert` and `Badge`: non-color-only status feedback +- `Dialog`: native modal semantics, Escape/backdrop close, and trigger focus + restoration +- async and access state surfaces: loading, refresh, empty, terminal error, + auth required, forbidden, and not found Arbitrary-value policy: @@ -13,5 +37,5 @@ Arbitrary-value policy: - user-controlled or runtime-composed class strings are forbidden - class variants must be selected from a closed static map -The removable sample may demonstrate tokens, but product modules must not -import from `src/sample/contract-fixture`. +The removable sample may demonstrate tokens, but production starter modules do +not import from `src/sample/contract-fixture`. diff --git a/docs/testing/taxonomy.md b/docs/testing/taxonomy.md index 5a9a72c..0f460aa 100644 --- a/docs/testing/taxonomy.md +++ b/docs/testing/taxonomy.md @@ -12,6 +12,13 @@ Each gate is blocking in its declared scope. Failures are not downgraded with | end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` | | accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` | +End-to-end and automated accessibility scenarios run on the pinned Chromium, +Firefox, and WebKit engines. The responsive contract explicitly exercises +320px reflow, a mobile navigation drawer, and a wide two-column gallery. +Theme scenarios verify persistence, operating-system changes, and dark-surface +axe results. Native modal focus and validation association are exercised in +both component and browser tests. + A control is verified only when a positive fixture passes and its deliberately failing negative fixture is rejected. Generated evidence is retained by CI; the repository tracks only the evidence directory structure. diff --git a/playwright.config.js b/playwright.config.js index 43ec1ab..b87fd86 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -19,5 +19,7 @@ export default defineConfig({ }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, ], }); diff --git a/scripts/lib/manual-a11y-evidence.mjs b/scripts/lib/manual-a11y-evidence.mjs index a56dd61..2fbeac7 100644 --- a/scripts/lib/manual-a11y-evidence.mjs +++ b/scripts/lib/manual-a11y-evidence.mjs @@ -1,5 +1,8 @@ export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([ "APP_HOME", + "EXAMPLES_UI", + "EXAMPLES_STATES", + "EXAMPLES_AUTH", "SAMPLE_RESOURCE_LIST", "NOT_FOUND", ]); diff --git a/scripts/write-a11y-report.mjs b/scripts/write-a11y-report.mjs index 73faf22..b686a33 100644 --- a/scripts/write-a11y-report.mjs +++ b/scripts/write-a11y-report.mjs @@ -1,5 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; +import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.mjs"; + await mkdir("artifacts/tests", { recursive: true }); await writeFile( "artifacts/tests/a11y.json", @@ -7,7 +9,7 @@ await writeFile( { schemaVersion: 1, generatedAt: new Date().toISOString(), - scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"], + scope: MANUAL_A11Y_ROUTE_IDS, threshold: { critical: 0, serious: 0 }, automatedStatus: "passed", manualReview: "see artifacts/tests/a11y-manual/report.json", diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js new file mode 100644 index 0000000..663ca19 --- /dev/null +++ b/tests/e2e/responsive.spec.js @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +test("reflows the UI gallery at the 320px minimum without horizontal overflow", async ({ + page, +}) => { + await page.setViewportSize({ width: 320, height: 720 }); + await page.goto("/examples/ui"); + await expect( + page.getByRole("heading", { level: 1, name: "UI 구성요소" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "메뉴", exact: true }), + ).toBeVisible(); + + const viewport = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth); +}); + +test("keeps desktop navigation and two-column examples at wide viewports", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/examples/ui"); + + await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "메뉴", exact: true }), + ).toBeHidden(); + await expect(page.locator(".component-grid--two").first()).toHaveCSS( + "grid-template-columns", + /.+px .+px/, + ); +}); diff --git a/tests/unit/manual-a11y-evidence.test.js b/tests/unit/manual-a11y-evidence.test.js index 09d6f4b..80dabff 100644 --- a/tests/unit/manual-a11y-evidence.test.js +++ b/tests/unit/manual-a11y-evidence.test.js @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { validateManualA11yEvidence } from "../../scripts/lib/manual-a11y-evidence.mjs"; +import { + MANUAL_A11Y_ROUTE_IDS, + validateManualA11yEvidence, +} from "../../scripts/lib/manual-a11y-evidence.mjs"; +import { ROUTE_REGISTRY } from "../../src/contracts/routes.js"; const reviewed = `Status: reviewed Route ID: APP_HOME @@ -20,6 +24,12 @@ Screen reader: pass Notes: no defects`; describe("manual accessibility evidence", () => { + it("requires evidence for every registered user-visible route", () => { + expect([...MANUAL_A11Y_ROUTE_IDS].sort()).toEqual( + Object.keys(ROUTE_REGISTRY).sort(), + ); + }); + it("accepts a complete signed human review record", () => { expect(validateManualA11yEvidence(reviewed)).toMatchObject({ failures: [],