Compare commits

...
42 changed files with 1784 additions and 72 deletions
+4 -4
View File
@@ -53,9 +53,9 @@ jobs:
run: | run: |
corepack enable corepack enable
corepack pnpm install --frozen-lockfile corepack pnpm install --frozen-lockfile
- name: Install Chromium - name: Install Playwright browsers
if: ${{ matrix.browser }} if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate - name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }} run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence - name: Upload gate evidence
@@ -91,9 +91,9 @@ jobs:
run: | run: |
corepack enable corepack enable
corepack pnpm install --frozen-lockfile corepack pnpm install --frozen-lockfile
- name: Install Chromium - name: Install Playwright browsers
if: ${{ matrix.browser }} if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate - name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }} run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence - name: Upload gate evidence
+35 -4
View File
@@ -16,6 +16,27 @@ corepack pnpm dev
Runtime-public settings live in `public/config.json` and are validated before Runtime-public settings live in `public/config.json` and are validated before
the product tree mounts. Client secrets are forbidden. 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 ## Architecture
Dependencies point inward: Dependencies point inward:
@@ -27,9 +48,11 @@ bootstrap composes concrete adapters
contracts own cross-cutting registries contracts own cross-cutting registries
``` ```
See `docs/architecture/overview.md` and `docs/architecture/layers.md`. The See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and
removable sample slice is under `src/sample/contract-fixture`; product code is `docs/architecture/starter-experience.md`. The removable sample slice is under
not allowed to import it. `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 ## Verification
@@ -52,9 +75,17 @@ corepack pnpm drill:runbooks
corepack pnpm check:ci 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: Two gates intentionally need external evidence:
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review. - `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review
for all six registered routes.
- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum - `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum
eligible-sample threshold and 28 days of production data exist. eligible-sample threshold and 28 days of production data exist.
@@ -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.
+3
View File
@@ -125,6 +125,9 @@
"evidence": [ "evidence": [
"artifacts/tests/a11y.json", "artifacts/tests/a11y.json",
"artifacts/tests/a11y-manual/APP_HOME.md", "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/SAMPLE_RESOURCE_LIST.md",
"artifacts/tests/a11y-manual/NOT_FOUND.md", "artifacts/tests/a11y-manual/NOT_FOUND.md",
"artifacts/tests/a11y-manual/report.json" "artifacts/tests/a11y-manual/report.json"
+10 -6
View File
@@ -1,10 +1,12 @@
# Manual accessibility review checklist # Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must Automated axe checks do not establish WCAG conformance. A human reviewer must
review all three route records in `artifacts/tests/a11y-manual/` against one review all six route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. Copy the template fields exactly; the gate release candidate and sign them. The required scope is derived from the route
rejects blank identity/timestamp/signature fields, pending verdicts, mismatched registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`,
release IDs, or missing routes. `SAMPLE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the
gate rejects blank identity/timestamp/signature fields, pending verdicts,
mismatched release IDs, or missing routes.
Allowed item verdicts: Allowed item verdicts:
@@ -43,5 +45,7 @@ The reviewer must verify:
- M7: non-essential motion is suppressed with reduced-motion preference - M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once - Screen reader: headings, live regions, errors, and actions are announced once
Passing automated evidence means only that tested pages had no critical or `EXAMPLES_UI` requires real M4 modal and M5 field-error review; those items must
serious axe findings under the recorded browser run. 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.
+8
View File
@@ -8,6 +8,8 @@ in `review-ledger.json`, not automatically to edits in this file.
flowchart LR flowchart LR
Bootstrap[bootstrap / composition root] --> Presentation[presentation] Bootstrap[bootstrap / composition root] --> Presentation[presentation]
Bootstrap --> Adapters[adapters] Bootstrap --> Adapters[adapters]
Presentation --> Shell[app shell and route surfaces]
Shell --> Providers[session and theme providers]
Presentation --> Application[application] Presentation --> Application[application]
Adapters --> Application Adapters --> Application
Application --> Domain[domain] Application --> Domain[domain]
@@ -21,3 +23,9 @@ implement application ports, and only the composition root selects concrete
adapters. Contract registries are the single named source for routes, API adapters. Contract registries are the single named source for routes, API
operations, environment values, storage keys, errors, queries, telemetry, and operations, environment values, storage keys, errors, queries, telemetry, and
release tokens. 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.
+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.
+5
View File
@@ -30,6 +30,11 @@ registry: merge evidence through the PR decision, coherent release evidence
through the next release promotion, drill evidence through the next production through the next release promotion, drill evidence through the next production
promotion, and field evidence through aggregation. 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: Repository variables required by higher tiers:
- `HOSTING_BASE_URL` for live header verification - `HOSTING_BASE_URL` for live header verification
+28 -4
View File
@@ -1,8 +1,32 @@
# Design-token styling contract # Design-token styling contract
`src/presentation/styles/theme.css` is the styling SSOT. Components consume `src/presentation/styles/theme.css` is the styling SSOT. Components consume
semantic color, spacing, typography, and radius tokens through static Tailwind semantic color, spacing, typography, status, and radius tokens through static
classes. 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: Arbitrary-value policy:
@@ -13,5 +37,5 @@ Arbitrary-value policy:
- user-controlled or runtime-composed class strings are forbidden - user-controlled or runtime-composed class strings are forbidden
- class variants must be selected from a closed static map - class variants must be selected from a closed static map
The removable sample may demonstrate tokens, but product modules must not The removable sample may demonstrate tokens, but production starter modules do
import from `src/sample/contract-fixture`. 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/` | | end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` | | 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 A control is verified only when a positive fixture passes and its deliberately
failing negative fixture is rejected. Generated evidence is retained by CI; failing negative fixture is rejected. Generated evidence is retained by CI;
the repository tracks only the evidence directory structure. the repository tracks only the evidence directory structure.
+2
View File
@@ -19,5 +19,7 @@ export default defineConfig({
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, { name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
], ],
}); });
+3
View File
@@ -1,5 +1,8 @@
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([ export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([
"APP_HOME", "APP_HOME",
"EXAMPLES_UI",
"EXAMPLES_STATES",
"EXAMPLES_AUTH",
"SAMPLE_RESOURCE_LIST", "SAMPLE_RESOURCE_LIST",
"NOT_FOUND", "NOT_FOUND",
]); ]);
+3 -1
View File
@@ -1,5 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises"; 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 mkdir("artifacts/tests", { recursive: true });
await writeFile( await writeFile(
"artifacts/tests/a11y.json", "artifacts/tests/a11y.json",
@@ -7,7 +9,7 @@ await writeFile(
{ {
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"], scope: MANUAL_A11Y_ROUTE_IDS,
threshold: { critical: 0, serious: 0 }, threshold: { critical: 0, serious: 0 },
automatedStatus: "passed", automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/report.json", manualReview: "see artifacts/tests/a11y-manual/report.json",
+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;
}
+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 });
}
+3
View File
@@ -5,6 +5,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx"; import { AppRouter } from "../presentation/routes/app-router.jsx";
import { createRuntimeComposition } from "./create-runtime-composition.js"; import { createRuntimeComposition } from "./create-runtime-composition.js";
import { initializeColorScheme } from "./initialize-color-scheme.js";
import { BootConfigError } from "./load-runtime-config.js"; import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js"; import { ReleaseManifestError } from "./load-release-manifest.js";
import "../presentation/styles/theme.css"; import "../presentation/styles/theme.css";
@@ -20,6 +21,7 @@ const root = createRoot(rootElement);
async function boot() { async function boot() {
try { try {
const composition = await createRuntimeComposition(); const composition = await createRuntimeComposition();
initializeColorScheme(composition.ports.storage);
root.render( root.render(
<StrictMode> <StrictMode>
<QueryClientProvider client={composition.ports.queryClient}> <QueryClientProvider client={composition.ports.queryClient}>
@@ -27,6 +29,7 @@ async function boot() {
authSession={composition.ports.authSession} authSession={composition.ports.authSession}
basename={composition.config.build.routerBasePath} basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId} buildId={composition.release.buildId}
storage={composition.ports.storage}
telemetry={composition.ports.telemetry} telemetry={composition.ports.telemetry}
/> />
</QueryClientProvider> </QueryClientProvider>
+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 */ /** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) { export function LoadingSurface({ label = "불러오는 중" }) {
return ( return (
<section <section
className="state-surface state-surface--loading"
aria-busy="true" aria-busy="true"
aria-label={label} aria-label={label}
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
> >
<div className="ui-skeleton" aria-hidden="true" /> <div className="ui-skeleton" aria-hidden="true" />
<span className="sr-only">{label}</span> <span className="visually-hidden">{label}</span>
</section> </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 ( return (
<section className="ui-empty" aria-live="polite"> <section className="ui-empty state-surface" aria-live="polite">
<p>{title}</p> <h2>{title}</h2>
{description ? <p>{description}</p> : null}
{action} {action}
</section> </section>
); );
@@ -32,17 +49,24 @@ export function EmptySurface({ title = "표시할 항목이 없습니다.", acti
* }} props * }} props
*/ */
export function TerminalErrorSurface({ userMessageKey, action, onAction }) { export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
const messageId = useId();
const actionLabels = Object.freeze({
retry: "다시 시도",
reauth: "로그인",
navigate: "안전한 화면으로 이동",
"reload-once": "한 번 새로고침",
"contact-support": "지원 정보 확인",
});
return ( return (
<section <section
className="ui-terminal-error" className="ui-terminal-error state-surface state-surface--danger"
role="alert" 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" && ( {action !== "none" && (
<button className="ui-button" type="button" onClick={onAction}> <Button onClick={onAction}>{actionLabels[action]}</Button>
{action}
</button>
)} )}
</section> </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,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>
);
}
@@ -1,24 +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 { 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() { export default function StateGalleryPage() {
const [lastAction, setLastAction] = useState(
"상태 화면의 작업을 선택하면 결과가 여기에 표시됩니다.",
);
const refreshingState = deriveAsyncState({
data: ["기존 데이터"],
isFetching: true,
});
return ( return (
<section className="ui-page"> <section className="ui-page">
<PageHeader <PageHeader
eyebrow="예제" eyebrow="예제"
title="화면 상태" title="화면 상태"
description="로딩, 빈 화면, 오류, 인증 필요와 권한 없음 상태의 기본 표현을 확인합니다." description="로딩, 빈 화면, 오류, 인증 필요와 권한 없음 상태가 다음 행동까지 일관되게 안내합니다."
/> />
<div className="readiness-grid">
<section className="ui-skeleton" aria-label="로딩 상태 예제" /> <section className="gallery-section" aria-labelledby="async-states-title">
<section className="ui-empty"> <header className="gallery-section__header">
<h2>표시할 항목이 없습니다.</h2> <h2 id="async-states-title">비동기 데이터 상태</h2>
<p>다음 행동이 있다면 위치에 명확한 안내 제공합니다.</p> <p>초기 로딩과 백그라운드 갱신을 구분해 기존 콘텐츠 보존합니다.</p>
</section> </header>
<section className="ui-terminal-error" role="alert"> <div className="component-grid component-grid--two">
<h2>요청을 완료하지 못했습니다.</h2> <Card title="초기 로딩">
<p>안전한 재시도 또는 지원 참조 정보를 제공합니다.</p> <LoadingSurface label="예제 데이터를 불러오는 중" />
</section> </Card>
</div> <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> </section>
); );
} }
+172 -7
View File
@@ -1,20 +1,185 @@
import { useState } from "react";
import { PageHeader } from "../components/page-header.jsx"; 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() { 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 ( return (
<section className="ui-page"> <section className="ui-page">
<PageHeader <PageHeader
eyebrow="예제" eyebrow="예제"
title="UI 구성요소" title="UI 구성요소"
description="제품 도메인과 독립적인 공통 컨트롤과 표면을 확인하는 공간입니다." description="제품 도메인과 독립적인 공통 컨트롤, 피드백, 표면과 디자인 토큰을 직접 조작할 수 있습니다."
/> />
<section className="ui-panel" aria-labelledby="ui-gallery-status">
<h2 id="ui-gallery-status">구성요소 계약</h2> <section className="gallery-section" aria-labelledby="controls-title">
<p> <header className="gallery-section__header">
버튼, 입력, 카드, 알림, 모달의 상호작용과 디자인 토큰을 <h2 id="controls-title">버튼과 입력</h2>
라우트에 조립합니다. <p>키보드, 비활성 상태, 오류 설명을 포함한 기본 상호작용입니다.</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>
<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> </section>
); );
} }
+21
View File
@@ -3,6 +3,7 @@ import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js"; import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js";
import { useSession } from "../providers/session-provider.jsx"; import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
const SESSION_LABELS = Object.freeze({ const SESSION_LABELS = Object.freeze({
authenticated: "인증됨", authenticated: "인증됨",
@@ -14,6 +15,7 @@ const SESSION_LABELS = Object.freeze({
export function AppShell() { export function AppShell() {
const location = useLocation(); const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession(); const { sessionState, beginSignIn, signOut, recover } = useSession();
const { preference, setPreference } = useTheme();
const [navigationOpen, setNavigationOpen] = useState(false); const [navigationOpen, setNavigationOpen] = useState(false);
const [sessionActionPending, setSessionActionPending] = useState(false); const [sessionActionPending, setSessionActionPending] = useState(false);
const [sessionActionFailed, setSessionActionFailed] = useState(false); const [sessionActionFailed, setSessionActionFailed] = useState(false);
@@ -80,6 +82,25 @@ export function AppShell() {
Frontend Skeleton Frontend Skeleton
</NavLink> </NavLink>
<div className="app-shell__session"> <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}> <span className="session-status" data-state={sessionState}>
{SESSION_LABELS[sessionState]} {SESSION_LABELS[sessionState]}
</span> </span>
@@ -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;
}
+11 -6
View File
@@ -11,6 +11,7 @@ import { RouteBoundary } from "../boundaries/render-error-boundary.jsx";
import { AppShell } from "../layouts/app-shell.jsx"; import { AppShell } from "../layouts/app-shell.jsx";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../components/page-header.jsx";
import { SessionProvider, useSession } from "../providers/session-provider.jsx"; import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx";
import { decideRouteAccess } from "./navigation-policy.js"; import { decideRouteAccess } from "./navigation-policy.js";
const HomePage = lazy(() => import("../pages/home-page.jsx")); const HomePage = lazy(() => import("../pages/home-page.jsx"));
@@ -171,6 +172,7 @@ function PublicRoute({ routeId, buildId, telemetry, children }) {
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string, * basename?: string,
* buildId?: string, * buildId?: string,
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort
* }} props * }} props
*/ */
@@ -178,13 +180,15 @@ export function AppRouter({
authSession, authSession,
basename = "/", basename = "/",
buildId = "local-build", buildId = "local-build",
storage,
telemetry, telemetry,
}) { }) {
return ( return (
<BrowserRouter basename={basename}> <BrowserRouter basename={basename}>
<SessionProvider authSession={authSession}> <ThemeProvider storage={storage}>
<Routes> <SessionProvider authSession={authSession}>
<Route element={<AppShell />}> <Routes>
<Route element={<AppShell />}>
<Route <Route
index index
element={ element={
@@ -259,9 +263,10 @@ export function AppRouter({
</PublicRoute> </PublicRoute>
} }
/> />
</Route> </Route>
</Routes> </Routes>
</SessionProvider> </SessionProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
); );
} }
+463 -12
View File
@@ -3,12 +3,28 @@
@theme { @theme {
--color-surface: oklch(0.985 0.003 247); --color-surface: oklch(0.985 0.003 247);
--color-surface-muted: oklch(0.94 0.01 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: oklch(0.25 0.025 247);
--color-content-muted: oklch(0.48 0.025 247); --color-content-muted: oklch(0.48 0.025 247);
--color-action: oklch(0.55 0.18 255); --color-action: oklch(0.55 0.18 255);
--color-action-hover: oklch(0.48 0.2 255); --color-action-hover: oklch(0.48 0.2 255);
--color-danger: oklch(0.55 0.2 25); --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-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-control: 0.5rem;
--radius-surface: 0.75rem; --radius-surface: 0.75rem;
--spacing-page: 1.5rem; --spacing-page: 1.5rem;
@@ -22,6 +38,33 @@
font-family: var(--font-sans); 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 { body {
margin: 0; margin: 0;
min-width: 20rem; min-width: 20rem;
@@ -81,7 +124,7 @@
left: 0.75rem; left: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: var(--radius-control); border-radius: var(--radius-control);
color: white; color: var(--color-panel);
background: var(--color-content); background: var(--color-content);
transform: translateY(-200%); transform: translateY(-200%);
} }
@@ -111,7 +154,7 @@
min-width: 0; min-width: 0;
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--color-surface-muted); border-bottom: 1px solid var(--color-surface-muted);
background: color-mix(in oklch, white 92%, var(--color-surface)); 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); box-shadow: 0 1px 8px color-mix(in oklch, var(--color-content) 8%, transparent);
} }
@@ -132,7 +175,7 @@
border-radius: var(--radius-control); border-radius: var(--radius-control);
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
color: var(--color-content); color: var(--color-content);
background: white; background: var(--color-panel);
cursor: pointer; cursor: pointer;
} }
@@ -143,13 +186,24 @@
margin-inline-start: auto; 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 { .app-shell__session-error {
position: absolute; position: absolute;
top: calc(100% + 0.25rem); top: calc(100% + 0.25rem);
right: 1rem; right: 1rem;
padding: 0.5rem; padding: 0.5rem;
color: var(--color-danger); color: var(--color-danger);
background: white; background: var(--color-panel);
} }
.session-status { .session-status {
@@ -165,8 +219,8 @@
} }
.session-status[data-state="authenticated"] { .session-status[data-state="authenticated"] {
color: oklch(0.35 0.12 155); color: var(--color-success-content);
background: oklch(0.93 0.05 155); background: var(--color-success-surface);
} }
.session-status[data-state="integration-failed"] { .session-status[data-state="integration-failed"] {
@@ -179,8 +233,8 @@
grid-area: sidebar; grid-area: sidebar;
height: calc(100vh - 4.5rem); height: calc(100vh - 4.5rem);
padding: 1.25rem 0.75rem; padding: 1.25rem 0.75rem;
border-right: 1px solid var(--color-surface-muted); border-right: 1px solid var(--color-border);
background: white; background: var(--color-panel);
} }
.app-navigation { .app-navigation {
@@ -208,7 +262,11 @@
.app-navigation__link.is-active { .app-navigation__link.is-active {
color: var(--color-action-hover); color: var(--color-action-hover);
background: color-mix(in oklch, var(--color-action) 10%, white); background: color-mix(
in oklch,
var(--color-action) 14%,
var(--color-panel)
);
font-weight: 800; font-weight: 800;
} }
@@ -224,16 +282,19 @@
} }
.ui-panel { .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 { .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; display: inline-flex;
min-height: 2.75rem; min-height: 2.75rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 0; border: 0;
color: var(--color-on-action);
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@@ -250,7 +311,7 @@
.ui-button--secondary { .ui-button--secondary {
border: 1px solid var(--color-surface-muted); border: 1px solid var(--color-surface-muted);
color: var(--color-content); color: var(--color-content);
background: white; background: var(--color-panel);
} }
.ui-button--secondary:hover { .ui-button--secondary:hover {
@@ -258,6 +319,25 @@
background: var(--color-surface); 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 { .ui-button--compact {
min-height: 2.25rem; min-height: 2.25rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
@@ -273,11 +353,227 @@
@apply rounded-surface border border-surface-muted p-6; @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 { .page-header {
max-width: 50rem; max-width: 50rem;
padding-block: clamp(1rem, 5vw, 3.5rem) 0.5rem; padding-block: clamp(1rem, 5vw, 3.5rem) 0.5rem;
} }
[data-route-heading]:focus {
outline: none;
}
.page-header__eyebrow { .page-header__eyebrow {
margin-block-end: 0.75rem; margin-block-end: 0.75rem;
color: var(--color-action); color: var(--color-action);
@@ -326,6 +622,152 @@
padding-block-start: 4rem; 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 { .app-shell__scrim {
display: none; display: none;
} }
@@ -378,9 +820,18 @@
display: none; display: none;
} }
.app-shell__brand {
display: none;
}
.readiness-grid { .readiness-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.component-grid--two,
.component-grid--three {
grid-template-columns: minmax(0, 1fr);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+8 -2
View File
@@ -59,8 +59,14 @@ describe("async UI state matrix", () => {
const state = deriveAsyncState({ failure }); const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />); render(<AsyncSurface state={state} />);
expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey); expect(screen.getByRole("alert")).toHaveTextContent(
expect(screen.getByRole("button")).toHaveTextContent("retry"); "요청을 완료하지 못했습니다.",
);
expect(screen.getByRole("alert")).toHaveAttribute(
"data-message-key",
failure.userMessageKey,
);
expect(screen.getByRole("button")).toHaveTextContent("다시 시도");
expect(screen.getByRole("alert")).not.toHaveTextContent("stack"); expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
}); });
+7 -1
View File
@@ -44,7 +44,13 @@ describe("render recovery boundaries", () => {
<AsyncSurface state={state} /> <AsyncSurface state={state} />
</FeatureBoundary>, </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", () => { it("renders a safe boot shell with no endpoint or stack", () => {
@@ -44,6 +44,11 @@ describe("removable sample feature page", () => {
}; };
render(<SampleResourcePage facade={facade} />); 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",
);
});
});
+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();
});
+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");
});
});
+11 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { validateManualA11yEvidence } from "../../scripts/lib/manual-a11y-evidence.mjs"; import {
MANUAL_A11Y_ROUTE_IDS,
validateManualA11yEvidence,
} from "../../scripts/lib/manual-a11y-evidence.mjs";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
const reviewed = `Status: reviewed const reviewed = `Status: reviewed
Route ID: APP_HOME Route ID: APP_HOME
@@ -20,6 +24,12 @@ Screen reader: pass
Notes: no defects`; Notes: no defects`;
describe("manual accessibility evidence", () => { 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", () => { it("accepts a complete signed human review record", () => {
expect(validateManualA11yEvidence(reviewed)).toMatchObject({ expect(validateManualA11yEvidence(reviewed)).toMatchObject({
failures: [], failures: [],