diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs
index 6cd78b5..d320e6d 100644
--- a/.dependency-cruiser.cjs
+++ b/.dependency-cruiser.cjs
@@ -31,6 +31,16 @@ module.exports = {
path: "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)",
},
},
+ {
+ name: "icon-vendor-is-facade-only",
+ severity: "error",
+ from: {
+ path: "^src",
+ pathNot:
+ "^src/presentation/design-system/icons/vendors/lucide\\.tsx$",
+ },
+ to: { path: "^lucide-react$" },
+ },
{
name: "adapters-do-not-know-presentation",
severity: "error",
diff --git a/artifacts/tests/a11y-manual/EXAMPLES_UI.md b/artifacts/tests/a11y-manual/EXAMPLES_UI.md
index b82c1cf..6f89235 100644
--- a/artifacts/tests/a11y-manual/EXAMPLES_UI.md
+++ b/artifacts/tests/a11y-manual/EXAMPLES_UI.md
@@ -15,4 +15,4 @@ 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.
+Notes: Review form primitives, Menu/Tabs keyboard behavior, Toast announcements, Tooltip supplemental copy, text-field error association and modal focus containment/restoration.
diff --git a/config/ci/gates.json b/config/ci/gates.json
index 9a13e71..cf78832 100644
--- a/config/ci/gates.json
+++ b/config/ci/gates.json
@@ -81,7 +81,8 @@
{ "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "expect": "fail" },
- { "script": "check:types:fixture:page-action", "expect": "fail" }
+ { "script": "check:types:fixture:page-action", "expect": "fail" },
+ { "script": "check:types:fixture:icon-button", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
@@ -151,6 +152,8 @@
"name": "architecture",
"steps": [
{ "script": "check:architecture", "expect": "pass" },
+ { "script": "check:design-system", "expect": "pass" },
+ { "script": "check:design-system:fixture", "expect": "fail" },
{ "script": "check:registries", "expect": "pass" },
{ "script": "check:registries:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" }
@@ -158,6 +161,8 @@
"logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": [
"artifacts/quality/dependency-report.json",
+ "artifacts/quality/design-system.json",
+ "artifacts/quality/design-system-fixture.json",
"artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json"
diff --git a/docs/architecture/decisions/VD-05-icon-and-headless-ui.md b/docs/architecture/decisions/VD-05-icon-and-headless-ui.md
new file mode 100644
index 0000000..9bda349
--- /dev/null
+++ b/docs/architecture/decisions/VD-05-icon-and-headless-ui.md
@@ -0,0 +1,57 @@
+# VD-05: Semantic icon facade와 native-first interaction
+
+- 상태: Accepted
+- 결정일: 2026-07-26
+- 적용 브랜치: `feature-frontend-design-system-platform`
+- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
+
+## 배경
+
+앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
+사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
+접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
+없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
+의존성과 중복 interaction 모델이 생긴다.
+
+## 결정
+
+1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
+ 고정한다.
+2. `lucide-react`의 static named import는
+ `design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
+3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
+ 노출한다. vendor component type, icon name, stroke API와 dynamic icon
+ registry는 노출하지 않는다.
+4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
+ 아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
+5. 현재 복합 control은 native `dialog`, form control, `details`와 local
+ TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
+ Tabs는 manual/automatic activation, Drawer는 modal/background
+ 비활성화/focus restore 계약을 가진다.
+6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
+ collision, nested overlay, virtualized collection 또는 복합 select 요구를
+ 충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
+ 평가한다.
+7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
+ runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
+ 주장하지 않는다.
+
+## 경계와 검증
+
+- 제품 코드는 `presentation/design-system/index`만 import한다.
+- design-system 검사기는 direct icon/headless import, deep import, raw palette,
+ undefined token과 tooltip-only required information fixture를 거절한다.
+- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
+- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
+ 검증한다.
+- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
+ restore, gallery keyboard interaction과 axe를 검증한다.
+- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
+ 공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
+
+## Rollback
+
+기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
+재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
+semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
+도입해도 public props와 interaction test를 유지한다.
diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md
index e6e3ac1..4b86056 100644
--- a/docs/architecture/frontend-platform-capability-review.md
+++ b/docs/architecture/frontend-platform-capability-review.md
@@ -73,11 +73,11 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다.
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
-| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 |
-| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status slot 계약과 reference 소비 화면 | RP-07 public design-system entry로 이동 |
-| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 |
-| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 |
-| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 |
+| 앱 셸·반응형 | 준비됨 | native modal Drawer, compact/desktop layout, Escape/link dismiss와 focus restore | RP-08에서 RTL/direction 검증 |
+| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 |
+| 디자인 토큰 | 준비됨 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 |
+| 공통 UI | 준비됨 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | Storybook/visual은 RP-10 |
+| 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 |
| 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md
index 27d5abd..fdc7754 100644
--- a/docs/architecture/frontend-platform-implementation-roadmap.md
+++ b/docs/architecture/frontend-platform-implementation-roadmap.md
@@ -588,6 +588,22 @@ RP-07은 compatibility export로 기존 primitive import를 복구할 수 있어
token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을
분리한다.
+**구현 증거 (2026-07-26)**
+
+- VD-05에서 `lucide-react@1.25.0` static semantic facade와 native-first
+ interaction을 채택하고 headless vendor 재평가 조건을 닫았다.
+- `src/presentation/design-system`이 48개 필수 token, public TypeScript barrel,
+ action/form/feedback/overlay/navigation primitive와 공통 pattern을 제공한다.
+- 기존 `components/ui` 경로는 compatibility export로 유지하고 앱 셸, gallery와
+ reference feature는 public entry를 소비한다.
+- 모바일 navigation은 native modal Drawer로 전환되어 배경 비활성화, Escape,
+ route dismiss와 trigger focus restore를 제공한다.
+- source/negative fixture gate가 undefined token, raw palette, direct vendor,
+ deep import, tooltip-only 정보와 accessible name 누락을 거절한다.
+- component/browser test가 Menu typeahead, Tabs activation, Toast queue,
+ form controls, compact reflow와 open-dialog axe를 실행한다. 로컬 WebKit은 host
+ `libevent-2.1.so.7` 부재로 환경 검증 상태를 유지한다.
+
### 08. `feature-frontend-i18n-message-formatting-contract`
**목표**
diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md
index c4b857a..db32916 100644
--- a/docs/architecture/frontend-ports-adapters-and-boundaries.md
+++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md
@@ -171,6 +171,13 @@ query vendor import는 architecture gate가 거절한다. reference feature의
list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
실제 consumer다.
+RP-07에서 React inbound adapter 안의 UI 공급자 경계도 닫혔다.
+`src/presentation/design-system/index.ts`는 token → primitive → pattern →
+template public API이며 feature와 shell은 이 entry만 소비한다. Lucide는
+`icons/vendors/lucide.tsx`에 격리된 inbound vendor facade이므로 application
+port가 아니다. native Dialog/Drawer/Menu/Tabs의 focus·keyboard 상태도
+presentation이 소유하고 use case나 outbound adapter로 올리지 않는다.
+
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다.
diff --git a/docs/styling/design-system-platform.md b/docs/styling/design-system-platform.md
index 6a03aab..642bf3d 100644
--- a/docs/styling/design-system-platform.md
+++ b/docs/styling/design-system-platform.md
@@ -47,23 +47,23 @@
- Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사
-이 기반은 유효하지만 아직 디자인 시스템 플랫폼 전체는 아니다.
+RP-07 구현 이후 이 기반은 저장소 내부 디자인 시스템 플랫폼 계약을 충족한다.
+아래 목록은 구현 전 공백과 현재 해결 상태를 함께 보존한다.
-1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size,
- breakpoint가 계약으로 닫혀 있지 않다.
+1. typography, elevation, motion, z-layer, control size와 breakpoint는
+ `design-system/tokens`의 3계층과 자동 gate로 닫혔다.
2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
-3. 앱 셸과 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크
- class를 다시 작성하는 곳이 있다.
-4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다.
-5. `theme.css` 하나가 token, layout, primitive, pattern 스타일을 모두 소유한다.
+3. 앱 셸, 예제와 reference feature는 public design-system barrel을 소비한다.
+4. 문자 glyph는 semantic Lucide facade로 교체됐다.
+5. token은 세 CSS 파일로 분리됐고 `theme.css`는 layout/component styling만
+ 소유한다.
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
-8. page template은 준비됐지만 RP-07 design-system public entry와 token/pattern
- 계층으로 정리해야 한다.
-9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus
- 복원, 배경 비활성화 계약은 없다.
+8. page template, form과 pattern은 public barrel에서 제공된다.
+9. 모바일 navigation은 native modal Drawer로 focus 이동, 배경 비활성화,
+ Escape/link dismiss와 trigger focus restore를 제공한다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md
index cc0cfad..50721e4 100644
--- a/docs/testing/frontend-platform-testing-strategy.md
+++ b/docs/testing/frontend-platform-testing-strategy.md
@@ -562,6 +562,13 @@ client route guard는 UX이며 authorization이 아님을 test 이름과 문서
공용 컴포넌트는 한 번의 결함이 모든 페이지로 전파되므로 위험도가 높다.
+RP-07의 실행 경로는 `check:design-system`,
+`check:design-system:fixture`, `check:types:fixture:icon-button`,
+`tests/component/design-system-platform.test.tsx`와
+`tests/e2e/design-system-interactions.spec.js`다. Story interaction/visual
+baseline은 VD-08/RP-10에서 추가하며 현재 runtime gallery를 isolated workshop
+완료 증거로 사용하지 않는다.
+
### 10.1 Component behavior
- native role/name/value
diff --git a/package.json b/package.json
index 64f9bf5..f12f677 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,8 @@
"preview": "vite preview",
"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:design-system": "node scripts/check-design-system.mjs",
+ "check:design-system:fixture": "node scripts/check-design-system.mjs --fixture",
"check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test",
"check:types:app": "tsc --project tsconfig.app.json",
"check:types:node": "tsc --project tsconfig.node.json",
@@ -27,6 +29,7 @@
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
+ "check:types:fixture:icon-button": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx",
"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",
@@ -58,6 +61,7 @@
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
+ "lucide-react": "1.25.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d70fc0e..c23938d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
'@tanstack/react-query':
specifier: 5.101.4
version: 5.101.4(react@19.2.8)
+ lucide-react:
+ specifier: 1.25.0
+ version: 1.25.0(react@19.2.8)
react:
specifier: 19.2.8
version: 19.2.8
@@ -1536,6 +1539,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lucide-react@1.25.0:
+ resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -3314,6 +3322,10 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lucide-react@1.25.0(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+
lz-string@1.5.0: {}
magic-string@0.30.21:
diff --git a/scripts/check-design-system.mjs b/scripts/check-design-system.mjs
new file mode 100644
index 0000000..2e87141
--- /dev/null
+++ b/scripts/check-design-system.mjs
@@ -0,0 +1,148 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
+import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
+
+const fixtureMode = process.argv.includes("--fixture");
+const failures = [];
+const tokenFiles = {
+ primitive: "src/presentation/design-system/tokens/primitive.css",
+ semantic: "src/presentation/design-system/tokens/semantic.css",
+ component: "src/presentation/design-system/tokens/component.css",
+};
+/** @type {Array} */
+const tokenLayers = [
+ ["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS],
+ ["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS],
+ ["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS],
+];
+
+for (const [layer, file, tokens] of tokenLayers) {
+ const source = await readFile(file, "utf8");
+ for (const token of tokens) {
+ if (!source.includes(`${token}:`)) {
+ failures.push(`${layer} token is missing: ${token}`);
+ }
+ }
+}
+
+const cssSources = await Promise.all(
+ [...Object.values(tokenFiles), "src/presentation/styles/theme.css"].map(
+ async (file) => ({ file, source: await readFile(file, "utf8") }),
+ ),
+);
+const definitions = new Set(
+ cssSources.flatMap(({ source }) =>
+ [...source.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]),
+ ),
+);
+for (const { file, source } of cssSources) {
+ for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) {
+ if (!definitions.has(match[1])) {
+ failures.push(`${file} uses undefined token ${match[1]}`);
+ }
+ }
+}
+
+const semanticSource = await readFile(tokenFiles.semantic, "utf8");
+const darkSource =
+ semanticSource.match(/:root\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/)?.[1] ??
+ "";
+for (const token of [
+ "--color-surface",
+ "--color-surface-muted",
+ "--color-surface-elevated",
+ "--color-content",
+ "--color-content-muted",
+ "--color-content-inverse",
+ "--color-border",
+ "--color-border-strong",
+ "--color-action",
+ "--color-action-hover",
+ "--color-action-pressed",
+ "--color-danger",
+ "--color-focus",
+ "--color-disabled-content",
+ "--color-disabled-surface",
+]) {
+ if (!darkSource.includes(`${token}:`)) {
+ failures.push(`dark theme token is missing: ${token}`);
+ }
+}
+const componentSource = await readFile(tokenFiles.component, "utf8");
+if (!componentSource.includes("@media (forced-colors: active)")) {
+ failures.push("forced-colors token fallback is missing");
+}
+
+/** @param {string} directory @returns {Promise} */
+async function listSourceFiles(directory) {
+ const result = [];
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const target = path.join(directory, entry.name);
+ if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
+ else if (/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) result.push(target);
+ }
+ return result;
+}
+
+const sources = fixtureMode
+ ? await listSourceFiles("tests/fixtures/design-system/forbidden")
+ : await listSourceFiles("src");
+for (const file of sources) {
+ const source = await readFile(file, "utf8");
+ const vendorFacade =
+ file === "src/presentation/design-system/icons/vendors/lucide.tsx";
+ if (!vendorFacade && /from\s+["']lucide-react["']/.test(source)) {
+ failures.push(`direct icon vendor import in ${file}`);
+ }
+ if (
+ /from\s+["'](?:react-aria-components|@radix-ui\/[^"']+)["']/.test(source)
+ ) {
+ failures.push(`direct headless vendor import in ${file}`);
+ }
+ if (
+ !file.includes("src/presentation/design-system/") &&
+ /presentation\/design-system\/(?!index(?:\.js)?["'])/.test(source)
+ ) {
+ failures.push(`design-system deep import in ${file}`);
+ }
+ if (
+ !file.includes("src/presentation/design-system/tokens/") &&
+ /(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source)
+ ) {
+ failures.push(`raw palette value in ${file}`);
+ }
+ if (
+ file.includes("tests/fixtures/design-system/forbidden") &&
+ source.includes("TOOLTIP_ONLY_REQUIRED_INFORMATION")
+ ) {
+ failures.push(`tooltip-only required information in ${file}`);
+ }
+}
+
+const report = {
+ schemaVersion: 1,
+ mode: fixtureMode ? "negative-fixture" : "source",
+ checkedTokenCount:
+ REQUIRED_PRIMITIVE_TOKENS.length +
+ REQUIRED_SEMANTIC_TOKENS.length +
+ REQUIRED_COMPONENT_TOKENS.length,
+ failures,
+ passed: failures.length === 0,
+};
+await mkdir("artifacts/quality", { recursive: true });
+await writeFile(
+ fixtureMode
+ ? "artifacts/quality/design-system-fixture.json"
+ : "artifacts/quality/design-system.json",
+ `${JSON.stringify(report, null, 2)}\n`,
+);
+
+if (failures.length > 0) {
+ process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
+ process.exit(1);
+}
+process.stdout.write(
+ `Design system contract: ${report.checkedTokenCount} tokens and vendor boundaries PASS\n`,
+);
diff --git a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx
index cedacca..9b62f3b 100644
--- a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx
+++ b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx
@@ -1,7 +1,9 @@
import { Link } from "react-router-dom";
-import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
-import { DetailPage } from "../../../presentation/templates/index.js";
+import {
+ AsyncSurface,
+ DetailPage,
+} from "../../../presentation/design-system/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useReferenceDetail } from "./use-reference-feature.js";
diff --git a/src/features/reference-feature/presentation/reference-resource-form-page.tsx b/src/features/reference-feature/presentation/reference-resource-form-page.tsx
index 8e90782..53334a8 100644
--- a/src/features/reference-feature/presentation/reference-resource-form-page.tsx
+++ b/src/features/reference-feature/presentation/reference-resource-form-page.tsx
@@ -1,17 +1,17 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
-import { Button } from "../../../presentation/components/ui/button.jsx";
import {
+ Button,
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
+ FormPage,
FormField,
useAppForm,
useDirtyNavigationGuard,
-} from "../../../presentation/forms/index.js";
-import { FormPage } from "../../../presentation/templates/index.js";
+} from "../../../presentation/design-system/index.js";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
diff --git a/src/features/reference-feature/presentation/reference-resource-page.tsx b/src/features/reference-feature/presentation/reference-resource-page.tsx
index f396df6..65ed671 100644
--- a/src/features/reference-feature/presentation/reference-resource-page.tsx
+++ b/src/features/reference-feature/presentation/reference-resource-page.tsx
@@ -1,8 +1,10 @@
import { Link, useNavigate } from "react-router-dom";
-import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
-import { Button } from "../../../presentation/components/ui/button.jsx";
-import { CollectionPage } from "../../../presentation/templates/index.js";
+import {
+ AsyncSurface,
+ Button,
+ CollectionPage,
+} from "../../../presentation/design-system/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
diff --git a/src/features/reference-feature/presentation/reference-resource-status-page.tsx b/src/features/reference-feature/presentation/reference-resource-status-page.tsx
index 4f748d6..1a6a61f 100644
--- a/src/features/reference-feature/presentation/reference-resource-status-page.tsx
+++ b/src/features/reference-feature/presentation/reference-resource-status-page.tsx
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
-import { StatusPage } from "../../../presentation/templates/index.js";
+import { StatusPage } from "../../../presentation/design-system/index.js";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
diff --git a/src/presentation/components/ui/alert.jsx b/src/presentation/components/ui/alert.jsx
index 26beed8..cdeda8b 100644
--- a/src/presentation/components/ui/alert.jsx
+++ b/src/presentation/components/ui/alert.jsx
@@ -1,36 +1 @@
-/**
- * @param {{
- * title: string,
- * children?: React.ReactNode,
- * variant?: "info" | "success" | "warning" | "danger",
- * onDismiss?: () => void
- * }} props
- */
-export function Alert({
- title,
- children,
- variant = "info",
- onDismiss,
-}) {
- return (
-
-
-
{title}
- {children ?
{children}
: null}
-
- {onDismiss ? (
-
- ) : null}
-
- );
-}
+export { Alert } from "../../design-system/primitives/core.js";
diff --git a/src/presentation/components/ui/badge.jsx b/src/presentation/components/ui/badge.jsx
index a4ae2ff..2ad826a 100644
--- a/src/presentation/components/ui/badge.jsx
+++ b/src/presentation/components/ui/badge.jsx
@@ -1,9 +1 @@
-/**
- * @param {{
- * children: React.ReactNode,
- * variant?: "neutral" | "info" | "success" | "warning" | "danger"
- * }} props
- */
-export function Badge({ children, variant = "neutral" }) {
- return {children};
-}
+export { Badge } from "../../design-system/primitives/core.js";
diff --git a/src/presentation/components/ui/button.jsx b/src/presentation/components/ui/button.jsx
index 7c7e312..6072cba 100644
--- a/src/presentation/components/ui/button.jsx
+++ b/src/presentation/components/ui/button.jsx
@@ -1,24 +1 @@
-/**
- * @param {React.ButtonHTMLAttributes & {
- * 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 ;
-}
+export { Button } from "../../design-system/primitives/core.js";
diff --git a/src/presentation/components/ui/card.jsx b/src/presentation/components/ui/card.jsx
index 1cf8ccf..8a98ab3 100644
--- a/src/presentation/components/ui/card.jsx
+++ b/src/presentation/components/ui/card.jsx
@@ -1,28 +1 @@
-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 (
-
-
-
{title}
- {description ?
{description}
: null}
-
- {children ? {children}
: null}
- {footer ? : null}
-
- );
-}
+export { Card } from "../../design-system/primitives/core.js";
diff --git a/src/presentation/components/ui/dialog.jsx b/src/presentation/components/ui/dialog.jsx
index 3bf0425..9f4d8ff 100644
--- a/src/presentation/components/ui/dialog.jsx
+++ b/src/presentation/components/ui/dialog.jsx
@@ -1,88 +1 @@
-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 (
-
- );
-}
+export { Dialog } from "../../design-system/primitives/core.js";
diff --git a/src/presentation/components/ui/text-field.jsx b/src/presentation/components/ui/text-field.jsx
index 4ffb5b6..b29b3ed 100644
--- a/src/presentation/components/ui/text-field.jsx
+++ b/src/presentation/components/ui/text-field.jsx
@@ -1,52 +1 @@
-import { useId } from "react";
-
-/**
- * @param {Omit, "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 (
-
-
- {description ? (
-
- {description}
-
- ) : null}
-
- {error ? (
-
- {error}
-
- ) : null}
-
- );
-}
+export { TextField } from "../../design-system/primitives/core.js";
diff --git a/src/presentation/design-system/icons/semantic-icons.tsx b/src/presentation/design-system/icons/semantic-icons.tsx
new file mode 100644
index 0000000..158bbbc
--- /dev/null
+++ b/src/presentation/design-system/icons/semantic-icons.tsx
@@ -0,0 +1,47 @@
+import type { ComponentType, SVGProps } from "react";
+
+import {
+ CloseGlyph,
+ ErrorGlyph,
+ InfoGlyph,
+ MenuGlyph,
+ NextGlyph,
+ PreviousGlyph,
+ SearchGlyph,
+ SuccessGlyph,
+ WarningGlyph,
+} from "./vendors/lucide.js";
+
+export type SemanticIconProps = Readonly<{
+ label?: string;
+ size?: "small" | "medium" | "large";
+}>;
+
+function createSemanticIcon(
+ Glyph: ComponentType>,
+) {
+ return function SemanticIcon({
+ label,
+ size = "medium",
+ }: SemanticIconProps) {
+ return (
+
+ );
+ };
+}
+
+export const MenuIcon = createSemanticIcon(MenuGlyph);
+export const CloseIcon = createSemanticIcon(CloseGlyph);
+export const WarningIcon = createSemanticIcon(WarningGlyph);
+export const SuccessIcon = createSemanticIcon(SuccessGlyph);
+export const ErrorIcon = createSemanticIcon(ErrorGlyph);
+export const InfoIcon = createSemanticIcon(InfoGlyph);
+export const SearchIcon = createSemanticIcon(SearchGlyph);
+export const PreviousIcon = createSemanticIcon(PreviousGlyph);
+export const NextIcon = createSemanticIcon(NextGlyph);
diff --git a/src/presentation/design-system/icons/vendors/lucide.tsx b/src/presentation/design-system/icons/vendors/lucide.tsx
new file mode 100644
index 0000000..b098b94
--- /dev/null
+++ b/src/presentation/design-system/icons/vendors/lucide.tsx
@@ -0,0 +1,21 @@
+import {
+ AlertTriangle,
+ Check,
+ ChevronLeft,
+ ChevronRight,
+ CircleX,
+ Info,
+ Menu,
+ Search,
+ X,
+} from "lucide-react";
+
+export const MenuGlyph = Menu;
+export const CloseGlyph = X;
+export const WarningGlyph = AlertTriangle;
+export const SuccessGlyph = Check;
+export const ErrorGlyph = CircleX;
+export const InfoGlyph = Info;
+export const SearchGlyph = Search;
+export const PreviousGlyph = ChevronLeft;
+export const NextGlyph = ChevronRight;
diff --git a/src/presentation/design-system/index.ts b/src/presentation/design-system/index.ts
new file mode 100644
index 0000000..4508f35
--- /dev/null
+++ b/src/presentation/design-system/index.ts
@@ -0,0 +1,146 @@
+export {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Dialog,
+ Field,
+ FocusRing,
+ IconButton,
+ LinkButton,
+ Portal,
+ Spinner,
+ TextField,
+ VisuallyHidden,
+} from "./primitives/core.js";
+export type {
+ AlertProps,
+ BadgeProps,
+ ButtonProps,
+ ButtonSize,
+ ButtonVariant,
+ CardProps,
+ DialogProps,
+ IconButtonProps,
+ LinkButtonProps,
+ SpinnerProps,
+ TextFieldProps,
+} from "./primitives/core.js";
+export {
+ Checkbox,
+ RadioGroup,
+ SearchField,
+ Select,
+ Switch,
+ TextArea,
+} from "./primitives/forms.js";
+export type {
+ CheckboxProps,
+ RadioGroupProps,
+ RadioOption,
+ SearchFieldProps,
+ SelectOption,
+ SelectProps,
+ SwitchProps,
+ TextAreaProps,
+} from "./primitives/forms.js";
+export {
+ ProgressBar,
+ Separator,
+ Skeleton,
+} from "./primitives/feedback.js";
+export type {
+ ProgressBarProps,
+ SkeletonProps,
+} from "./primitives/feedback.js";
+export {
+ ConfirmationDialog,
+ Drawer,
+ Menu,
+ Popover,
+ ToastProvider,
+ Tooltip,
+ useToast,
+} from "./primitives/overlays.js";
+export type {
+ DrawerProps,
+ MenuItemDefinition,
+ MenuProps,
+ PopoverProps,
+ TooltipProps,
+} from "./primitives/overlays.js";
+export {
+ Breadcrumbs,
+ Pagination,
+ Tabs,
+} from "./primitives/navigation.js";
+export type {
+ BreadcrumbItem,
+ TabDefinition,
+} from "./primitives/navigation.js";
+export {
+ CloseIcon,
+ ErrorIcon,
+ InfoIcon,
+ MenuIcon,
+ NextIcon,
+ PreviousIcon,
+ SearchIcon,
+ SuccessIcon,
+ WarningIcon,
+} from "./icons/semantic-icons.js";
+export type {
+ SemanticIconProps,
+} from "./icons/semantic-icons.js";
+export {
+ DESIGN_TOKEN_CONTRACT,
+ REQUIRED_COMPONENT_TOKENS,
+ REQUIRED_PRIMITIVE_TOKENS,
+ REQUIRED_SEMANTIC_TOKENS,
+} from "./tokens/token-contract.js";
+export {
+ AccessSurface,
+ DataTable,
+ DisclosureGroup,
+ PaginationBar,
+ SearchFilterToolbar,
+} from "./patterns/common-patterns.js";
+export type {
+ AccessSurfaceProps,
+ DataTableColumn,
+ DisclosureDefinition,
+} from "./patterns/common-patterns.js";
+
+export {
+ AsyncSurface,
+ EmptySurface,
+ LoadingSurface,
+ TerminalErrorSurface,
+} from "../components/async-surface.jsx";
+export { PageHeader } from "../components/page-header.jsx";
+export {
+ AuthRequiredSurface,
+ ForbiddenSurface,
+ NotFoundSurface,
+} from "../components/state-surfaces.jsx";
+export {
+ DetailPage,
+ CollectionPage,
+ FormPage,
+ StandardPage,
+ StatusPage,
+} from "../templates/index.js";
+export {
+ DirtyNavigationDialog,
+ ErrorSummary,
+ Form,
+ FormActions,
+ FormField,
+ useAppForm,
+ useDirtyNavigationGuard,
+} from "../forms/index.js";
+export type {
+ PageActionDefinition,
+ PageHeading,
+} from "../templates/page-templates.js";
+export type { FormResult } from "../forms/form-contracts.js";
diff --git a/src/presentation/design-system/patterns/common-patterns.tsx b/src/presentation/design-system/patterns/common-patterns.tsx
new file mode 100644
index 0000000..3c15b45
--- /dev/null
+++ b/src/presentation/design-system/patterns/common-patterns.tsx
@@ -0,0 +1,139 @@
+import { useId } from "react";
+
+import {
+ AuthRequiredSurface,
+ ForbiddenSurface,
+ NotFoundSurface,
+} from "../../components/state-surfaces.jsx";
+import { Button } from "../primitives/core.js";
+import { Pagination } from "../primitives/navigation.js";
+
+export type DataTableColumn = Readonly<{
+ id: string;
+ header: string;
+ cell(row: Row): React.ReactNode;
+}>;
+
+export function DataTable({
+ caption,
+ columns,
+ rows,
+ rowKey,
+ empty,
+}: Readonly<{
+ caption: string;
+ columns: readonly DataTableColumn[];
+ rows: readonly Row[];
+ rowKey(row: Row): string;
+ empty: React.ReactNode;
+}>) {
+ if (rows.length === 0) return <>{empty}>;
+ return (
+
+
+ {caption}
+
+
+ {columns.map((column) => (
+ |
+ {column.header}
+ |
+ ))}
+
+
+
+ {rows.map((row) => (
+
+ {columns.map((column) => (
+ | {column.cell(row)} |
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+export function SearchFilterToolbar({
+ label,
+ search,
+ filters,
+ resetLabel,
+ onReset,
+ resultCount,
+}: Readonly<{
+ label: string;
+ search: React.ReactNode;
+ filters?: React.ReactNode;
+ resetLabel: string;
+ onReset(): void;
+ resultCount: React.ReactNode;
+}>) {
+ return (
+
+ {search}
+ {filters ? {filters}
: null}
+
+
+
+ );
+}
+
+export function PaginationBar({
+ range,
+ ...pagination
+}: React.ComponentProps & Readonly<{ range: string }>) {
+ return (
+
+ );
+}
+
+export type DisclosureDefinition = Readonly<{
+ id: string;
+ title: string;
+ content: React.ReactNode;
+}>;
+
+export function DisclosureGroup({
+ label,
+ items,
+}: Readonly<{
+ label: string;
+ items: readonly DisclosureDefinition[];
+}>) {
+ const groupId = useId();
+ return (
+
+
+ {label}
+
+ {items.map((item) => (
+
+ {item.title}
+ {item.content}
+
+ ))}
+
+ );
+}
+
+export type AccessSurfaceProps =
+ | Readonly<{ kind: "auth-required"; onAction(): void }>
+ | Readonly<{ kind: "forbidden"; onAction(): void }>
+ | Readonly<{ kind: "not-found"; onAction(): void }>;
+
+export function AccessSurface(props: AccessSurfaceProps) {
+ if (props.kind === "auth-required") {
+ return ;
+ }
+ if (props.kind === "forbidden") {
+ return ;
+ }
+ return ;
+}
diff --git a/src/presentation/design-system/primitives/core.tsx b/src/presentation/design-system/primitives/core.tsx
new file mode 100644
index 0000000..3c0557c
--- /dev/null
+++ b/src/presentation/design-system/primitives/core.tsx
@@ -0,0 +1,446 @@
+import {
+ forwardRef,
+ useEffect,
+ useId,
+ useImperativeHandle,
+ useRef,
+} from "react";
+import { createPortal } from "react-dom";
+
+import { CloseIcon } from "../icons/semantic-icons.js";
+
+export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
+export type ButtonSize = "default" | "compact";
+
+export type ButtonProps = React.ButtonHTMLAttributes &
+ Readonly<{
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ pending?: boolean;
+ pendingLabel?: string;
+ }>;
+
+export const Button = forwardRef(
+ function Button(
+ {
+ variant = "primary",
+ size = "default",
+ pending = false,
+ pendingLabel,
+ className = "",
+ type = "button",
+ children,
+ disabled,
+ ...props
+ },
+ ref,
+ ) {
+ const classes = [
+ "ui-button",
+ `ui-button--${variant}`,
+ size === "compact" ? "ui-button--compact" : "",
+ pending ? "ui-button--pending" : "",
+ className,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+
+ );
+ },
+);
+
+export type LinkButtonProps = React.AnchorHTMLAttributes &
+ Readonly<{
+ href: string;
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ }>;
+
+export const LinkButton = forwardRef(
+ function LinkButton(
+ {
+ href,
+ variant = "primary",
+ size = "default",
+ className = "",
+ ...props
+ },
+ ref,
+ ) {
+ return (
+
+ );
+ },
+);
+
+export type IconButtonProps = Omit &
+ Readonly<{
+ accessibleName: string;
+ children: React.ReactElement;
+ }>;
+
+export const IconButton = forwardRef(
+ function IconButton({ accessibleName, className = "", ...props }, ref) {
+ return (
+
+ );
+ },
+);
+
+type FieldFrameProps = Readonly<{
+ id?: string;
+ label: string;
+ description?: string;
+ error?: string;
+ required?: boolean;
+ className?: string;
+ children(
+ contract: Readonly<{
+ controlId: string;
+ describedBy: string | undefined;
+ invalid: boolean;
+ }>,
+ ): React.ReactNode;
+}>;
+
+export function Field({
+ id,
+ label,
+ description,
+ error,
+ required,
+ className = "",
+ children,
+}: FieldFrameProps) {
+ const generatedId = useId();
+ const controlId = id ?? `field-${generatedId}`;
+ const descriptionId = description ? `${controlId}-description` : undefined;
+ const errorId = error ? `${controlId}-error` : undefined;
+ const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
+
+ return (
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {children({
+ controlId,
+ describedBy: describedBy || undefined,
+ invalid: Boolean(error),
+ })}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+export type TextFieldProps = Omit<
+ React.InputHTMLAttributes,
+ "id"
+> &
+ Readonly<{
+ id?: string;
+ label: string;
+ description?: string;
+ error?: string;
+ }>;
+
+export const TextField = forwardRef(
+ function TextField(
+ {
+ id,
+ label,
+ description,
+ error,
+ className = "",
+ required,
+ ...inputProps
+ },
+ ref,
+ ) {
+ return (
+
+ {({ controlId, describedBy, invalid }) => (
+
+ )}
+
+ );
+ },
+);
+
+export type CardProps = Readonly<{
+ title: string;
+ headingLevel?: 2 | 3 | 4;
+ description?: string;
+ children?: React.ReactNode;
+ footer?: React.ReactNode;
+ className?: string;
+}>;
+
+export function Card({
+ title,
+ headingLevel = 3,
+ description,
+ children,
+ footer,
+ className = "",
+}: CardProps) {
+ const titleId = useId();
+ const Heading = `h${headingLevel}` as "h2" | "h3" | "h4";
+
+ return (
+
+
+
{title}
+ {description ?
{description}
: null}
+
+ {children ? {children}
: null}
+ {footer ? : null}
+
+ );
+}
+
+export type AlertProps = Readonly<{
+ title: string;
+ children?: React.ReactNode;
+ variant?: "info" | "success" | "warning" | "danger";
+ dismissLabel?: string;
+ onDismiss?: () => void;
+}>;
+
+export function Alert({
+ title,
+ children,
+ variant = "info",
+ dismissLabel,
+ onDismiss,
+}: AlertProps) {
+ return (
+
+
+
{title}
+ {children ?
{children}
: null}
+
+ {onDismiss ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export type BadgeProps = Readonly<{
+ children: React.ReactNode;
+ variant?: "neutral" | "info" | "success" | "warning" | "danger";
+}>;
+
+export function Badge({ children, variant = "neutral" }: BadgeProps) {
+ return {children};
+}
+
+export type DialogProps = Readonly<{
+ open: boolean;
+ onClose(): void;
+ title: string;
+ description?: string;
+ closeLabel?: string;
+ children?: React.ReactNode;
+ actions?: React.ReactNode;
+ className?: string;
+}>;
+
+export const Dialog = forwardRef(
+ function Dialog(
+ {
+ open,
+ onClose,
+ title,
+ description,
+ closeLabel,
+ children,
+ actions,
+ className = "",
+ },
+ forwardedRef,
+ ) {
+ const dialogRef = useRef(null);
+ const previousFocusRef = useRef(null);
+ const titleId = useId();
+ const descriptionId = useId();
+ useImperativeHandle(forwardedRef, () => dialogRef.current!, []);
+
+ 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'])",
+ );
+ 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 (
+
+ );
+ },
+);
+
+export type SpinnerProps =
+ | Readonly<{ decorative: true; label?: never }>
+ | Readonly<{ decorative?: false; label: string }>;
+
+export function Spinner(props: SpinnerProps) {
+ const accessibility = props.decorative
+ ? { "aria-hidden": true as const }
+ : { "aria-label": props.label, role: "status" };
+ return ;
+}
+
+export function VisuallyHidden({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return {children};
+}
+
+export function Portal({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ if (typeof document === "undefined") return <>{children}>;
+ return createPortal(children, document.body);
+}
+
+export function FocusRing({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return {children};
+}
diff --git a/src/presentation/design-system/primitives/feedback.tsx b/src/presentation/design-system/primitives/feedback.tsx
new file mode 100644
index 0000000..0e12652
--- /dev/null
+++ b/src/presentation/design-system/primitives/feedback.tsx
@@ -0,0 +1,55 @@
+export type ProgressBarProps = Readonly<{
+ label: string;
+ value?: number;
+ max?: number;
+}>;
+
+export function ProgressBar({ label, value, max = 100 }: ProgressBarProps) {
+ const determinate = typeof value === "number";
+ return (
+
+
+ {label}
+ {determinate ? {Math.round((value / max) * 100)}% : null}
+
+
+
+ );
+}
+
+export type SkeletonProps = Readonly<{
+ label?: string;
+ height?: "text" | "control" | "surface";
+}>;
+
+export function Skeleton({
+ label,
+ height = "surface",
+}: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function Separator({
+ label,
+ decorative = true,
+}: Readonly<{ label?: string; decorative?: boolean }>) {
+ return (
+
+ );
+}
diff --git a/src/presentation/design-system/primitives/forms.tsx b/src/presentation/design-system/primitives/forms.tsx
new file mode 100644
index 0000000..a062dd4
--- /dev/null
+++ b/src/presentation/design-system/primitives/forms.tsx
@@ -0,0 +1,409 @@
+import {
+ forwardRef,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from "react";
+
+import { CloseIcon, SearchIcon } from "../icons/semantic-icons.js";
+import { Field, IconButton } from "./core.js";
+import type { TextFieldProps } from "./core.js";
+
+type FieldCopy = Readonly<{
+ id?: string;
+ label: string;
+ description?: string;
+ error?: string;
+}>;
+
+export type TextAreaProps = Omit<
+ React.TextareaHTMLAttributes,
+ "id"
+> &
+ FieldCopy &
+ Readonly<{ maxLengthMessage?: (remaining: number) => string }>;
+
+export const TextArea = forwardRef(
+ function TextArea(
+ {
+ id,
+ label,
+ description,
+ error,
+ required,
+ className = "",
+ maxLength,
+ maxLengthMessage,
+ value,
+ defaultValue,
+ ...props
+ },
+ ref,
+ ) {
+ const [uncontrolledValue, setUncontrolledValue] = useState(
+ String(defaultValue ?? ""),
+ );
+ const currentValue =
+ value === undefined ? uncontrolledValue : String(value ?? "");
+ const remaining =
+ typeof maxLength === "number" ? maxLength - currentValue.length : null;
+
+ return (
+
+ {({ controlId, describedBy, invalid }) => (
+ <>
+
+ );
+ },
+);
+
+export type SelectOption = Readonly<{
+ value: string;
+ label: string;
+ disabled?: boolean;
+}>;
+
+export type SelectProps = Omit<
+ React.SelectHTMLAttributes,
+ "id" | "children"
+> &
+ FieldCopy &
+ Readonly<{
+ options: readonly SelectOption[];
+ placeholder?: string;
+ }>;
+
+export const Select = forwardRef(
+ function Select(
+ {
+ id,
+ label,
+ description,
+ error,
+ options,
+ placeholder,
+ required,
+ className = "",
+ ...props
+ },
+ ref,
+ ) {
+ return (
+
+ {({ controlId, describedBy, invalid }) => (
+
+ )}
+
+ );
+ },
+);
+
+export type CheckboxProps = Omit<
+ React.InputHTMLAttributes,
+ "type"
+> &
+ Readonly<{
+ label: string;
+ description?: string;
+ error?: string;
+ indeterminate?: boolean;
+ }>;
+
+export const Checkbox = forwardRef(
+ function Checkbox(
+ { label, description, error, indeterminate = false, id, ...props },
+ forwardedRef,
+ ) {
+ const generatedId = useId();
+ const controlId = id ?? `checkbox-${generatedId}`;
+ const inputRef = useRef(null);
+ const descriptionId = description ? `${controlId}-description` : undefined;
+ const errorId = error ? `${controlId}-error` : undefined;
+ useEffect(() => {
+ if (inputRef.current) inputRef.current.indeterminate = indeterminate;
+ }, [indeterminate]);
+
+ return (
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+ },
+);
+
+export type RadioOption = Readonly<{
+ value: string;
+ label: string;
+ description?: string;
+ disabled?: boolean;
+}>;
+
+export type RadioGroupProps = Readonly<{
+ name: string;
+ label: string;
+ options: readonly RadioOption[];
+ value?: string;
+ defaultValue?: string;
+ onChange?(value: string): void;
+ disabled?: boolean;
+ error?: string;
+}>;
+
+export function RadioGroup({
+ name,
+ label,
+ options,
+ value,
+ defaultValue,
+ onChange,
+ disabled,
+ error,
+}: RadioGroupProps) {
+ const [internalValue, setInternalValue] = useState(defaultValue ?? "");
+ const selected = value ?? internalValue;
+ const errorId = useId();
+ const refs = useRef>([]);
+
+ function choose(nextValue: string) {
+ if (value === undefined) setInternalValue(nextValue);
+ onChange?.(nextValue);
+ }
+
+ return (
+
+ );
+}
+
+export type SwitchProps = Readonly<{
+ label: string;
+ checked: boolean;
+ onChange(checked: boolean): void;
+ disabled?: boolean;
+ description?: string;
+}>;
+
+export function Switch({
+ label,
+ checked,
+ onChange,
+ disabled,
+ description,
+}: SwitchProps) {
+ const descriptionId = useId();
+ return (
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ );
+}
+
+export type SearchFieldProps = Omit &
+ Readonly<{
+ clearLabel: string;
+ onClear(): void;
+ }>;
+
+export const SearchField = forwardRef(
+ function SearchField(
+ {
+ clearLabel,
+ onClear,
+ value,
+ className = "",
+ id,
+ label,
+ description,
+ error,
+ required,
+ ...inputProps
+ },
+ ref,
+ ) {
+ return (
+
+
+
+ {({ controlId, describedBy, invalid }) => (
+
+ )}
+
+ {String(value ?? "").length > 0 ? (
+
+
+
+ ) : null}
+
+ );
+ },
+);
diff --git a/src/presentation/design-system/primitives/navigation.tsx b/src/presentation/design-system/primitives/navigation.tsx
new file mode 100644
index 0000000..cce4965
--- /dev/null
+++ b/src/presentation/design-system/primitives/navigation.tsx
@@ -0,0 +1,193 @@
+import { useId, useRef, useState } from "react";
+
+import { NextIcon, PreviousIcon } from "../icons/semantic-icons.js";
+import { IconButton, LinkButton } from "./core.js";
+
+export type BreadcrumbItem = Readonly<{
+ label: string;
+ href?: string;
+}>;
+
+export function Breadcrumbs({
+ label,
+ items,
+}: Readonly<{ label: string; items: readonly BreadcrumbItem[] }>) {
+ return (
+
+ );
+}
+
+export type TabDefinition = Readonly<{
+ id: string;
+ label: string;
+ panel: React.ReactNode;
+ disabled?: boolean;
+}>;
+
+export function Tabs({
+ label,
+ tabs,
+ value,
+ defaultValue,
+ activation = "automatic",
+ onChange,
+}: Readonly<{
+ label: string;
+ tabs: readonly TabDefinition[];
+ value?: string;
+ defaultValue?: string;
+ activation?: "automatic" | "manual";
+ onChange?(id: string): void;
+}>) {
+ const fallback = tabs.find((tab) => !tab.disabled)?.id ?? "";
+ const [internalValue, setInternalValue] = useState(defaultValue ?? fallback);
+ const [focusValue, setFocusValue] = useState(value ?? internalValue);
+ const selected = value ?? internalValue;
+ const tabRefs = useRef>([]);
+ const baseId = useId();
+
+ function select(id: string) {
+ if (value === undefined) setInternalValue(id);
+ onChange?.(id);
+ }
+
+ function move(currentIndex: number, direction: 1 | -1) {
+ let next = currentIndex;
+ do {
+ next = (next + direction + tabs.length) % tabs.length;
+ } while (tabs[next]?.disabled && next !== currentIndex);
+ const nextTab = tabs[next];
+ if (!nextTab || nextTab.disabled) return;
+ setFocusValue(nextTab.id);
+ if (activation === "automatic") select(nextTab.id);
+ tabRefs.current[next]?.focus();
+ }
+
+ return (
+
+
+ {tabs.map((tab, index) => (
+
+ ))}
+
+ {tabs.map((tab) => (
+
+ {tab.panel}
+
+ ))}
+
+ );
+}
+
+export function Pagination({
+ label,
+ page,
+ pageCount,
+ previousLabel,
+ nextLabel,
+ pageLabel,
+ onChange,
+}: Readonly<{
+ label: string;
+ page: number;
+ pageCount: number;
+ previousLabel: string;
+ nextLabel: string;
+ pageLabel(page: number): string;
+ onChange(page: number): void;
+}>) {
+ const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
+ return (
+
+ );
+}
diff --git a/src/presentation/design-system/primitives/overlays.tsx b/src/presentation/design-system/primitives/overlays.tsx
new file mode 100644
index 0000000..b3db88b
--- /dev/null
+++ b/src/presentation/design-system/primitives/overlays.tsx
@@ -0,0 +1,441 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { CloseIcon } from "../icons/semantic-icons.js";
+import { Button, Dialog, IconButton } from "./core.js";
+
+export type DrawerProps = Readonly<{
+ open: boolean;
+ onClose(): void;
+ title: string;
+ closeLabel?: string;
+ placement?: "start" | "end";
+ children: React.ReactNode;
+}>;
+
+export function Drawer({
+ open,
+ onClose,
+ title,
+ closeLabel,
+ placement = "start",
+ children,
+}: DrawerProps) {
+ return (
+
+ );
+}
+
+export type PopoverProps = Readonly<{
+ triggerLabel: string;
+ children: React.ReactNode;
+}>;
+
+export function Popover({ triggerLabel, children }: PopoverProps) {
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+ const triggerRef = useRef(null);
+ const contentId = useId();
+
+ useEffect(() => {
+ if (!open) return;
+ function closeOutside(event: PointerEvent) {
+ if (
+ event.target instanceof Node &&
+ !rootRef.current?.contains(event.target)
+ ) {
+ setOpen(false);
+ triggerRef.current?.focus();
+ }
+ }
+ function closeOnEscape(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ setOpen(false);
+ triggerRef.current?.focus();
+ }
+ }
+ document.addEventListener("pointerdown", closeOutside);
+ document.addEventListener("keydown", closeOnEscape);
+ return () => {
+ document.removeEventListener("pointerdown", closeOutside);
+ document.removeEventListener("keydown", closeOnEscape);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open ? (
+
+ {children}
+
+ ) : null}
+
+ );
+}
+
+export type TooltipProps = Readonly<{
+ content: string;
+ children: React.ReactElement;
+}>;
+
+export function Tooltip({ content, children }: TooltipProps) {
+ const tooltipId = useId();
+ return (
+
+
+ {children}
+
+
+ {content}
+
+
+ );
+}
+
+export type MenuItemDefinition = Readonly<{
+ id: string;
+ label: string;
+ disabled?: boolean;
+ onSelect(): void;
+}>;
+
+export type MenuProps = Readonly<{
+ triggerLabel: string;
+ items: readonly MenuItemDefinition[];
+}>;
+
+export function Menu({ triggerLabel, items }: MenuProps) {
+ const [open, setOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const triggerRef = useRef(null);
+ const rootRef = useRef(null);
+ const itemRefs = useRef>([]);
+ const searchRef = useRef("");
+ const resetSearchRef = useRef(undefined);
+
+ const enabledIndexes = useMemo(
+ () => items.flatMap((item, index) => (item.disabled ? [] : [index])),
+ [items],
+ );
+
+ const focusIndex = useCallback(
+ (index: number) => {
+ setActiveIndex(index);
+ queueMicrotask(() => itemRefs.current[index]?.focus());
+ },
+ [],
+ );
+
+ function openAt(index: number) {
+ setOpen(true);
+ focusIndex(index);
+ }
+
+ function dismiss() {
+ setOpen(false);
+ queueMicrotask(() => triggerRef.current?.focus());
+ }
+
+ function move(direction: 1 | -1) {
+ const currentPosition = Math.max(enabledIndexes.indexOf(activeIndex), 0);
+ const nextPosition =
+ (currentPosition + direction + enabledIndexes.length) %
+ enabledIndexes.length;
+ const nextIndex = enabledIndexes[nextPosition];
+ if (nextIndex !== undefined) focusIndex(nextIndex);
+ }
+
+ useEffect(() => {
+ if (!open) return;
+ function closeOutside(event: PointerEvent) {
+ if (
+ event.target instanceof Node &&
+ !rootRef.current?.contains(event.target)
+ ) {
+ setOpen(false);
+ queueMicrotask(() => triggerRef.current?.focus());
+ }
+ }
+ document.addEventListener("pointerdown", closeOutside);
+ return () => document.removeEventListener("pointerdown", closeOutside);
+ }, [open]);
+
+ return (
+
+
+ {open ? (
+
{
+ if (event.key === "Escape") {
+ event.preventDefault();
+ dismiss();
+ } else if (event.key === "ArrowDown") {
+ event.preventDefault();
+ move(1);
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ move(-1);
+ } else if (event.key === "Home") {
+ event.preventDefault();
+ focusIndex(enabledIndexes[0] ?? 0);
+ } else if (event.key === "End") {
+ event.preventDefault();
+ focusIndex(enabledIndexes.at(-1) ?? 0);
+ } else if (
+ event.key.length === 1 &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey
+ ) {
+ searchRef.current += event.key.toLocaleLowerCase();
+ window.clearTimeout(resetSearchRef.current);
+ resetSearchRef.current = window.setTimeout(() => {
+ searchRef.current = "";
+ }, 500);
+ const match = items.findIndex(
+ (item) =>
+ !item.disabled &&
+ item.label
+ .toLocaleLowerCase()
+ .startsWith(searchRef.current),
+ );
+ if (match >= 0) focusIndex(match);
+ }
+ }}
+ role="menu"
+ >
+ {items.map((item, index) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+type ToastTone = "info" | "success" | "warning" | "danger";
+type ToastInput = Readonly<{
+ id: string;
+ title: string;
+ description?: string;
+ tone?: ToastTone;
+ durationMs?: number;
+}>;
+type ToastEntry = ToastInput & Readonly<{ count: number }>;
+type ToastContextValue = Readonly<{
+ push(toast: ToastInput): void;
+ dismiss(id: string): void;
+}>;
+
+const ToastContext = createContext(null);
+
+export function ToastProvider({
+ children,
+ limit = 3,
+}: Readonly<{ children: React.ReactNode; limit?: number }>) {
+ const [toasts, setToasts] = useState([]);
+
+ const dismiss = useCallback((id: string) => {
+ setToasts((current) => current.filter((toast) => toast.id !== id));
+ }, []);
+
+ const push = useCallback(
+ (toast: ToastInput) => {
+ setToasts((current) => {
+ const existing = current.find((entry) => entry.id === toast.id);
+ if (existing) {
+ return current.map((entry) =>
+ entry.id === toast.id
+ ? { ...entry, ...toast, count: entry.count + 1 }
+ : entry,
+ );
+ }
+ return [...current, { ...toast, count: 1 }].slice(-limit);
+ });
+ },
+ [limit],
+ );
+
+ const value = useMemo(() => ({ push, dismiss }), [dismiss, push]);
+ return (
+
+ {children}
+
+
+ );
+}
+
+export function useToast() {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error("ToastProvider is required.");
+ }
+ return context;
+}
+
+function ToastRegion({
+ toasts,
+ dismiss,
+}: Readonly<{
+ toasts: readonly ToastEntry[];
+ dismiss(id: string): void;
+}>) {
+ return (
+
+ {toasts.map((toast) => (
+
+ ))}
+
+ );
+}
+
+function ToastItem({
+ toast,
+ dismiss,
+}: Readonly<{
+ toast: ToastEntry;
+ dismiss(id: string): void;
+}>) {
+ const [paused, setPaused] = useState(false);
+ useEffect(() => {
+ if (paused) return;
+ const timeout = window.setTimeout(
+ () => dismiss(toast.id),
+ toast.durationMs ?? 5000,
+ );
+ return () => window.clearTimeout(timeout);
+ }, [dismiss, paused, toast.durationMs, toast.id, toast.count]);
+
+ return (
+ {
+ if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false);
+ }}
+ onFocus={() => setPaused(true)}
+ onMouseEnter={() => setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ >
+
+
{toast.title}
+ {toast.count > 1 ? (
+
×{toast.count}
+ ) : null}
+ {toast.description ?
{toast.description}
: null}
+
+ dismiss(toast.id)}
+ variant="ghost"
+ >
+
+
+
+ );
+}
+
+export function ConfirmationDialog({
+ open,
+ title,
+ description,
+ confirmLabel,
+ cancelLabel,
+ danger = false,
+ onConfirm,
+ onCancel,
+}: Readonly<{
+ open: boolean;
+ title: string;
+ description: string;
+ confirmLabel: string;
+ cancelLabel: string;
+ danger?: boolean;
+ onConfirm(): void;
+ onCancel(): void;
+}>) {
+ return (
+
+
+
+ 원시 값에서 의미와 컴포넌트 토큰을 파생합니다.,
+ },
+ {
+ id: "primitives",
+ label: "프리미티브",
+ panel: native semantics와 interaction을 닫습니다.
,
+ },
+ {
+ id: "patterns",
+ label: "패턴",
+ panel: 반복되는 사용자 문제를 조합으로 해결합니다.
,
+ },
+ ]}
+ />
+
+