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 - - {children ?
{children}
: null} - {actions ?
{actions}
: null} - - - ); -} +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 ( +
+ + + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
{caption}
+ {column.header} +
{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} + + {resultCount} +
+ ); +} + +export function PaginationBar({ + range, + ...pagination +}: React.ComponentProps & Readonly<{ range: string }>) { + return ( +
+

{range}

+ +
+ ); +} + +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 ( +