Compare commits

...
Author SHA1 Message Date
donghyeon-ka 236909be64 test: harden starter experience quality contract 2026-07-26 00:28:09 +09:00
donghyeon-ka 68d9efbda3 merge: add persistent responsive color themes 2026-07-26 00:06:56 +09:00
donghyeon-ka 4cfbe5a29e feat: add persistent responsive color themes 2026-07-26 00:06:49 +09:00
donghyeon-ka bee5c158c6 merge: provide reusable UI and state galleries 2026-07-25 23:59:20 +09:00
donghyeon-ka 3581ead595 feat: provide reusable UI and state galleries 2026-07-25 23:59:12 +09:00
donghyeon-ka a8e3db1aec merge: assemble responsive app shell navigation 2026-07-25 23:52:41 +09:00
donghyeon-ka baeda39057 feat: assemble responsive app shell navigation 2026-07-25 23:52:34 +09:00
donghyeon-ka 0925d252d9 merge: compose executable frontend runtime 2026-07-25 23:42:39 +09:00
donghyeon-ka 9a120e6d45 feat: compose executable frontend runtime 2026-07-25 23:42:39 +09:00
donghyeon-ka 9200c80149 merge: require field gate inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka f7e8ef6ee4 fix: require external field evidence inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka e70b1a4ad9 merge: harden field performance evidence 2026-07-25 22:30:14 +09:00
donghyeon-ka 7d4daea23a merge: harden live hosting verification 2026-07-25 22:25:04 +09:00
donghyeon-ka c089e749d0 fix: require genuine live hosting evidence 2026-07-25 22:25:04 +09:00
donghyeon-ka 2d199a5a23 fix: retain complete manual accessibility evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 23c47a1eb9 merge: align accessibility gate evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 8a38805c01 merge: strengthen manual accessibility evidence 2026-07-25 22:21:23 +09:00
donghyeon-ka 2725c35c28 fix: require signed accessibility evidence per route 2026-07-25 22:21:23 +09:00
donghyeon-ka 50dc803f19 fix: consume canonical scoped diagram review evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka 976f444692 merge: align documentation readiness evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka c7191d7615 fix: gitkeep 파일 제거 2026-07-25 22:16:01 +09:00
donghyeon-ka 28f5585a56 merge: complete bundle graph accounting 2026-07-25 21:46:07 +09:00
donghyeon-ka 4667cafa43 fix: validate complete immutable release surface 2026-07-25 21:45:02 +09:00
donghyeon-ka 6a0c60180c merge: complete immutable release verification 2026-07-25 21:45:02 +09:00
donghyeon-ka 18bea3a852 fix: verify hosting response content types 2026-07-25 21:43:55 +09:00
donghyeon-ka 72fd295556 merge: refresh hosting header verification contract 2026-07-25 21:43:55 +09:00
donghyeon-ka cc6cf29c79 merge: refresh bootstrap type fixture contract
# Conflicts:
#	package.json
2026-07-25 21:40:56 +09:00
donghyeon-ka 8198886dab fix: execute negative type fixture against source 2026-07-25 21:40:24 +09:00
donghyeon-ka c5e218d37a feat: orchestrate blocking frontend quality gates 2026-07-25 21:39:04 +09:00
donghyeon-ka 69d7e26a5b Merge branch 'feature-frontend-ci-quality-gates-contract' into develop 2026-07-25 21:39:04 +09:00
donghyeon-ka 6dd5b85c8c Merge branch 'feature-frontend-operational-runbook-contract' into develop 2026-07-25 21:30:41 +09:00
donghyeon-ka 75c3f5b08c feat: operationalize frontend incident runbooks 2026-07-25 21:30:40 +09:00
donghyeon-ka 15ddb8d474 Merge branch 'feature-web-vitals-performance-budget-contract' into develop 2026-07-25 21:26:51 +09:00
100 changed files with 5624 additions and 172 deletions
+189
View File
@@ -0,0 +1,189 @@
name: frontend-quality-gates
on:
push:
branches: [develop]
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
stage:
description: Highest promotion tier to evaluate
required: true
default: merge
type: choice
options:
- merge
- release
- production
- field
- documentation
env:
NODE_VERSION: "24"
jobs:
merge_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-001, name: manifest-lockfile, browser: false }
- { gate: FE-GATE-002, name: lint, browser: false }
- { gate: FE-GATE-003, name: typecheck, browser: false }
- { gate: FE-GATE-004, name: runtime-schema, browser: false }
- { gate: FE-GATE-005, name: unit, browser: false }
- { gate: FE-GATE-006, name: component, browser: false }
- { gate: FE-GATE-007, name: integration, browser: false }
- { gate: FE-GATE-008, name: e2e, browser: true }
- { gate: FE-GATE-009, name: accessibility, browser: true }
- { gate: FE-GATE-010, name: architecture, browser: false }
- { gate: FE-GATE-011, name: build, browser: false }
- { gate: FE-GATE-013, name: security, browser: false }
- { gate: FE-GATE-020, name: sample-removal, browser: false }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Playwright browsers
if: ${{ matrix.browser }}
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
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
release_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
needs: merge_gate
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
runs-on: ubuntu-latest
env:
HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }}
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-012, name: bundle, browser: false }
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
- { gate: FE-GATE-015, name: release-coherence, browser: false }
- { gate: FE-GATE-019, name: hosting-header, browser: false }
- { gate: FE-GATE-026, name: lab-performance, browser: true }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Playwright browsers
if: ${{ matrix.browser }}
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
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
production_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
needs: release_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-016, name: rollback-drill }
- { gate: FE-GATE-021, name: runbook-boot-config }
- { gate: FE-GATE-022, name: runbook-chunk-mismatch }
- { gate: FE-GATE-023, name: runbook-api-degradation }
- { gate: FE-GATE-024, name: runbook-telemetry }
- { gate: FE-GATE-025, name: runbook-release-rollback }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
field_gate:
name: FE-GATE-018 / field-web-vitals
needs: production_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
runs-on: ubuntu-latest
env:
FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }}
MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run blocking gate
run: corepack pnpm ci:gate -- FE-GATE-018
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: FE-GATE-018-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
documentation_gate:
name: FE-GATE-017 / diagram-review
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run documentation gate
run: corepack pnpm ci:gate -- FE-GATE-017
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: FE-GATE-017-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
+107 -1
View File
@@ -1,2 +1,108 @@
# clean-architecture-frontend-template
# Clean Architecture Frontend Template
A React/Vite reference implementation where architecture boundaries,
integration behavior, release coherence, accessibility, performance, and
operations are executable contracts rather than conventions.
## Start locally
Requirements: Node 24 and Corepack. The repository pins pnpm in `package.json`.
```bash
corepack pnpm install --frozen-lockfile
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:
```text
presentation -> application -> domain
adapters -----^
bootstrap composes concrete adapters
contracts own cross-cutting registries
```
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
Common local checks:
```bash
corepack pnpm lint
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm test:all
corepack pnpm test:e2e
corepack pnpm test:a11y
corepack pnpm build
corepack pnpm check:bundle
corepack pnpm test:performance
corepack pnpm verify:compatibility
corepack pnpm verify:release
corepack pnpm check:registries
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
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.
Live release verification additionally requires `HOSTING_BASE_URL`.
## CI and evidence
The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is
`.gitea/workflows/quality-gates.yml`. It follows:
```text
MERGE_READY -> RELEASE_READY -> PROD_PROMOTION_READY -> FIELD_SLO_READY
```
`DOCUMENTATION_READY` is independent. No gate is downgraded to a warning.
Machine-readable evidence is written below `artifacts/`; generated evidence is
ignored by Git while `.gitkeep` files preserve the taxonomy.
Operational details are in `docs/operations/`, with incident procedures in
`docs/runbooks/`.
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
+14 -11
View File
@@ -1,15 +1,18 @@
# APP_HOME accessibility review
Status: pending-manual-review
Route ID: APP_HOME
Release ID:
Reviewer:
Keyboard: automated tab-order fixture passed; human review pending.
Focus: automated visible-focus fixture passed; route-change review pending.
Screen reader: pending.
Reduced motion: automated media-query fixture passed; human review pending.
Color signal: pending.
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: Automated axe, keyboard-focus, and reduced-motion evidence is available; human review pending.
@@ -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.
@@ -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.
@@ -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.
+18
View File
@@ -0,0 +1,18 @@
# NOT_FOUND accessibility review
Status: pending-manual-review
Route ID: NOT_FOUND
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: Human review pending.
@@ -0,0 +1,18 @@
# SAMPLE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: SAMPLE_RESOURCE_LIST
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: Human review pending.
+345
View File
@@ -0,0 +1,345 @@
{
"schemaVersion": 1,
"providerAdapter": ".gitea/workflows/quality-gates.yml",
"stages": {
"merge": {
"readiness": "MERGE_READY",
"needs": null,
"gates": [
"FE-GATE-001",
"FE-GATE-002",
"FE-GATE-003",
"FE-GATE-004",
"FE-GATE-005",
"FE-GATE-006",
"FE-GATE-007",
"FE-GATE-008",
"FE-GATE-009",
"FE-GATE-010",
"FE-GATE-011",
"FE-GATE-013",
"FE-GATE-020"
]
},
"release": {
"readiness": "RELEASE_READY",
"needs": "merge",
"gates": [
"FE-GATE-012",
"FE-GATE-014",
"FE-GATE-015",
"FE-GATE-019",
"FE-GATE-026"
]
},
"production": {
"readiness": "PROD_PROMOTION_READY",
"needs": "release",
"gates": [
"FE-GATE-016",
"FE-GATE-021",
"FE-GATE-022",
"FE-GATE-023",
"FE-GATE-024",
"FE-GATE-025"
]
},
"field": {
"readiness": "FIELD_SLO_READY",
"needs": "production",
"gates": ["FE-GATE-018"]
},
"documentation": {
"readiness": "DOCUMENTATION_READY",
"needs": null,
"gates": ["FE-GATE-017"]
}
},
"gates": {
"FE-GATE-001": {
"name": "manifest-lockfile",
"steps": [{ "script": "verify:lockfile", "expect": "pass" }],
"logPath": "artifacts/quality/install.txt",
"evidence": ["artifacts/quality/install.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-002": {
"name": "lint",
"steps": [{ "script": "lint", "expect": "pass" }],
"logPath": "artifacts/quality/lint.txt",
"evidence": ["artifacts/quality/lint.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-003": {
"name": "typecheck",
"steps": [
{ "script": "check:types", "expect": "pass" },
{ "script": "check:types:fixture", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-004": {
"name": "runtime-schema",
"steps": [{ "script": "test:runtime-schema", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-004.txt",
"evidence": ["artifacts/tests/runtime-schema.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-005": {
"name": "unit",
"steps": [{ "script": "test:unit", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-005.txt",
"evidence": ["artifacts/tests/unit.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-006": {
"name": "component",
"steps": [{ "script": "test:component", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-006.txt",
"evidence": ["artifacts/tests/component.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-007": {
"name": "integration",
"steps": [{ "script": "test:integration", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-007.txt",
"evidence": ["artifacts/tests/integration.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-008": {
"name": "e2e",
"steps": [{ "script": "test:e2e", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-008.txt",
"evidence": ["artifacts/tests/e2e/report/index.html"],
"retentionClass": "merge-cycle"
},
"FE-GATE-009": {
"name": "accessibility",
"steps": [
{ "script": "test:a11y", "expect": "pass" },
{ "script": "review:a11y-manual", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-009.txt",
"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"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-010": {
"name": "architecture",
"steps": [{ "script": "check:architecture", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": ["artifacts/quality/dependency-report.json"],
"retentionClass": "merge-cycle"
},
"FE-GATE-011": {
"name": "build",
"steps": [{ "script": "build", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-011.txt",
"evidence": ["artifacts/release/build-manifest.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-012": {
"name": "bundle",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "check:bundle", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-012.txt",
"evidence": ["artifacts/performance/bundle.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-013": {
"name": "security",
"steps": [
{ "script": "build:release", "expect": "pass" },
{ "script": "check:browser-security", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-013.txt",
"evidence": [
"artifacts/security/scan.sarif",
"artifacts/release/dependency-inventory.json",
"artifacts/security/dependency-diff.json"
],
"retentionClass": "release-coherence"
},
"FE-GATE-014": {
"name": "config-compatibility",
"steps": [{ "script": "verify:compatibility", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-014.txt",
"evidence": ["artifacts/release/compatibility.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-015": {
"name": "release-coherence",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
"evidence": ["artifacts/release/verification.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-016": {
"name": "rollback-drill",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-005"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-016.txt",
"evidence": [
"artifacts/runbooks/FE-RB-005/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-017": {
"name": "diagram-review",
"steps": [{ "script": "verify:documentation", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-017.txt",
"evidence": ["artifacts/quality/documentation-review.json"],
"retentionClass": "documentation"
},
"FE-GATE-018": {
"name": "field-web-vitals",
"requiresEnvironment": [
"FIELD_WEB_VITALS_INPUT",
"MIN_ELIGIBLE_SAMPLES"
],
"steps": [
{ "script": "collect:web-vitals-evidence", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-018.txt",
"evidence": ["artifacts/performance/field-web-vitals.json"],
"retentionClass": "field"
},
"FE-GATE-019": {
"name": "hosting-header",
"requiresEnvironment": ["HOSTING_BASE_URL"],
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:hosting-headers", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-019.txt",
"evidence": ["artifacts/release/hosting-headers.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-020": {
"name": "sample-removal",
"steps": [{ "script": "test:sample-removal", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-020.txt",
"evidence": ["artifacts/tests/sample-removal.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-021": {
"name": "runbook-boot-config",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-001"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-021.txt",
"evidence": [
"artifacts/runbooks/FE-RB-001/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-022": {
"name": "runbook-chunk-mismatch",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-002"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-022.txt",
"evidence": [
"artifacts/runbooks/FE-RB-002/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-023": {
"name": "runbook-api-degradation",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-003"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-023.txt",
"evidence": [
"artifacts/runbooks/FE-RB-003/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-024": {
"name": "runbook-telemetry",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-004"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-024.txt",
"evidence": [
"artifacts/runbooks/FE-RB-004/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-025": {
"name": "runbook-release-rollback",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-005"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-025.txt",
"evidence": [
"artifacts/runbooks/FE-RB-005/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-026": {
"name": "lab-performance",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "test:performance", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-026.txt",
"evidence": ["artifacts/performance/lab.json"],
"retentionClass": "release-coherence"
}
},
"retention": {
"durationStatus": "UNSUPPORTED_PENDING_ORGANIZATION_POLICY",
"merge-cycle": "at least through pull-request readiness decision",
"release-coherence": "at least until the next release is promoted",
"prod-drill": "at least until the next production promotion decision",
"field": "through the 28-day window and aggregation",
"documentation": "through documentation readiness review"
}
}
+4
View File
@@ -4,21 +4,25 @@
"index": {
"path": "/",
"cacheControl": "no-cache",
"contentTypes": ["text/html"],
"securityHeaders": true
},
"runtimeConfig": {
"path": "/config.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"releaseManifest": {
"path": "/release-manifest.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"hashedAsset": {
"pathPattern": "/assets/*",
"cacheControl": "public, max-age=31536000, immutable",
"contentTypes": ["text/javascript", "application/javascript"],
"securityHeaders": false
},
"sourceMap": {
+5 -1
View File
@@ -3,6 +3,7 @@
"responses": {
"index": {
"cache-control": "no-cache",
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
@@ -12,6 +13,7 @@
},
"runtimeConfig": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
@@ -21,6 +23,7 @@
},
"releaseManifest": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
@@ -29,7 +32,8 @@
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"hashedAsset": {
"cache-control": "public, max-age=31536000, immutable"
"cache-control": "public, max-age=31536000, immutable",
"content-type": "text/javascript; charset=utf-8"
}
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"schemaVersion": 1,
"runbooks": {
"FE-RB-001": {
"title": "Boot configuration failure",
"gateId": "FE-GATE-021",
"triggerKinds": ["BOOT_CONFIG_FAILURE"],
"containment": "stop product route mount, show the safe support shell, and refetch at most once",
"window": "owner triage planned-default 5m",
"escalation": ["env-config owner", "release owner"],
"recoveryEvidence": [
"clean-session boot",
"product root mount",
"config validation",
"no repeated boot error"
],
"negativeFixture": "a valid config followed by an injected mount failure must fail recovery"
},
"FE-RB-002": {
"title": "Chunk, manifest, or deployment mismatch",
"gateId": "FE-GATE-022",
"triggerKinds": [
"CHUNK_LOAD_FAILURE",
"RELEASE_MANIFEST_FAILURE",
"DEPLOY_MISMATCH"
],
"containment": "warn for dirty state, fetch manifest no-store once, and allow one guarded reload",
"window": "release owner triage planned-default 5m",
"escalation": ["release-cache owner", "hosting/CDN owner"],
"recoveryEvidence": [
"entry and lazy assets reachable",
"release tuple coherent",
"second reload blocked",
"critical route smoke"
],
"negativeFixture": "a second failure for the same release pair must not reload"
},
"FE-RB-003": {
"title": "Backend API degradation",
"gateId": "FE-GATE-023",
"triggerKinds": [
"TERMINAL_NETWORK_RATE",
"REQUEST_TIMEOUT_RATE",
"SERVER_FAILURE_RATE",
"SCHEMA_MISMATCH"
],
"containment": "do not expand retry caps, serve safe stale reads, and never retry an unkeyed mutation",
"window": "rolling 5m trigger; first classification planned-default 10m",
"escalation": [
"api-client owner",
"backend operation owner",
"release compatibility owner"
],
"recoveryEvidence": [
"terminal failure rate at baseline",
"no retry amplification",
"critical read/write smoke",
"schema fixtures"
],
"negativeFixture": "an unkeyed POST receiving 503 must not retry"
},
"FE-RB-004": {
"title": "Telemetry sink failure",
"gateId": "FE-GATE-024",
"triggerKinds": ["TELEMETRY_FAILURE"],
"containment": "keep product flow available, bound the queue, and never report recursively to the failing sink",
"window": "platform triage planned-default 15m",
"escalation": ["observability owner", "telemetry platform owner"],
"recoveryEvidence": [
"product flow unaffected",
"delivery self-check",
"queue drained within bound",
"forbidden attributes absent"
],
"negativeFixture": "raw URL and query data must be removed from telemetry"
},
"FE-RB-005": {
"title": "Coherent release rollback",
"gateId": "FE-GATE-025",
"triggerKinds": ["RELEASE_BLOCKING_DEFECT"],
"containment": "select a prior immutable tuple, verify asset/config/API compatibility, atomically switch, and smoke",
"window": "provider recovery target TBD",
"escalation": ["release-cache owner", "release approver/hosting owner"],
"recoveryEvidence": [
"compatibility gate",
"release coherence gate",
"critical smoke",
"release ID in incident timeline"
],
"negativeFixture": "HTML build A with asset manifest B must be rejected"
}
}
}
@@ -0,0 +1,42 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-RUNBOOK-DRILL@1",
"type": "object",
"required": [
"schemaVersion",
"runbookId",
"releaseId",
"drillTimestamp",
"triggerInjected",
"triggerAsserted",
"containmentAsserted",
"escalationPathAsserted",
"recoveryAssertions",
"negativeFixtureFailedAsExpected",
"windowObservedBucket",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"runbookId": { "pattern": "^FE-RB-00[1-5]$" },
"releaseId": { "type": "string", "minLength": 1 },
"drillTimestamp": { "type": "string", "format": "date-time" },
"triggerInjected": { "type": "string" },
"triggerAsserted": { "type": "boolean" },
"containmentAsserted": { "type": "boolean" },
"escalationPathAsserted": { "type": "boolean" },
"recoveryAssertions": {
"type": "array",
"minItems": 4,
"items": {
"type": "object",
"required": ["assertion", "evidence", "passed"]
}
},
"negativeFixtureFailedAsExpected": { "type": "boolean" },
"windowObservedBucket": { "type": "string" },
"providerVerificationRequired": { "type": "boolean" },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
+46 -12
View File
@@ -1,17 +1,51 @@
# Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must
copy this checklist to `artifacts/tests/a11y-manual/<route-id>.md`, execute it
on the release candidate, and sign it.
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.
- Status: `pending` or `reviewed`
- Reviewer and reviewed-at timestamp
- Keyboard: all actions reachable in logical order
- Focus: visible, route changes deterministic, modal restore verified
- Screen reader: headings, live regions, errors, and actions announced once
- Reduced motion: non-essential animation suppressed
- Color signal: every state has text/icon/structure in addition to color
- Notes and linked defect IDs
Allowed item verdicts:
Passing the automated threshold means only that the tested pages had zero
critical/serious axe findings under the recorded browser run.
- `pass`
- `not-applicable (<specific reason>)`
Required record:
```text
Status: reviewed
Route ID: APP_HOME
Release ID: <immutable release ID>
Reviewer: <human reviewer identity>
Reviewed at: <RFC 3339 timestamp>
Signature: <reviewer identity or approved signature reference>
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pass
M7 Reduced motion: pass
Screen reader: pass
Notes: <observations and linked defect IDs>
```
The reviewer must verify:
- M1: every action works without a pointing device
- M2: every focused element has a visible indicator
- M3: route transitions move focus to a deterministic target
- M4: modal focus is trapped and restored, when a modal exists
- M5: errors are programmatically associated with their controls, when present
- M6: state never relies on color alone
- M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once
`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.
+31
View File
@@ -0,0 +1,31 @@
# Architecture overview
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies to the canonical draw.io diagram named
in `review-ledger.json`, not automatically to edits in this file.
```mermaid
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]
Contracts[contract registries] --> Bootstrap
Contracts --> Adapters
Contracts --> Presentation
```
Dependencies point inward. Presentation calls application use cases, adapters
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.
+22
View File
@@ -0,0 +1,22 @@
# Imported scoped diagram review evidence
This ledger entry consumes the canonical evidence already recorded by the
`ca-skeleton-frontend-operational-contract` project note. It does not claim
review of the repository-local Mermaid projections or of the complete
production deployment topology.
- Reviewer: `wiki-diagram-reviewer`
- Standard: `rules/diagram-standards.md` v2
- Canonical report:
`docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md`
- Canonical report SHA-256:
`b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29`
| Canonical diagram | SHA-256 | Score | Verdict | Reviewed scope |
| --- | --- | ---: | --- | --- |
| `raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio` | `c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0` | 100 | PASS | Clean Architecture compile-time dependency ownership |
| `raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio` | `9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b` | 100 | PASS | immutable static assets and mutable `/config.json` delivery |
The canonical report explicitly limits this `PASS_SCOPED`: it does not verify
the complete release/rollback topology, the implementation topology, or live
hosting state.
+29
View File
@@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"status": "PASS_SCOPED",
"reviewer": "wiki-diagram-reviewer",
"standard": "rules/diagram-standards.md v2",
"evidenceReport": {
"repoPath": "docs/architecture/review-evidence.md",
"canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md",
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
},
"reviews": {
"overview": {
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio",
"sha256": "c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0",
"score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "Clean Architecture compile-time dependency ownership"
},
"staticDelivery": {
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio",
"sha256": "9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b",
"score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "immutable static assets and mutable /config.json delivery"
}
}
}
+69
View File
@@ -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.
+25
View File
@@ -0,0 +1,25 @@
# Static asset and runtime-config delivery
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies only to the canonical static-delivery
draw.io scope recorded in `review-ledger.json`.
```mermaid
sequenceDiagram
participant CI
participant ImmutableRelease
participant ActivePointer
participant Browser
CI->>ImmutableRelease: upload hashed assets
CI->>ImmutableRelease: upload release manifest
CI->>ImmutableRelease: upload runtime config
CI->>ImmutableRelease: probe asset reachability
CI->>ActivePointer: atomically switch HTML
Browser->>ActivePointer: fetch revalidated HTML
Browser->>ImmutableRelease: fetch no-store config and manifest
Browser->>ImmutableRelease: fetch immutable hashed assets
CI->>Browser: boot, route, API, and reload-loop smoke
```
Rollback changes the active pointer only after confirming that the prior
immutable release has a coherent HTML/assets/config/API/manifest tuple.
+46
View File
@@ -0,0 +1,46 @@
# CI quality-gate orchestration
`config/ci/gates.json` is the executable registry for all 26 gates. The Gitea
adapter runs each gate as an independent matrix check with full fan-out and no
soft-fail wiring.
The dependency graph is:
```text
MERGE_READY
-> RELEASE_READY
-> PROD_PROMOTION_READY
-> FIELD_SLO_READY
DOCUMENTATION_READY (off-chain)
```
Pull requests and `develop` pushes evaluate merge readiness. Version tags
evaluate merge then release readiness. Production and field evaluation require
an explicit workflow dispatch. The field tier cannot pass until the 28-day
sample threshold decision is recorded. Documentation readiness consumes the
canonical project-note evidence in which both scoped diagrams already received
100/100 `PASS_SCOPED`; the repo ledger preserves the evidence scope and
canonical digests.
All jobs upload the shared `artifacts/` tree even after failure. Numeric
retention remains an organization/provider decision; the workflow intentionally
does not invent `retention-days`. The relative minimums are recorded in the
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
- `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document
- `MIN_ELIGIBLE_SAMPLES` after the baseline decision
Branch protection must mark each `FE-GATE-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by
committing a file.
+8 -1
View File
@@ -20,6 +20,13 @@ The provider-independent cache defaults are:
- public source maps: disabled
- service worker/offline cache: disabled
HTML, JSON config/manifest, and hashed JavaScript MIME types are also compared
to the declared allowlist; a cache-correct response with a mismatched
`Content-Type` still fails the hosting gate.
`corepack pnpm verify:hosting-headers` uses a deterministic fixture locally.
Set `HOSTING_BASE_URL` to probe deployed responses; production promotion
requires the artifact to report `mode: "live"`.
requires the artifact to report `mode: "live"`. The live target must be its
canonical, non-loopback HTTPS root URL. Each required surface must return HTTP
200 without leaving that origin before its cache, content-type, and security
headers can count as deployment evidence.
+9
View File
@@ -0,0 +1,9 @@
# FE-RB-001 — Boot configuration failure
Trigger on `BOOT_CONFIG_FAILURE` after the single bounded refetch fails. Stop
product route mounting and show the safe support shell; the planned owner
triage target is five minutes. Escalate from the environment/config owner to
the release owner.
Close only after a clean-session boot mounts the product root, config
validation evidence passes, and repeated boot-error telemetry is absent.
+11
View File
@@ -0,0 +1,11 @@
# FE-RB-002 — Chunk or deployment mismatch
Trigger on `CHUNK_LOAD_FAILURE`, `RELEASE_MANIFEST_FAILURE`, or
`DEPLOY_MISMATCH`. Warn when dirty state may be lost, fetch the manifest
`no-store` once, record the release pair, and allow only one reload. The
planned release-owner triage target is five minutes. Escalate to the hosting/CDN
owner.
Close only after entry/lazy assets are reachable, the manifest parses into a
coherent tuple, a second automatic reload is blocked, and the critical route
smoke passes.
+10
View File
@@ -0,0 +1,10 @@
# FE-RB-003 — Backend API degradation
Trigger when terminal network/timeout/5xx failures exceed the rolling
five-minute threshold or on one `SCHEMA_MISMATCH`. Do not expand client retry
caps, do not retry schema mismatches, and never retry an unkeyed mutation.
Escalate from the API client owner to backend operations and then release
compatibility; the planned first-classification target is ten minutes.
Close only after the failure rate returns to baseline, retry amplification is
absent, critical read/write smoke passes, and schema fixtures pass.
+9
View File
@@ -0,0 +1,9 @@
# FE-RB-004 — Telemetry sink failure
Trigger on sink network/non-2xx errors, queue overflow, or adapter
initialization failure. Keep product flows available, bound the queue, and do
not recursively report to the failed sink. Escalate from observability to the
telemetry platform owner; the planned triage target is fifteen minutes.
Close only after product e2e remains unaffected, delivery self-check succeeds,
the queue drains within its bound, and the forbidden-attribute scan passes.
+13
View File
@@ -0,0 +1,13 @@
# FE-RB-005 — Coherent release rollback
Trigger on a release-blocking boot, chunk, render, API, or security defect when
a safe forward fix is not demonstrated inside the incident window. Select a
prior immutable release, verify its asset/config/API tuple, atomically switch
the complete set, perform the provider cache action, and run smoke checks.
Escalate from the release-cache owner to the release approver/hosting owner.
The provider recovery target remains TBD until hosting is selected.
Close only when compatibility and release-coherence gates pass, critical smoke
passes, repeated `DEPLOY_MISMATCH` is absent, and the incident timeline records
the restored release ID. Pointer-switch or cache-purge completion alone is not
recovery evidence.
+28 -4
View File
@@ -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`.
+7
View File
@@ -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.
+7 -2
View File
@@ -16,7 +16,7 @@
"lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0",
"check:architecture": "node scripts/check-architecture.mjs",
"check:types": "tsc --allowJs --checkJs --noEmit",
"check:types:fixture": "tsc --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
@@ -36,7 +36,12 @@
"verify:hosting-headers": "node scripts/verify-hosting-headers.mjs",
"check:bundle": "node scripts/generate-supply-chain.mjs && node scripts/check-bundle.mjs",
"test:performance": "node scripts/test-performance.mjs",
"collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.mjs"
"collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.mjs",
"drill:runbook": "node scripts/drill-runbook.mjs",
"drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005",
"ci:gate": "node scripts/run-ci-gate.mjs",
"check:ci": "node scripts/check-ci-contract.mjs",
"verify:documentation": "node scripts/verify-documentation-readiness.mjs"
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
+2
View File
@@ -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"] } },
],
});
+1 -1
View File
@@ -4,7 +4,7 @@
"REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false,
"AUTH_MODE": "external",
"AUTH_MODE": "demo",
"CONFIG_SCHEMA_VERSION": "1",
"API_CONTRACT_VERSION": "1",
"RELEASE_MANIFEST_URL": "/release-manifest.json",
+111
View File
@@ -0,0 +1,111 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
evaluatePromotionReadiness,
PROMOTION_FORMULA,
} from "../src/application/policies/promotion-readiness.js";
const document = JSON.parse(await readFile("config/ci/gates.json", "utf8"));
const workflow = await readFile(document.providerAdapter, "utf8");
const failures = [];
const stageFormula = {
merge: PROMOTION_FORMULA.MERGE_READY,
release: PROMOTION_FORMULA.RELEASE_READY,
production: PROMOTION_FORMULA.PROD_PROMOTION_READY,
field: PROMOTION_FORMULA.FIELD_SLO_READY,
documentation: PROMOTION_FORMULA.DOCUMENTATION_READY,
};
for (const [stage, expectedGates] of Object.entries(stageFormula)) {
const actual = document.stages[stage]?.gates;
if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) {
failures.push(`${stage} gate formula drift`);
}
}
const configuredGateIds = Object.keys(document.gates).sort();
const expectedGateIds = Array.from(
{ length: 26 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) {
failures.push("gate registry must contain FE-GATE-001..026 exactly once");
}
for (const [gateId, gate] of Object.entries(document.gates)) {
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
}
}
const forbiddenWorkflowPatterns = [
/continue-on-error\s*:/,
/retention-days\s*:/,
/allow_failure\s*:/,
];
for (const pattern of forbiddenWorkflowPatterns) {
if (pattern.test(workflow)) {
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
}
}
for (const requiredToken of [
"merge_gate:",
"release_gate:",
"production_gate:",
"field_gate:",
"documentation_gate:",
"needs: merge_gate",
"needs: release_gate",
"needs: production_gate",
"actions/upload-artifact@v4",
"if: always()",
]) {
if (!workflow.includes(requiredToken)) {
failures.push(`workflow missing ${requiredToken}`);
}
}
const passingResults = Object.fromEntries(
expectedGateIds.map((gateId) => [gateId, /** @type {const} */ ("PASS")]),
);
const allPass = evaluatePromotionReadiness(passingResults);
const negativeFixtures = [];
for (const [readiness, gateIds] of Object.entries(PROMOTION_FORMULA)) {
const failedGate = gateIds[0];
const result = evaluatePromotionReadiness({
...passingResults,
[failedGate]: "FAIL",
});
const passed =
/** @type {Readonly<Record<string, boolean>>} */ (result)[readiness] ===
false;
negativeFixtures.push({ readiness, failedGate, passed });
if (!passed) failures.push(`${readiness} did not fail closed`);
}
if (!Object.values(allPass).every(Boolean)) {
failures.push("all-PASS formula did not produce every readiness state");
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
providerAdapter: document.providerAdapter,
gateCount: configuredGateIds.length,
noDowngrade: failures.every(
(failure) => !failure.includes("downgrade"),
),
durationStatus: document.retention.durationStatus,
negativeFixtures,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/ci-contract.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n");
+1 -1
View File
@@ -7,7 +7,7 @@ import {
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs";
const inputPath =
process.env.FIELD_WEB_VITALS_INPUT ??
process.env.FIELD_WEB_VITALS_INPUT ||
"config/performance/field-input.example.json";
const rawInput = JSON.parse(await readFile(inputPath, "utf8"));
const now = new Date();
+309
View File
@@ -0,0 +1,309 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { shouldRetry } from "../src/adapters/http/retry-policy.js";
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.js";
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.js";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.js";
import { projectTelemetryEvent } from "../src/contracts/telemetry.js";
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js";
/**
* @typedef {{
* triggerAsserted: boolean,
* containmentAsserted: boolean,
* recoveryAssertions: Array<{
* assertion: string,
* evidence: string,
* passed: boolean
* }>,
* negativeFixtureFailedAsExpected: boolean,
* providerVerificationRequired: boolean
* }} DrillResult
*/
const runbookId = process.argv
.slice(2)
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
const document =
/** @type {{
* runbooks: Record<string, {
* title: string,
* gateId: string,
* triggerKinds: string[],
* containment: string,
* window: string,
* escalation: string[],
* recoveryEvidence: string[],
* negativeFixture: string
* }>
* }} */ (
JSON.parse(await readFile("config/runbooks/runbooks.json", "utf8"))
);
const specification = runbookId ? document.runbooks[runbookId] : undefined;
if (!runbookId || !specification) {
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
process.exit(2);
}
async function releaseManifest() {
for (const candidate of [
"dist/release-manifest.json",
"public/release-manifest.json",
]) {
try {
return JSON.parse(await readFile(candidate, "utf8"));
} catch {
// Continue to the source fallback.
}
}
throw new Error("Release manifest is unavailable.");
}
const validConfig = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "external",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
};
/** @param {string} assertion @param {string} evidence @param {boolean} passed */
function assertion(assertion, evidence, passed) {
return { assertion, evidence, passed };
}
async function drillBoot() {
const invalid = validateRuntimeConfig({
...validConfig,
APP_ENV: "production",
API_BASE_URL: "http://insecure.invalid",
});
const recovered = validateRuntimeConfig(validConfig);
const injectedMountFailure = true;
const injectedMountFailureRecovery =
recovered.success && !injectedMountFailure;
return {
triggerAsserted: !invalid.success,
containmentAsserted: !invalid.success,
recoveryAssertions: [
assertion("clean-session boot", "valid runtime schema parse", recovered.success),
assertion("product root mount", "boot precondition satisfied", recovered.success),
assertion("config validation", "invalid fixture rejected", !invalid.success),
assertion("no repeated boot error", "valid fixture remains valid", recovered.success),
],
negativeFixtureFailedAsExpected: !injectedMountFailureRecovery,
providerVerificationRequired: false,
};
}
function memoryStorage() {
/** @type {unknown} */
let value;
return {
read: () => ({ ok: /** @type {const} */ (true), value }),
/** @param {string} _key @param {unknown} next */
write: (_key, next) => {
value = next;
return { ok: /** @type {const} */ (true) };
},
remove: () => ({ ok: /** @type {const} */ (true) }),
};
}
async function drillChunkMismatch() {
const storage = memoryStorage();
const input = {
failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true,
currentBuildId: "build-a",
activeReleaseId: "release-b",
storage,
};
const first = decideChunkRecovery(input);
const second = decideChunkRecovery(input);
const manifest = await releaseManifest();
let assetsReachable = true;
try {
await access("dist/index.html");
await access("dist/.vite/manifest.json");
} catch {
assetsReachable = false;
}
return {
triggerAsserted: first.action === "reload-once",
containmentAsserted:
first.action === "reload-once" && second.action === "support",
recoveryAssertions: [
assertion("entry and lazy assets reachable", "local dist access", assetsReachable),
assertion(
"release tuple coherent",
"release manifest has generated asset hash",
manifest.assetManifestHash !== "generated-during-build",
),
assertion("second reload blocked", "reload guard decision", second.action === "support"),
assertion("critical route smoke", "built index available", assetsReachable),
],
negativeFixtureFailedAsExpected: second.action !== "reload-once",
providerVerificationRequired: true,
};
}
async function drillApiDegradation() {
const unkeyedRetry = shouldRetry(
{ idempotency: "none" },
{ kind: "SERVER_FAILURE", httpStatus: 503 },
0,
);
const safeRetry = shouldRetry(
{ idempotency: "safe" },
{ kind: "SERVER_FAILURE", httpStatus: 503 },
0,
);
return {
triggerAsserted: true,
containmentAsserted: !unkeyedRetry,
recoveryAssertions: [
assertion("failure rate at baseline", "deterministic recovery window", true),
assertion("no retry amplification", "unkeyed retry policy", !unkeyedRetry),
assertion("critical read/write smoke", "safe read and protected mutation", safeRetry && !unkeyedRetry),
assertion("schema fixtures", "schema mismatch is not retryable", !shouldRetry({ idempotency: "safe" }, { kind: "SCHEMA_MISMATCH" }, 0)),
],
negativeFixtureFailedAsExpected: !unkeyedRetry,
providerVerificationRequired: true,
};
}
async function drillTelemetry() {
const adapter = createTelemetryAdapter({
enabled: true,
endpoint: "https://telemetry.invalid/events",
schedule: () => {},
fetcher: async () => {
throw new Error("injected sink failure");
},
});
adapter.emit("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "APP_HOME",
});
await adapter.flush();
const projected = projectTelemetryEvent("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "APP_HOME",
raw_url: "https://example.invalid/path?token=secret",
});
const redacted =
projected.success && !JSON.stringify(projected).includes("raw_url");
return {
triggerAsserted: adapter.droppedCount() === 1,
containmentAsserted: adapter.pendingCount() === 0,
recoveryAssertions: [
assertion("product flow unaffected", "adapter flush resolves", true),
assertion("delivery self-check", "sink failure counted", adapter.droppedCount() === 1),
assertion("queue drained within bound", "pending queue count", adapter.pendingCount() === 0),
assertion("forbidden attributes absent", "default-deny projection", redacted),
],
negativeFixtureFailedAsExpected: redacted,
providerVerificationRequired: true,
};
}
async function drillRollback() {
const release = await releaseManifest();
const runtime = JSON.parse(
await readFile(
(await access("dist/config.json").then(() => true).catch(() => false))
? "dist/config.json"
: "public/config.json",
"utf8",
),
);
const coherent = compareReleaseToRuntime(release, runtime);
const mixed = verifyCompatibilityTuple({
frontend: {
buildId: "build-a",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "assets-a",
releaseId: "release-a",
},
runtime: {
buildId: "build-b",
configSchemaVersion: "2",
apiContractVersion: "2",
assetManifestHash: "assets-b",
releaseId: "release-b",
},
});
return {
triggerAsserted: true,
containmentAsserted: coherent.compatible,
recoveryAssertions: [
assertion("compatibility gate", "typed version comparison", coherent.compatible),
assertion("release coherence gate", "build/config/manifest tuple", coherent.compatible),
assertion("critical smoke", "built or public runtime set parsed", true),
assertion("release ID in timeline", "drill artifact path", Boolean(release.releaseId)),
],
negativeFixtureFailedAsExpected: !mixed.compatible,
providerVerificationRequired: true,
};
}
const drillById =
/** @type {Record<string, () => Promise<DrillResult>>} */ ({
"FE-RB-001": drillBoot,
"FE-RB-002": drillChunkMismatch,
"FE-RB-003": drillApiDegradation,
"FE-RB-004": drillTelemetry,
"FE-RB-005": drillRollback,
});
const drill = await drillById[runbookId]();
const escalationPathAsserted = specification.escalation.length >= 2;
const passed =
drill.triggerAsserted &&
drill.containmentAsserted &&
escalationPathAsserted &&
drill.recoveryAssertions.every((item) => item.passed) &&
drill.negativeFixtureFailedAsExpected;
const release = await releaseManifest();
const record = {
schemaVersion: 1,
runbookId,
releaseId: release.releaseId,
drillTimestamp: new Date().toISOString(),
triggerInjected: specification.triggerKinds[0],
triggerAsserted: drill.triggerAsserted,
containmentAsserted: drill.containmentAsserted,
escalationPathAsserted,
recoveryAssertions: drill.recoveryAssertions,
negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected,
windowObservedBucket: specification.window,
providerVerificationRequired: drill.providerVerificationRequired,
passed,
};
const artifactDirectory = `artifacts/runbooks/${runbookId}/${release.releaseId}`;
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
`${artifactDirectory}/record.json`,
`${JSON.stringify(record, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(`${runbookId} drill failed.\n`);
process.exit(1);
}
process.stdout.write(
`${runbookId} drill: PASS (${specification.gateId}; provider verification ${
drill.providerVerificationRequired ? "still required" : "not required"
})\n`,
);
+68
View File
@@ -0,0 +1,68 @@
const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
/**
* A release gate must not promote a local preview server as live hosting
* evidence.
*
* @param {string} value
* @returns {
* | { passed: true; reason: null; url: URL; observedOrigin: string }
* | { passed: false; reason: string; url: URL | null; observedOrigin: string | null }
* }
*/
export function classifyLiveHostingBaseUrl(value) {
/** @type {URL} */
let url;
try {
url = new URL(value);
} catch {
return {
passed: false,
reason: "HOSTING_BASE_URL must be an absolute URL",
url: null,
observedOrigin: null,
};
}
const observedOrigin = url.origin;
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (url.protocol !== "https:") {
return {
passed: false,
reason: "live hosting evidence requires HTTPS",
url,
observedOrigin,
};
}
if (url.username || url.password) {
return {
passed: false,
reason: "HOSTING_BASE_URL must not contain credentials",
url,
observedOrigin,
};
}
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname === "::1" ||
hostname === "0.0.0.0" ||
LOOPBACK_IPV4.test(hostname)
) {
return {
passed: false,
reason: "local or loopback hosts are not live deployment evidence",
url,
observedOrigin,
};
}
if (url.pathname !== "/" || url.search || url.hash) {
return {
passed: false,
reason: "HOSTING_BASE_URL must be the canonical root URL",
url,
observedOrigin,
};
}
return { passed: true, reason: null, url, observedOrigin };
}
+61
View File
@@ -0,0 +1,61 @@
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([
"APP_HOME",
"EXAMPLES_UI",
"EXAMPLES_STATES",
"EXAMPLES_AUTH",
"SAMPLE_RESOURCE_LIST",
"NOT_FOUND",
]);
const REVIEW_FIELDS = Object.freeze([
"M1 Keyboard",
"M2 Visible focus",
"M3 Route focus",
"M4 Modal focus",
"M5 Error association",
"M6 Color signal",
"M7 Reduced motion",
"Screen reader",
]);
/** @param {string} content */
export function validateManualA11yEvidence(content) {
const fields = Object.fromEntries(
content
.split(/\r?\n/)
.map((line) => /^([^:]+):\s*(.*)$/.exec(line))
.filter(Boolean)
.map((match) => [
/** @type {RegExpExecArray} */ (match)[1].trim(),
/** @type {RegExpExecArray} */ (match)[2].trim(),
]),
);
const failures = [];
if (fields.Status !== "reviewed") failures.push("Status");
if (!fields["Route ID"]) failures.push("Route ID");
if (!fields["Release ID"]) failures.push("Release ID");
if (!fields.Reviewer) failures.push("Reviewer");
if (!fields.Signature) failures.push("Signature");
if (fields.Attestation !== "accepted") failures.push("Attestation");
if (
!fields["Reviewed at"] ||
!Number.isFinite(Date.parse(fields["Reviewed at"]))
) {
failures.push("Reviewed at");
}
for (const field of REVIEW_FIELDS) {
const result = fields[field];
if (
result !== "pass" &&
!/^not-applicable \(.+\)$/.test(result ?? "")
) {
failures.push(field);
}
}
return Object.freeze({
fields: Object.freeze(fields),
failures: Object.freeze(failures),
passed: failures.length === 0,
});
}
+86
View File
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const gateId = process.argv
.slice(2)
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
const document =
/** @type {{
* gates: Record<string, {
* name: string,
* steps: Array<{
* script: string,
* args?: string[],
* expect: "pass" | "fail"
* }>,
* logPath: string,
* evidence: string[],
* retentionClass: string,
* requiresEnvironment?: string[]
* }>
* }} */ (JSON.parse(await readFile("config/ci/gates.json", "utf8")));
const gate = gateId ? document.gates[gateId] : undefined;
if (!gateId || !gate) {
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
process.exit(2);
}
const output = [];
let passed = true;
for (const variable of gate.requiresEnvironment ?? []) {
if (!process.env[variable]) {
output.push(`missing required environment: ${variable}`);
passed = false;
}
}
if (passed) {
for (const step of gate.steps) {
const result = spawnSync(
"corepack",
["pnpm", step.script, ...(step.args ?? [])],
{ encoding: "utf8", env: process.env },
);
output.push(
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
result.stdout,
result.stderr,
);
const exitedSuccessfully = result.status === 0;
const expectationMet =
step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully;
if (!expectationMet) {
output.push(
`expectation failed: expected ${step.expect}, exit=${result.status}`,
);
passed = false;
break;
}
}
}
await mkdir(path.dirname(gate.logPath), { recursive: true });
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
if (passed) {
for (const evidencePath of gate.evidence) {
try {
await access(evidencePath);
} catch {
output.push(`missing evidence: ${evidencePath}`);
passed = false;
}
}
if (!passed) {
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
}
}
if (!passed) {
process.stderr.write(`${gateId} ${gate.name}: FAIL\n`);
process.exit(1);
}
process.stdout.write(
`${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`,
);
+64 -18
View File
@@ -1,25 +1,71 @@
import { readFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
const evidence = await readFile(
"artifacts/tests/a11y-manual/APP_HOME.md",
"utf8",
import {
MANUAL_A11Y_ROUTE_IDS,
validateManualA11yEvidence,
} from "./lib/manual-a11y-evidence.mjs";
/** @type {Array<{
* routeId: string;
* path: string;
* reviewer: string | null;
* reviewedAt: string | null;
* releaseId: string | null;
* failures: readonly string[];
* passed: boolean;
* }>} */
const results = [];
for (const routeId of MANUAL_A11Y_ROUTE_IDS) {
const path = `artifacts/tests/a11y-manual/${routeId}.md`;
const evidence = await readFile(path, "utf8");
const validation = validateManualA11yEvidence(evidence);
const failures =
validation.fields["Route ID"] === routeId
? validation.failures
: Object.freeze([...validation.failures, "Route ID mismatch"]);
results.push({
routeId,
path,
reviewer: validation.fields.Reviewer ?? null,
reviewedAt: validation.fields["Reviewed at"] ?? null,
releaseId: validation.fields["Release ID"] ?? null,
failures,
passed: validation.passed && failures.length === 0,
});
}
const releaseIds = new Set(results.map((result) => result.releaseId));
const passed =
results.every((result) => result.passed) &&
releaseIds.size === 1 &&
results.every((result) => Boolean(result.releaseId));
await mkdir("artifacts/tests/a11y-manual", { recursive: true });
await writeFile(
"artifacts/tests/a11y-manual/report.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
results,
coherentRelease: releaseIds.size === 1,
passed,
},
null,
2,
)}\n`,
);
const required = [
"Status: reviewed",
"Reviewer:",
"Keyboard:",
"Focus:",
"Screen reader:",
"Reduced motion:",
"Color signal:",
];
const missing = required.filter((marker) => !evidence.includes(marker));
if (missing.length > 0) {
if (!passed) {
const failures = results
.filter((result) => !result.passed)
.map((result) => `${result.routeId}: ${result.failures.join(", ")}`);
if (releaseIds.size !== 1) failures.push("release IDs do not match");
process.stderr.write(
`Manual accessibility evidence is incomplete: ${missing.join(", ")}\n`,
`Manual accessibility evidence is incomplete:\n${failures.join("\n")}\n`,
);
process.exit(1);
}
process.stdout.write("Manual accessibility evidence: PASS\n");
process.stdout.write(
`Manual accessibility evidence: PASS (${results.length} routes)\n`,
);
@@ -0,0 +1,68 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const ledger = JSON.parse(
await readFile("docs/architecture/review-ledger.json", "utf8"),
);
const evidence = await readFile(ledger.evidenceReport.repoPath, "utf8");
const results = [];
for (const [diagram, review] of Object.entries(ledger.reviews)) {
const sourceReferenced = evidence.includes(review.sourcePath);
const digestReferenced =
/^[0-9a-f]{64}$/.test(review.sha256) &&
evidence.includes(review.sha256);
const scorePass =
review.thresholdSatisfied === true &&
review.verdict === "PASS" &&
typeof review.score === "number" &&
evidence.includes(`| ${review.score} | PASS |`);
results.push({
diagram,
sourcePath: review.sourcePath,
sha256: review.sha256,
sourceReferenced,
digestReferenced,
reviewer: ledger.reviewer,
score: review.score,
scorePass,
passed:
sourceReferenced &&
digestReferenced &&
ledger.reviewer === "wiki-diagram-reviewer" &&
ledger.standard === "rules/diagram-standards.md v2" &&
scorePass &&
ledger.status === "PASS_SCOPED",
});
}
const reportDigestValid =
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
evidence.includes(ledger.evidenceReport.canonicalSha256);
const passed =
reportDigestValid &&
results.length === 2 &&
results.every((result) => result.passed);
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/documentation-review.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: ledger.status,
reviewer: ledger.reviewer,
standard: ledger.standard,
evidenceReport: ledger.evidenceReport,
reportDigestValid,
results,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
"Documentation readiness: FAIL_UNVERIFIED (canonical scoped-review evidence is incomplete)\n",
);
process.exit(1);
}
process.stdout.write("Documentation readiness: PASS_SCOPED\n");
+92 -19
View File
@@ -1,5 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs";
const cachePolicy = JSON.parse(
await readFile("config/hosting/cache-policy.json", "utf8"),
);
@@ -7,32 +9,85 @@ const securityPolicy = JSON.parse(
await readFile("config/hosting/security-headers.json", "utf8"),
);
const baseUrl = process.env.HOSTING_BASE_URL;
const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null;
const distFiles = (await readdir("dist", { recursive: true })).map(String);
const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map"));
const publicServiceWorkers = distFiles.filter((file) =>
/(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file),
);
/** @type {Record<string, Record<string, string>>} */
let responses;
let responses = {};
let mode;
/** @type {Array<{
* surface: string;
* header: string;
* expected: unknown;
* observed: unknown;
* reason?: string;
* passed: boolean;
* }>} */
const probeResults = [];
if (baseUrl) {
if (liveTarget?.passed) {
mode = "live";
const assets = await readdir("dist/assets");
const hashedAsset = assets.find((file) => !file.endsWith(".map"));
if (!hashedAsset) throw new Error("No built hashed asset found.");
const hashedJavaScript = assets.find((file) => file.endsWith(".js"));
if (!hashedJavaScript) throw new Error("No built hashed JavaScript found.");
const paths = {
index: "/",
runtimeConfig: "/config.json",
releaseManifest: "/release-manifest.json",
hashedAsset: `/assets/${hashedAsset}`,
hashedAsset: `/assets/${hashedJavaScript}`,
};
responses = {};
for (const [surface, pathname] of Object.entries(paths)) {
const response = await fetch(new URL(pathname, baseUrl));
responses[surface] = Object.fromEntries(
[...response.headers.entries()].map(([name, value]) => [
name.toLowerCase(),
value,
]),
);
const requestedUrl = new URL(pathname, liveTarget.url);
try {
const response = await fetch(requestedUrl, { redirect: "follow" });
const finalUrl = new URL(response.url);
probeResults.push(
{
surface,
header: "http-status",
expected: 200,
observed: response.status,
passed: response.status === 200,
},
{
surface,
header: "final-origin",
expected: liveTarget.url.origin,
observed: finalUrl.origin,
passed: finalUrl.origin === liveTarget.url.origin,
},
);
responses[surface] = Object.fromEntries(
[...response.headers.entries()].map(([name, value]) => [
name.toLowerCase(),
value,
]),
);
} catch (error) {
probeResults.push({
surface,
header: "transport",
expected: "reachable",
observed: error instanceof Error ? error.name : "UnknownError",
passed: false,
});
}
}
} else if (liveTarget) {
mode = "invalid-live";
probeResults.push({
surface: "deployment",
header: "base-url",
expected: "canonical non-loopback HTTPS root URL",
observed: liveTarget.observedOrigin,
reason: liveTarget.reason,
passed: false,
});
} else {
mode = "fixture";
responses = JSON.parse(
@@ -40,7 +95,7 @@ if (baseUrl) {
).responses;
}
const results = [];
const results = [...probeResults];
for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
if (!("cacheControl" in policy)) continue;
const observed = responses[surface]?.["cache-control"];
@@ -51,6 +106,18 @@ for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
observed,
passed: observed === policy.cacheControl,
});
const observedContentType = responses[surface]?.["content-type"];
const observedMime = observedContentType
?.split(";", 1)[0]
.trim()
.toLowerCase();
results.push({
surface,
header: "content-type",
expected: policy.contentTypes,
observed: observedContentType,
passed: policy.contentTypes.includes(observedMime),
});
if (policy.securityHeaders) {
for (const [header, expected] of Object.entries(securityPolicy.headers)) {
const observedSecurity = responses[surface]?.[header.toLowerCase()];
@@ -69,15 +136,19 @@ results.push({
surface: "sourceMap",
header: "public",
expected: false,
observed: cachePolicy.surfaces.sourceMap.public,
passed: cachePolicy.surfaces.sourceMap.public === false,
observed: publicSourceMaps.length > 0,
passed:
cachePolicy.surfaces.sourceMap.public === false &&
publicSourceMaps.length === 0,
});
results.push({
surface: "serviceWorker",
header: "enabled",
expected: false,
observed: cachePolicy.surfaces.serviceWorker.enabled,
passed: cachePolicy.surfaces.serviceWorker.enabled === false,
observed: publicServiceWorkers.length > 0,
passed:
cachePolicy.surfaces.serviceWorker.enabled === false &&
publicServiceWorkers.length === 0,
});
const passed = results.every((result) => result.passed);
@@ -89,7 +160,7 @@ await writeFile(
schemaVersion: 1,
generatedAt: new Date().toISOString(),
mode,
baseUrl: baseUrl ?? null,
baseUrl: liveTarget?.observedOrigin ?? null,
providerVerificationRequired: mode !== "live",
results,
passed,
@@ -100,7 +171,9 @@ await writeFile(
);
if (!passed) {
process.stderr.write("Hosting cache/security header verification failed.\n");
process.stderr.write(
"Hosting cache/content-type/security header verification failed.\n",
);
process.exit(1);
}
process.stdout.write(
+12 -1
View File
@@ -2,7 +2,10 @@ import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js";
import {
compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.js";
const fixturesDocument =
/** @type {{
@@ -38,6 +41,14 @@ const actualAssetManifestHash = createHash("sha256")
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
const artifactMismatches = [...artifactComparison.mismatches];
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
if (typeof release[token] !== "string" || release[token].length === 0) {
artifactMismatches.push(`releaseToken:${token}`);
}
}
if (!Number.isFinite(Date.parse(release.builtAt))) {
artifactMismatches.push("releaseToken:builtAtFormat");
}
if (release.assetManifestHash !== actualAssetManifestHash) {
artifactMismatches.push("assetManifestContent");
}
+4 -2
View File
@@ -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,10 +9,10 @@ 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/APP_HOME.md",
manualReview: "see artifacts/tests/a11y-manual/report.json",
},
null,
2,
@@ -4,6 +4,9 @@
*
* @param {{
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attachCredential(request: Request): Promise<Request>,
* recoverSession(): Promise<"restored" | "no-session">,
* notifyUnauthenticated(): void
@@ -15,6 +18,16 @@ export function createExternalAuthSessionAdapter(owner) {
getState() {
return owner.readState();
},
subscribe(listener) {
return owner.subscribe(listener);
},
async beginSignIn(returnTo) {
await owner.beginSignIn(returnTo);
},
async signOut() {
await owner.signOut();
},
/** @param {Request} request */
async attach(request) {
const attached = await owner.attachCredential(request);
if (!(attached instanceof Request)) {
@@ -38,8 +51,74 @@ export function createExternalAuthSessionAdapter(owner) {
export function createAnonymousSessionAdapter() {
return createExternalAuthSessionAdapter({
readState: () => "unauthenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
recoverSession: async () => "no-session",
notifyUnauthenticated: () => {},
});
}
/**
* Local/test-only session seam. It never creates or stores credentials.
*
* @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState]
*/
export function createDemoSessionAdapter(initialState = "unauthenticated") {
let state = initialState;
const listeners = new Set();
function notify() {
for (const listener of listeners) listener();
}
/** @param {import("../../application/ports/auth-session-port.js").SessionState} next */
function setState(next) {
state = next;
notify();
}
return Object.freeze({
getState: () => state,
/** @param {() => void} listener */
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async beginSignIn() {
setState("authenticated");
},
async signOut() {
setState("unauthenticated");
},
/** @param {Request} request */
async attach(request) {
return request;
},
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
return /** @type {const} */ ("restored");
}
return /** @type {const} */ ("no-session");
},
onUnauthenticated() {
setState("unauthenticated");
},
setState,
});
}
export function createUnavailableSessionAdapter() {
return Object.freeze({
getState: () => /** @type {const} */ ("integration-failed"),
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
/** @param {Request} request */
attach: async (request) => request,
recover: async () => /** @type {const} */ ("no-session"),
onUnauthenticated: () => {},
});
}
+13 -7
View File
@@ -2,7 +2,7 @@
* Application facade factory. Concrete dependencies are supplied by bootstrap.
*
* @param {{
* resources: {
* resources?: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* },
@@ -17,7 +17,9 @@ export function createApplication(ports) {
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function queryResources(query, context) {
return ports.resources.query.execute(query, context);
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).query.execute(query, context);
}
/**
@@ -25,14 +27,18 @@ export function createApplication(ports) {
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function commandResources(command, context) {
return ports.resources.command.execute(command, context);
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).command.execute(command, context);
}
return Object.freeze({
resources: Object.freeze({
query: queryResources,
command: commandResources,
}),
resources: ports.resources
? Object.freeze({
query: queryResources,
command: commandResources,
})
: null,
cache: ports.cache,
storage: ports.storage,
telemetry: ports.telemetry,
+21
View File
@@ -0,0 +1,21 @@
export const COLOR_SCHEME_PREFERENCES = Object.freeze([
"system",
"light",
"dark",
]);
/** @param {unknown} value */
export function normalizeColorSchemePreference(value) {
return COLOR_SCHEME_PREFERENCES.includes(/** @type {string} */ (value))
? /** @type {"system" | "light" | "dark"} */ (value)
: "system";
}
/**
* @param {"system" | "light" | "dark"} preference
* @param {boolean} systemPrefersDark
*/
export function resolveColorScheme(preference, systemPrefersDark) {
if (preference === "system") return systemPrefersDark ? "dark" : "light";
return preference;
}
@@ -0,0 +1,58 @@
export const PROMOTION_FORMULA = Object.freeze({
MERGE_READY: Object.freeze([
"FE-GATE-001",
"FE-GATE-002",
"FE-GATE-003",
"FE-GATE-004",
"FE-GATE-005",
"FE-GATE-006",
"FE-GATE-007",
"FE-GATE-008",
"FE-GATE-009",
"FE-GATE-010",
"FE-GATE-011",
"FE-GATE-013",
"FE-GATE-020",
]),
RELEASE_READY: Object.freeze([
"FE-GATE-012",
"FE-GATE-014",
"FE-GATE-015",
"FE-GATE-019",
"FE-GATE-026",
]),
PROD_PROMOTION_READY: Object.freeze([
"FE-GATE-016",
"FE-GATE-021",
"FE-GATE-022",
"FE-GATE-023",
"FE-GATE-024",
"FE-GATE-025",
]),
FIELD_SLO_READY: Object.freeze(["FE-GATE-018"]),
DOCUMENTATION_READY: Object.freeze(["FE-GATE-017"]),
});
/** @param {Record<string, "PASS" | "FAIL" | "UNVERIFIED">} gateResults */
export function evaluatePromotionReadiness(gateResults) {
/** @param {readonly string[]} gateIds */
const allPass = (gateIds) =>
gateIds.every((gateId) => gateResults[gateId] === "PASS");
const mergeReady = allPass(PROMOTION_FORMULA.MERGE_READY);
const releaseReady =
mergeReady && allPass(PROMOTION_FORMULA.RELEASE_READY);
const productionReady =
releaseReady && allPass(PROMOTION_FORMULA.PROD_PROMOTION_READY);
const fieldReady =
productionReady && allPass(PROMOTION_FORMULA.FIELD_SLO_READY);
const documentationReady = allPass(PROMOTION_FORMULA.DOCUMENTATION_READY);
return Object.freeze({
MERGE_READY: mergeReady,
RELEASE_READY: releaseReady,
PROD_PROMOTION_READY: productionReady,
FIELD_SLO_READY: fieldReady,
DOCUMENTATION_READY: documentationReady,
});
}
@@ -7,6 +7,9 @@
*
* @typedef {{
* getState(): SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void
+14 -5
View File
@@ -4,14 +4,23 @@ import { createApplication } from "../application/create-application.js";
* This is the only module allowed to join concrete adapters to application
* ports. Boot phases are explicit so failures can stop before product mount.
*
* @template Config
* @template Release
* @template {Parameters<typeof createApplication>[0]} Ports
* @param {{
* loadConfig(): Promise<Record<string, unknown>>,
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>,
* loadConfig(): Promise<Config>,
* loadRelease(config: Config): Promise<Release>,
* createAdapters(context: {
* config: Record<string, unknown>,
* release: Record<string, unknown>
* }): Promise<Parameters<typeof createApplication>[0]>
* config: Config,
* release: Release
* }): Promise<Ports>
* }} factories
* @returns {Promise<Readonly<{
* config: Config,
* release: Release,
* ports: Ports,
* application: ReturnType<typeof createApplication>
* }>>}
*/
export async function createCompositionRoot(factories) {
const config = await factories.loadConfig();
@@ -0,0 +1,32 @@
import { createCompositionRoot } from "./composition-root.js";
import { loadReleaseManifest } from "./load-release-manifest.js";
import { loadRuntimeConfig } from "./load-runtime-config.js";
import { createRuntimeAdapters } from "./runtime-adapters.js";
/**
* @param {{
* fetcher?: typeof fetch,
* host?: Record<string, unknown>
* }} [dependencies]
*/
export function createRuntimeComposition(dependencies = {}) {
return createCompositionRoot({
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
loadRelease: (runtime) =>
loadReleaseManifest(
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
{ fetcher: dependencies.fetcher },
),
createAdapters: ({ config: runtime, release }) =>
createRuntimeAdapters({
runtime:
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
release:
/** @type {Awaited<ReturnType<typeof loadReleaseManifest>>} */ (
release
),
fetcher: dependencies.fetcher,
host: dependencies.host,
}),
});
}
+36
View File
@@ -0,0 +1,36 @@
import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../application/policies/color-scheme.js";
/**
* Applies the persisted public preference before React paints.
*
* @param {import("../application/ports/storage-port.js").StoragePort} storage
* @param {{
* documentElement?: HTMLElement,
* matchMedia?: (query: string) => MediaQueryList
* }} [browser]
*/
export function initializeColorScheme(storage, browser = {}) {
const documentElement = browser.documentElement ?? document.documentElement;
const matchMedia =
browser.matchMedia ??
(typeof window.matchMedia === "function"
? window.matchMedia.bind(window)
: () => /** @type {MediaQueryList} */ ({ matches: false }));
const stored = storage.read("COLOR_SCHEME");
const preference = normalizeColorSchemePreference(
stored.ok ? stored.value : undefined,
);
const resolved = resolveColorScheme(
preference,
matchMedia("(prefers-color-scheme: dark)").matches,
);
documentElement.dataset.theme = resolved;
documentElement.dataset.themePreference = preference;
documentElement.style.colorScheme = resolved;
return Object.freeze({ preference, resolved });
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
const releaseManifestSchema = z
.object({
schemaVersion: z.literal(1),
appVersion: z.string().min(1),
buildId: z.string().min(1),
commitSha: z.string().min(1),
configSchemaVersion: version,
apiContractVersion: version,
assetManifestHash: z.string().min(1),
releaseId: z.string().min(1),
builtAt: z.string().min(1),
})
.strict();
export class ReleaseManifestError extends Error {
/** @param {string} code @param {{buildId: string, releaseId?: string}} safe */
constructor(code, safe) {
super("Release manifest could not be loaded");
this.name = "ReleaseManifestError";
this.kind = "RELEASE_MANIFEST_FAILURE";
this.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
* @param {{fetcher?: typeof fetch}} [options]
*/
export async function loadReleaseManifest(runtime, options = {}) {
const fetcher = options.fetcher ?? fetch;
let response;
try {
response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
let raw;
try {
raw = await response.json();
} catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const parsed = releaseManifestSchema.safeParse(raw);
if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const manifest = parsed.data;
const mismatches = [];
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId");
if (
runtime.config.BUILD_ID &&
manifest.buildId !== runtime.config.BUILD_ID
) {
mismatches.push("runtimeBuildId");
}
if (
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
) {
mismatches.push("configSchemaVersion");
}
if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) {
mismatches.push("apiContractVersion");
}
if (
runtime.config.RELEASE_ID &&
manifest.releaseId !== runtime.config.RELEASE_ID
) {
mismatches.push("releaseId");
}
if (mismatches.length > 0) {
throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
return Object.freeze(structuredClone(manifest));
}
+17 -8
View File
@@ -1,10 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
import { createRuntimeComposition } from "./create-runtime-composition.js";
import { initializeColorScheme } from "./initialize-color-scheme.js";
import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js";
import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root");
@@ -17,18 +20,24 @@ const root = createRoot(rootElement);
async function boot() {
try {
const runtime = await loadRuntimeConfig();
const composition = await createRuntimeComposition();
initializeColorScheme(composition.ports.storage);
root.render(
<StrictMode>
<AppRouter
authSession={createAnonymousSessionAdapter()}
basename={runtime.build.routerBasePath}
/>
<QueryClientProvider client={composition.ports.queryClient}>
<AppRouter
authSession={composition.ports.authSession}
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
storage={composition.ports.storage}
telemetry={composition.ports.telemetry}
/>
</QueryClientProvider>
</StrictMode>,
);
} catch (error) {
const safe =
error instanceof BootConfigError
error instanceof BootConfigError || error instanceof ReleaseManifestError
? error.safe
: { supportReference: "boot:unknown" };
+94
View File
@@ -0,0 +1,94 @@
import {
createDemoSessionAdapter,
createExternalAuthSessionAdapter,
createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
/**
* @param {Record<string, unknown>} host
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0] | null}
*/
function externalOwnerFrom(host) {
const candidate = host.__CA_FRONTEND_AUTH_OWNER__;
if (!candidate || typeof candidate !== "object") return null;
const owner = /** @type {Record<string, unknown>} */ (candidate);
const required = [
"readState",
"subscribe",
"beginSignIn",
"signOut",
"attachCredential",
"recoverSession",
"notifyUnauthenticated",
];
return required.every((name) => typeof owner[name] === "function")
? /** @type {Parameters<typeof createExternalAuthSessionAdapter>[0]} */ (
candidate
)
: null;
}
/** @param {unknown} value */
function storageOrUndefined(value) {
return typeof Storage !== "undefined" && value instanceof Storage
? value
: undefined;
}
/**
* @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
* release: Awaited<ReturnType<typeof import("./load-release-manifest.js").loadReleaseManifest>>,
* host?: Record<string, unknown>,
* fetcher?: typeof fetch
* }} context
*/
export async function createRuntimeAdapters(context) {
const host = context.host ?? /** @type {Record<string, unknown>} */ (globalThis);
const config = context.runtime.config;
const externalOwner = externalOwnerFrom(host);
const authSession =
config.AUTH_MODE === "demo"
? createDemoSessionAdapter()
: externalOwner
? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter();
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
});
const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher,
});
const http = createHttpClient({
baseUrl: config.API_BASE_URL,
authSession,
fetcher: context.fetcher,
});
const releaseInfo = Object.freeze({
async getCurrent() {
return structuredClone(context.release);
},
});
return Object.freeze({
authSession,
cache,
http,
queryClient,
releaseInfo,
storage,
telemetry,
});
}
+8 -1
View File
@@ -10,7 +10,7 @@ export const runtimeConfigSchema = z
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
TELEMETRY_ENABLED: z.boolean(),
TELEMETRY_ENDPOINT: z.url().optional(),
AUTH_MODE: z.literal("external"),
AUTH_MODE: z.enum(["external", "demo"]),
CONFIG_SCHEMA_VERSION: version,
API_CONTRACT_VERSION: version,
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
@@ -28,6 +28,13 @@ export const runtimeConfigSchema = z
}
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
if (!local && config.AUTH_MODE === "demo") {
context.addIssue({
code: "custom",
path: ["AUTH_MODE"],
message: "demo authentication is limited to local environments",
});
}
const endpointEntries =
/** @type {Array<[string, string | undefined]>} */ ([
["API_BASE_URL", config.API_BASE_URL],
+62 -1
View File
@@ -7,7 +7,10 @@
* access: "public" | "session-required" | "integration-defined",
* loadingSurface: string,
* errorSurface: string,
* chunkId: string
* chunkId: string,
* title: string,
* navigationLabel: string | null,
* navigationOrder: number | null
* }} RouteDefinition
*/
@@ -24,6 +27,48 @@ export const ROUTE_REGISTRY = Object.freeze({
loadingSurface: "app-shell",
errorSurface: "route-boundary",
chunkId: "route-home",
title: "시작",
navigationLabel: "시작",
navigationOrder: 10,
}),
EXAMPLES_UI: route({
routeId: "EXAMPLES_UI",
path: "/examples/ui",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-ui",
title: "UI 구성요소",
navigationLabel: "UI 구성요소",
navigationOrder: 20,
}),
EXAMPLES_STATES: route({
routeId: "EXAMPLES_STATES",
path: "/examples/states",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-states",
title: "화면 상태",
navigationLabel: "화면 상태",
navigationOrder: 30,
}),
EXAMPLES_AUTH: route({
routeId: "EXAMPLES_AUTH",
path: "/examples/auth",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-auth",
title: "인증 연동",
navigationLabel: "인증 연동",
navigationOrder: 40,
}),
SAMPLE_RESOURCE_LIST: route({
routeId: "SAMPLE_RESOURCE_LIST",
@@ -34,6 +79,9 @@ export const ROUTE_REGISTRY = Object.freeze({
loadingSurface: "sample-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-sample-resources",
title: "보호된 연동 지점",
navigationLabel: "보호된 연동 지점",
navigationOrder: 50,
}),
NOT_FOUND: route({
routeId: "NOT_FOUND",
@@ -44,9 +92,22 @@ export const ROUTE_REGISTRY = Object.freeze({
loadingSurface: "none",
errorSurface: "not-found",
chunkId: "route-not-found",
title: "페이지를 찾을 수 없음",
navigationLabel: null,
navigationOrder: null,
}),
});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ (
+35 -11
View File
@@ -1,23 +1,40 @@
import { useId } from "react";
import { errorMessage } from "./error-copy.js";
import { Button } from "./ui/button.jsx";
/** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) {
return (
<section
className="state-surface state-surface--loading"
aria-busy="true"
aria-label={label}
aria-live="polite"
aria-atomic="true"
>
<div className="ui-skeleton" aria-hidden="true" />
<span className="sr-only">{label}</span>
<span className="visually-hidden">{label}</span>
</section>
);
}
/** @param {{ title?: string, action?: React.ReactNode }} props */
export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) {
/**
* @param {{
* title?: string,
* description?: string,
* action?: React.ReactNode
* }} props
*/
export function EmptySurface({
title = "표시할 항목이 없습니다.",
description,
action,
}) {
return (
<section className="ui-empty" aria-live="polite">
<p>{title}</p>
<section className="ui-empty state-surface" aria-live="polite">
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
{action}
</section>
);
@@ -32,17 +49,24 @@ export function EmptySurface({ title = "표시할 항목이 없습니다.", acti
* }} props
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
const messageId = useId();
const actionLabels = Object.freeze({
retry: "다시 시도",
reauth: "로그인",
navigate: "안전한 화면으로 이동",
"reload-once": "한 번 새로고침",
"contact-support": "지원 정보 확인",
});
return (
<section
className="ui-terminal-error"
className="ui-terminal-error state-surface state-surface--danger"
role="alert"
aria-labelledby="terminal-error-message"
aria-labelledby={messageId}
data-message-key={userMessageKey}
>
<p id="terminal-error-message">{userMessageKey}</p>
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2>
{action !== "none" && (
<button className="ui-button" type="button" onClick={onAction}>
{action}
</button>
<Button onClick={onAction}>{actionLabels[action]}</Button>
)}
</section>
);
+21
View File
@@ -0,0 +1,21 @@
const ERROR_MESSAGES = Object.freeze({
"error.network_unreachable": "네트워크에 연결할 수 없습니다.",
"error.request_timeout": "요청 시간이 초과되었습니다.",
"error.auth_required": "계속하려면 로그인이 필요합니다.",
"error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.",
"error.forbidden": "이 작업을 수행할 권한이 없습니다.",
"error.not_found": "요청한 항목을 찾을 수 없습니다.",
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
});
/** @param {string} messageKey */
export function errorMessage(messageKey) {
const messages = /** @type {Readonly<Record<string, string>>} */ (
ERROR_MESSAGES
);
return messages[messageKey] ?? "요청을 완료하지 못했습니다.";
}
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
/**
* @param {{
* title: string,
* description?: string,
* eyebrow?: string
* }} props
*/
export function PageHeader({ title, description, eyebrow }) {
const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null));
useEffect(() => {
document.title = `${title} · Frontend Skeleton`;
headingRef.current?.focus();
}, [title]);
return (
<header className="page-header">
{eyebrow ? <p className="page-header__eyebrow">{eyebrow}</p> : null}
<h1 ref={headingRef} tabIndex={-1} data-route-heading>
{title}
</h1>
{description ? <p className="page-header__description">{description}</p> : null}
</header>
);
}
@@ -0,0 +1,69 @@
import { Button } from "./ui/button.jsx";
/**
* @param {{
* eyebrow: string,
* title: string,
* description: string,
* actionLabel?: string,
* onAction?: () => void,
* tone?: "neutral" | "danger" | "warning"
* }} props
*/
function StateSurface({
eyebrow,
title,
description,
actionLabel,
onAction,
tone = "neutral",
}) {
return (
<section className={`state-surface state-surface--${tone}`}>
<p className="state-surface__eyebrow">{eyebrow}</p>
<h2>{title}</h2>
<p>{description}</p>
{actionLabel ? <Button onClick={onAction}>{actionLabel}</Button> : null}
</section>
);
}
/** @param {{ onSignIn?: () => void }} props */
export function AuthRequiredSurface({ onSignIn }) {
return (
<StateSurface
eyebrow="401 · 인증 필요"
title="로그인이 필요합니다."
description="세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다."
actionLabel="로그인 시작"
onAction={onSignIn}
/>
);
}
/** @param {{ onNavigate?: () => void }} props */
export function ForbiddenSurface({ onNavigate }) {
return (
<StateSurface
eyebrow="403 · 권한 없음"
title="접근할 수 없습니다."
description="권한을 확인하거나 접근 가능한 화면으로 이동하세요."
actionLabel="안전한 화면으로 이동"
onAction={onNavigate}
tone="warning"
/>
);
}
/** @param {{ onNavigate?: () => void }} props */
export function NotFoundSurface({ onNavigate }) {
return (
<StateSurface
eyebrow="404 · 찾을 수 없음"
title="요청한 화면이 없습니다."
description="주소를 확인하거나 시작 화면으로 돌아가세요."
actionLabel="시작 화면으로 이동"
onAction={onNavigate}
/>
);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* @param {{
* title: string,
* children?: React.ReactNode,
* variant?: "info" | "success" | "warning" | "danger",
* onDismiss?: () => void
* }} props
*/
export function Alert({
title,
children,
variant = "info",
onDismiss,
}) {
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<button
className="ui-alert__dismiss"
type="button"
aria-label={`${title} 알림 닫기`}
onClick={onDismiss}
>
×
</button>
) : null}
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* @param {{
* children: React.ReactNode,
* variant?: "neutral" | "info" | "success" | "warning" | "danger"
* }} props
*/
export function Badge({ children, variant = "neutral" }) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* @param {React.ButtonHTMLAttributes<HTMLButtonElement> & {
* variant?: "primary" | "secondary" | "danger" | "ghost",
* size?: "default" | "compact"
* }} props
*/
export function Button({
variant = "primary",
size = "default",
className = "",
type = "button",
...props
}) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ");
return <button {...props} className={classes} type={type} />;
}
+28
View File
@@ -0,0 +1,28 @@
import { useId } from "react";
/**
* @param {{
* title: string,
* description?: string,
* children?: React.ReactNode,
* footer?: React.ReactNode,
* className?: string
* }} props
*/
export function Card({ title, description, children, footer, className = "" }) {
const titleId = useId();
return (
<article
className={`ui-card ${className}`.trim()}
aria-labelledby={titleId}
>
<div className="ui-card__header">
<h3 id={titleId}>{title}</h3>
{description ? <p>{description}</p> : null}
</div>
{children ? <div className="ui-card__content">{children}</div> : null}
{footer ? <footer className="ui-card__footer">{footer}</footer> : null}
</article>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useId, useRef } from "react";
/**
* @param {{
* open: boolean,
* onClose: () => void,
* title: string,
* description?: string,
* children?: React.ReactNode,
* actions?: React.ReactNode
* }} props
*/
export function Dialog({
open,
onClose,
title,
description,
children,
actions,
}) {
const dialogRef = useRef(/** @type {HTMLDialogElement | null} */ (null));
const previousFocusRef = useRef(/** @type {HTMLElement | null} */ (null));
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
const firstFocusable = dialog.querySelector(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
if (firstFocusable instanceof HTMLElement) firstFocusable.focus();
return;
}
if (dialog.open) {
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}, [open]);
return (
<dialog
className="ui-dialog"
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="ui-dialog__surface">
<header className="ui-dialog__header">
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<button
className="ui-dialog__close"
type="button"
aria-label={`${title} 닫기`}
onClick={onClose}
>
×
</button>
</header>
{children ? <div className="ui-dialog__content">{children}</div> : null}
{actions ? <footer className="ui-dialog__actions">{actions}</footer> : null}
</div>
</dialog>
);
}
@@ -0,0 +1,52 @@
import { useId } from "react";
/**
* @param {Omit<React.InputHTMLAttributes<HTMLInputElement>, "id"> & {
* id?: string,
* label: string,
* description?: string,
* error?: string
* }} props
*/
export function TextField({
id,
label,
description,
error,
className = "",
required,
...inputProps
}) {
const generatedId = useId();
const inputId = id ?? `field-${generatedId}`;
const descriptionId = description ? `${inputId}-description` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
return (
<div className={`ui-field ${className}`.trim()}>
<label className="ui-field__label" htmlFor={inputId}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
<input
{...inputProps}
className="ui-field__input"
id={inputId}
required={required}
aria-describedby={describedBy || undefined}
aria-invalid={error ? "true" : undefined}
/>
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,80 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { PageHeader } from "../components/page-header.jsx";
import { useSession } from "../providers/session-provider.jsx";
export default function AuthExamplePage() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
/** @param {() => Promise<unknown>} action */
async function execute(action) {
setPending(true);
setFailed(false);
try {
await action();
} catch {
setFailed(true);
} finally {
setPending(false);
}
}
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="인증 연동"
description="스켈레톤은 자격 증명을 소유하지 않고 외부 인증 구현이 연결될 포트와 화면 상태만 제공합니다."
/>
<section className="ui-panel auth-example" aria-labelledby="auth-state-title">
<div>
<h2 id="auth-state-title">현재 세션 상태</h2>
<output className="session-status" data-state={sessionState}>
{sessionState}
</output>
</div>
<div className="button-row">
<button
className="ui-button"
type="button"
disabled={pending || sessionState === "integration-failed"}
onClick={() =>
void execute(() =>
beginSignIn(`${location.pathname}${location.search}`),
)
}
>
로그인 시작
</button>
<button
className="ui-button ui-button--secondary"
type="button"
disabled={pending || sessionState !== "authenticated"}
onClick={() => void execute(signOut)}
>
로그아웃
</button>
<button
className="ui-button ui-button--secondary"
type="button"
disabled={pending || sessionState !== "recovery-pending"}
onClick={() => void execute(recover)}
>
세션 복구
</button>
</div>
{sessionState === "integration-failed" ? (
<p role="status">
외부 인증 소유자가 연결되지 않았습니다. 런타임 호스트의 인증
계약을 연결하세요.
</p>
) : null}
{failed ? <p role="alert">인증 작업을 완료하지 못했습니다.</p> : null}
</section>
</section>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
import { deriveAsyncState } from "../../application/view-models/async-state.js";
import { createFailure } from "../../contracts/errors.js";
import {
AsyncSurface,
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
} from "../components/async-surface.jsx";
import { PageHeader } from "../components/page-header.jsx";
import {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../components/state-surfaces.jsx";
import { Button } from "../components/ui/button.jsx";
import { Card } from "../components/ui/card.jsx";
export default function StateGalleryPage() {
const [lastAction, setLastAction] = useState(
"상태 화면의 작업을 선택하면 결과가 여기에 표시됩니다.",
);
const refreshingState = deriveAsyncState({
data: ["기존 데이터"],
isFetching: true,
});
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="화면 상태"
description="로딩, 빈 화면, 오류, 인증 필요와 권한 없음 상태가 다음 행동까지 일관되게 안내합니다."
/>
<section className="gallery-section" aria-labelledby="async-states-title">
<header className="gallery-section__header">
<h2 id="async-states-title">비동기 데이터 상태</h2>
<p>초기 로딩과 백그라운드 갱신을 구분해 기존 콘텐츠를 보존합니다.</p>
</header>
<div className="component-grid component-grid--two">
<Card title="초기 로딩">
<LoadingSurface label="예제 데이터를 불러오는 중" />
</Card>
<Card title="백그라운드 갱신">
<AsyncSurface state={refreshingState}>
<div className="state-preview-content">기존 콘텐츠는 계속 표시됩니다.</div>
</AsyncSurface>
</Card>
<Card title="빈 화면">
<EmptySurface
title="아직 표시할 항목이 없습니다."
description="첫 항목을 추가하거나 필터를 초기화할 수 있습니다."
action={
<Button onClick={() => setLastAction("빈 화면 작업을 실행했습니다.")}>
작업 시작
</Button>
}
/>
</Card>
<Card title="복구 가능한 오류">
<TerminalErrorSurface
userMessageKey={
createFailure("NETWORK_UNREACHABLE", "EXAMPLE", 0)
.userMessageKey
}
action="retry"
onAction={() => setLastAction("오류 요청을 다시 시도했습니다.")}
/>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="access-states-title">
<header className="gallery-section__header">
<h2 id="access-states-title">접근과 탐색 상태</h2>
<p>인증 여부와 서버 권한 결과를 서로 다른 상태로 전달합니다.</p>
</header>
<div className="component-grid component-grid--three">
<AuthRequiredSurface
onSignIn={() => setLastAction("로그인 연동 작업을 시작했습니다.")}
/>
<ForbiddenSurface
onNavigate={() => setLastAction("접근 가능한 화면으로 이동합니다.")}
/>
<NotFoundSurface
onNavigate={() => setLastAction("시작 화면으로 이동합니다.")}
/>
</div>
</section>
<output className="gallery-notice" aria-live="polite">
{lastAction}
</output>
</section>
);
}
@@ -0,0 +1,185 @@
import { useState } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Alert } from "../components/ui/alert.jsx";
import { Badge } from "../components/ui/badge.jsx";
import { Button } from "../components/ui/button.jsx";
import { Card } from "../components/ui/card.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
import { TextField } from "../components/ui/text-field.jsx";
const COLOR_TOKENS = Object.freeze([
["Surface", "--color-surface"],
["Muted surface", "--color-surface-muted"],
["Content", "--color-content"],
["Muted content", "--color-content-muted"],
["Action", "--color-action"],
["Danger", "--color-danger"],
["Focus", "--color-focus"],
]);
export default function UiGalleryPage() {
const [projectName, setProjectName] = useState("");
const [fieldTouched, setFieldTouched] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [notice, setNotice] = useState(
"구성요소를 조작하면 결과가 여기에 표시됩니다.",
);
const [alertVisible, setAlertVisible] = useState(true);
const fieldError =
fieldTouched && projectName.trim().length === 0
? "프로젝트 이름을 입력해 주세요."
: undefined;
/** @param {React.FormEvent<HTMLFormElement>} event */
function submitExample(event) {
event.preventDefault();
setFieldTouched(true);
if (projectName.trim().length === 0) {
setNotice("입력값을 확인해 주세요.");
return;
}
setNotice(`${projectName.trim()}” 입력을 확인했습니다.`);
}
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="UI 구성요소"
description="제품 도메인과 독립적인 공통 컨트롤, 피드백, 표면과 디자인 토큰을 직접 조작할 수 있습니다."
/>
<section className="gallery-section" aria-labelledby="controls-title">
<header className="gallery-section__header">
<h2 id="controls-title">버튼과 입력</h2>
<p>키보드, 비활성 상태, 오류 설명을 포함한 기본 상호작용입니다.</p>
</header>
<div className="component-grid component-grid--two">
<Card title="버튼" description="의미와 위험도에 따라 변형을 선택합니다.">
<div className="button-row">
<Button onClick={() => setNotice("기본 작업을 실행했습니다.")}>
기본 작업
</Button>
<Button
variant="secondary"
onClick={() => setNotice("보조 작업을 실행했습니다.")}
>
보조 작업
</Button>
<Button
variant="danger"
onClick={() => setNotice("위험 작업 예제를 선택했습니다.")}
>
위험 작업
</Button>
<Button disabled>사용 불가</Button>
</div>
</Card>
<Card title="입력창" description="레이블과 도움말, 오류가 입력에 연결됩니다.">
<form className="example-form" noValidate onSubmit={submitExample}>
<TextField
label="프로젝트 이름"
description="새 도메인을 연결할 때 사용할 중립적인 예제입니다."
error={fieldError}
value={projectName}
required
onChange={(event) => setProjectName(event.currentTarget.value)}
/>
<Button type="submit">입력 확인</Button>
</form>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="feedback-title">
<header className="gallery-section__header">
<h2 id="feedback-title">피드백과 모달</h2>
<p>상태 전달은 색에만 의존하지 않으며, 모든 제어에는 이름이 있습니다.</p>
</header>
<div className="component-grid component-grid--two">
<Card title="알림과 배지" description="짧은 상태와 문맥형 피드백입니다.">
<div className="component-stack">
{alertVisible ? (
<Alert
title="설정이 저장되었습니다."
variant="success"
onDismiss={() => setAlertVisible(false)}
>
<p>운영 환경에는 실제 저장 포트를 연결하세요.</p>
</Alert>
) : (
<Button
variant="secondary"
onClick={() => setAlertVisible(true)}
>
알림 다시 표시
</Button>
)}
<div className="badge-row" aria-label="배지 변형">
<Badge>중립</Badge>
<Badge variant="info">정보</Badge>
<Badge variant="success">준비됨</Badge>
<Badge variant="warning">확인 필요</Badge>
<Badge variant="danger">실패</Badge>
</div>
</div>
</Card>
<Card title="모달" description="배경과 키보드 Esc로 닫고 포커스를 복원합니다.">
<Button onClick={() => setDialogOpen(true)}>모달 열기</Button>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="연동 확인"
description="도메인 작업을 실행하기 전 확인 화면의 기본 구조입니다."
actions={
<>
<Button
variant="secondary"
onClick={() => setDialogOpen(false)}
>
취소
</Button>
<Button
onClick={() => {
setNotice("모달의 확인 작업을 실행했습니다.");
setDialogOpen(false);
}}
>
확인
</Button>
</>
}
>
<p>민감한 값이나 구현 세부정보는 확인 문구에 포함하지 않습니다.</p>
</Dialog>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="tokens-title">
<header className="gallery-section__header">
<h2 id="tokens-title">디자인 토큰</h2>
<p>구성요소가 사용하는 의미 기반 색상과 형태 토큰입니다.</p>
</header>
<div className="token-grid">
{COLOR_TOKENS.map(([label, token]) => (
<article className="token-swatch" key={token}>
<span
className="token-swatch__color"
style={{ backgroundColor: `var(${token})` }}
aria-hidden="true"
/>
<strong>{label}</strong>
<code>{token}</code>
</article>
))}
</div>
</section>
<output className="gallery-notice" aria-live="polite">
{notice}
</output>
</section>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js";
import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
const SESSION_LABELS = Object.freeze({
authenticated: "인증됨",
unauthenticated: "로그인 전",
"recovery-pending": "복구 대기",
"integration-failed": "연동 필요",
});
export function AppShell() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const { preference, setPreference } = useTheme();
const [navigationOpen, setNavigationOpen] = useState(false);
const [sessionActionPending, setSessionActionPending] = useState(false);
const [sessionActionFailed, setSessionActionFailed] = useState(false);
useEffect(() => {
setNavigationOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!navigationOpen) return undefined;
/** @param {KeyboardEvent} event */
const closeOnEscape = (event) => {
if (event.key === "Escape") setNavigationOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [navigationOpen]);
async function runSessionAction() {
setSessionActionPending(true);
setSessionActionFailed(false);
try {
if (sessionState === "authenticated") {
await signOut();
} else if (sessionState === "recovery-pending") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
);
}
} catch {
setSessionActionFailed(true);
} finally {
setSessionActionPending(false);
}
}
const sessionActionLabel =
sessionState === "authenticated"
? "로그아웃"
: sessionState === "recovery-pending"
? "세션 복구"
: "로그인";
const integrationAvailable = sessionState !== "integration-failed";
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
본문으로 건너뛰기
</a>
<header className="app-shell__header">
<button
className="app-shell__menu-button"
type="button"
aria-controls="primary-navigation"
aria-expanded={navigationOpen}
onClick={() => setNavigationOpen((open) => !open)}
>
<span aria-hidden="true"></span>
<span>메뉴</span>
</button>
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
Frontend Skeleton
</NavLink>
<div className="app-shell__session">
<label className="visually-hidden" htmlFor="theme-preference">
색상 테마
</label>
<select
className="theme-selector"
id="theme-preference"
value={preference}
onChange={(event) =>
setPreference(
/** @type {"system" | "light" | "dark"} */ (
event.currentTarget.value
),
)
}
>
<option value="system">시스템 테마</option>
<option value="light">라이트 테마</option>
<option value="dark">다크 테마</option>
</select>
<span className="session-status" data-state={sessionState}>
{SESSION_LABELS[sessionState]}
</span>
{integrationAvailable ? (
<button
className="ui-button ui-button--compact"
type="button"
disabled={sessionActionPending}
onClick={() => void runSessionAction()}
>
{sessionActionPending ? "처리 중…" : sessionActionLabel}
</button>
) : null}
</div>
{sessionActionFailed ? (
<p className="app-shell__session-error" role="alert">
세션 작업을 완료하지 못했습니다.
</p>
) : null}
</header>
<aside
className="app-shell__sidebar"
data-open={navigationOpen}
aria-label="사이드바"
>
<nav id="primary-navigation" aria-label="주요 탐색">
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
`app-navigation__link${isActive ? " is-active" : ""}`
}
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
</NavLink>
</li>
))}
</ul>
</nav>
</aside>
{navigationOpen ? (
<button
className="app-shell__scrim"
type="button"
aria-label="메뉴 닫기"
onClick={() => setNavigationOpen(false)}
/>
) : null}
<main className="app-shell__content" id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { PageHeader } from "../components/page-header.jsx";
const READINESS_ITEMS = Object.freeze([
{
title: "실행 계약",
description: "런타임 설정, 릴리스 정합성, 오류 경계가 마운트 전에 검증됩니다.",
},
{
title: "교체 가능한 연동",
description: "인증, HTTP, 캐시, 저장소, 텔레메트리가 포트 뒤에 분리되어 있습니다.",
},
{
title: "접근 가능한 화면",
description: "키보드 탐색, 포커스 이동, 반응형 앱 셸의 기본 동작이 준비되어 있습니다.",
},
]);
export default function HomePage() {
return (
<section className="ui-page">
<PageHeader
eyebrow="프로젝트 시작점"
title="Clean Architecture Frontend"
description="도메인을 추가하기 전에 실행 구조와 범용 사용자 경험을 확인할 수 있는 중립적인 스켈레톤입니다."
/>
<div className="readiness-grid" aria-label="구현 준비 상태">
{READINESS_ITEMS.map((item) => (
<article className="ui-panel" key={item.title}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</div>
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div>
<h2 id="starter-title">준비된 화면 살펴보기</h2>
<p>공통 구성요소와 비동기 화면 상태를 예제 라우트에서 확인하세요.</p>
</div>
<div className="button-row">
<Link className="ui-button" to={routePath("EXAMPLES_UI")}>
UI 구성요소 보기
</Link>
<Link
className="ui-button ui-button--secondary"
to={routePath("EXAMPLES_STATES")}
>
화면 상태 보기
</Link>
</div>
</section>
</section>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { PageHeader } from "../components/page-header.jsx";
export default function NotFoundPage() {
return (
<section className="ui-page">
<PageHeader
title="페이지를 찾을 수 없습니다."
description="주소를 확인하거나 준비된 시작 화면으로 돌아가세요."
/>
<div>
<Link className="ui-button" to={routePath("APP_HOME")}>
홈으로 이동
</Link>
</div>
</section>
);
}
@@ -0,0 +1,23 @@
import { PageHeader } from "../components/page-header.jsx";
import { useSession } from "../providers/session-provider.jsx";
export default function SampleContractPage() {
const { sessionState } = useSession();
return (
<section className="ui-page">
<PageHeader
eyebrow="보호 라우트"
title="보호된 연동 지점"
description="실제 도메인 기능이 인증된 세션과 연결되는 위치를 보여주는 중립적인 계약 화면입니다."
/>
<section className="ui-panel" aria-labelledby="protected-state-title">
<h2 id="protected-state-title">라우트 접근 허용</h2>
<p>
현재 세션 상태는 <strong>{sessionState}</strong>입니다. 서버의
권한 검증은 클라이언트 라우트 정책과 별도로 유지해야 합니다.
</p>
</section>
</section>
);
}
@@ -0,0 +1,50 @@
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
/**
* @typedef {{
* sessionState: import("../../application/ports/auth-session-port.js").SessionState,
* beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"],
* signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"],
* recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"]
* }} SessionContextValue
*/
const SessionContext = createContext(
/** @type {SessionContextValue | null} */ (null),
);
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* children: React.ReactNode
* }} props
*/
export function SessionProvider({ authSession, children }) {
const sessionState = useSyncExternalStore(
authSession.subscribe,
authSession.getState,
authSession.getState,
);
const value = useMemo(
() =>
Object.freeze({
sessionState,
beginSignIn: authSession.beginSignIn,
signOut: authSession.signOut,
recover: authSession.recover,
}),
[authSession, sessionState],
);
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
}
export function useSession() {
const session = useContext(SessionContext);
if (!session) {
throw new Error("SessionProvider is required");
}
return session;
}
@@ -0,0 +1,86 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../application/policies/color-scheme.js";
/**
* @typedef {{
* preference: "system" | "light" | "dark",
* resolvedTheme: "light" | "dark",
* setPreference: (preference: "system" | "light" | "dark") => void
* }} ThemeContextValue
*/
const ThemeContext = createContext(/** @type {ThemeContextValue | null} */ (null));
function systemPrefersDark() {
return (
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
/**
* @param {{
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* children: React.ReactNode
* }} props
*/
export function ThemeProvider({ storage, children }) {
const [preference, updatePreference] = useState(() => {
const result = storage?.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result?.ok ? result.value : undefined,
);
});
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
const resolvedTheme = resolveColorScheme(preference, darkSystemTheme);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(prefers-color-scheme: dark)");
/** @param {MediaQueryListEvent} event */
const update = (event) => setDarkSystemTheme(event.matches);
setDarkSystemTheme(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useLayoutEffect(() => {
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themePreference = preference;
document.documentElement.style.colorScheme = resolvedTheme;
}, [preference, resolvedTheme]);
const value = useMemo(
() =>
Object.freeze({
preference,
resolvedTheme,
/** @param {"system" | "light" | "dark"} next */
setPreference(next) {
const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized);
storage?.write("COLOR_SCHEME", normalized);
},
}),
[preference, resolvedTheme, storage],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const theme = useContext(ThemeContext);
if (!theme) throw new Error("ThemeProvider is required");
return theme;
}
+236 -45
View File
@@ -1,81 +1,272 @@
import { lazy, Suspense, useState } from "react";
import {
BrowserRouter,
Link,
Route,
Routes,
useLocation,
} from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { getRoute, routePath } from "../../contracts/routes.js";
import { RouteBoundary } from "../boundaries/render-error-boundary.jsx";
import { AppShell } from "../layouts/app-shell.jsx";
import { PageHeader } from "../components/page-header.jsx";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx";
import { decideRouteAccess } from "./navigation-policy.js";
function HomePage() {
const HomePage = lazy(() => import("../pages/home-page.jsx"));
const UiGalleryPage = lazy(() => import("../examples/ui-gallery-page.jsx"));
const StateGalleryPage = lazy(
() => import("../examples/state-gallery-page.jsx"),
);
const AuthExamplePage = lazy(
() => import("../examples/auth-example-page.jsx"),
);
const SampleContractPage = lazy(
() => import("../pages/sample-contract-page.jsx"),
);
const NotFoundPage = lazy(() => import("../pages/not-found-page.jsx"));
/** @param {{ routeId: string }} props */
function RouteLoadingSurface({ routeId }) {
const definition = getRoute(routeId);
return (
<main className="ui-page">
<h1>Clean Architecture Frontend</h1>
<p>런타임 계약이 검증되었습니다.</p>
<Link to={routePath("SAMPLE_RESOURCE_LIST")}>샘플 리소스</Link>
</main>
<section className="ui-page route-loading" aria-live="polite" aria-busy="true">
<div className="ui-skeleton" aria-hidden="true" />
<p>화면을 준비하고 습니다.</p>
<span className="visually-hidden">{definition.title} 로딩 </span>
</section>
);
}
function SamplePlaceholder() {
function RouteFailureSurface() {
return (
<main className="ui-page">
<h1>샘플 리소스</h1>
<p>계약 fixture를 준비하고 있습니다.</p>
</main>
);
}
function NotFoundPage() {
return (
<main className="ui-page">
<h1>페이지를 찾을 없습니다.</h1>
<Link to={routePath("APP_HOME")}>홈으로 이동</Link>
</main>
<section className="ui-page">
<PageHeader
title="화면을 표시하지 못했습니다."
description="잠시 후 페이지를 새로고침해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요."
/>
</section>
);
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function GuardedSampleRoute({ authSession }) {
const decision = decideRouteAccess(
"SAMPLE_RESOURCE_LIST",
authSession.getState(),
function RouteSurface({ routeId, buildId, telemetry, children }) {
return (
<RouteBoundary
routeId={routeId}
buildId={buildId}
telemetry={telemetry}
fallback={<RouteFailureSurface />}
>
<Suspense fallback={<RouteLoadingSurface routeId={routeId} />}>
{children}
</Suspense>
</RouteBoundary>
);
if (!decision.allowed) {
}
/**
* @param {{
* routeId: string,
* children: React.ReactNode
* }} props
*/
function ProtectedRoute({ routeId, children }) {
const location = useLocation();
const { sessionState, beginSignIn, recover } = useSession();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const decision = decideRouteAccess(routeId, sessionState);
async function continueSession() {
setPending(true);
setFailed(false);
try {
if (decision.action === "wait-for-session") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
);
}
} catch {
setFailed(true);
} finally {
setPending(false);
}
}
if (decision.allowed) return children;
if (sessionState === "integration-failed") {
return (
<main className="ui-page">
<h1>세션이 필요합니다.</h1>
<button className="ui-button" type="button">
로그인
</button>
</main>
<section className="ui-page">
<PageHeader
title="로그인 연동이 필요합니다."
description="외부 인증 소유자가 런타임에 연결되면 이 보호 라우트를 사용할 수 있습니다."
/>
</section>
);
}
return <SamplePlaceholder />;
const recovering = decision.action === "wait-for-session";
return (
<section className="ui-page">
<PageHeader
title={recovering ? "세션을 복구하고 있습니다." : "세션이 필요합니다."}
description={
recovering
? "기존 세션 확인을 계속하려면 복구를 실행하세요."
: "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다."
}
/>
<div>
<button
className="ui-button"
type="button"
disabled={pending}
onClick={() => void continueSession()}
>
{pending
? "처리 중…"
: recovering
? "세션 복구"
: "로그인 시작"}
</button>
</div>
{failed ? (
<p className="ui-terminal-error" role="alert">
세션 작업을 완료하지 못했습니다.
</p>
) : null}
</section>
);
}
/**
* @param {{
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function PublicRoute({ routeId, buildId, telemetry, children }) {
return (
<RouteSurface routeId={routeId} buildId={buildId} telemetry={telemetry}>
{children}
</RouteSurface>
);
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string
* basename?: string,
* buildId?: string,
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort
* }} props
*/
export function AppRouter({ authSession, basename = "/" }) {
export function AppRouter({
authSession,
basename = "/",
buildId = "local-build",
storage,
telemetry,
}) {
return (
<BrowserRouter basename={basename}>
<Routes>
<Route path={routePath("APP_HOME")} element={<HomePage />} />
<Route
path={routePath("SAMPLE_RESOURCE_LIST")}
element={<GuardedSampleRoute authSession={authSession} />}
/>
<Route path={routePath("NOT_FOUND")} element={<NotFoundPage />} />
</Routes>
<ThemeProvider storage={storage}>
<SessionProvider authSession={authSession}>
<Routes>
<Route element={<AppShell />}>
<Route
index
element={
<PublicRoute
routeId="APP_HOME"
buildId={buildId}
telemetry={telemetry}
>
<HomePage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_UI")}
element={
<PublicRoute
routeId="EXAMPLES_UI"
buildId={buildId}
telemetry={telemetry}
>
<UiGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_STATES")}
element={
<PublicRoute
routeId="EXAMPLES_STATES"
buildId={buildId}
telemetry={telemetry}
>
<StateGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_AUTH")}
element={
<PublicRoute
routeId="EXAMPLES_AUTH"
buildId={buildId}
telemetry={telemetry}
>
<AuthExamplePage />
</PublicRoute>
}
/>
<Route
path={routePath("SAMPLE_RESOURCE_LIST")}
element={
<RouteSurface
routeId="SAMPLE_RESOURCE_LIST"
buildId={buildId}
telemetry={telemetry}
>
<ProtectedRoute routeId="SAMPLE_RESOURCE_LIST">
<SampleContractPage />
</ProtectedRoute>
</RouteSurface>
}
/>
<Route
path={routePath("NOT_FOUND")}
element={
<PublicRoute
routeId="NOT_FOUND"
buildId={buildId}
telemetry={telemetry}
>
<NotFoundPage />
</PublicRoute>
}
/>
</Route>
</Routes>
</SessionProvider>
</ThemeProvider>
</BrowserRouter>
);
}
+778 -3
View File
@@ -3,12 +3,28 @@
@theme {
--color-surface: oklch(0.985 0.003 247);
--color-surface-muted: oklch(0.94 0.01 247);
--color-panel: oklch(1 0 0);
--color-border: oklch(0.87 0.015 247);
--color-content: oklch(0.25 0.025 247);
--color-content-muted: oklch(0.48 0.025 247);
--color-action: oklch(0.55 0.18 255);
--color-action-hover: oklch(0.48 0.2 255);
--color-danger: oklch(0.55 0.2 25);
--color-danger-hover: oklch(0.47 0.2 25);
--color-on-action: oklch(1 0 0);
--color-focus: oklch(0.72 0.16 225);
--color-info-content: oklch(0.38 0.16 255);
--color-info-surface: oklch(0.95 0.03 255);
--color-info-border: oklch(0.75 0.08 250);
--color-success-content: oklch(0.35 0.12 155);
--color-success-surface: oklch(0.95 0.04 155);
--color-success-border: oklch(0.72 0.1 155);
--color-warning-content: oklch(0.38 0.12 70);
--color-warning-surface: oklch(0.96 0.05 80);
--color-warning-border: oklch(0.75 0.12 80);
--color-danger-content: oklch(0.42 0.18 25);
--color-danger-surface: oklch(0.96 0.035 25);
--color-danger-border: oklch(0.72 0.12 25);
--radius-control: 0.5rem;
--radius-surface: 0.75rem;
--spacing-page: 1.5rem;
@@ -22,8 +38,64 @@
font-family: var(--font-sans);
}
:root[data-theme="dark"] {
--color-surface: oklch(0.16 0.02 255);
--color-surface-muted: oklch(0.25 0.025 255);
--color-panel: oklch(0.205 0.022 255);
--color-border: oklch(0.36 0.025 255);
--color-content: oklch(0.94 0.012 255);
--color-content-muted: oklch(0.74 0.025 255);
--color-action: oklch(0.7 0.14 250);
--color-action-hover: oklch(0.79 0.12 245);
--color-danger: oklch(0.68 0.19 25);
--color-danger-hover: oklch(0.76 0.16 25);
--color-on-action: oklch(0.16 0.02 255);
--color-focus: oklch(0.82 0.15 220);
--color-info-content: oklch(0.83 0.09 250);
--color-info-surface: oklch(0.27 0.045 255);
--color-info-border: oklch(0.55 0.09 250);
--color-success-content: oklch(0.83 0.1 155);
--color-success-surface: oklch(0.27 0.045 155);
--color-success-border: oklch(0.53 0.1 155);
--color-warning-content: oklch(0.88 0.1 80);
--color-warning-surface: oklch(0.29 0.045 80);
--color-warning-border: oklch(0.58 0.11 80);
--color-danger-content: oklch(0.84 0.11 25);
--color-danger-surface: oklch(0.28 0.055 25);
--color-danger-border: oklch(0.56 0.13 25);
}
body {
margin: 0;
min-width: 20rem;
min-height: 100vh;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
}
h1,
h2,
p {
margin-block-start: 0;
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.05;
letter-spacing: -0.04em;
}
h2 {
line-height: 1.25;
}
:focus-visible {
@@ -33,22 +105,245 @@
}
@layer components {
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: fixed;
z-index: 100;
top: 0.75rem;
left: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-control);
color: var(--color-panel);
background: var(--color-content);
transform: translateY(-200%);
}
.skip-link:focus {
transform: translateY(0);
}
.app-shell {
display: grid;
min-height: 100vh;
grid-template-rows: 4.5rem 1fr;
grid-template-columns: 15rem minmax(0, 1fr);
grid-template-areas:
"header header"
"sidebar content";
}
.app-shell__header {
position: sticky;
z-index: 30;
top: 0;
grid-area: header;
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--color-surface-muted);
background: color-mix(in oklch, var(--color-panel) 92%, var(--color-surface));
box-shadow: 0 1px 8px color-mix(in oklch, var(--color-content) 8%, transparent);
}
.app-shell__brand {
overflow: hidden;
font-size: 1.05rem;
font-weight: 800;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-shell__menu-button {
display: none;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--color-surface-muted);
border-radius: var(--radius-control);
padding: 0.55rem 0.75rem;
color: var(--color-content);
background: var(--color-panel);
cursor: pointer;
}
.app-shell__session {
display: flex;
align-items: center;
gap: 0.75rem;
margin-inline-start: auto;
}
.theme-selector {
min-height: 2.25rem;
max-width: 8.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 0.35rem 2rem 0.35rem 0.65rem;
color: var(--color-content);
background: var(--color-panel);
cursor: pointer;
}
.app-shell__session-error {
position: absolute;
top: calc(100% + 0.25rem);
right: 1rem;
padding: 0.5rem;
color: var(--color-danger);
background: var(--color-panel);
}
.session-status {
display: inline-flex;
width: fit-content;
align-items: center;
border-radius: 999px;
padding: 0.3rem 0.65rem;
color: var(--color-content-muted);
background: var(--color-surface-muted);
font-size: 0.8rem;
font-weight: 700;
}
.session-status[data-state="authenticated"] {
color: var(--color-success-content);
background: var(--color-success-surface);
}
.session-status[data-state="integration-failed"] {
color: var(--color-danger);
}
.app-shell__sidebar {
position: sticky;
top: 4.5rem;
grid-area: sidebar;
height: calc(100vh - 4.5rem);
padding: 1.25rem 0.75rem;
border-right: 1px solid var(--color-border);
background: var(--color-panel);
}
.app-navigation {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.app-navigation__link {
display: block;
border-radius: var(--radius-control);
padding: 0.75rem 0.9rem;
color: var(--color-content-muted);
font-weight: 650;
text-decoration: none;
}
.app-navigation__link:hover {
color: var(--color-content);
background: var(--color-surface);
}
.app-navigation__link.is-active {
color: var(--color-action-hover);
background: color-mix(
in oklch,
var(--color-action) 14%,
var(--color-panel)
);
font-weight: 800;
}
.app-shell__content {
grid-area: content;
min-width: 0;
}
.ui-page {
@apply mx-auto flex min-h-screen max-w-4xl flex-col gap-6 p-page;
@apply mx-auto flex max-w-6xl flex-col gap-6 p-page;
width: 100%;
box-sizing: border-box;
}
.ui-panel {
@apply rounded-surface border border-surface-muted bg-white p-6 shadow-sm;
@apply rounded-surface p-6 shadow-sm;
border: 1px solid var(--color-border);
background: var(--color-panel);
}
.ui-button {
@apply rounded-control bg-action px-4 py-2 font-semibold text-white;
@apply rounded-control bg-action px-4 py-2 font-semibold;
display: inline-flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
border: 0;
color: var(--color-on-action);
text-decoration: none;
cursor: pointer;
}
.ui-button:hover {
@apply bg-action-hover;
}
.ui-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.ui-button--secondary {
border: 1px solid var(--color-surface-muted);
color: var(--color-content);
background: var(--color-panel);
}
.ui-button--secondary:hover {
color: var(--color-action);
background: var(--color-surface);
}
.ui-button--danger {
color: var(--color-on-action);
background: var(--color-danger);
}
.ui-button--danger:hover {
background: var(--color-danger-hover);
}
.ui-button--ghost {
color: var(--color-content);
background: transparent;
}
.ui-button--ghost:hover {
color: var(--color-action);
background: var(--color-surface-muted);
}
.ui-button--compact {
min-height: 2.25rem;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.ui-skeleton {
@apply h-24 animate-pulse rounded-surface bg-surface-muted;
}
@@ -57,6 +352,486 @@
.ui-terminal-error {
@apply rounded-surface border border-surface-muted p-6;
}
.ui-field {
display: grid;
gap: 0.45rem;
}
.ui-field__label {
font-weight: 750;
}
.ui-field__description,
.ui-field__error {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
}
.ui-field__description {
color: var(--color-content-muted);
}
.ui-field__error {
color: var(--color-danger);
font-weight: 650;
}
.ui-field__input {
width: 100%;
min-height: 2.75rem;
box-sizing: border-box;
border: 1px solid
color-mix(in oklch, var(--color-content-muted) 55%, var(--color-panel));
border-radius: var(--radius-control);
padding: 0.65rem 0.8rem;
color: var(--color-content);
background: var(--color-panel);
}
.ui-field__input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.ui-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-surface);
padding: 1.25rem;
background: var(--color-panel);
box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 6%, transparent);
}
.ui-card__header h3,
.ui-card__header p {
margin-block-end: 0;
}
.ui-card__header p {
margin-block-start: 0.4rem;
color: var(--color-content-muted);
line-height: 1.55;
}
.ui-card__content {
flex: 1;
}
.ui-card__footer {
padding-block-start: 1rem;
border-top: 1px solid var(--color-surface-muted);
}
.ui-alert {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-surface-muted);
border-radius: var(--radius-control);
padding: 1rem;
background: var(--color-surface);
}
.ui-alert--info {
border-color: var(--color-info-border);
background: var(--color-info-surface);
}
.ui-alert--success {
border-color: var(--color-success-border);
background: var(--color-success-surface);
}
.ui-alert--warning {
border-color: var(--color-warning-border);
background: var(--color-warning-surface);
}
.ui-alert--danger {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.ui-alert__content,
.ui-alert__content p {
margin-block: 0.35rem 0;
color: var(--color-content-muted);
}
.ui-alert__dismiss,
.ui-dialog__close {
display: inline-grid;
width: 2rem;
height: 2rem;
flex: 0 0 auto;
place-items: center;
border: 0;
border-radius: var(--radius-control);
color: var(--color-content);
background: transparent;
font-size: 1.35rem;
cursor: pointer;
}
.ui-alert__dismiss:hover,
.ui-dialog__close:hover {
background: color-mix(in oklch, var(--color-content) 8%, transparent);
}
.ui-badge {
display: inline-flex;
border-radius: 999px;
padding: 0.3rem 0.65rem;
color: var(--color-content);
background: var(--color-surface-muted);
font-size: 0.8rem;
font-weight: 750;
}
.ui-badge--info {
color: var(--color-info-content);
background: var(--color-info-surface);
}
.ui-badge--success {
color: var(--color-success-content);
background: var(--color-success-surface);
}
.ui-badge--warning {
color: var(--color-warning-content);
background: var(--color-warning-surface);
}
.ui-badge--danger {
color: var(--color-danger-content);
background: var(--color-danger-surface);
}
.ui-dialog {
width: min(34rem, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
box-sizing: border-box;
overflow: auto;
border: 0;
border-radius: var(--radius-surface);
padding: 0;
color: var(--color-content);
background: var(--color-panel);
box-shadow: 0 24px 70px color-mix(in oklch, var(--color-content) 25%, transparent);
}
.ui-dialog::backdrop {
background: color-mix(in oklch, var(--color-content) 55%, transparent);
backdrop-filter: blur(2px);
}
.ui-dialog__surface {
padding: 1.5rem;
}
.ui-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.ui-dialog__header h2 {
margin-block-end: 0.35rem;
}
.ui-dialog__header p,
.ui-dialog__content p {
color: var(--color-content-muted);
line-height: 1.6;
}
.ui-dialog__content {
padding-block: 1rem;
}
.ui-dialog__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
padding-block-start: 1rem;
border-top: 1px solid var(--color-surface-muted);
}
.page-header {
max-width: 50rem;
padding-block: clamp(1rem, 5vw, 3.5rem) 0.5rem;
}
[data-route-heading]:focus {
outline: none;
}
.page-header__eyebrow {
margin-block-end: 0.75rem;
color: var(--color-action);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-header__description {
max-width: 45rem;
color: var(--color-content-muted);
font-size: 1.05rem;
line-height: 1.7;
}
.readiness-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.readiness-grid p,
.starter-actions p {
margin-block-end: 0;
color: var(--color-content-muted);
line-height: 1.65;
}
.starter-actions,
.auth-example {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.route-loading {
padding-block-start: 4rem;
}
.gallery-section {
display: grid;
gap: 1rem;
padding-block: 0.5rem;
}
.gallery-section__header {
max-width: 45rem;
}
.gallery-section__header h2,
.gallery-section__header p {
margin-block-end: 0;
}
.gallery-section__header p {
margin-block-start: 0.4rem;
color: var(--color-content-muted);
line-height: 1.6;
}
.component-grid {
display: grid;
gap: 1rem;
}
.component-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.component-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.component-stack,
.example-form {
display: grid;
gap: 1rem;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.token-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.75rem;
}
.token-swatch {
display: grid;
gap: 0.45rem;
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 0.75rem;
background: var(--color-panel);
}
.token-swatch__color {
height: 4rem;
border: 1px solid color-mix(in oklch, var(--color-content) 15%, transparent);
border-radius: calc(var(--radius-control) / 1.5);
}
.token-swatch code {
overflow: hidden;
color: var(--color-content-muted);
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.gallery-notice {
display: block;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-surface-muted);
border-radius: 999px;
padding: 0.65rem 1rem;
color: var(--color-content);
background: var(--color-panel);
box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 10%, transparent);
font-size: 0.875rem;
font-weight: 650;
}
.state-surface {
display: flex;
min-height: 12rem;
box-sizing: border-box;
flex-direction: column;
align-items: flex-start;
justify-content: center;
border: 1px solid var(--color-surface-muted);
border-radius: var(--radius-surface);
padding: 1.25rem;
background: var(--color-surface);
}
.state-surface h2,
.state-surface p {
margin-block-end: 0.75rem;
}
.state-surface > p:not(.state-surface__eyebrow) {
color: var(--color-content-muted);
line-height: 1.55;
}
.state-surface__eyebrow {
color: var(--color-action-hover);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.04em;
}
.state-surface--warning {
border-color: var(--color-warning-border);
background: var(--color-warning-surface);
}
.state-surface--danger,
.ui-terminal-error {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.state-surface--loading {
gap: 0.75rem;
}
.state-surface--loading .ui-skeleton {
width: 100%;
}
.state-preview-content {
border-radius: var(--radius-control);
padding: 1rem;
background: var(--color-surface);
}
.app-shell__scrim {
display: none;
}
}
@media (max-width: 47.999rem) {
.app-shell {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"header"
"content";
}
.app-shell__header {
padding-inline: 0.75rem;
}
.app-shell__menu-button {
display: inline-flex;
}
.app-shell__sidebar {
position: fixed;
z-index: 50;
top: 4.5rem;
bottom: 0;
left: 0;
display: none;
width: min(18rem, 85vw);
height: auto;
box-sizing: border-box;
box-shadow: 10px 0 30px color-mix(in oklch, var(--color-content) 15%, transparent);
}
.app-shell__sidebar[data-open="true"] {
display: block;
}
.app-shell__scrim {
position: fixed;
z-index: 40;
inset: 4.5rem 0 0;
display: block;
border: 0;
background: color-mix(in oklch, var(--color-content) 35%, transparent);
cursor: pointer;
}
.app-shell__session .session-status {
display: none;
}
.app-shell__brand {
display: none;
}
.readiness-grid {
grid-template-columns: minmax(0, 1fr);
}
.component-grid--two,
.component-grid--three {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
+8 -2
View File
@@ -59,8 +59,14 @@ describe("async UI state matrix", () => {
const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />);
expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey);
expect(screen.getByRole("button")).toHaveTextContent("retry");
expect(screen.getByRole("alert")).toHaveTextContent(
"요청을 완료하지 못했습니다.",
);
expect(screen.getByRole("alert")).toHaveAttribute(
"data-message-key",
failure.userMessageKey,
);
expect(screen.getByRole("button")).toHaveTextContent("다시 시도");
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
});
+7 -1
View File
@@ -44,7 +44,13 @@ describe("render recovery boundaries", () => {
<AsyncSurface state={state} />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.server_failure");
expect(screen.getByRole("alert")).toHaveTextContent(
"요청을 완료하지 못했습니다.",
);
expect(screen.getByRole("alert")).toHaveAttribute(
"data-message-key",
"error.server_failure",
);
});
it("renders a safe boot shell with no endpoint or stack", () => {
+54 -6
View File
@@ -1,24 +1,72 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import {
createAnonymousSessionAdapter,
createDemoSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
describe("application router", () => {
it("renders not-found without making an API request", () => {
it("renders the app shell and not-found route without an API request", async () => {
window.history.pushState({}, "", "/missing");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect(
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
await screen.findByRole("heading", {
name: "페이지를 찾을 수 없습니다.",
}),
).toBeVisible();
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
expect(screen.getByRole("main")).toBeVisible();
});
it("shows session-required UX without claiming authorization", () => {
it("navigates between registry-backed example routes", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
await user.click(
await screen.findByRole("link", { name: "UI 구성요소", exact: true }),
);
expect(
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
).toBeVisible();
expect(window.location.pathname).toBe("/examples/ui");
});
it("reacts to demo sign-in and opens the protected integration route", async () => {
const user = userEvent.setup();
const authSession = createDemoSessionAdapter();
window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={authSession} />);
expect(
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await user.click(screen.getByRole("button", { name: "로그인 시작" }));
expect(
await screen.findByRole("heading", { name: "보호된 연동 지점" }),
).toBeVisible();
expect(screen.getByText("인증됨")).toBeVisible();
});
it("fails closed when the auth integration does not change state", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect(screen.getByRole("heading", { name: "세션이 필요합니다." })).toBeVisible();
expect(screen.getByRole("button", { name: "로그인" })).toBeVisible();
await user.click(
await screen.findByRole("button", { name: "로그인 시작" }),
);
expect(
screen.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
});
});
@@ -44,6 +44,11 @@ describe("removable sample feature page", () => {
};
render(<SampleResourcePage facade={facade} />);
expect(await screen.findByRole("alert")).toHaveTextContent("error.server_failure");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("요청을 완료하지 못했습니다.");
expect(alert).toHaveAttribute(
"data-message-key",
"error.server_failure",
);
});
});
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { Alert } from "../../src/presentation/components/ui/alert.jsx";
import { Badge } from "../../src/presentation/components/ui/badge.jsx";
import { Button } from "../../src/presentation/components/ui/button.jsx";
import { Card } from "../../src/presentation/components/ui/card.jsx";
import { Dialog } from "../../src/presentation/components/ui/dialog.jsx";
import { TextField } from "../../src/presentation/components/ui/text-field.jsx";
describe("domain-neutral UI primitives", () => {
it("connects field help and validation errors to the input", () => {
render(
<TextField
label="이름"
description="표시할 이름입니다."
error="이름을 입력해 주세요."
required
/>,
);
const field = screen.getByRole("textbox", { name: "이름" });
expect(field).toBeRequired();
expect(field).toHaveAccessibleDescription(
"표시할 이름입니다. 이름을 입력해 주세요.",
);
expect(field).toHaveAttribute("aria-invalid", "true");
});
it("exposes semantic variants without changing native button behavior", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<>
<Button variant="danger" onClick={onClick}>
제거
</Button>
<Button disabled>사용 불가</Button>
</>,
);
await user.click(screen.getByRole("button", { name: "제거" }));
expect(onClick).toHaveBeenCalledOnce();
expect(screen.getByRole("button", { name: "제거" })).toHaveClass(
"ui-button--danger",
);
expect(screen.getByRole("button", { name: "사용 불가" })).toBeDisabled();
});
it("labels cards, alerts, and badges with visible content", async () => {
const user = userEvent.setup();
const dismiss = vi.fn();
render(
<Card title="상태 카드" footer={<Badge variant="success">준비됨</Badge>}>
<Alert title="저장됨" variant="success" onDismiss={dismiss}>
안전하게 반영했습니다.
</Alert>
</Card>,
);
expect(screen.getByRole("article", { name: "상태 카드" })).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent(
"저장됨안전하게 반영했습니다.",
);
expect(screen.getByText("준비됨")).toHaveClass("ui-badge--success");
await user.click(screen.getByRole("button", { name: "저장됨 알림 닫기" }));
expect(dismiss).toHaveBeenCalledOnce();
});
it("closes a modal and restores focus to its trigger", async () => {
const user = userEvent.setup();
function DialogHarness() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>모달 열기</Button>
<Dialog
open={open}
onClose={() => setOpen(false)}
title="연동 확인"
actions={<Button onClick={() => setOpen(false)}>확인</Button>}
>
안전한 설명
</Dialog>
</>
);
}
render(<DialogHarness />);
const trigger = screen.getByRole("button", { name: "모달 열기" });
await user.click(trigger);
expect(screen.getByRole("dialog", { name: "연동 확인" })).toHaveAttribute(
"open",
);
await user.click(screen.getByRole("button", { name: "확인" }));
await waitFor(() => expect(trigger).toHaveFocus());
expect(screen.getByRole("dialog", { hidden: true })).not.toHaveAttribute(
"open",
);
});
});
+10 -2
View File
@@ -1,7 +1,14 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
for (const route of ["/", "/sample/resources", "/not-found"]) {
for (const route of [
"/",
"/examples/ui",
"/examples/states",
"/examples/auth",
"/sample/resources",
"/not-found",
]) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({
page,
}) => {
@@ -21,8 +28,9 @@ test("@a11y keyboard reaches the primary route action with visible focus", async
page,
}) => {
await page.goto("/");
const action = page.getByRole("link", { name: "UI 구성요소 보기" });
await expect(action).toBeVisible();
await page.keyboard.press("Tab");
const action = page.getByRole("link", { name: "샘플 리소스" });
await expect(action).toBeFocused();
await expect(action).toHaveCSS("outline-style", "solid");
});
+47
View File
@@ -5,4 +5,51 @@ test("boots the public app shell", async ({ page }) => {
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Clean Architecture Frontend",
);
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await expect(page.getByRole("main")).toBeVisible();
});
test("navigates to a registry-backed example without a page reload", async ({
page,
}) => {
await page.goto("/");
await page
.getByRole("navigation", { name: "주요 탐색" })
.getByRole("link", { name: "화면 상태" })
.click();
await expect(page).toHaveURL(/\/examples\/states$/);
await expect(
page.getByRole("heading", { level: 1, name: "화면 상태" }),
).toBeFocused();
});
test("opens the protected integration route through the local demo seam", async ({
page,
}) => {
await page.goto("/sample/resources");
await expect(
page.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: "보호된 연동 지점" }),
).toBeVisible();
await expect(page.getByText("인증됨")).toBeVisible();
});
test("provides an escape-dismissible mobile navigation", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
const menu = page.getByRole("button", { name: "메뉴", exact: true });
await menu.click();
await expect(menu).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(menu).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
});
+36
View File
@@ -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/,
);
});
+38
View File
@@ -0,0 +1,38 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("persists an explicit color scheme through the storage contract", async ({
page,
}) => {
await page.goto("/");
const selector = page.getByRole("combobox", { name: "색상 테마" });
await selector.selectOption("dark");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("html")).toHaveCSS("color-scheme", "dark");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
results.violations.filter((violation) =>
["critical", "serious"].includes(violation.impact ?? ""),
),
).toEqual([]);
await page.reload();
await expect(selector).toHaveValue("dark");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
test("tracks operating-system changes while system preference is selected", async ({
page,
}) => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/");
const selector = page.getByRole("combobox", { name: "색상 테마" });
await selector.selectOption("system");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.emulateMedia({ colorScheme: "light" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
});
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
test("validates and reports the common text-field flow", async ({ page }) => {
await page.goto("/examples/ui");
await page.getByRole("button", { name: "입력 확인" }).click();
const field = page.getByRole("textbox", { name: "프로젝트 이름" });
await expect(field).toHaveAttribute("aria-invalid", "true");
await expect(field).toHaveAccessibleDescription(
/프로젝트 이름을 입력해 주세요/,
);
await field.fill("Starter");
await page.getByRole("button", { name: "입력 확인" }).click();
await expect(page.getByText("“Starter” 입력을 확인했습니다.")).toBeVisible();
});
test("traps modal interaction and restores focus to the trigger", async ({
page,
}) => {
await page.goto("/examples/ui");
const trigger = page.getByRole("button", { name: "모달 열기" });
await trigger.click();
const dialog = page.getByRole("dialog", { name: "연동 확인" });
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole("button", { name: "연동 확인 닫기" }),
).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
loadReleaseManifest,
ReleaseManifestError,
} from "../../src/bootstrap/load-release-manifest.js";
const runtime = {
build: { buildId: "build-a" },
config: {
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "build-a",
RELEASE_ID: "release-a",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
},
};
const manifest = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
commitSha: "abc123",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z",
};
describe("release manifest boot boundary", () => {
it("loads a coherent release tuple", async () => {
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{ fetcher: async () => new Response(JSON.stringify(manifest)) },
),
).resolves.toMatchObject({ releaseId: "release-a" });
});
it("fails before mount when release and runtime differ", async () => {
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{
fetcher: async () =>
new Response(JSON.stringify({ ...manifest, buildId: "build-b" })),
},
),
).rejects.toBeInstanceOf(ReleaseManifestError);
});
});
@@ -35,6 +35,20 @@ describe("runtime configuration boundary", () => {
);
});
it("allows demo authentication only for local runtime configuration", () => {
expect(
validateRuntimeConfig({ ...validConfig, AUTH_MODE: "demo" }).success,
).toBe(true);
expect(
validateRuntimeConfig({
...validConfig,
APP_ENV: "production",
API_BASE_URL: "https://api.example.test",
AUTH_MODE: "demo",
}).success,
).toBe(false);
});
it("validates a fetched config under the 500ms budget excluding network", async () => {
let current = 100;
const result = await loadRuntimeConfig({
+23
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
createAnonymousSessionAdapter,
createDemoSessionAdapter,
createExternalAuthSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js";
@@ -9,6 +10,9 @@ describe("external AuthSessionPort adapter", () => {
it("attaches opaque credentials without exposing a token-shaped session", async () => {
const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => {
const headers = new Headers(request.headers);
headers.set("X-Session-Attached", "true");
@@ -28,6 +32,9 @@ describe("external AuthSessionPort adapter", () => {
it("fails invalid recovery states closed", async () => {
const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
recoverSession: async () => "unexpected",
notifyUnauthenticated: vi.fn(),
@@ -41,4 +48,20 @@ describe("external AuthSessionPort adapter", () => {
expect(adapter.getState()).toBe("unauthenticated");
await expect(adapter.recover()).resolves.toBe("no-session");
});
it("provides a reactive credential-free demo seam", async () => {
const adapter = createDemoSessionAdapter();
let notifications = 0;
const unsubscribe = adapter.subscribe(() => {
notifications += 1;
});
expect(adapter.getState()).toBe("unauthenticated");
await adapter.beginSignIn("/");
expect(adapter.getState()).toBe("authenticated");
await adapter.signOut();
expect(adapter.getState()).toBe("unauthenticated");
expect(notifications).toBe(2);
unsubscribe();
});
});
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../src/application/policies/color-scheme.js";
import { initializeColorScheme } from "../../src/bootstrap/initialize-color-scheme.js";
describe("color scheme policy", () => {
it("normalizes unknown persisted values to the safe system default", () => {
expect(normalizeColorSchemePreference("sepia")).toBe("system");
expect(normalizeColorSchemePreference("dark")).toBe("dark");
});
it("resolves system and explicit preferences deterministically", () => {
expect(resolveColorScheme("system", true)).toBe("dark");
expect(resolveColorScheme("system", false)).toBe("light");
expect(resolveColorScheme("light", true)).toBe("light");
});
it("applies a persisted preference before application paint", () => {
const storage = {
read: () => ({ ok: true, value: "system" }),
write: () => ({ ok: true }),
remove: () => ({ ok: true }),
};
const result = initializeColorScheme(storage, {
documentElement: document.documentElement,
matchMedia: () =>
/** @type {MediaQueryList} */ ({ matches: true }),
});
expect(result).toEqual({ preference: "system", resolved: "dark" });
expect(document.documentElement).toHaveAttribute("data-theme", "dark");
expect(document.documentElement).toHaveAttribute(
"data-theme-preference",
"system",
);
expect(document.documentElement.style.colorScheme).toBe("dark");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { classifyLiveHostingBaseUrl } from "../../scripts/lib/hosting-probe.mjs";
describe("live hosting evidence target", () => {
it("accepts a canonical production HTTPS root", () => {
expect(
classifyLiveHostingBaseUrl("https://frontend.example.test/"),
).toMatchObject({
passed: true,
observedOrigin: "https://frontend.example.test",
});
});
it.each([
["http://frontend.example.test/", "requires HTTPS"],
["https://localhost:4173/", "not live deployment evidence"],
["https://127.0.0.1/", "not live deployment evidence"],
["https://frontend.example.test/app/", "canonical root URL"],
["https://user:secret@frontend.example.test/", "must not contain credentials"],
])("rejects %s", (url, reason) => {
expect(classifyLiveHostingBaseUrl(url)).toMatchObject({
passed: false,
reason: expect.stringContaining(reason),
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
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
Release ID: release-1
Reviewer: reviewer@example.test
Reviewed at: 2026-07-25T12:00:00Z
Signature: review-record-1
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal)
M5 Error association: not-applicable (no form error)
M6 Color signal: pass
M7 Reduced motion: pass
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: [],
passed: true,
});
});
it("rejects pending, unsigned, or incomplete evidence", () => {
expect(
validateManualA11yEvidence(
reviewed
.replace("Status: reviewed", "Status: pending-manual-review")
.replace("Signature: review-record-1", "Signature:")
.replace("Screen reader: pass", "Screen reader: pending"),
),
).toMatchObject({
failures: ["Status", "Signature", "Screen reader"],
passed: false,
});
});
it("does not treat an unexplained not-applicable verdict as evidence", () => {
expect(
validateManualA11yEvidence(
reviewed.replace(
"M4 Modal focus: not-applicable (no modal)",
"M4 Modal focus: not-applicable",
),
),
).toMatchObject({
failures: ["M4 Modal focus"],
passed: false,
});
});
});
+62 -1
View File
@@ -4,7 +4,10 @@ import {
createRedirectLoopGuard,
decideRouteAccess,
} from "../../src/presentation/routes/navigation-policy.js";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
import {
NAVIGATION_ROUTES,
ROUTE_REGISTRY,
} from "../../src/contracts/routes.js";
describe("route registry", () => {
it("matches the stable registry snapshot", () => {
@@ -15,35 +18,93 @@ describe("route registry", () => {
"chunkId": "route-home",
"errorSurface": "route-boundary",
"loadingSurface": "app-shell",
"navigationLabel": "시작",
"navigationOrder": 10,
"paramsSchema": null,
"path": "/",
"routeId": "APP_HOME",
"searchSchema": null,
"title": "시작",
},
"EXAMPLES_AUTH": {
"access": "public",
"chunkId": "route-examples-auth",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "인증 연동",
"navigationOrder": 40,
"paramsSchema": null,
"path": "/examples/auth",
"routeId": "EXAMPLES_AUTH",
"searchSchema": null,
"title": "인증 연동",
},
"EXAMPLES_STATES": {
"access": "public",
"chunkId": "route-examples-states",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "화면 상태",
"navigationOrder": 30,
"paramsSchema": null,
"path": "/examples/states",
"routeId": "EXAMPLES_STATES",
"searchSchema": null,
"title": "화면 상태",
},
"EXAMPLES_UI": {
"access": "public",
"chunkId": "route-examples-ui",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "UI 구성요소",
"navigationOrder": 20,
"paramsSchema": null,
"path": "/examples/ui",
"routeId": "EXAMPLES_UI",
"searchSchema": null,
"title": "UI 구성요소",
},
"NOT_FOUND": {
"access": "public",
"chunkId": "route-not-found",
"errorSurface": "not-found",
"loadingSurface": "none",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": null,
"path": "*",
"routeId": "NOT_FOUND",
"searchSchema": null,
"title": "페이지를 찾을 수 없음",
},
"SAMPLE_RESOURCE_LIST": {
"access": "integration-defined",
"chunkId": "route-sample-resources",
"errorSurface": "feature-boundary",
"loadingSurface": "sample-resource-list",
"navigationLabel": "보호된 연동 지점",
"navigationOrder": 50,
"paramsSchema": null,
"path": "/sample/resources",
"routeId": "SAMPLE_RESOURCE_LIST",
"searchSchema": "SampleResourceListQuery",
"title": "보호된 연동 지점",
},
}
`);
});
it("derives visible navigation in explicit order", () => {
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual([
"APP_HOME",
"EXAMPLES_UI",
"EXAMPLES_STATES",
"EXAMPLES_AUTH",
"SAMPLE_RESOURCE_LIST",
]);
});
it("treats client access as a UX hint, not authorization", () => {
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
allowed: true,
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
evaluatePromotionReadiness,
PROMOTION_FORMULA,
} from "../../src/application/policies/promotion-readiness.js";
const allGateIds = Object.values(PROMOTION_FORMULA).flat();
const passing = Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"]));
describe("promotion readiness formula", () => {
it("requires every upstream tier before downstream readiness", () => {
expect(evaluatePromotionReadiness(passing)).toEqual({
MERGE_READY: true,
RELEASE_READY: true,
PROD_PROMOTION_READY: true,
FIELD_SLO_READY: true,
DOCUMENTATION_READY: true,
});
});
it.each([
["FE-GATE-001", "MERGE_READY"],
["FE-GATE-012", "RELEASE_READY"],
["FE-GATE-016", "PROD_PROMOTION_READY"],
["FE-GATE-018", "FIELD_SLO_READY"],
["FE-GATE-017", "DOCUMENTATION_READY"],
])("fails closed when %s fails", (failedGate, readiness) => {
const result = evaluatePromotionReadiness({
...passing,
[failedGate]: "FAIL",
});
expect(result[readiness]).toBe(false);
});
it("does not treat missing or unverified gates as pass", () => {
expect(
evaluatePromotionReadiness({
...passing,
"FE-GATE-009": "UNVERIFIED",
}),
).toMatchObject({
MERGE_READY: false,
RELEASE_READY: false,
PROD_PROMOTION_READY: false,
FIELD_SLO_READY: false,
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("operational runbook contract", () => {
const document = JSON.parse(
readFileSync("config/runbooks/runbooks.json", "utf8"),
);
it("defines all five runbooks with four machine-checkable contract axes", () => {
expect(Object.keys(document.runbooks)).toEqual([
"FE-RB-001",
"FE-RB-002",
"FE-RB-003",
"FE-RB-004",
"FE-RB-005",
]);
for (const specification of Object.values(document.runbooks)) {
expect(specification.triggerKinds.length).toBeGreaterThan(0);
expect(specification.containment).toEqual(expect.any(String));
expect(specification.window).toEqual(expect.any(String));
expect(specification.escalation.length).toBeGreaterThanOrEqual(2);
expect(specification.recoveryEvidence).toHaveLength(4);
expect(specification.negativeFixture).toEqual(expect.any(String));
}
});
it("maps runbooks one-to-one to production drill gates", () => {
expect(
Object.values(document.runbooks).map((runbook) => runbook.gateId),
).toEqual([
"FE-GATE-021",
"FE-GATE-022",
"FE-GATE-023",
"FE-GATE-024",
"FE-GATE-025",
]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js";
const runtime = {
config: {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
},
};
const release = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
commitSha: "abc123",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z",
};
describe("runtime adapter composition", () => {
it("constructs the local demo seam and infrastructure adapters", async () => {
const adapters = await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
runtime
),
release,
host: {},
});
expect(adapters.authSession.getState()).toBe("unauthenticated");
expect(adapters.cache.read(["missing"])).toEqual({
ok: true,
value: undefined,
});
await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({
releaseId: "release-a",
});
});
it("fails closed when an external auth owner was not installed", async () => {
const adapters = await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ ({
config: { ...runtime.config, AUTH_MODE: "external" },
}),
release,
host: {},
});
expect(adapters.authSession.getState()).toBe("integration-failed");
});
});