From a090c5cc2be95134fcd520f833f377a59b135cbf Mon Sep 17 00:00:00 2001 From: willxue Date: Wed, 22 Apr 2026 06:29:57 +0800 Subject: [PATCH 1/5] docs: add design system v1 plan Complete landing plan for WeMail Web design system v1.0 covering: - Token foundation (color/space/radius/shadow/font/motion/z-index/focus/chart) - 20 primitive components (15 new + 2 extended + 3 unchanged) - Cross-cutting infrastructure (icon wrapper, a11y contract, test utils, reduced-motion, visual regression) - 7-sprint implementation order with acceptance checklist - Risk register and open decisions --- docs/plans/2026-04-22-design-system-v1.md | 428 ++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/plans/2026-04-22-design-system-v1.md diff --git a/docs/plans/2026-04-22-design-system-v1.md b/docs/plans/2026-04-22-design-system-v1.md new file mode 100644 index 0000000..fc72eae --- /dev/null +++ b/docs/plans/2026-04-22-design-system-v1.md @@ -0,0 +1,428 @@ +# WeMail Web 端 UI 设计系统 v1.0 落地方案 + +- 日期:2026-04-22 +- 分支:`feature/design-system-v1` +- 范围:仅 Web 端(`apps/web`),不含移动端与邮件模板 +- 目标:把分散在各 feature 的视觉/交互规范收敛为一套可复用、可测试、可演进的 design token + 原语组件库,并提供一个可导航的预览页 + +--- + +## 1. 背景与目标 + +- 参考图列出了 13 个模块(色彩、布局、卡片、按钮、输入、标签、导航、开关、复选框、单选、进度、提示、阴影、圆角、间距) +- 现有仓库已有 `shared/{button,form,table,switch,chart,overlay}` 原语与一套 CSS 变量,但缺 Tag、Badge、Breadcrumb、Pagination、Tabs、Steps、Progress、Alert、Card、Typography 等常用原语 +- 新功能 / 重构页面时,团队频繁复制粘贴样式片段,偏差累积 +- **本方案目标**:一次性补齐缺口,保持对现有代码的零破坏(通过别名 / 渐进迁移),并让后续页面"只用原语、不写业务 CSS" + +### 非目标(本期不做) +- 移动端适配、邮件 HTML 模板、打印样式 +- 从 lucide-react 迁移到自有图标库 +- Storybook / Ladle 预览工具(用自建 `/_design` 路由替代) +- i18n 接入(只做"提供可覆盖 prop",不拉 i18n 库) + +--- + +## 2. 现状盘点 + +| 参考图模块 | 现有 | 缺口 | +|---|---|---| +| 色彩 | `--accent`、`--success-soft`、`--warning-soft`、中性 `--text/--text-muted/--text-soft` | 缺 `--success/--warning/--danger/--info` 主色梯度;缺中性灰 9 阶;缺品牌 9 阶 | +| 圆角 | `--radius-shell/card/pill/field` | 缺系统化的 xs/sm/md/lg/xl 命名 | +| 阴影 | `--shadow-soft/card/border` | 缺 xs/sm/md/lg/xl 梯度 | +| 间距 | 无 token,散在各 css | 缺 4px 网格完整 `--space-*` | +| 字号 | 浏览器默认 + 局部 clamp | 缺 display/title/body/caption 语义 token | +| Button | `Button / ButtonLink / ButtonAnchor`(7 变体) | 需新增 xs icon-only 变体;可选新增 subtle 变体 | +| 输入 | `TextInput / SelectInput / TextareaInput / FormField / CheckboxField / RadioGroupField` | 缺 `SearchInput`、独立 `Checkbox`/`Radio`、`MultiSelect` | +| 表格 | 已有 `TableContainer / Table / ...` | OK | +| Switch | 已有 | OK | +| 卡片 | 只有 `.panel` 全局类 + 业务 css | 需 `Card / CardHeader / CardBody / CardFooter` | +| 标签与状态 | 各页 inline chip | 需 `Tag`(分类)+ `Badge`(状态)两原语 | +| 导航 | 局部 `workspace-pill-nav`、面包屑散落 | 需 `Breadcrumb / Pagination / Tabs / Steps` | +| 进度 | 无 | `Progress` | +| 提示 | `WemailToast`(飞出)/ 无静态 | `Alert`(页内静态) | +| Overlay | `shared/overlay` 已有 | 需核对 Modal/Drawer/Dialog 一致 | +| 排版 | `

`+ className | 需 `Heading / Text / Label / Muted` 排版原语 | + +--- + +## 3. Design Tokens(新建 `apps/web/src/shared/styles/tokens.css`) + +### 3.1 颜色 + +```css +:root, +:root[data-theme="light"] { + /* 品牌 — 保留 --accent=#ff7a00 作为 --brand-500 */ + --brand-50: #fff2e6; + --brand-100: #ffe0c2; + --brand-200: #ffc78a; + --brand-300: #ffa74d; + --brand-400: #ff8b26; + --brand-500: #ff7a00; + --brand-600: #e66d00; + --brand-700: #b35400; + + /* 辅助橙 */ + --brand-soft-500: #ffb366; + + /* 语义色 */ + --success-500: #22c55e; --success-100: #d1fadf; --success-600: #16a34a; --success-soft: rgba(34,197,94,.14); + --warning-500: #f59e0b; --warning-100: #fef0c7; --warning-600: #d97706; --warning-soft: rgba(245,158,11,.14); + --danger-500: #ef4444; --danger-100: #fee2e2; --danger-600: #dc2626; --danger-soft: rgba(239,68,68,.14); + --info-500: #3b82f6; --info-100: #dbeafe; --info-600: #2563eb; --info-soft: rgba(59,130,246,.14); + + /* 中性 9 阶 */ + --neutral-0: #ffffff; + --neutral-50: #f9fafb; + --neutral-100:#f3f4f6; + --neutral-200:#e5e7eb; + --neutral-300:#d1d5db; + --neutral-400:#9ca3af; + --neutral-500:#6b7280; /* 图中"中性色" */ + --neutral-700:#374151; + --neutral-900:#111827; + + /* 兼容别名 — 保证现有 CSS 零回归 */ + --accent: var(--brand-500); + --accent-strong: var(--brand-400); + --accent-soft: color-mix(in srgb, var(--brand-500) 14%, transparent); +} + +:root[data-theme="dark"] { /* 同名重定义,保持语义色跨主题稳定 */ } +``` + +### 3.2 间距(4px 网格) +```css +--space-1:4; --space-2:8; --space-3:12; --space-4:16; +--space-5:20; --space-6:24; --space-8:32; --space-10:40; +--space-12:48;--space-14:56;--space-16:64;--space-18:72; +--space-20:80;--space-24:96;--space-28:112;--space-32:128; +``` + +### 3.3 圆角 +```css +--radius-xs:4px; --radius-sm:8px; --radius-md:12px; +--radius-lg:16px; --radius-xl:24px; --radius-full:9999px; +/* 现有别名重定向 */ +--radius-field: var(--radius-lg); +--radius-card: var(--radius-xl); +--radius-pill: var(--radius-full); +``` + +### 3.4 阴影 +```css +--elevation-xs: 0 1px 2px rgba(17,17,17,.04); +--elevation-sm: 0 2px 6px rgba(17,17,17,.06), 0 1px 2px rgba(17,17,17,.04); +--elevation-md: 0 6px 16px rgba(17,17,17,.08), 0 2px 4px rgba(17,17,17,.04); +--elevation-lg: 0 14px 32px rgba(17,17,17,.10), 0 4px 8px rgba(17,17,17,.04); +--elevation-xl: 0 24px 64px rgba(17,17,17,.14), 0 8px 16px rgba(17,17,17,.06); +``` + +### 3.5 字号 / 行高语义 +```css +--font-display-lg: 48px/1.1; +--font-display-md: 32px/1.2; +--font-title-lg: 24px/1.3; +--font-title-md: 20px/1.35; +--font-body-lg: 16px/1.55; +--font-body-md: 14px/1.55; +--font-caption: 12px/1.45; +--font-mono: 13px/1.6; +``` + +### 3.6 Motion tokens(新增) +```css +--duration-instant: 80ms; +--duration-fast: 150ms; +--duration-base: 200ms; +--duration-slow: 320ms; +--duration-slower: 480ms; +--ease-standard: cubic-bezier(0.25, 1, 0.5, 1); +--ease-emphasized: cubic-bezier(0.2, 1, 0.3, 1); +--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); +``` + +### 3.7 Z-index 分层(新增,收敛各处散落的 z 值) +```css +--z-base: 0; +--z-raised: 1; +--z-dropdown: 10; +--z-sticky: 20; +--z-fixed: 30; +--z-overlay: 40; +--z-modal: 50; +--z-popover: 60; +--z-toast: 70; +--z-tooltip: 80; +``` + +### 3.8 Focus ring(新增) +```css +--focus-ring-width: 3px; +--focus-ring-offset: 2px; +--focus-ring-color: color-mix(in srgb, var(--brand-500) 30%, transparent); +--focus-ring: 0 0 0 var(--focus-ring-width) var(--focus-ring-color); +``` + +### 3.9 Data viz 色板(供 `shared/chart/nivoTheme` 消费) +```css +--chart-series-1: var(--brand-500); +--chart-series-2: var(--info-500); +--chart-series-3: var(--success-500); +--chart-series-4: var(--warning-500); +--chart-series-5: var(--danger-500); +--chart-series-6: #8b5cf6; +--chart-series-7: #06b6d4; +--chart-series-8: #ec4899; +``` + +--- + +## 4. 横向基础设施 + +### 4.1 Icon 规范(`shared/icon/README.md` + lint 约定) +- 继续用 `lucide-react`,不引入自有图标库 +- 尺寸 token:14(caption)、16(inline body)、20(button)、24(section) +- 颜色默认 `currentColor`,stroke-width 1.5 +- 规则:所有图标必须 `aria-hidden="true"` 或带 `aria-label` +- 在 `shared/icon/Icon.tsx` 导出 `Icon`(统一 size + aria 包装),而非到处裸用 lucide + +### 4.2 A11y 契约(每个原语 README 强制字段) +- role / ARIA attrs +- 键盘导航(Tab / Enter / Space / Arrow / Esc) +- 对比度(≥ 4.5:1 正文 / 3:1 大字或图形) +- `prefers-reduced-motion` 兜底 +- 关键组件(Modal/Drawer/Popover)焦点陷阱 + 归还 + +### 4.3 i18n 接入点 +- 不引入 i18n 库;原语的 `aria-label`、`loadingLabel`、`closeLabel` 等暴露成 prop,允许页面层替换 +- 默认值用中文(WeMail 默认语言);禁止在原语内部硬编码需要翻译的动词 + +### 4.4 测试工具(`apps/web/src/test/utils/`) +- `renderWithTheme(ui, { theme })` — 挂载 `data-theme` 并等待样式就绪 +- `pressKey(el, key)` — 封装 fireEvent.keyDown/Up 的组合 +- `axeRun(container)` — 可选的 a11y 自动巡检(先留挂载点,不强求本期接) + +### 4.5 Reduced-motion 统一开关 +- 所有新原语的 transition 必须在 `@media (prefers-reduced-motion: reduce)` 下降级为 `none` +- `base.css` 新增一条全局兜底:`*{ transition-duration: 0 !important }` 放 reduced-motion 媒体查询里(可被个别 opt-out) + +### 4.6 视觉回归(补丁,放最后一个 Sprint) +- Playwright 跑 `/_design` 路由在 light/dark 两个主题下截图 +- 存到 `apps/web/tests/visual/__snapshots__/`,PR 校对 + +--- + +## 5. 组件清单 + +> 命名:目录 `shared//`;文件 `Primitives.tsx + index.ts + README.md + test`;CSS 类 `ui--...`;状态 `is-` + `data-state`。 + +### A. 已有 · 零改动 +Table、Switch、Chart、Overlay(需内部核对 Modal/Drawer 完整性) + +### B. 已有 · 扩展 + +#### B1. `shared/button` +- 新增 size `xs` + `iconOnly` 组合(32×32) +- 新增 `variant="subtle"`(灰底 hover tint,对应图中"次要按钮·默认"浅色态) +- README 补 xs 尺寸、subtle 变体用法 + +#### B2. `shared/form` +- 新增 `SearchInput`(`leadingIcon` + 清除按钮) +- 拆出独立 `Checkbox` / `Radio` 原子(当前只有 `CheckboxField`/`RadioGroupField` 组合) +- 新增 `MultiSelect`(headless 下拉 + 内部用 Checkbox) + +### C. 新增原语 + +| 组件 | 目录 | 核心 Props | 变体 | 状态 | +|---|---|---|---|---| +| Card | `shared/card` | variant / padding / elevation / tone | base / data / status / accent | hover / is-interactive | +| Tag | `shared/tag` | variant / shape / size / dot / icon | neutral / brand / info / success / warning / danger | — | +| Badge | `shared/badge` | variant / appearance / size | soft / solid | — | +| Breadcrumb | `shared/breadcrumb` | separator | — | is-current | +| Pagination | `shared/pagination` | total / pageSize / page / onChange / siblings | size sm/md | is-active / is-disabled | +| Tabs | `shared/tabs` | value / onChange / variant | segmented / underline | is-selected / is-disabled | +| Steps | `shared/steps` | current / items | horizontal / vertical | upcoming / active / completed / error | +| Progress | `shared/progress` | value / max / variant / size / showLabel | linear(环形延后) | indeterminate | +| Alert | `shared/alert` | variant / appearance / title / onClose | soft / outline | dismissible | +| Typography | `shared/typography` | as / size / tone | Heading / Text / Label / Muted / Code / Kbd | — | +| Divider | `shared/divider` | orientation / inset / dashed | horizontal / vertical | — | +| Avatar | `shared/avatar` | src / alt / size / fallback | circle / square | is-loading | +| EmptyState | `shared/empty-state` | icon / title / description / actions | default / error / no-access | — | +| Skeleton | `shared/skeleton` | shape / width / height / rounded | rect / text / circle | is-animating | +| Spinner | `shared/spinner` | size / tone | — | — | +| Tooltip | `shared/tooltip` | content / placement / openDelay | — | open / closed | +| Popover | `shared/popover` | trigger / content / placement | — | open / closed | +| Kbd | `shared/typography` 子组件 | — | — | — | +| CopyButton | `shared/copy-button` | value / onCopy / children | — | is-copied | +| KVList | `shared/kv-list` | items / density | — | — | +| ScrollArea | `shared/scroll-area` | maxHeight / direction | — | has-scroll | +| PageLayout | `shared/page-layout` | `` `` `` `` `` | — | — | +| FilterBar | `shared/filter-bar` | fields 数组 | — | has-active-filters | + +### D. 合计 +- 新增 15 类(C1–C15),扩展 2 类(B1–B2),零动 3 类,总计 20 个原语 +- 所有原语统一:forwardRef + `cx()` + `data-state` + test 文件 + +--- + +## 6. 页面级抽象 + +### 6.1 `PageLayout` 脚手架 +```tsx + + ...} + title="账号列表" + description="..." + actions={} + /> + + + + + + + ... + + +``` +- 落地在 v1.1(本期只登记,不实施),避免一次性动太多页面 + +### 6.2 FilterBar 抽象 +- 账号列表、邮件列表、发件箱、公告都有"搜索 + 状态 + 创建人 + 时间"的组合,抽出 `FilterBar` +- props:`fields: Array<{ type: "search" | "select" | "date" | "toggle", label, ... }>` +- 实现放 v1.1 + +--- + +## 7. 文件结构 + +``` +apps/web/src/shared/ + styles/ + tokens.css ← 新建(所有 token) + base.css ← 抽出 reset / 全局排版(可选,为减轻 index.css) + index.css ← 顶部 @import "./tokens.css" + "./base.css" + button/ ← 扩展 + form/ ← 扩展 + icon/ ← 新增 + typography/ ← 新增 + card/ ← 新增 + tag/ ← 新增 + badge/ ← 新增 + breadcrumb/ ← 新增 + pagination/ ← 新增 + tabs/ ← 新增 + steps/ ← 新增 + progress/ ← 新增 + alert/ ← 新增 + divider/ ← 新增 + avatar/ ← 新增 + empty-state/ ← 新增 + skeleton/ ← 新增 + spinner/ ← 新增 + tooltip/ ← 新增 + popover/ ← 新增 + copy-button/ ← 新增 + kv-list/ ← 新增 + scroll-area/ ← 新增 + table/ switch/ chart/ overlay/ ← 保持 + +apps/web/src/pages/ + DesignSystemPage.tsx ← 新增,路由 /_design + +apps/web/src/test/utils/ + renderWithTheme.ts ← 新增 + pressKey.ts ← 新增 + +apps/web/tests/visual/ + design-system.spec.ts ← 新增 Playwright 快照 + __snapshots__/ ← 基线截图 + +docs/ + design-system/ + README.md ← 设计原则 + token 清单 + 组件索引 + CHANGELOG.md ← token / 原语变更日志 + plans/ + 2026-04-22-design-system-v1.md ← 本文档 +``` + +--- + +## 8. 实施顺序(7 个 Sprint) + +每个 Sprint 结束:`pnpm test:web && pnpm lint && pnpm typecheck && pnpm build` 必须全绿,已有页面视觉零回归。 + +| # | Sprint | 内容 | 预估 | +|---|---|---|---| +| S1 | Token foundation | `tokens.css` + 兼容别名 + `base.css` 抽出 + `DesignSystemPage` 骨架路由(/_design)+ 测试 utils + `CHANGELOG.md` | 1 天 | +| S2 | 排版与原子 | `typography / divider / spinner / skeleton / icon` + 单测 + 在预览页展示 | 1 天 | +| S3 | 卡片与状态 | `card / tag / badge / empty-state` + 单测 + 账号列表接入 Badge | 1 天 | +| S4 | 导航四件套 | `breadcrumb / pagination / tabs / steps` + 把 `.workspace-pill-nav` 与 `.landing-code-tab-switch` 迁入 `Tabs` | 1 天 | +| S5 | 输入扩展 + 开关类 | `SearchInput / Checkbox / Radio / MultiSelect` + `progress / alert / copy-button / kv-list / avatar` | 1 天 | +| S6 | 浮层补齐 | `tooltip / popover / scroll-area`;核对 `overlay/` 目录的 Modal/Drawer | 1 天 | +| S7 | 文档 + 视觉回归 | 每原语 README 完整;`/_design` 预览完整化;Playwright 快照;`docs/design-system/README.md` 索引 | 1 天 | + +**每 Sprint 的提交约定**: +- 每个原语独立 commit(feat: add shared/…) +- PR 合并前跑 `pnpm --filter web test` + 预览页截图对比 + +--- + +## 9. 验收清单 + +- [ ] `tokens.css` 落地,现有 `--accent/--border/...` 继续可用(via 兼容别名),历史页面零视觉回归 +- [ ] 20 个原语全部就位(第 5 节表格) +- [ ] 每原语 3 件套齐全:`*Primitives.tsx + index.ts + README.md` +- [ ] 每原语单测覆盖:渲染 / 受控 / disabled / 变体 class / a11y role;≥80% 行覆盖 +- [ ] `/_design` 预览页展示所有变体与状态,按图中 13 分区对齐排版 +- [ ] `docs/design-system/README.md` 索引 + `CHANGELOG.md` 记录 token 变更 +- [ ] `pnpm test:web && lint && typecheck && build` 全绿 +- [ ] Playwright `/design-system` light + dark 快照基线提交 +- [ ] 至少 2 个既有页面接入新原语(账号列表 → Badge + Pagination,Landing 开发者段 → Tabs),前后截图对比入 PR +- [ ] Reduced-motion 媒体查询在所有动画组件生效 +- [ ] 焦点环可见,Tab 键顺序合理 + +--- + +## 10. 风险与降级 + +| 风险 | 影响 | 降级 | +|---|---|---| +| tokens 重定向导致旧 CSS 色偏 | 高 | 只新增变量 + 别名指回,不改旧变量;S1 结束做 diff 截图对比 | +| Tabs 原语迁移改到 `workspace-pill-nav` 破坏现有 topbar | 中 | 保留旧类名作"外观 shim",先让 Tabs 内部渲染相同 className;两周后废弃 | +| `focus-visible` 样式与现有按钮冲突 | 低 | 新 focus-ring 变量只给新增组件使用;Button 等已有组件保留自身 focus 样式 | +| 一次性新增 20 目录体量大,review 吃力 | 中 | 按 Sprint 拆 commit + PR;每组件独立评审单元 | +| 视觉回归误报(字体/浏览器抗锯齿) | 中 | 截图生成设定 deviceScaleFactor=2 + 禁用动画;阈值允许 ≤1% 像素偏差 | +| lucide-react 图标 tree-shaking 失败 | 低 | 通过 `Icon` 组件统一导入,便于将来替换 | + +--- + +## 11. 待决策开放问题 + +1. 预览页路径:`/_design` vs `/design-system` vs 仅 dev 环境可见? +2. Dark 主题的阴影是否要独立一套(现 dark 只改 `--shadow-soft/card`)?建议 S1 统一重算 +3. `Card variant="data"` 的数值排版要不要用 `--font-mono`?参考图是等宽 +4. `Tabs.underline` 本期是否实现?现在只用到 segmented,建议延后 +5. `Skeleton` 要不要含 shimmer 动画?建议默认无动画,prop 开启 +6. `Tooltip` 用原生 `title` 属性 fallback 还是完全 JS 实现?建议完全 JS,title 仅作 SSR 兜底 +7. 是否顺带引入 `@radix-ui/react-*` 做 headless 基座(Popover/Tabs/Tooltip)?当前无第三方 UI 依赖,引入需 ADR。建议暂不,先自写;若 S6 成本高再换 +8. Visual regression 要不要在 CI 上强制?建议先跑但仅 warn,观察一周后转 block + +--- + +## 12. 与现有规范的衔接 + +- 架构约束:原语属 `apps/web/src/shared`,不跨依赖到 `features/*` 或 `pages/*`(见 `docs/code-standard.md`) +- 测试策略:单测 + 预览页 + 视觉回归;E2E 由现有 Playwright 关键流程兜底(见 `docs/testing-strategy.md`) +- 提交规范:feat/refactor/docs 前缀;每原语独立 commit;PR 体记录 before/after 截图 +- 一旦本方案 merge,后续页面若重复造轮子将在 code review 被拒 + +--- + +## 13. 下一步行动 + +1. 本文档合并到 main 后(或批准后直接在分支内)开始 **S1 Token foundation** +2. 每完成 Sprint,在 PR 描述附预览页截图 + 影响页清单 +3. 全部合并后,追写 ADR:`docs/adr/0003-design-system-v1.md` 记录本期决策 From d82f8caf5f62ef5e73a73ba466185ff0e63416b4 Mon Sep 17 00:00:00 2001 From: willxue Date: Wed, 22 Apr 2026 06:47:43 +0800 Subject: [PATCH 2/5] feat(design-system): tokens foundation + test utils (S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add additive design token layer (no existing variable is changed): - apps/web/src/shared/styles/tokens.css — brand 9-stop, semantic (success/warning/danger/info), neutrals 9-stop, spacing 4px grid, radius xs-xl + full, elevation xs-xl, font semantic sizes/leading, motion durations + eases, z-index layers, focus-ring, chart series - apps/web/src/shared/styles/primitives.css — empty barrel for S2-S6 component CSS to append via @import - apps/web/src/shared/styles/index.css — prepend @import of tokens + primitives; existing :root blocks untouched, zero visual regression - apps/web/src/test/utils/{renderWithTheme,pressKey,index}.ts — shared test helpers consumed by subsequent sprint tests See docs/plans/2026-04-22-design-system-v1.md §3–§4. --- apps/web/src/shared/styles/index.css | 3 + apps/web/src/shared/styles/primitives.css | 20 +++ apps/web/src/shared/styles/tokens.css | 177 +++++++++++++++++++++ apps/web/src/test/utils/index.ts | 2 + apps/web/src/test/utils/pressKey.ts | 25 +++ apps/web/src/test/utils/renderWithTheme.ts | 29 ++++ 6 files changed, 256 insertions(+) create mode 100644 apps/web/src/shared/styles/primitives.css create mode 100644 apps/web/src/shared/styles/tokens.css create mode 100644 apps/web/src/test/utils/index.ts create mode 100644 apps/web/src/test/utils/pressKey.ts create mode 100644 apps/web/src/test/utils/renderWithTheme.ts diff --git a/apps/web/src/shared/styles/index.css b/apps/web/src/shared/styles/index.css index 2d1281f..d610628 100644 --- a/apps/web/src/shared/styles/index.css +++ b/apps/web/src/shared/styles/index.css @@ -1,3 +1,6 @@ +@import "./tokens.css"; +@import "./primitives.css"; + :root { --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --radius-shell: 34px; diff --git a/apps/web/src/shared/styles/primitives.css b/apps/web/src/shared/styles/primitives.css new file mode 100644 index 0000000..498b290 --- /dev/null +++ b/apps/web/src/shared/styles/primitives.css @@ -0,0 +1,20 @@ +/* + * WeMail primitive CSS barrel. + * + * Each primitive team appends a single line: + * @import "..//.css"; + * below, ordered roughly by sprint. + * + * See docs/plans/2026-04-22-design-system-v1.md §7. + * + * === S2 atoms (typography / divider / icon / spinner / skeleton) === + * + * === S3 cards & status (card / tag / badge / empty-state) === + * + * === S4 navigation (breadcrumb / pagination / tabs / steps) === + * + * === S5 forms & feedback (SearchInput / Checkbox / Radio / MultiSelect / + * progress / alert / copy-button / kv-list / avatar) === + * + * === S6 overlays (tooltip / popover / scroll-area) === + */ diff --git a/apps/web/src/shared/styles/tokens.css b/apps/web/src/shared/styles/tokens.css new file mode 100644 index 0000000..ec1bd14 --- /dev/null +++ b/apps/web/src/shared/styles/tokens.css @@ -0,0 +1,177 @@ +/* + * WeMail design system tokens — v1.0 (2026-04-22) + * + * Purpose: central source of truth for color / spacing / radius / shadow / + * font / motion / z-index / focus / chart tokens. Additive layer — this file + * only introduces NEW variables. The legacy :root blocks in index.css continue + * to own existing variables (--accent, --border, --shadow-card, etc.) so the + * current UI keeps rendering with zero regression while new primitives adopt + * the system below. + * + * Source: docs/plans/2026-04-22-design-system-v1.md §3 + */ + +:root { + /* ===== Spacing (4px grid) ===== */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-14: 56px; + --space-16: 64px; + --space-18: 72px; + --space-20: 80px; + --space-24: 96px; + --space-28: 112px; + --space-32: 128px; + + /* ===== Radius (new scale — legacy aliases remain in index.css) ===== */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-full: 9999px; + + /* ===== Font — semantic sizes ===== */ + --font-display-lg: 48px; + --font-display-md: 32px; + --font-title-lg: 24px; + --font-title-md: 20px; + --font-body-lg: 16px; + --font-body-md: 14px; + --font-caption: 12px; + --font-mono-size: 13px; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + --line-display: 1.1; + --line-title: 1.3; + --line-body: 1.55; + --line-caption: 1.45; + --line-mono: 1.6; + + /* ===== Motion ===== */ + --duration-instant: 80ms; + --duration-fast: 150ms; + --duration-base: 200ms; + --duration-slow: 320ms; + --duration-slower: 480ms; + --ease-standard: cubic-bezier(0.25, 1, 0.5, 1); + --ease-emphasized: cubic-bezier(0.2, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + /* ===== Z-index layering ===== */ + --z-base: 0; + --z-raised: 1; + --z-dropdown: 10; + --z-sticky: 20; + --z-fixed: 30; + --z-overlay: 40; + --z-modal: 50; + --z-popover: 60; + --z-toast: 70; + --z-tooltip: 80; + + /* ===== Focus ring geometry ===== */ + --focus-ring-width: 3px; + --focus-ring-offset: 2px; +} + +/* ===== Theme-dependent tokens — light ===== */ +:root, +:root[data-theme="light"] { + /* Brand — 9 stops, --brand-500 equals the historical --accent */ + --brand-50: #fff2e6; + --brand-100: #ffe0c2; + --brand-200: #ffc78a; + --brand-300: #ffa74d; + --brand-400: #ff8b26; + --brand-500: #ff7a00; + --brand-600: #e66d00; + --brand-700: #b35400; + --brand-soft-500: #ffb366; + + /* Semantic — 500 / 100 / 600 / soft tint */ + --success-500: #22c55e; + --success-100: #d1fadf; + --success-600: #16a34a; + --success-bg: rgba(34, 197, 94, 0.14); + + --warning-500: #f59e0b; + --warning-100: #fef0c7; + --warning-600: #d97706; + --warning-bg: rgba(245, 158, 11, 0.14); + + --danger-500: #ef4444; + --danger-100: #fee2e2; + --danger-600: #dc2626; + --danger-bg: rgba(239, 68, 68, 0.14); + + --info-500: #3b82f6; + --info-100: #dbeafe; + --info-600: #2563eb; + --info-bg: rgba(59, 130, 246, 0.14); + + /* Neutrals — 9 stops */ + --neutral-0: #ffffff; + --neutral-50: #f9fafb; + --neutral-100: #f3f4f6; + --neutral-200: #e5e7eb; + --neutral-300: #d1d5db; + --neutral-400: #9ca3af; + --neutral-500: #6b7280; + --neutral-700: #374151; + --neutral-900: #111827; + + /* Elevation */ + --elevation-xs: 0 1px 2px rgba(17, 17, 17, 0.04); + --elevation-sm: 0 2px 6px rgba(17, 17, 17, 0.06), 0 1px 2px rgba(17, 17, 17, 0.04); + --elevation-md: 0 6px 16px rgba(17, 17, 17, 0.08), 0 2px 4px rgba(17, 17, 17, 0.04); + --elevation-lg: 0 14px 32px rgba(17, 17, 17, 0.1), 0 4px 8px rgba(17, 17, 17, 0.04); + --elevation-xl: 0 24px 64px rgba(17, 17, 17, 0.14), 0 8px 16px rgba(17, 17, 17, 0.06); + + /* Focus ring color (alpha-mixed brand) */ + --focus-ring-color: color-mix(in srgb, var(--brand-500) 30%, transparent); + --focus-ring: 0 0 0 var(--focus-ring-width) var(--focus-ring-color); + + /* Chart series — 8 distinct hues */ + --chart-series-1: var(--brand-500); + --chart-series-2: var(--info-500); + --chart-series-3: var(--success-500); + --chart-series-4: var(--warning-500); + --chart-series-5: var(--danger-500); + --chart-series-6: #8b5cf6; + --chart-series-7: #06b6d4; + --chart-series-8: #ec4899; +} + +/* ===== Theme-dependent tokens — dark ===== */ +:root[data-theme="dark"] { + /* Brand kept unchanged — consistent accent across themes */ + + /* Neutrals inverted — light values for text on dark surfaces */ + --neutral-0: #0a0a0a; + --neutral-50: #111113; + --neutral-100: #1a1a1d; + --neutral-200: #222227; + --neutral-300: #2e2e34; + --neutral-400: #6b7280; + --neutral-500: #9ca3af; + --neutral-700: #d1d5db; + --neutral-900: #f3f4f6; + + /* Deeper shadows for dark surfaces */ + --elevation-xs: 0 1px 2px rgba(0, 0, 0, 0.32); + --elevation-sm: 0 2px 6px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.28); + --elevation-md: 0 6px 16px rgba(0, 0, 0, 0.48), 0 2px 4px rgba(0, 0, 0, 0.32); + --elevation-lg: 0 14px 32px rgba(0, 0, 0, 0.55), 0 4px 8px rgba(0, 0, 0, 0.3); + --elevation-xl: 0 24px 64px rgba(0, 0, 0, 0.6), 0 8px 16px rgba(0, 0, 0, 0.4); + + /* Focus ring slightly brighter in dark */ + --focus-ring-color: color-mix(in srgb, var(--brand-500) 40%, transparent); +} diff --git a/apps/web/src/test/utils/index.ts b/apps/web/src/test/utils/index.ts new file mode 100644 index 0000000..66b123a --- /dev/null +++ b/apps/web/src/test/utils/index.ts @@ -0,0 +1,2 @@ +export { renderWithTheme, type SupportedTheme, type RenderWithThemeResult } from "./renderWithTheme"; +export { pressKey, type PressKeyOptions } from "./pressKey"; diff --git a/apps/web/src/test/utils/pressKey.ts b/apps/web/src/test/utils/pressKey.ts new file mode 100644 index 0000000..f809869 --- /dev/null +++ b/apps/web/src/test/utils/pressKey.ts @@ -0,0 +1,25 @@ +import { fireEvent } from "@testing-library/react"; + +export type PressKeyOptions = { + code?: string; + shiftKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +}; + +export function pressKey(element: Element, key: string, options: PressKeyOptions = {}) { + const init: KeyboardEventInit = { + key, + code: options.code ?? key, + shiftKey: options.shiftKey, + ctrlKey: options.ctrlKey, + altKey: options.altKey, + metaKey: options.metaKey, + bubbles: true, + cancelable: true + }; + + fireEvent.keyDown(element, init); + fireEvent.keyUp(element, init); +} diff --git a/apps/web/src/test/utils/renderWithTheme.ts b/apps/web/src/test/utils/renderWithTheme.ts new file mode 100644 index 0000000..b5bdf43 --- /dev/null +++ b/apps/web/src/test/utils/renderWithTheme.ts @@ -0,0 +1,29 @@ +import { type ReactElement, type ReactNode } from "react"; +import { act, render, type RenderOptions, type RenderResult } from "@testing-library/react"; + +export type SupportedTheme = "light" | "dark"; + +type RenderWithThemeOptions = RenderOptions & { + theme?: SupportedTheme; +}; + +export type RenderWithThemeResult = RenderResult & { + setTheme: (next: SupportedTheme) => void; +}; + +export function renderWithTheme( + ui: ReactElement | ReactNode, + { theme = "light", ...rest }: RenderWithThemeOptions = {} +): RenderWithThemeResult { + document.documentElement.dataset.theme = theme; + + const result = render(ui as ReactElement, rest); + + function setTheme(next: SupportedTheme) { + act(() => { + document.documentElement.dataset.theme = next; + }); + } + + return { ...result, setTheme }; +} From a2adaa034083b6e57130a4e94eeb9ad9585d87e8 Mon Sep 17 00:00:00 2001 From: willxue Date: Wed, 22 Apr 2026 06:47:57 +0800 Subject: [PATCH 3/5] feat(design-system): /design-system preview page (S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a new workspace route that renders a skeleton preview of the token system (principles, colors, spacing, radius, shadows, typography) and a placeholder for component primitives coming in S2-S6. - apps/web/src/pages/DesignSystemPage.tsx — functional preview with design-swatch / design-spacing-row / design-radius-grid / design- shadow-grid / design-typography-grid sections - apps/web/src/app/AppRoutes.tsx — register /design-system route Follow-up sprints will plug each primitive group into this page. --- apps/web/src/app/AppRoutes.tsx | 2 + apps/web/src/pages/DesignSystemPage.tsx | 182 ++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 apps/web/src/pages/DesignSystemPage.tsx diff --git a/apps/web/src/app/AppRoutes.tsx b/apps/web/src/app/AppRoutes.tsx index 201b517..c1e6b28 100644 --- a/apps/web/src/app/AppRoutes.tsx +++ b/apps/web/src/app/AppRoutes.tsx @@ -23,6 +23,7 @@ import { TelegramSettingsPage } from "../features/settings/TelegramSettingsPage" import { WebhookPage } from "../features/settings/WebhookPage"; import { AnnouncementsPage } from "../pages/AnnouncementsPage"; import { DashboardPage } from "../pages/DashboardPage"; +import { DesignSystemPage } from "../pages/DesignSystemPage"; import { InboxPage } from "../pages/InboxPage"; import { SystemSettingsPage } from "../pages/SystemSettingsPage"; import { SystemProfilePage } from "../pages/SystemProfilePage"; @@ -233,6 +234,7 @@ export function AppRoutes({ session, inbox, selectedMessage, settings, admin, ap } /> + } /> ); } diff --git a/apps/web/src/pages/DesignSystemPage.tsx b/apps/web/src/pages/DesignSystemPage.tsx new file mode 100644 index 0000000..8466425 --- /dev/null +++ b/apps/web/src/pages/DesignSystemPage.tsx @@ -0,0 +1,182 @@ +import type { CSSProperties, ReactNode } from "react"; + +type SwatchProps = { + name: string; + hex: string; + varName?: string; +}; + +function Swatch({ name, hex, varName }: SwatchProps) { + const style: CSSProperties = { background: hex }; + return ( +

+
+ ); +} + +type TokenRowProps = { + label: string; + value: ReactNode; + hint?: string; +}; + +function TokenRow({ label, value, hint }: TokenRowProps) { + return ( +
+
{value}
+
+ {label} + {hint ? {hint} : null} +
+
+ ); +} + +export function DesignSystemPage() { + return ( +
+
+

WeMail UI 系统 · v1.0

+

WeMail 设计系统

+

+ 一套简洁、现代、易用的界面规范。所有组件与样式均基于此页展示的设计原则、色彩、字号、间距、圆角与阴影 + token 构建;新功能应优先从这里取用原语与变量,避免在业务层重造样式。 +

+
+ +
+

设计原则

+

四个不变量

+
+
+ 简洁清晰 + 减少视觉干扰,一屏只解决一件事。 +
+
+ 一致性 + 相同操作使用相同组件与状态表达。 +
+
+ 易用性 + 优先键盘可达与屏幕阅读器语义。 +
+
+ 可扩展 + 原语遵守 forwardRef + className + state 约定,便于组合。 +
+
+
+ +
+

色彩系统

+

品牌与语义色

+
+ + + + + + +
+
+ +
+

间距系统

+

4px 网格

+
+ {[4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 72, 80, 96, 112, 128].map((value) => ( +
+
+ ))} +
+
+ +
+

圆角系统

+

6 档梯度

+
+ } + hint="--radius-xs · 内嵌边框、标签边缘" + /> + } + hint="--radius-sm · 小按钮、输入内部" + /> + } + hint="--radius-md · 一般按钮、标签页头部" + /> + } + hint="--radius-lg · 输入框、卡片内嵌" + /> + } + hint="--radius-xl · 卡片外壳" + /> + } + hint="--radius-full · 头像、pill、switch 轨道" + /> +
+
+ +
+

阴影系统

+

5 档层级

+
+ {[ + { name: "xs", varName: "--elevation-xs" }, + { name: "sm", varName: "--elevation-sm" }, + { name: "md", varName: "--elevation-md" }, + { name: "lg", varName: "--elevation-lg" }, + { name: "xl", varName: "--elevation-xl" } + ].map(({ name, varName }) => ( +
+ {name} + {varName} +
+ ))} +
+
+ +
+

字号系统

+

7 档语义字号

+
+
显示大号 · 48/1.1
+
显示中号 · 32/1.2
+
标题大号 · 24/1.3
+
标题中号 · 20/1.35
+
正文大号 · 16/1.55
+
正文中号 · 14/1.55
+
辅助说明 · 12/1.45
+
+
+ +
+

组件库

+

原语预览

+

+ 所有原语(Button / Form / Table / Switch / Tabs 等)将在后续 Sprint 陆续接入本页。目前可以查看各自仓库目录下的 + README:apps/web/src/shared/。 +

+
+
+ ); +} From 03ff4dcc990583763eddd583bbf3a2468d801c84 Mon Sep 17 00:00:00 2001 From: willxue Date: Wed, 22 Apr 2026 06:47:57 +0800 Subject: [PATCH 4/5] docs(design-system): initial README and changelog (S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/design-system/README.md — scope, token quick-index, primitive roster with sprint ownership, a11y contract, preview entry point - docs/design-system/CHANGELOG.md — Keep-a-Changelog format seeded with the S1 additions --- docs/design-system/CHANGELOG.md | 55 +++++++++++++++++++++++++++++ docs/design-system/README.md | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 docs/design-system/CHANGELOG.md create mode 100644 docs/design-system/README.md diff --git a/docs/design-system/CHANGELOG.md b/docs/design-system/CHANGELOG.md new file mode 100644 index 0000000..b44a371 --- /dev/null +++ b/docs/design-system/CHANGELOG.md @@ -0,0 +1,55 @@ +# Design System Changelog + +约定:格式参考 [Keep a Changelog](https://keepachangelog.com/)。日期使用 ISO 8601。版本号与设计系统独立演进。 + +## [Unreleased] + +### Added — Sprint 1 Foundation (2026-04-22) + +- `apps/web/src/shared/styles/tokens.css` — 新增 token 层 + - 品牌色 9 阶(`--brand-50 … 700`)+ 辅助橙(`--brand-soft-500`) + - 语义色(success / warning / danger / info)各含 500 / 100 / 600 + `-bg` soft 变体 + - 中性色 9 阶(`--neutral-0 … 900`),dark 主题反转 + - 间距 `--space-1 … --space-32`(4px 网格) + - 圆角 `--radius-xs/sm/md/lg/xl/full`(与旧 `--radius-shell/card/pill/field` 并存) + - 阴影 `--elevation-xs … xl`(与旧 `--shadow-*` 并存) + - 字号 `--font-display-lg/md`、`--font-title-lg/md`、`--font-body-lg/md`、`--font-caption`、`--font-mono-size` + 对应行高 + - 动效 `--duration-instant/fast/base/slow/slower` + 三条 ease 曲线 + - 层级 `--z-base … --z-tooltip` 共 10 档 + - 焦点环 `--focus-ring-width/offset/color/ring` + - 图表色板 `--chart-series-1 … 8` +- `apps/web/src/shared/styles/primitives.css` — 空 barrel,供 S2-S6 component 追加自己的 `@import` +- `apps/web/src/shared/styles/index.css` — 顶部新增 `@import "./tokens.css"; @import "./primitives.css";`;旧内容全部保留 +- `apps/web/src/test/utils/renderWithTheme.ts` — 基于 `@testing-library/react` 的 `render` 包装,挂 `data-theme`,支持运行时切换 +- `apps/web/src/test/utils/pressKey.ts` — 键盘按键测试辅助,封装 `keyDown + keyUp` +- `apps/web/src/test/utils/index.ts` — 桶式导出 +- `apps/web/src/pages/DesignSystemPage.tsx` — 路由 `/design-system`,展示 token 与原语的可视预览骨架 +- `apps/web/src/app/AppRoutes.tsx` — 注册 `/design-system` 路由 +- `docs/design-system/README.md` — 索引与 token 速查表 + +### Changed +- (无 — S1 为 additive 落地,不破坏既有样式) + +### Removed +- (无) + +--- + +## 模板(后续 Sprint 填写) + +```md +### Added — Sprint N Name (YYYY-MM-DD) +- ... + +### Changed +- ... + +### Deprecated +- ... + +### Removed +- ... + +### Fixed +- ... +``` diff --git a/docs/design-system/README.md b/docs/design-system/README.md new file mode 100644 index 0000000..068cda7 --- /dev/null +++ b/docs/design-system/README.md @@ -0,0 +1,61 @@ +# WeMail 设计系统 + +参考 [`docs/plans/2026-04-22-design-system-v1.md`](../plans/2026-04-22-design-system-v1.md) 了解整体方案、实施节奏与验收清单。 + +## 范围 + +- 仅 Web 端(`apps/web`),暂不覆盖移动端与邮件模板 +- 附加式(additive)落地:不改动既有 CSS 变量与选择器,新 token 与原语独立存在;业务页在各自节奏下迁移 + +## Token 快速索引 + +所有 token 定义在 [`apps/web/src/shared/styles/tokens.css`](../../apps/web/src/shared/styles/tokens.css),按类别分组: + +| 类别 | 变量前缀 | 说明 | +|---|---|---| +| 品牌 | `--brand-50 … --brand-700`、`--brand-soft-500` | 主色梯度,`--brand-500` 与旧 `--accent` 等值 | +| 语义 | `--success-*`、`--warning-*`、`--danger-*`、`--info-*` | 各自 500 / 100 / 600 + soft 变体 | +| 中性 | `--neutral-0 … --neutral-900` | 9 阶灰度;dark 模式镜像反转 | +| 间距 | `--space-1 … --space-32` | 4px 网格 | +| 圆角 | `--radius-xs/sm/md/lg/xl/full` | 6 档 | +| 阴影 | `--elevation-xs/sm/md/lg/xl` | 5 档 | +| 字号 | `--font-display-lg/md`、`--font-title-lg/md`、`--font-body-lg/md`、`--font-caption`、`--font-mono-size` | 语义字号 | +| 行高 | `--line-display/title/body/caption/mono` | 与字号配套 | +| 动效 | `--duration-instant/fast/base/slow/slower`、`--ease-standard/emphasized/in-out` | 统一过渡时长与曲线 | +| 层级 | `--z-base/raised/dropdown/sticky/fixed/overlay/modal/popover/toast/tooltip` | z-index 语义层 | +| 焦点 | `--focus-ring-width/offset/color/ring` | 统一键盘焦点环 | +| 图表 | `--chart-series-1 … --chart-series-8` | Nivo 主题消费 | + +**兼容性**:旧变量 (`--accent` / `--border` / `--surface-*` / `--shadow-*` / `--radius-shell|card|pill|field`) 仍保留原值在 `index.css`,新旧并行、零回归。 + +## 组件清单 + +| 名称 | 目录 | 状态 | +|---|---|---| +| Button / ButtonLink / ButtonAnchor | `shared/button` | ✅ 已有 | +| TextInput / SelectInput / TextareaInput / CheckboxField / RadioGroupField / FormField | `shared/form` | ✅ 已有 | +| Table 全家桶 | `shared/table` | ✅ 已有 | +| Switch | `shared/switch` | ✅ 已有 | +| Chart(Nivo 主题) | `shared/chart` | ✅ 已有 | +| Overlay(Modal/Drawer) | `shared/overlay` | ✅ 已有(S6 复核) | +| Typography / Divider / Icon / Spinner / Skeleton | `shared/{typography,divider,icon,spinner,skeleton}` | 🕐 S2 | +| Card / Tag / Badge / EmptyState | `shared/{card,tag,badge,empty-state}` | 🕐 S3 | +| Breadcrumb / Pagination / Tabs / Steps | `shared/{breadcrumb,pagination,tabs,steps}` | 🕐 S4 | +| SearchInput / Checkbox / Radio / MultiSelect / Progress / Alert / CopyButton / KVList / Avatar | `shared/…` | 🕐 S5 | +| Tooltip / Popover / ScrollArea | `shared/{tooltip,popover,scroll-area}` | 🕐 S6 | + +## A11y 契约(所有原语必须满足) + +- role / ARIA attrs 明确 +- 键盘可达 (Tab / Enter / Space / Arrow / Esc) +- 对比度 ≥ 4.5:1 (正文) / 3:1 (大字或图形) +- `prefers-reduced-motion` 下降级动画 +- Modal / Drawer / Popover 焦点陷阱 + 归还 + +## 预览 + +开发服务器启动后访问 `/design-system` 查看当前 token 与原语的可视预览。 + +## 变更记录 + +每次 token / 原语变更写入 [`CHANGELOG.md`](./CHANGELOG.md)。 From 32ebe6dabdfa15c5bb5ed17428853749925596d3 Mon Sep 17 00:00:00 2001 From: willxue Date: Thu, 14 May 2026 22:38:56 +0800 Subject: [PATCH 5/5] update --- .gitignore | 4 +- apps/web/e2e/README.md | 3 + apps/web/e2e/design-system.spec.ts | 54 + apps/web/src/app/App.tsx | 11 + apps/web/src/app/AppLayout.tsx | 44 +- apps/web/src/app/AppRoutes.tsx | 2 - .../features/accounts/AccountsListPage.tsx | 219 ++-- .../features/landing/PublicSiteNavigation.tsx | 194 +++ .../features/landing/WemailLandingPage.tsx | 258 ++-- apps/web/src/features/landing/landing.css | 124 +- .../src/features/outbound/OutboundPage.tsx | 95 +- .../web/src/features/settings/ApiKeysPage.tsx | 69 +- apps/web/src/pages/DashboardPage.tsx | 18 +- apps/web/src/pages/DesignSystemPage.tsx | 332 +++-- apps/web/src/pages/SystemProfilePage.tsx | 55 +- .../web/src/pages/UsersGlobalSettingsPage.tsx | 21 +- apps/web/src/pages/UsersListPage.tsx | 250 ++-- .../design-system/DesignSystemDocContent.tsx | 161 +++ .../DesignSystemPreviewContent.tsx | 143 +++ .../design-system/designSystemContent.ts | 1098 +++++++++++++++++ .../designSystemPreviewParts.tsx | 131 ++ .../design-system/designSystemSections.tsx | 820 ++++++++++++ .../pages/design-system/designSystemStyles.ts | 253 ++++ apps/web/src/shared/README.md | 12 +- apps/web/src/shared/alert/AlertPrimitives.tsx | 70 ++ apps/web/src/shared/alert/README.md | 18 + apps/web/src/shared/alert/alert.css | 65 + apps/web/src/shared/alert/index.ts | 1 + .../src/shared/avatar/AvatarPrimitives.tsx | 65 + apps/web/src/shared/avatar/README.md | 18 + apps/web/src/shared/avatar/avatar.css | 60 + apps/web/src/shared/avatar/index.ts | 1 + apps/web/src/shared/badge/BadgePrimitives.tsx | 43 + apps/web/src/shared/badge/README.md | 26 + apps/web/src/shared/badge/badge.css | 88 ++ apps/web/src/shared/badge/index.ts | 1 + .../breadcrumb/BreadcrumbPrimitives.tsx | 87 ++ apps/web/src/shared/breadcrumb/README.md | 27 + apps/web/src/shared/breadcrumb/breadcrumb.css | 40 + apps/web/src/shared/breadcrumb/index.ts | 8 + .../src/shared/button/ButtonPrimitives.tsx | 2 +- apps/web/src/shared/card/CardPrimitives.tsx | 73 ++ apps/web/src/shared/card/README.md | 29 + apps/web/src/shared/card/card.css | 163 +++ apps/web/src/shared/card/index.ts | 1 + .../copy-button/CopyButtonPrimitives.tsx | 95 ++ apps/web/src/shared/copy-button/README.md | 17 + .../src/shared/copy-button/copy-button.css | 33 + apps/web/src/shared/copy-button/index.ts | 1 + .../src/shared/divider/DividerPrimitives.tsx | 47 + apps/web/src/shared/divider/README.md | 18 + apps/web/src/shared/divider/divider.css | 57 + apps/web/src/shared/divider/index.ts | 1 + .../empty-state/EmptyStatePrimitives.tsx | 63 + apps/web/src/shared/empty-state/README.md | 27 + .../src/shared/empty-state/empty-state.css | 76 ++ apps/web/src/shared/empty-state/index.ts | 1 + .../shared/filter-bar/FilterBarPrimitives.tsx | 38 + apps/web/src/shared/filter-bar/README.md | 20 + apps/web/src/shared/filter-bar/filter-bar.css | 41 + apps/web/src/shared/filter-bar/index.ts | 1 + apps/web/src/shared/form/FormPrimitives.tsx | 476 ++++++- apps/web/src/shared/form/README.md | 16 +- apps/web/src/shared/form/form.css | 161 +++ apps/web/src/shared/form/index.ts | 4 + apps/web/src/shared/icon/IconPrimitives.tsx | 58 + apps/web/src/shared/icon/README.md | 18 + apps/web/src/shared/icon/icon.css | 52 + apps/web/src/shared/icon/index.ts | 1 + .../src/shared/kv-list/KVListPrimitives.tsx | 46 + apps/web/src/shared/kv-list/README.md | 13 + apps/web/src/shared/kv-list/index.ts | 1 + apps/web/src/shared/kv-list/kv-list.css | 40 + .../metric-card/MetricCardPrimitives.tsx | 47 + apps/web/src/shared/metric-card/README.md | 18 + apps/web/src/shared/metric-card/index.ts | 1 + .../src/shared/metric-card/metric-card.css | 37 + .../src/shared/overlay/OverlayPrimitives.tsx | 113 +- apps/web/src/shared/overlay/README.md | 4 + apps/web/src/shared/overlay/layer-utils.ts | 233 ++++ .../page-layout/PageLayoutPrimitives.tsx | 80 ++ apps/web/src/shared/page-layout/README.md | 23 + apps/web/src/shared/page-layout/index.ts | 1 + .../src/shared/page-layout/page-layout.css | 63 + .../pagination/PaginationPrimitives.tsx | 226 ++++ apps/web/src/shared/pagination/README.md | 26 + apps/web/src/shared/pagination/index.ts | 1 + apps/web/src/shared/pagination/pagination.css | 45 + .../src/shared/popover/PopoverPrimitives.tsx | 220 ++++ apps/web/src/shared/popover/README.md | 21 + apps/web/src/shared/popover/index.ts | 1 + apps/web/src/shared/popover/popover.css | 48 + .../shared/progress/ProgressPrimitives.tsx | 63 + apps/web/src/shared/progress/README.md | 18 + apps/web/src/shared/progress/index.ts | 1 + apps/web/src/shared/progress/progress.css | 77 ++ apps/web/src/shared/scroll-area/README.md | 20 + .../scroll-area/ScrollAreaPrimitives.tsx | 70 ++ apps/web/src/shared/scroll-area/index.ts | 6 + .../src/shared/scroll-area/scroll-area.css | 39 + apps/web/src/shared/skeleton/README.md | 19 + .../shared/skeleton/SkeletonPrimitives.tsx | 70 ++ apps/web/src/shared/skeleton/index.ts | 1 + apps/web/src/shared/skeleton/skeleton.css | 72 ++ apps/web/src/shared/spinner/README.md | 19 + .../src/shared/spinner/SpinnerPrimitives.tsx | 45 + apps/web/src/shared/spinner/index.ts | 1 + apps/web/src/shared/spinner/spinner.css | 74 ++ apps/web/src/shared/steps/README.md | 28 + apps/web/src/shared/steps/StepsPrimitives.tsx | 191 +++ apps/web/src/shared/steps/index.ts | 1 + apps/web/src/shared/steps/steps.css | 66 + apps/web/src/shared/styles/README.md | 3 + apps/web/src/shared/styles/base.css | 60 + apps/web/src/shared/styles/index.css | 81 +- apps/web/src/shared/styles/primitives.css | 51 + apps/web/src/shared/styles/tokens.css | 12 +- apps/web/src/shared/tabs/README.md | 30 + apps/web/src/shared/tabs/TabsPrimitives.tsx | 243 ++++ apps/web/src/shared/tabs/index.ts | 1 + apps/web/src/shared/tabs/tabs.css | 54 + apps/web/src/shared/tag/README.md | 26 + apps/web/src/shared/tag/TagPrimitives.tsx | 51 + apps/web/src/shared/tag/index.ts | 1 + apps/web/src/shared/tag/tag.css | 92 ++ apps/web/src/shared/tooltip/README.md | 21 + .../src/shared/tooltip/TooltipPrimitives.tsx | 243 ++++ apps/web/src/shared/tooltip/index.ts | 1 + apps/web/src/shared/tooltip/tooltip.css | 39 + apps/web/src/shared/typography/README.md | 24 + .../typography/TypographyPrimitives.tsx | 126 ++ apps/web/src/shared/typography/index.ts | 1 + apps/web/src/shared/typography/typography.css | 132 ++ apps/web/src/test/app.test.tsx | 206 +++- apps/web/src/test/design-system-page.test.tsx | 259 ++++ apps/web/src/test/shared-alert.test.tsx | 32 + apps/web/src/test/shared-avatar.test.tsx | 28 + apps/web/src/test/shared-badge.test.tsx | 32 + apps/web/src/test/shared-breadcrumb.test.tsx | 45 + apps/web/src/test/shared-button.test.tsx | 4 + apps/web/src/test/shared-card.test.tsx | 46 + apps/web/src/test/shared-copy-button.test.tsx | 53 + apps/web/src/test/shared-divider.test.tsx | 35 + apps/web/src/test/shared-empty-state.test.tsx | 40 + apps/web/src/test/shared-filter-bar.test.tsx | 40 + apps/web/src/test/shared-form.test.tsx | 159 ++- apps/web/src/test/shared-icon.test.tsx | 30 + apps/web/src/test/shared-kv-list.test.tsx | 24 + apps/web/src/test/shared-metric-card.test.tsx | 22 + apps/web/src/test/shared-overlay.test.tsx | 53 +- apps/web/src/test/shared-page-layout.test.tsx | 47 + apps/web/src/test/shared-pagination.test.tsx | 61 + apps/web/src/test/shared-popover.test.tsx | 94 ++ apps/web/src/test/shared-progress.test.tsx | 23 + apps/web/src/test/shared-scroll-area.test.tsx | 37 + apps/web/src/test/shared-skeleton.test.tsx | 32 + apps/web/src/test/shared-spinner.test.tsx | 34 + apps/web/src/test/shared-steps.test.tsx | 82 ++ apps/web/src/test/shared-tabs.test.tsx | 90 ++ apps/web/src/test/shared-tag.test.tsx | 32 + apps/web/src/test/shared-tooltip.test.tsx | 69 ++ apps/web/src/test/shared-typography.test.tsx | 49 + apps/web/src/test/users-list-page.test.tsx | 6 +- docs/design-system/CHANGELOG.md | 60 +- docs/design-system/README.md | 82 +- ...design-system-component-docsite-sidebar.md | 434 +++++++ ...026-04-25-design-system-docsite-upgrade.md | 402 ++++++ 167 files changed, 11914 insertions(+), 888 deletions(-) create mode 100644 apps/web/e2e/design-system.spec.ts create mode 100644 apps/web/src/features/landing/PublicSiteNavigation.tsx create mode 100644 apps/web/src/pages/design-system/DesignSystemDocContent.tsx create mode 100644 apps/web/src/pages/design-system/DesignSystemPreviewContent.tsx create mode 100644 apps/web/src/pages/design-system/designSystemContent.ts create mode 100644 apps/web/src/pages/design-system/designSystemPreviewParts.tsx create mode 100644 apps/web/src/pages/design-system/designSystemSections.tsx create mode 100644 apps/web/src/pages/design-system/designSystemStyles.ts create mode 100644 apps/web/src/shared/alert/AlertPrimitives.tsx create mode 100644 apps/web/src/shared/alert/README.md create mode 100644 apps/web/src/shared/alert/alert.css create mode 100644 apps/web/src/shared/alert/index.ts create mode 100644 apps/web/src/shared/avatar/AvatarPrimitives.tsx create mode 100644 apps/web/src/shared/avatar/README.md create mode 100644 apps/web/src/shared/avatar/avatar.css create mode 100644 apps/web/src/shared/avatar/index.ts create mode 100644 apps/web/src/shared/badge/BadgePrimitives.tsx create mode 100644 apps/web/src/shared/badge/README.md create mode 100644 apps/web/src/shared/badge/badge.css create mode 100644 apps/web/src/shared/badge/index.ts create mode 100644 apps/web/src/shared/breadcrumb/BreadcrumbPrimitives.tsx create mode 100644 apps/web/src/shared/breadcrumb/README.md create mode 100644 apps/web/src/shared/breadcrumb/breadcrumb.css create mode 100644 apps/web/src/shared/breadcrumb/index.ts create mode 100644 apps/web/src/shared/card/CardPrimitives.tsx create mode 100644 apps/web/src/shared/card/README.md create mode 100644 apps/web/src/shared/card/card.css create mode 100644 apps/web/src/shared/card/index.ts create mode 100644 apps/web/src/shared/copy-button/CopyButtonPrimitives.tsx create mode 100644 apps/web/src/shared/copy-button/README.md create mode 100644 apps/web/src/shared/copy-button/copy-button.css create mode 100644 apps/web/src/shared/copy-button/index.ts create mode 100644 apps/web/src/shared/divider/DividerPrimitives.tsx create mode 100644 apps/web/src/shared/divider/README.md create mode 100644 apps/web/src/shared/divider/divider.css create mode 100644 apps/web/src/shared/divider/index.ts create mode 100644 apps/web/src/shared/empty-state/EmptyStatePrimitives.tsx create mode 100644 apps/web/src/shared/empty-state/README.md create mode 100644 apps/web/src/shared/empty-state/empty-state.css create mode 100644 apps/web/src/shared/empty-state/index.ts create mode 100644 apps/web/src/shared/filter-bar/FilterBarPrimitives.tsx create mode 100644 apps/web/src/shared/filter-bar/README.md create mode 100644 apps/web/src/shared/filter-bar/filter-bar.css create mode 100644 apps/web/src/shared/filter-bar/index.ts create mode 100644 apps/web/src/shared/form/form.css create mode 100644 apps/web/src/shared/icon/IconPrimitives.tsx create mode 100644 apps/web/src/shared/icon/README.md create mode 100644 apps/web/src/shared/icon/icon.css create mode 100644 apps/web/src/shared/icon/index.ts create mode 100644 apps/web/src/shared/kv-list/KVListPrimitives.tsx create mode 100644 apps/web/src/shared/kv-list/README.md create mode 100644 apps/web/src/shared/kv-list/index.ts create mode 100644 apps/web/src/shared/kv-list/kv-list.css create mode 100644 apps/web/src/shared/metric-card/MetricCardPrimitives.tsx create mode 100644 apps/web/src/shared/metric-card/README.md create mode 100644 apps/web/src/shared/metric-card/index.ts create mode 100644 apps/web/src/shared/metric-card/metric-card.css create mode 100644 apps/web/src/shared/overlay/layer-utils.ts create mode 100644 apps/web/src/shared/page-layout/PageLayoutPrimitives.tsx create mode 100644 apps/web/src/shared/page-layout/README.md create mode 100644 apps/web/src/shared/page-layout/index.ts create mode 100644 apps/web/src/shared/page-layout/page-layout.css create mode 100644 apps/web/src/shared/pagination/PaginationPrimitives.tsx create mode 100644 apps/web/src/shared/pagination/README.md create mode 100644 apps/web/src/shared/pagination/index.ts create mode 100644 apps/web/src/shared/pagination/pagination.css create mode 100644 apps/web/src/shared/popover/PopoverPrimitives.tsx create mode 100644 apps/web/src/shared/popover/README.md create mode 100644 apps/web/src/shared/popover/index.ts create mode 100644 apps/web/src/shared/popover/popover.css create mode 100644 apps/web/src/shared/progress/ProgressPrimitives.tsx create mode 100644 apps/web/src/shared/progress/README.md create mode 100644 apps/web/src/shared/progress/index.ts create mode 100644 apps/web/src/shared/progress/progress.css create mode 100644 apps/web/src/shared/scroll-area/README.md create mode 100644 apps/web/src/shared/scroll-area/ScrollAreaPrimitives.tsx create mode 100644 apps/web/src/shared/scroll-area/index.ts create mode 100644 apps/web/src/shared/scroll-area/scroll-area.css create mode 100644 apps/web/src/shared/skeleton/README.md create mode 100644 apps/web/src/shared/skeleton/SkeletonPrimitives.tsx create mode 100644 apps/web/src/shared/skeleton/index.ts create mode 100644 apps/web/src/shared/skeleton/skeleton.css create mode 100644 apps/web/src/shared/spinner/README.md create mode 100644 apps/web/src/shared/spinner/SpinnerPrimitives.tsx create mode 100644 apps/web/src/shared/spinner/index.ts create mode 100644 apps/web/src/shared/spinner/spinner.css create mode 100644 apps/web/src/shared/steps/README.md create mode 100644 apps/web/src/shared/steps/StepsPrimitives.tsx create mode 100644 apps/web/src/shared/steps/index.ts create mode 100644 apps/web/src/shared/steps/steps.css create mode 100644 apps/web/src/shared/styles/base.css create mode 100644 apps/web/src/shared/tabs/README.md create mode 100644 apps/web/src/shared/tabs/TabsPrimitives.tsx create mode 100644 apps/web/src/shared/tabs/index.ts create mode 100644 apps/web/src/shared/tabs/tabs.css create mode 100644 apps/web/src/shared/tag/README.md create mode 100644 apps/web/src/shared/tag/TagPrimitives.tsx create mode 100644 apps/web/src/shared/tag/index.ts create mode 100644 apps/web/src/shared/tag/tag.css create mode 100644 apps/web/src/shared/tooltip/README.md create mode 100644 apps/web/src/shared/tooltip/TooltipPrimitives.tsx create mode 100644 apps/web/src/shared/tooltip/index.ts create mode 100644 apps/web/src/shared/tooltip/tooltip.css create mode 100644 apps/web/src/shared/typography/README.md create mode 100644 apps/web/src/shared/typography/TypographyPrimitives.tsx create mode 100644 apps/web/src/shared/typography/index.ts create mode 100644 apps/web/src/shared/typography/typography.css create mode 100644 apps/web/src/test/design-system-page.test.tsx create mode 100644 apps/web/src/test/shared-alert.test.tsx create mode 100644 apps/web/src/test/shared-avatar.test.tsx create mode 100644 apps/web/src/test/shared-badge.test.tsx create mode 100644 apps/web/src/test/shared-breadcrumb.test.tsx create mode 100644 apps/web/src/test/shared-card.test.tsx create mode 100644 apps/web/src/test/shared-copy-button.test.tsx create mode 100644 apps/web/src/test/shared-divider.test.tsx create mode 100644 apps/web/src/test/shared-empty-state.test.tsx create mode 100644 apps/web/src/test/shared-filter-bar.test.tsx create mode 100644 apps/web/src/test/shared-icon.test.tsx create mode 100644 apps/web/src/test/shared-kv-list.test.tsx create mode 100644 apps/web/src/test/shared-metric-card.test.tsx create mode 100644 apps/web/src/test/shared-page-layout.test.tsx create mode 100644 apps/web/src/test/shared-pagination.test.tsx create mode 100644 apps/web/src/test/shared-popover.test.tsx create mode 100644 apps/web/src/test/shared-progress.test.tsx create mode 100644 apps/web/src/test/shared-scroll-area.test.tsx create mode 100644 apps/web/src/test/shared-skeleton.test.tsx create mode 100644 apps/web/src/test/shared-spinner.test.tsx create mode 100644 apps/web/src/test/shared-steps.test.tsx create mode 100644 apps/web/src/test/shared-tabs.test.tsx create mode 100644 apps/web/src/test/shared-tag.test.tsx create mode 100644 apps/web/src/test/shared-tooltip.test.tsx create mode 100644 apps/web/src/test/shared-typography.test.tsx create mode 100644 docs/plans/2026-04-25-design-system-component-docsite-sidebar.md create mode 100644 docs/plans/2026-04-25-design-system-docsite-upgrade.md diff --git a/.gitignore b/.gitignore index 209f0b2..fc25514 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ apps/web/test-results/ .spec-workflow/ .omx/ optimus/ -.superpowers/ \ No newline at end of file +.superpowers/ +.claude +.playwright-mcp/ \ No newline at end of file diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index c511af0..0b38dc7 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -17,9 +17,12 @@ - mailbox 创建 - inbox 读取 - admin 基本操作 +- `/design-system` 结构与视觉回归脚手架 ## 📏 约定 - 端到端测试优先验证“用户路径”,不是内部实现细节 - 当前阶段允许通过 `page.route()` 做 API mock,先保证前端壳层和交互路径稳定 - 更接近真实后端联调的 e2e 可在后续独立 staging 环境中补充 +- `design-system.spec.ts` 默认只跑结构断言;截图基线需在预览页示例稳定后,通过 `PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1` 显式开启 +- `/design-system` 现在是公开页面;相关 e2e 不要求先登录或挂后台壳层 diff --git a/apps/web/e2e/design-system.spec.ts b/apps/web/e2e/design-system.spec.ts new file mode 100644 index 0000000..a9e652d --- /dev/null +++ b/apps/web/e2e/design-system.spec.ts @@ -0,0 +1,54 @@ +import { expect, test, type Page } from "@playwright/test"; + +const snapshotUpdatesEnabled = process.env.PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS === "1"; + +async function mockPublicSession(page: Page) { + await page.route("**/auth/session", async (route) => { + await route.fulfill({ status: 401, json: { error: "not authenticated" } }); + }); +} + +async function visitDesignSystem(page: Page, theme: "light" | "dark") { + await mockPublicSession(page); + await page.addInitScript((nextTheme) => { + window.localStorage.setItem("wemail-workspace-theme", nextTheme); + document.documentElement.dataset.theme = nextTheme; + document.documentElement.style.colorScheme = nextTheme; + }, theme); + + await page.goto("/design-system"); + await expect(page.getByRole("navigation", { name: "首页导航" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Foundations" })).toBeVisible(); + await expect(page.getByRole("navigation", { name: /design system sidebar/i })).toBeVisible(); + await expect(page.getByTestId("design-system-page")).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => document.documentElement.dataset.theme), { timeout: 10_000 }) + .toBe(theme); +} + +test("shows the design system page as a sidebar-driven public docsite", async ({ page }) => { + await visitDesignSystem(page, "light"); + + const sidebarButtons = page.getByRole("navigation", { name: /design system sidebar/i }).getByRole("button"); + await expect(sidebarButtons).toHaveCount(14); + await expect(page.getByText("WeMail Design System v1")).toBeVisible(); + await expect(page.getByRole("button", { name: "打开对话框" })).toBeVisible(); + await expect(page.getByRole("button", { name: "打开抽屉" })).toBeVisible(); +}); + +test.describe("design system visual regression scaffold", () => { + test.skip( + !snapshotUpdatesEnabled, + "Enable PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1 when the preview examples and CI screenshot environment are ready." + ); + + for (const theme of ["light", "dark"] as const) { + test(`captures the ${theme} theme shell`, async ({ page }) => { + await visitDesignSystem(page, theme); + + await expect(page.getByTestId("design-system-page")).toHaveScreenshot(`design-system-${theme}.png`, { + animations: "disabled" + }); + }); + } +}); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index fa4f69c..b4ea28d 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, useLocation } from "react-router-dom"; import { AppLayout } from "./AppLayout"; import { AppRoutes } from "./AppRoutes"; import { AuthPage } from "../pages/AuthPage"; +import { DesignSystemPage } from "../pages/DesignSystemPage"; import { WemailLoadingShell } from "../shared/WemailLoadingShell"; import { WemailToastViewport } from "../shared/WemailToastViewport"; import { useAppStore } from "./appStore"; @@ -59,6 +60,16 @@ function AppContent() { }, [location.pathname, session]); const toastViewport = ; + const isPublicDesignSystemPage = location.pathname === "/design-system"; + + if (isPublicDesignSystemPage) { + return ( + <> + {toastViewport} + + + ); + } if (auth.loadingSession) { return ( diff --git a/apps/web/src/app/AppLayout.tsx b/apps/web/src/app/AppLayout.tsx index 67627c0..de91c01 100644 --- a/apps/web/src/app/AppLayout.tsx +++ b/apps/web/src/app/AppLayout.tsx @@ -1,5 +1,5 @@ import { ReactNode, useEffect, useRef, useState } from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { NavLink, useLocation, useNavigate } from "react-router-dom"; import { Bell, ChevronDown, @@ -18,7 +18,8 @@ import { import type { SessionSummary } from "@wemail/shared"; -import { Button, ButtonLink } from "../shared/button"; +import { Button } from "../shared/button"; +import { Tabs, TabsList, TabsPanel, TabsTrigger } from "../shared/tabs"; import { WemailBrandLockup } from "../shared/WemailBrandLockup"; import type { WorkspaceRailIcon, WorkspaceShellState } from "./workspaceShell"; import type { WorkspaceTheme } from "./useWorkspaceTheme"; @@ -81,6 +82,7 @@ export function AppLayout({ children }: AppLayoutProps) { const location = useLocation(); + const navigate = useNavigate(); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const userMenuRef = useRef(null); const railScrollRef = useRef(null); @@ -108,6 +110,9 @@ export function AppLayout({ }; }, []); + const activeSecondaryRoute = + shell.secondaryNav.find((item) => item.to === location.pathname)?.to ?? shell.secondaryNav[0]?.to ?? ""; + useEffect(() => { const areas = [railScrollRef.current, mainScrollRef.current].filter(Boolean) as Array; if (areas.length === 0) return; @@ -162,19 +167,28 @@ export function AppLayout({
{shell.secondaryNav.length > 0 ? ( -
-
-
- {developerTabs.map((tab, index) => ( - - ))} + setActiveTab(Number(nextValue))} value={String(activeTab)} variant="segmented"> +
+ + {developerTabs.map((tab, index) => ( + + {tab.label} + + ))} + +
- -
- - + {developerTabs.map((tab, index) => ( + + + + + ))} +
@@ -1065,7 +947,7 @@ export function WemailLandingPage({ }) { return (
- +
diff --git a/apps/web/src/features/landing/landing.css b/apps/web/src/features/landing/landing.css index 6bf51b9..b671e9b 100644 --- a/apps/web/src/features/landing/landing.css +++ b/apps/web/src/features/landing/landing.css @@ -169,6 +169,10 @@ transition: width 500ms cubic-bezier(0.22, 1, 0.36, 1), transform 500ms cubic-bezier(0.22, 1, 0.36, 1), padding 500ms cubic-bezier(0.22, 1, 0.36, 1); } +.landing-nav-edge-control { + justify-self: end; +} + .landing-nav.is-floating { width: min(1200px, 100%); padding-top: 16px; @@ -176,7 +180,7 @@ .landing-nav-bar { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 24px; min-height: 80px; @@ -247,6 +251,20 @@ .landing-nav-links { justify-content: center; + gap: 32px; +} + +.landing-nav-actions { + align-items: center; + justify-self: end; + gap: 12px; + margin-right: -1px; +} + +.landing-nav-theme-toggle, +.landing-nav-mobile-toggle { + border: 1px solid var(--border) !important; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--surface) 30%, transparent); } .landing-nav-link { @@ -296,22 +314,26 @@ .landing-mobile-menu-backdrop { position: fixed; inset: 0; + z-index: 35; display: grid; place-items: stretch; padding: 88px 24px 24px; background: color-mix(in srgb, var(--bg) 90%, transparent); backdrop-filter: blur(18px); + pointer-events: auto; } .landing-mobile-menu { - display: grid; - align-content: space-between; + display: flex; + flex-direction: column; gap: 24px; + height: calc(100dvh - 112px); border-radius: 32px; border: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 92%, transparent); padding: 32px 24px; box-shadow: var(--shadow-card); + pointer-events: auto; } .landing-mobile-links { @@ -331,6 +353,13 @@ .landing-mobile-actions { display: grid; gap: 12px; + margin-top: auto; +} + +.landing-mobile-theme-toggle, +.landing-mobile-theme-toggle:hover, +.landing-mobile-theme-toggle:focus-visible { + transform: none; } .landing-hero-section { @@ -895,6 +924,18 @@ margin-top: 12px; } +.landing-marquee-list-compact { + overflow: visible; +} + +.landing-marquee-track-compact { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 12px; + animation: none; +} + .landing-integration-card { flex-direction: column; align-items: flex-start; @@ -1735,6 +1776,18 @@ grid-template-columns: auto 1fr auto; } + .landing-nav-mobile-toggle { + position: static; + justify-self: end; + align-self: center; + transform: none; + } + + .landing-nav-mobile-toggle.ui-button:hover, + .landing-nav-mobile-toggle.ui-button:focus-visible { + transform: none; + } + .landing-hero-section { min-height: auto; padding-top: 124px; @@ -1777,6 +1830,10 @@ padding-inline: 16px; } + .landing-nav-mobile-toggle-tight { + margin-right: -24px; + } + .landing-nav.is-floating { padding-top: 10px; } @@ -1797,13 +1854,72 @@ min-height: calc(100dvh - 88px); } + .landing-hero-section { + padding-top: 100px; + padding-bottom: 24px; + min-height: auto; + } + + .landing-hero-copy-shell { + gap: 16px; + } + .landing-display-title, .landing-testimonial-grid blockquote p { font-size: clamp(2.2rem, 12vw, 3.8rem); } .landing-hero-title { - font-size: clamp(2.8rem, 16vw, 4.6rem); + gap: 2px; + font-size: clamp(2.45rem, 14vw, 4.1rem); + line-height: 0.92; + letter-spacing: -0.06em; + } + + .landing-hero-description { + max-width: none; + font-size: 1rem; + line-height: 1.65; + } + + .landing-hero-bottom { + gap: 16px; + } + + .landing-cta-row { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + } + + .landing-cta-row .ui-button { + width: 100%; + justify-content: center; + } + + .landing-stats-marquee { + padding-top: 20px; + overflow: visible; + } + + .landing-marquee-track, + .landing-marquee-track.reverse { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 12px; + animation: none; + } + + .landing-stat-chip { + width: 100%; + min-width: 0; + padding: 16px 18px; + gap: 12px; + } + + .landing-stat-chip strong { + font-size: 2rem; } .landing-stat-chip, diff --git a/apps/web/src/features/outbound/OutboundPage.tsx b/apps/web/src/features/outbound/OutboundPage.tsx index 517940d..c7c6738 100644 --- a/apps/web/src/features/outbound/OutboundPage.tsx +++ b/apps/web/src/features/outbound/OutboundPage.tsx @@ -2,7 +2,9 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useSearchParams } from "react-router-dom"; import { Button } from "../../shared/button"; -import { FormField, TextInput } from "../../shared/form"; +import { FilterBar, FilterBarActions } from "../../shared/filter-bar"; +import { FormField, SearchInput } from "../../shared/form"; +import { Page, PageBody, PageHeader, PageMain, PageSidebar, PageToolbar } from "../../shared/page-layout"; import type { OutboundHistoryItem } from "../inbox/types"; import { buildOutboundRecords, type OutboundRecord } from "./outboundMockData"; import { OutboundComposeDrawer } from "./OutboundComposeDrawer"; @@ -115,50 +117,51 @@ export function OutboundPage({ outboundHistory, hasActiveMailbox, onSendMail }: return ( <> -
+
-
-
-

邮件中心

-

发件箱

-

按发送结果回看历史、定位失败原因,并把异常 / 无匹配记录和正常外发放在同一套工作流里。

-
-
- - -
-
- -
- 发件箱搜索}> - setSearchValue(event.target.value)} - placeholder="搜索收件人 / 主题 / 发件结果" - type="search" - value={searchValue} - /> - -
- {(Object.keys(FILTER_LABELS) as OutboundFilter[]).map((filterKey) => ( - + - ))} -
-
+
+ } + description="按发送结果回看历史、定位失败原因,并把异常 / 无匹配记录和正常外发放在同一套工作流里。" + kicker="邮件中心" + title="发件箱" + /> + + + + 发件箱搜索}> + setSearchValue(event.target.value)} + placeholder="搜索收件人 / 主题 / 发件结果" + value={searchValue} + /> + + + {(Object.keys(FILTER_LABELS) as OutboundFilter[]).map((filterKey) => ( + + ))} + + + -
-
+ +

发送记录

@@ -193,9 +196,9 @@ export function OutboundPage({ outboundHistory, hasActiveMailbox, onSendMail }: ))}
-
+ -
+ {selectedRecord ? ( <>
@@ -268,9 +271,9 @@ export function OutboundPage({ outboundHistory, hasActiveMailbox, onSendMail }: ) : (

当前还没有发件记录。

)} -
-
- + + +
-
-

总密钥

-
-
- 总数 - {summary.totalKeys} -
-
-
+ -
-

活跃密钥

-
-
- 可用 - {summary.activeKeys} -
-
-
+ -
-

从未使用

-
-
- 未使用 - {summary.unusedKeys} -
-
-
+ -
-

已吊销

-
-
- 失效 - {summary.revokedKeys} -
-
-
+
diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 27e167f..929c6db 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -13,6 +13,7 @@ import { dashboardUserRoles } from "../features/dashboard/dashboardMockData"; import { nivoTheme } from "../shared/chart"; +import { MetricCard } from "../shared/metric-card"; const GROWTH_KEYS = ["accounts", "mailboxes"] as const; const GROWTH_LABELS: Record<(typeof GROWTH_KEYS)[number], string> = { @@ -62,13 +63,16 @@ export function DashboardPage() {
{dashboardKpis.map((kpi) => ( -
-

KPI

-

{kpi.label}

- {kpi.value} - {kpi.detail} - {kpi.change} -
+ ))}
diff --git a/apps/web/src/pages/DesignSystemPage.tsx b/apps/web/src/pages/DesignSystemPage.tsx index 8466425..917466f 100644 --- a/apps/web/src/pages/DesignSystemPage.tsx +++ b/apps/web/src/pages/DesignSystemPage.tsx @@ -1,182 +1,174 @@ -import type { CSSProperties, ReactNode } from "react"; +import { type CSSProperties, useEffect, useState } from "react"; -type SwatchProps = { - name: string; - hex: string; - varName?: string; -}; +import { type WorkspaceTheme } from "../app/appStore"; +import { PublicSiteNavigation } from "../features/landing/PublicSiteNavigation"; +import { Badge } from "../shared/badge"; +import { DesignSystemDocContent } from "./design-system/DesignSystemDocContent"; +import { DesignSystemPreviewOverlays, DesignSystemSectionList } from "./design-system/DesignSystemPreviewContent"; +import { designSystemGroups } from "./design-system/designSystemContent"; +import { PreviewActionButtons } from "./design-system/designSystemPreviewParts"; +import { designSystemSections, findDesignSystemSection } from "./design-system/designSystemSections"; +import { designSystemPageStyles, designSystemSharedStyles, resolveDesignSystemSidebarLayoutStyle } from "./design-system/designSystemStyles"; -function Swatch({ name, hex, varName }: SwatchProps) { - const style: CSSProperties = { background: hex }; - return ( -
-
- ); -} +const DESIGN_SYSTEM_THEME_STORAGE_KEY = "wemail-design-system-preview-theme"; -type TokenRowProps = { - label: string; - value: ReactNode; - hint?: string; -}; +function resolveInitialPreviewTheme(): WorkspaceTheme { + if (typeof window !== "undefined") { + const storedTheme = window.localStorage.getItem(DESIGN_SYSTEM_THEME_STORAGE_KEY); + if (storedTheme === "light" || storedTheme === "dark") return storedTheme; -function TokenRow({ label, value, hint }: TokenRowProps) { - return ( -
-
{value}
-
- {label} - {hint ? {hint} : null} -
-
- ); + if (typeof window.matchMedia === "function") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + } + + if (typeof document !== "undefined") { + const datasetTheme = document.documentElement.dataset.theme; + if (datasetTheme === "light" || datasetTheme === "dark") return datasetTheme; + } + + return "light"; } +const groups = designSystemGroups; + export function DesignSystemPage() { + const [previewTheme, setPreviewTheme] = useState(resolveInitialPreviewTheme); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const initialGroup = groups[0]; + const initialComponent = initialGroup?.components[0] ?? null; + const [activeGroupId, setActiveGroupId] = useState(initialGroup?.id ?? "foundations"); + const [activeComponentId, setActiveComponentId] = useState(initialComponent?.id ?? null); + const [sidebarLayoutStyle, setSidebarLayoutStyle] = useState(resolveDesignSystemSidebarLayoutStyle); + + const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0]; + const activeComponent = activeGroup?.components.find((component) => component.id === activeComponentId) ?? activeGroup?.components[0] ?? null; + + function togglePreviewTheme() { + setPreviewTheme((current) => (current === "dark" ? "light" : "dark")); + } + + function handleSelectComponent(groupId: string, componentId: string) { + setActiveGroupId(groupId); + setActiveComponentId(componentId); + } + + useEffect(() => { + const previousTheme = document.documentElement.dataset.theme; + const previousColorScheme = document.documentElement.style.colorScheme; + + document.documentElement.dataset.theme = previewTheme; + document.documentElement.style.colorScheme = previewTheme; + window.localStorage.setItem(DESIGN_SYSTEM_THEME_STORAGE_KEY, previewTheme); + + return () => { + if (previousTheme === "light" || previousTheme === "dark") { + document.documentElement.dataset.theme = previousTheme; + } else { + delete document.documentElement.dataset.theme; + } + + document.documentElement.style.colorScheme = previousColorScheme; + }; + }, [previewTheme]); + + useEffect(() => { + function handleResize() { + setSidebarLayoutStyle(resolveDesignSystemSidebarLayoutStyle()); + } + + handleResize(); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + return ( -
-
-

WeMail UI 系统 · v1.0

-

WeMail 设计系统

-

- 一套简洁、现代、易用的界面规范。所有组件与样式均基于此页展示的设计原则、色彩、字号、间距、圆角与阴影 - token 构建;新功能应优先从这里取用原语与变量,避免在业务层重造样式。 -

-
- -
-

设计原则

-

四个不变量

-
-
- 简洁清晰 - 减少视觉干扰,一屏只解决一件事。 -
-
- 一致性 - 相同操作使用相同组件与状态表达。 -
-
- 易用性 - 优先键盘可达与屏幕阅读器语义。 -
-
- 可扩展 - 原语遵守 forwardRef + className + state 约定,便于组合。 -
-
-
- -
-

色彩系统

-

品牌与语义色

-
- - - - - - -
-
- -
-

间距系统

-

4px 网格

-
- {[4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 72, 80, 96, 112, 128].map((value) => ( -
-
- ))} -
-
- -
-

圆角系统

-

6 档梯度

-
- } - hint="--radius-xs · 内嵌边框、标签边缘" - /> - } - hint="--radius-sm · 小按钮、输入内部" - /> - } - hint="--radius-md · 一般按钮、标签页头部" - /> - } - hint="--radius-lg · 输入框、卡片内嵌" - /> - } - hint="--radius-xl · 卡片外壳" - /> - } - hint="--radius-full · 头像、pill、switch 轨道" - /> -
-
- -
-

阴影系统

-

5 档层级

-
- {[ - { name: "xs", varName: "--elevation-xs" }, - { name: "sm", varName: "--elevation-sm" }, - { name: "md", varName: "--elevation-md" }, - { name: "lg", varName: "--elevation-lg" }, - { name: "xl", varName: "--elevation-xl" } - ].map(({ name, varName }) => ( -
- {name} - {varName} +
+ +
+
+
+

+ WeMail Design System v1 +

+
+
+ {`${groups.length} groups`} + {`${designSystemSections.length} sections`} + /design-system +
+
+ {previewTheme === "dark" ? "深色模式" : "浅色模式"} + setIsDialogOpen(true)} onOpenDrawer={() => setIsDrawerOpen(true)} /> +
- ))} -
-
- -
-

字号系统

-

7 档语义字号

-
-
显示大号 · 48/1.1
-
显示中号 · 32/1.2
-
标题大号 · 24/1.3
-
标题中号 · 20/1.35
-
正文大号 · 16/1.55
-
正文中号 · 14/1.55
-
辅助说明 · 12/1.45
+
+
+ +
+ + +
+ {activeComponent ? ( + <> + findDesignSystemSection(sectionId).title)} + /> + + + ) : null} +
-
- -
-

组件库

-

原语预览

-

- 所有原语(Button / Form / Table / Switch / Tabs 等)将在后续 Sprint 陆续接入本页。目前可以查看各自仓库目录下的 - README:apps/web/src/shared/。 -

-
-
+ + setIsDialogOpen(false)} + onCloseDrawer={() => setIsDrawerOpen(false)} + /> +
+
); } diff --git a/apps/web/src/pages/SystemProfilePage.tsx b/apps/web/src/pages/SystemProfilePage.tsx index e559bb5..adbced8 100644 --- a/apps/web/src/pages/SystemProfilePage.tsx +++ b/apps/web/src/pages/SystemProfilePage.tsx @@ -1,5 +1,8 @@ +import { Avatar } from "../shared/avatar"; import { Button } from "../shared/button"; import { FormField, RadioGroupField, SelectInput, TextInput, TextareaInput } from "../shared/form"; +import { KVList } from "../shared/kv-list"; +import { Page, PageHeader } from "../shared/page-layout"; type SystemProfilePageProps = { sessionSummary: { @@ -11,18 +14,16 @@ type SystemProfilePageProps = { export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) { return ( -
+
-
-

账号资料

-

你的账户信息

-

集中维护你的展示身份与基础资料,保持对外沟通时的一致性。

-
+
- +
WeMail Admin {sessionSummary.email} @@ -41,14 +42,12 @@ export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) {
-
- 角色 - {sessionSummary.role} -
-
- 创建时间 - {sessionSummary.createdAtLabel} -
+
@@ -59,11 +58,11 @@ export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) {
-
-

使用偏好

-

按你的工作方式来调整界面

-

这些设置会影响你进入 WeMail 后默认看到的节奏与信息密度。

-
+
-
-

安全与会话

-

管理密码和当前登录状态

-

优先显示你当前的会话信息,并把高风险操作收拢到同一个动作区。

-
+
@@ -156,6 +155,6 @@ export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) {
-
+ ); } diff --git a/apps/web/src/pages/UsersGlobalSettingsPage.tsx b/apps/web/src/pages/UsersGlobalSettingsPage.tsx index 57b7639..26ef214 100644 --- a/apps/web/src/pages/UsersGlobalSettingsPage.tsx +++ b/apps/web/src/pages/UsersGlobalSettingsPage.tsx @@ -7,6 +7,7 @@ import { InvitePanel } from "../features/admin/InvitePanel"; import { MailboxOversightPanel } from "../features/admin/MailboxOversightPanel"; import { QuotaPanel } from "../features/admin/QuotaPanel"; import type { InviteSummary } from "../features/admin/types"; +import { Page, PageHeader, PageMain } from "../shared/page-layout"; type UsersGlobalSettingsPageProps = { adminUsers: UserSummary[]; @@ -34,16 +35,16 @@ export function UsersGlobalSettingsPage({ onToggleFeatures }: UsersGlobalSettingsPageProps) { return ( -
+
-
-

用户设置

-

全局控制

-

集中管理邀请码、系统级配额、全局功能开关和邮箱总览。

-
+
-
+ -
-
+ + ); -} \ No newline at end of file +} diff --git a/apps/web/src/pages/UsersListPage.tsx b/apps/web/src/pages/UsersListPage.tsx index 07acbc3..2279586 100644 --- a/apps/web/src/pages/UsersListPage.tsx +++ b/apps/web/src/pages/UsersListPage.tsx @@ -3,8 +3,11 @@ import { useState, type FormEvent } from "react"; import type { QuotaSummary, UserSummary } from "@wemail/shared"; import { Button } from "../shared/button"; -import { CheckboxField, FormField, SelectInput, TextInput } from "../shared/form"; +import { Badge } from "../shared/badge"; +import { FilterBar } from "../shared/filter-bar"; +import { CheckboxField, FormField, SearchInput, SelectInput } from "../shared/form"; import { OverlayDrawer } from "../shared/overlay"; +import { Page, PageBody, PageHeader, PageMain, PageSidebar, PageToolbar } from "../shared/page-layout"; import { Table, TableBody, @@ -87,126 +90,141 @@ export function UsersListPage({ } return ( -
+
-
-
-

用户中心

-
-
- -
-
- -
- 搜索用户}> - onSearchChange(event.target.value)} - placeholder="搜索邮箱或显示名" - value={searchValue} - /> - - - 角色筛选}> - onRoleFilterChange(event.target.value as UsersRoleFilter)} value={roleFilter}> - - - - - - - 状态筛选}> - onStatusFilterChange(event.target.value as UsersStatusFilter)} value={statusFilter}> - - - - -
+ + + + } + kicker="用户中心" + /> + + + + 搜索用户}> + onSearchChange(event.target.value)} + placeholder="搜索邮箱或显示名" + value={searchValue} + /> + + + 角色筛选}> + onRoleFilterChange(event.target.value as UsersRoleFilter)} value={roleFilter}> + + + + + + + 状态筛选}> + onStatusFilterChange(event.target.value as UsersStatusFilter)} value={statusFilter}> + + + + + +
-
-
-
-

用户列表

+ + +
+
+

用户列表

+
-
- - {selectedCount > 0 ? ( -
-
-
-

批量操作

-

已选择 {selectedCount} 个用户

-
-
- - - -
+ + {selectedCount > 0 ? ( +
+ + + + +
+ } + kicker="批量操作" + title={`已选择 ${selectedCount} 个用户`} + /> +
+ ) : null} + + + + + + + 选择全部用户} + onChange={toggleSelectAll} + /> + + 用户 + 邮箱 + 角色 + 创建时间 + 状态 + + 操作 + + + + + {visibleUsers.map((user) => { + const isSelected = selectedUserIds.includes(user.id); + + return ( + + + 选择用户 {user.email}} + onChange={() => toggleUserSelection(user.id)} + /> + + + {buildDisplayName(user.email)} + + {user.email} + {formatRole(user.role)} + {user.createdAt.slice(0, 10)} + + 正常 + + + + + + ); + })} + +
+
+ + + +
+

筛选摘要

+
+
可见用户:{visibleUsers.length}
+
已选用户:{selectedCount}
+
角色筛选:{roleFilter === "all" ? "全部" : formatRole(roleFilter)}
- ) : null} - - - - - - - 选择全部用户} - onChange={toggleSelectAll} - /> - - 用户 - 邮箱 - 角色 - 创建时间 - 状态 - - 操作 - - - - - {visibleUsers.map((user) => { - const isSelected = selectedUserIds.includes(user.id); - - return ( - - - 选择用户 {user.email}} - onChange={() => toggleUserSelection(user.id)} - /> - - - {buildDisplayName(user.email)} - - {user.email} - {formatRole(user.role)} - {user.createdAt.slice(0, 10)} - - 正常 - - - - - - ); - })} - -
-
-
+ + {selectedUser ? ( ) : null} -
+ ); } diff --git a/apps/web/src/pages/design-system/DesignSystemDocContent.tsx b/apps/web/src/pages/design-system/DesignSystemDocContent.tsx new file mode 100644 index 0000000..424215f --- /dev/null +++ b/apps/web/src/pages/design-system/DesignSystemDocContent.tsx @@ -0,0 +1,161 @@ +import type { DesignSystemApiField, DesignSystemCodeSample, DesignSystemComponentDoc, DesignSystemDocSection } from "./designSystemContent"; +import { designSystemDocStyles, designSystemSharedStyles } from "./designSystemStyles"; + +interface DesignSystemDocContentProps { + componentDoc: DesignSystemComponentDoc; + groupTitle: string; + sectionTitles: string[]; +} + +const COMPONENT_SECTION_ORDER = ["真实示例", "API 接口", "使用说明", "设计规范"] as const; + +type ComponentSectionTitle = (typeof COMPONENT_SECTION_ORDER)[number]; + +function renderParagraphs(paragraphs: string[]) { + return ( +
+ {paragraphs.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+ ); +} + +function getSectionBodyMap(docSections?: DesignSystemDocSection[]): Map { + return new Map((docSections ?? []).map((section) => [section.title, section.body])); +} + +function renderCodeSamples(codeSamples: DesignSystemCodeSample[]) { + return ( +
+ {codeSamples.map((sample) => ( +
+

{sample.title}

+
+            {sample.code}
+          
+
+ ))} +
+ ); +} + +function renderApiTable(apiFields: DesignSystemApiField[]) { + return ( +
+ + + + + + + + + + + {apiFields.map((field) => ( + + + + + + + ))} + +
proptypedefaultdescription
{field.prop} + {field.type} + + {field.defaultValue} + {field.description}
+
+ ); +} + +function getComponentSections(componentDoc: DesignSystemComponentDoc, sectionTitles: string[]): Array<{ title: ComponentSectionTitle; body: string[] }> { + const sectionBodyMap = getSectionBodyMap(componentDoc.docSections); + const designNoteBodies = (componentDoc.docSections ?? []) + .filter((section) => !["适用场景", "不适用场景", "状态与变体", "设计规范"].includes(section.title)) + .flatMap((section) => section.body); + + const usageBody = [ + componentDoc.summary, + ...(sectionBodyMap.get("适用场景") ?? ["当前文档内容仍在补齐中,现阶段请先结合真实示例确认适用场景。"]), + ...(sectionBodyMap.get("不适用场景") ?? ["当当前任务只需要文本跳转或静态信息承载时,应优先考虑更轻量的原语。"]) + ]; + + const fallbackByTitle: Record = { + "真实示例": ["下方会继续保留该组件关联的 live preview,用来验证真实交互与文档说明是否一致。"], + "API 接口": [ + "API 表结构会在后续任务中补齐;当前阶段先固定阅读顺序与占位区块。", + componentDoc.codeSamples?.length ? "当前可先参考下方代码示例了解已接入的调用方式。" : "当前组件的调用方式将在后续任务中补充为结构化 API 表。" + ], + "使用说明": usageBody, + "设计规范": + sectionBodyMap.get("设计规范") ?? + sectionBodyMap.get("状态与变体") ?? + (sectionTitles.length + ? [`当前页面优先覆盖 ${sectionTitles.join("、")} 相关的状态与变体预览。`] + : designNoteBodies.length + ? designNoteBodies + : ["设计规范会持续沉淀到统一模板中,避免组件说明散落在页面实现里。"]) + }; + + return COMPONENT_SECTION_ORDER.map((title) => ({ + title, + body: fallbackByTitle[title] + })); +} + +export function DesignSystemDocContent({ componentDoc, groupTitle, sectionTitles }: DesignSystemDocContentProps) { + const sections = getComponentSections(componentDoc, sectionTitles); + + return ( +
+
+

{groupTitle} / Component

+

{componentDoc.title}

+ {componentDoc.chineseTitle} +
+ {sectionTitles.map((sectionTitle) => ( + + {sectionTitle} + + ))} +
+
+
+ {sections.map((section) => { + const isExampleSection = section.title === "真实示例"; + const isGuidanceSection = section.title === "API 接口" || section.title === "设计规范"; + + return ( +
+

{section.title}

+ {section.title === "API 接口" && componentDoc.api?.length ? renderApiTable(componentDoc.api) : renderParagraphs(section.body)} +
+ ); + })} + {componentDoc.codeSamples?.length ? ( +
+

代码示例

+ {renderCodeSamples(componentDoc.codeSamples)} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/pages/design-system/DesignSystemPreviewContent.tsx b/apps/web/src/pages/design-system/DesignSystemPreviewContent.tsx new file mode 100644 index 0000000..d448125 --- /dev/null +++ b/apps/web/src/pages/design-system/DesignSystemPreviewContent.tsx @@ -0,0 +1,143 @@ +import { Alert } from "../../shared/alert"; +import { Button } from "../../shared/button"; +import { SearchInput } from "../../shared/form"; +import { KVList } from "../../shared/kv-list"; +import { OverlayDialog, OverlayDrawer } from "../../shared/overlay"; +import { Text } from "../../shared/typography"; +import type { DesignSystemSectionId } from "./designSystemContent"; +import { findDesignSystemSection } from "./designSystemSections"; +import { designSystemExampleStyles, designSystemPageStyles, designSystemSharedStyles } from "./designSystemStyles"; + +interface DesignSystemSectionListProps { + sectionIds: DesignSystemSectionId[]; +} + +interface DesignSystemPreviewOverlaysProps { + isDialogOpen: boolean; + isDrawerOpen: boolean; + onCloseDialog: () => void; + onCloseDrawer: () => void; +} + +export function DesignSystemSectionList({ sectionIds }: DesignSystemSectionListProps) { + return sectionIds.map((sectionId) => { + const section = findDesignSystemSection(sectionId); + + return ( +
+
+

{section.sprint}

+
+

{section.title}

+ {section.chineseTitle} +

+ {section.summary} +

+
+
+ {section.primitives.slice(0, 4).map((item) => ( + + {item} + + ))} +
+
+
+

Coverage

+
    + {section.coverage.map((item) => ( +
  • {item}
  • + ))} +
+
+
+

Regression checklist

+
    + {section.checklist.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+
+ {section.preview} +
+
+ ); + }); +} + +export function DesignSystemPreviewOverlays({ + isDialogOpen, + isDrawerOpen, + onCloseDialog, + onCloseDrawer +}: DesignSystemPreviewOverlaysProps) { + return ( + <> + {isDialogOpen ? ( + + + + + } + onClose={onCloseDialog} + title="Dialog live preview" + > +
+ 按 `Tab` 循环焦点,按 `Esc` 或点击遮罩关闭。 + + + 当前弹层已通过 shared layer portal 挂载到 body。 + +
+
+ ) : null} + + {isDrawerOpen ? ( + +
+ 这里展示的是统一抽屉壳层,而不是业务页面专属样式。 + +
+ +
+
+
+ ) : null} + + ); +} diff --git a/apps/web/src/pages/design-system/designSystemContent.ts b/apps/web/src/pages/design-system/designSystemContent.ts new file mode 100644 index 0000000..ace5153 --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemContent.ts @@ -0,0 +1,1098 @@ +export const DESIGN_SYSTEM_SECTION_IDS = [ + "foundations", + "color-theme", + "layout-spacing", + "elevation-radius", + "typography-content", + "buttons-actions", + "form-inputs", + "selection-controls", + "navigation-wayfinding", + "surfaces-cards", + "data-display", + "feedback-status", + "overlays-utilities" +] as const; + +export type DesignSystemSectionId = (typeof DESIGN_SYSTEM_SECTION_IDS)[number]; + +export interface DesignSystemDocSection { + title: string; + body: string[]; +} + +export interface DesignSystemCodeSample { + title: string; + code: string; +} + +export interface DesignSystemApiField { + prop: string; + type: string; + defaultValue: string; + description: string; +} + +export interface DesignSystemComponentDoc { + id: string; + title: string; + chineseTitle: string; + summary: string; + sectionIds: DesignSystemSectionId[]; + docSections?: DesignSystemDocSection[]; + codeSamples?: DesignSystemCodeSample[]; + api?: DesignSystemApiField[]; +} + +export interface DesignSystemGroupDoc { + id: string; + title: string; + chineseTitle: string; + summary: string; + overviewDescription: string; + sectionIds: DesignSystemSectionId[]; + docSections?: DesignSystemDocSection[]; + components: DesignSystemComponentDoc[]; +} + +export const designSystemGroups: DesignSystemGroupDoc[] = [ + { + id: "foundations", + title: "Foundations", + chineseTitle: "基础层", + summary: "统一设计 token、主题、布局节奏和表面层级,作为所有共享原语的视觉地基。", + overviewDescription: "Design tokens、预览地图、文档入口与视觉回归基线都会先在这里校准,后续组件都只复用这套基础语言。", + sectionIds: ["foundations", "color-theme", "layout-spacing", "elevation-radius"], + docSections: [ + { + title: "适用范围", + body: [ + "Foundations 用来集中说明设计 token、主题切换、布局节奏和表面层级,作为所有共享原语的统一视觉起点。", + "当页面需要定义新的颜色、间距或容器层级时,应先回到这一组基础说明确认是否已经存在可复用规范。" + ] + }, + { + title: "适用场景", + body: [ + "用于统一解释颜色、间距、圆角、阴影和页面骨架的来源,帮助设计稿、实现代码和文档站保持同一套视觉语言。", + "当团队准备新增共享组件、重排页面层级或扩展主题时,应先确认这一组基础规范是否已经覆盖需求。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把业务规则、页面专属文案或一次性视觉修饰写进 Foundations;这些内容应留在具体组件或业务页面文档中。", + "不要绕过 token 直接在页面里临时定义颜色、间距或阴影,否则会让设计系统失去统一约束。" + ] + }, + { + title: "状态与变体", + body: [ + "当前首批基础层主要覆盖 color theme、layout spacing、elevation radius 三类文档块,分别负责主题、节奏和表面层级。", + "light / dark 主题、页面容器密度和 surface 层级都应视为基础变体,由共享 token 控制而不是由业务组件各自分叉。" + ] + }, + { + title: "交互示例", + body: [ + "右侧预览会同时展示 token 清单、主题卡片和基础布局样例,作为设计评审与视觉回归的共同参考。", + "如果某个组件在不同主题下表现不一致,应先回到 Foundations 预览确认问题来自 token 还是具体组件实现。" + ] + }, + { + title: "代码片段", + body: [ + "示例片段:import \"./tokens.css\"; import \"./primitives.css\"; 页面与组件都只消费已经定义好的 CSS variables。", + "示例片段:const surfaceStyle = { borderRadius: \"var(--radius-lg)\", boxShadow: \"var(--shadow-sm)\" }; 用统一 token 映射视觉层级。" + ] + }, + { + title: "设计规范", + body: [ + "基础层文档需要和 tokens、README、CHANGELOG 保持同一套命名,避免组件页和样式变量出现双重口径。", + "新增组件只能复用这里已经定义的主题、间距与 elevation 语言,不在业务页单独引入新的视觉档位。" + ] + }, + { + title: "维护约束", + body: [ + "基础层变更需要同步检查设计系统首页、共享样式文件和对应文档,避免只更新其中一个入口。", + "任何新增 token 都应回答它解决了哪一类复用问题,而不是只为当前页面补一个临时值。" + ] + } + ], + components: [ + { + id: "design-tokens", + title: "Design tokens", + chineseTitle: "设计令牌", + summary: "品牌色、语义色、间距、圆角和阴影的统一定义。", + sectionIds: ["foundations", "color-theme", "layout-spacing", "elevation-radius"], + api: [ + { + prop: "scope", + type: '"brand" | "semantic" | "spacing" | "radius" | "elevation"', + defaultValue: '"brand"', + description: "标记当前 token 所属的设计域,帮助页面按主题、间距和表面层级组织说明。" + }, + { + prop: "varName", + type: "string", + defaultValue: '"--brand-500"', + description: "对应共享样式里实际消费的 CSS custom property 名称。" + }, + { + prop: "usage", + type: "string", + defaultValue: '"component surfaces"', + description: "说明这个 token 在按钮、卡片、表格或页面容器中的典型落点。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于统一记录品牌色、语义色、间距、圆角和阴影等基础 token,保证页面与共享原语使用同一套视觉变量。", + "当团队准备新增颜色档位、页面节奏或表面层级时,应先确认是否可以直接复用现有 token。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把业务字段、页面专属文案或一次性视觉修饰包装成 design token;这些内容应留在具体组件或页面实现中。", + "不要绕过共享变量在业务页面直接手写颜色、阴影或间距,否则文档与实现会很快失去同步。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览重点覆盖 brand、semantic、spacing、radius 和 elevation 五类 token,分别对应主题、节奏与表面层级。", + "light / dark 主题切换只应替换 token 值,不应要求组件层额外维护一套独立样式。" + ] + }, + { + title: "交互示例", + body: [ + "右侧真实示例会同时展示色板、主题卡片与 token 行项目,帮助设计评审快速确认命名与视觉映射是否一致。", + "如果某个组件在不同主题下观感异常,应先回到 design tokens 检查语义色和表面层级映射。" + ] + }, + { + title: "设计规范", + body: [ + "新增 token 需要能解释跨页面复用价值,而不是只为当前一个业务块补临时变量。", + "token 名称应与 shared styles、README 和文档站入口保持一致,避免出现多套别名。" + ] + } + ] + }, + { + id: "page-layout", + title: "PageLayout", + chineseTitle: "页面布局", + summary: "页面头部、工具栏、主内容区和侧栏的组合骨架。", + sectionIds: ["layout-spacing"], + api: [ + { + prop: "Page", + type: "layout root", + defaultValue: "required", + description: "提供页面整体容器与垂直节奏,承接 header、body 和局部分区。" + }, + { + prop: "PageBody", + type: '"with-sidebar" body region', + defaultValue: '"main only"', + description: "定义主内容区与侧栏的排布关系,用于列表页、详情页和带过滤器的工作台页面。" + }, + { + prop: "PageToolbar", + type: "toolbar region", + defaultValue: "optional", + description: "承接筛选条、批量操作和摘要信息,避免业务页面重复拼装工具栏骨架。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于管理后台页面、设置页和列表详情页的页面骨架,统一 header、toolbar、main 与 sidebar 的关系。", + "当页面需要稳定的主次区域,而不是一次性自由摆放卡片时,应优先使用 PageLayout 原语。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把单个内容卡片或弹层局部结构包装成 PageLayout;它只处理页面级骨架,不负责局部信息块样式。", + "如果页面只有一个简短正文区域,也不需要为了形式完整强行引入 sidebar 或 toolbar 容器。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖 header + toolbar + main + sidebar 的标准工作台布局,以及无侧栏时的紧凑编排节奏。", + "布局密度应通过 spacing token 与容器组合控制,而不是为每个页面重新手写 margin 和 max-width。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例展示了页头操作、筛选条和双栏正文的组合,便于验证 PageLayout 与 Card、FilterBar 的拼接边界。", + "切换不同页面时应保持相同的 toolbar 与 sidebar 节奏,这样用户能更快建立导航预期。" + ] + }, + { + title: "设计规范", + body: [ + "页面级布局优先依赖 Page、PageHeader、PageBody、PageMain、PageSidebar 等原语,不在业务页面重复定义骨架。", + "新增区域前先确认它属于 header、toolbar、main 还是 sidebar,避免内容层与布局层混在一起。" + ] + } + ] + } + ] + }, + { + id: "content-actions", + title: "Content & Actions", + chineseTitle: "内容与动作", + summary: "承接排版、按钮、卡片和数据展示组件,负责页面中的主要内容编排。", + overviewDescription: "这组组件负责把设计系统真正落到业务页面,包括标题层级、主次动作、卡片容器和数据摘要。", + sectionIds: ["typography-content", "buttons-actions", "surfaces-cards", "data-display"], + components: [ + { + id: "typography", + title: "Typography", + chineseTitle: "排版", + summary: "统一标题、正文、说明、代码和快捷键标签的语义层级。", + sectionIds: ["typography-content"], + api: [ + { + prop: "as", + type: '"h1" | "h2" | "h3" | "p" | "span"', + defaultValue: '"p"', + description: "控制排版原语输出的语义标签,保证层级结构与无障碍阅读顺序一致。" + }, + { + prop: "size", + type: '"hero" | "title-lg" | "title-md" | "body" | "caption"', + defaultValue: '"body"', + description: "映射共享排版 token,控制标题、正文和辅助文案的视觉层级。" + }, + { + prop: "tone", + type: '"default" | "muted" | "brand"', + defaultValue: '"default"', + description: "为正文、说明文字或强调内容附加统一的文本色语义。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于统一标题、正文、说明、代码与快捷键标签的层级,让页面在高密度内容下仍然保持清晰阅读节奏。", + "当页面需要表达主标题、区块说明或辅助提示时,应优先使用共享排版原语,而不是直接手写字号和行高。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 Typography 当作布局容器使用;它负责文本语义和层级,不负责卡片、栅格或页面骨架。", + "不要为了特殊视觉效果跳过现有排版档位单独写字体大小,否则标题与正文很难跨页面保持一致。" + ] + }, + { + title: "状态与变体", + body: [ + "当前示例覆盖 hero、section title、正文、caption、code、kbd 与 muted copy 等高频文本形态。", + "不同文本强调程度应通过 size 与 tone 组合表达,而不是额外定义不透明度或临时色值。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例会同时展示标题层级、说明文字、代码片段和快捷键标签,帮助团队快速检查同页阅读节奏。", + "在表单、卡片和提示组件里复用同一套排版原语,可以减少页面之间的字体风格漂移。" + ] + }, + { + title: "设计规范", + body: [ + "先确定语义标签,再选择视觉档位;不要为了视觉效果牺牲正确的 heading 或 paragraph 结构。", + "正文、说明、代码与快捷键标签都应复用共享排版 token,避免页面各自定义字体系统。" + ] + } + ] + }, + { + id: "button", + title: "Button", + chineseTitle: "按钮", + summary: "覆盖主要、次要、轻量、危险和 icon-only 等动作样式。", + sectionIds: ["buttons-actions"], + codeSamples: [ + { + title: "主次操作组合", + code: `\n` + }, + { + title: "危险与加载状态", + code: `\n` + } + ], + api: [ + { + prop: "variant", + type: '"primary" | "secondary" | "subtle" | "ghost" | "danger" | "icon"', + defaultValue: '"primary"', + description: "定义按钮的视觉层级与语义强度。" + }, + { + prop: "size", + type: '"xs" | "sm" | "md" | "lg"', + defaultValue: '"md"', + description: "控制按钮的高度、内边距和文本密度。" + }, + { + prop: "isLoading", + type: "boolean", + defaultValue: "false", + description: "在异步提交中显示加载态并阻止重复触发。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于页面主次操作、确认提交、轻量辅助动作以及需要明确点击反馈的交互入口。", + "当界面需要把一个动作表达成清晰的按钮层级,而不是纯文本链接时,优先使用 Button。" + ] + }, + { + title: "不适用场景", + body: [ + "不要用 Button 承担纯导航文本、正文内联引用或不需要强调的次级跳转;这类场景更适合链接样式。", + "不要在同一个操作区并列多个 primary 按钮,也不要把危险操作伪装成普通次按钮。" + ] + }, + { + title: "状态与变体", + body: [ + "当前文档站优先覆盖 primary、secondary、subtle、ghost、danger、icon-only 与 loading 几类高频动作变体。", + "同一个动作组里应保留明确主次关系,避免在同一区块并列多个视觉上同权重的主按钮。" + ] + }, + { + title: "交互示例", + body: [ + "推荐同时展示一个主操作按钮、一个次操作按钮和一个危险操作按钮,帮助评审动作层级是否清晰。", + "需要展示加载态时,应让按钮保留原位置并明确 loading label,避免用户误以为点击没有生效。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例: 组合展示主次操作。", + "静态示例: 用于异步提交中的禁用反馈。" + ] + }, + { + title: "设计规范", + body: [ + "按钮文案应直接表达结果或下一步动作,避免使用模糊词汇如“确认一下”“继续处理”。", + "icon-only 按钮必须补充 aria-label,危险操作优先使用 danger 变体并与普通动作拉开视觉距离。" + ] + } + ] + }, + { + id: "card", + title: "Card", + chineseTitle: "卡片", + summary: "统一信息分组、数据容器和空状态承载方式。", + sectionIds: ["surfaces-cards"], + api: [ + { + prop: "variant", + type: '"default" | "data" | "status"', + defaultValue: '"default"', + description: "定义卡片承载普通信息、数据摘要还是状态提示的视觉结构。" + }, + { + prop: "tone", + type: '"default" | "brand" | "warning" | "info"', + defaultValue: '"default"', + description: "控制卡片在品牌、提醒或信息语义下的强调方式。" + }, + { + prop: "padding", + type: '"sm" | "md" | "lg"', + defaultValue: '"md"', + description: "统一 header、body、footer 的内边距密度,避免页面局部手写 spacing。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接一组相关信息、摘要数据或局部操作,让页面在高密度信息里仍然保留清晰分区。", + "当内容需要共享同一标题、正文和底部动作容器时,优先使用 Card,而不是在页面里手写边框盒子。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把整页布局直接塞进单个 Card;页面级栅格、分栏和主次区域仍应由 PageLayout 或 section 容器承担。", + "不要为了制造层级而无节制堆叠阴影卡片,连续信息块更适合通过间距和标题分组来解决。" + ] + }, + { + title: "状态与变体", + body: [ + "常见卡片变体包括基础信息卡、带操作 footer 的任务卡、带空状态说明的容器卡以及数据摘要卡。", + "是否需要 header、body、footer 应由内容结构决定,而不是为了视觉完整强行补齐三段式。" + ] + }, + { + title: "交互示例", + body: [ + "适合展示一个含标题与正文的基础卡片,再补一个带底部操作区的任务卡,帮助团队校验结构边界。", + "如果卡片内含按钮、标签或复制工具,应验证这些动作不会把卡片误导成整块可点击容器。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:域名配额展示剩余可用量与说明。", + "静态示例:摘要信息。" + ] + }, + { + title: "设计规范", + body: [ + "卡片只负责建立信息边界,不应该再承担页面级布局职责;页面编排仍由 PageLayout 和 section 容器控制。", + "同一视图中的卡片层级应依赖统一的 radius 与 elevation token,不为单个业务块临时定义新的阴影或圆角。" + ] + } + ] + }, + { + id: "data-display", + title: "Data display", + chineseTitle: "数据展示", + summary: "表格、键值列表、头像和统计卡等摘要展示原语。", + sectionIds: ["data-display"], + api: [ + { + prop: "TableContainer.variant", + type: '"default" | "liquid"', + defaultValue: '"default"', + description: "控制表格容器的表面风格,适配常规列表与更轻盈的数据面板。" + }, + { + prop: "KVList.items", + type: "Array<{ key: string; value: ReactNode; hint?: string; action?: ReactNode }>", + defaultValue: "[]", + description: "定义键值列表的字段、提示与附加操作,用于环境信息和配置摘要。" + }, + { + prop: "MetricCard.tone", + type: '"default" | "hero"', + defaultValue: '"default"', + description: "区分普通 KPI 卡与强调型指标卡,让关键数据有更明确的视觉层级。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接表格、键值列表、头像身份块和指标摘要卡,帮助页面稳定展示结构化信息。", + "当页面需要在不依赖真实接口的情况下校验数据密度与层级时,应优先复用这组展示原语。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 Data display 组件当作输入控件或导航容器使用;它们负责展示结果,不负责采集或切换。", + "如果内容只是简短正文说明,没有结构化字段或指标层级,也不需要强行包装成数据展示原语。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖 Avatar、KVList、MetricCard 和 Table shell,分别对应身份摘要、键值信息、核心指标和列表结果。", + "数据展示组件的层级应依赖统一的 badge、caption 与 container tone,而不是在业务页额外发明新的强调样式。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例同时摆放头像组、键值列表、指标卡和紧凑表格,方便对比不同数据密度下的节奏是否协调。", + "列表里的状态展示可以直接复用 Badge、Tag 等反馈原语,避免数据组件内部再重复定义语义色。" + ] + }, + { + title: "设计规范", + body: [ + "优先保证字段标签、指标标题和状态色在不同数据组件之间语义一致,再考虑局部强调样式。", + "如果一个数据块需要解释、动作和状态提示,应通过 Card、Badge、Alert 等原语组合,而不是让单一展示组件承担所有职责。" + ] + } + ] + } + ] + }, + { + id: "forms-navigation-feedback", + title: "Forms, Navigation & Feedback", + chineseTitle: "表单、导航与反馈", + summary: "覆盖输入、选择、路径导航、状态反馈和系统提示等高频交互组件。", + overviewDescription: "输入控件、路径组件和反馈组件会一起定义页面交互密度,让用户既能完成操作,也能获得明确状态回馈。", + sectionIds: ["form-inputs", "selection-controls", "navigation-wayfinding", "feedback-status"], + components: [ + { + id: "search-input", + title: "SearchInput", + chineseTitle: "搜索输入框", + summary: "统一搜索、筛选和快速清除交互。", + sectionIds: ["form-inputs"], + api: [ + { + prop: "placeholder", + type: "string", + defaultValue: '"搜索…"', + description: "说明搜索对象和预期输入内容,帮助用户快速理解筛选范围。" + }, + { + prop: "aria-label", + type: "string", + defaultValue: '"搜索"', + description: "为仅含图标或弱化 label 的搜索场景补充明确的无障碍名称。" + }, + { + prop: "value / defaultValue", + type: "string", + defaultValue: '""', + description: "支持受控与非受控输入,适配即时搜索与初始化筛选值两类场景。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于列表页、筛选条和弹层内的快速搜索入口,统一前缀图标、占位文案与清除交互。", + "当用户需要频繁按关键字缩小结果范围时,应优先使用 SearchInput,而不是普通 TextInput。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 SearchInput 用作需要复杂格式校验的表单字段,例如邮箱、密码或 API Key 输入;这类场景应使用普通输入控件。", + "如果页面没有即时筛选或查询反馈,单独放一个搜索框会制造误导,应该先明确搜索对象和结果承载区。" + ] + }, + { + title: "状态与变体", + body: [ + "首批文档优先覆盖默认搜索态、已输入可清除态以及与筛选条并列时的紧凑布局态。", + "是否显示清除按钮、是否带前缀图标、是否放进 FilterBar,都是 SearchInput 的常见组合变体。" + ] + }, + { + title: "交互示例", + body: [ + "典型示例是账号列表页顶部搜索框:输入关键字后立即过滤列表,并支持一键清除恢复默认结果。", + "当搜索与标签、状态等筛选器联动时,应保证控件同行对齐,并让占位文案说明搜索对象。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:。", + "静态示例:。" + ] + }, + { + title: "设计规范", + body: [ + "搜索框应直接表达可搜索对象,例如账号、地址或创建人,避免只写泛化的“请输入关键字”。", + "如果搜索会和其他筛选联动,建议放进 FilterBar 组合里保持同一行节奏。" + ] + } + ] + }, + { + id: "multi-select", + title: "MultiSelect", + chineseTitle: "多选器", + summary: "统一标签筛选、权限筛选和组合条件选择。", + sectionIds: ["form-inputs"], + api: [ + { + prop: "options", + type: "Array<{ label: string; value: string }>", + defaultValue: "[]", + description: "定义可选标签、权限或筛选条件,是多选器渲染候选项的基础数据。" + }, + { + prop: "defaultValue", + type: "string[]", + defaultValue: "[]", + description: "用于带初始筛选值的列表页,让多选器在首屏就呈现当前过滤结果。" + }, + { + prop: "aria-label", + type: "string", + defaultValue: '"多选器"', + description: "为紧凑筛选条或无可视标签场景补充清晰的控件名称。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于标签筛选、权限筛选和组合条件选择,让用户可以在同一控件内快速选择多个维度。", + "当页面需要展示已选状态并允许继续增删条件时,应优先使用 MultiSelect,而不是多个离散 checkbox。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把只有两三个永久可见选项的场景强行做成 MultiSelect;这类场景更适合直接使用 checkbox 组。", + "如果筛选条件之间互斥,只能单选,也不应使用多选器,应改用 Select 或 Radio。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖默认空态与带默认值的已选态,重点验证标签筛选与组合条件在同一筛选条中的节奏。", + "多选器的展示重点是已选项反馈、候选列表滚动与紧凑布局对齐,不额外承担复杂表单校验。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例展示了标签筛选场景:默认勾选异常账号,再结合搜索框和状态下拉形成组合过滤。", + "如果多选器放进 FilterBar,应确保选中项不会撑破工具栏节奏,并保留清晰的 aria-label。" + ] + }, + { + title: "设计规范", + body: [ + "多选器应清楚区分候选项与已选结果,避免用户在筛选面板里反复确认当前状态。", + "当条件较多时,优先使用统一滚动区域与标签密度,不在业务页面私自扩展弹层样式。" + ] + } + ] + }, + { + id: "selection-controls", + title: "Selection controls", + chineseTitle: "选择控件", + summary: "Checkbox、Radio、Switch 等二元与组选项控件。", + sectionIds: ["selection-controls"], + api: [ + { + prop: "checked / defaultChecked", + type: "boolean", + defaultValue: "false", + description: "控制二元开关和单项选择的默认状态,适配受控与非受控场景。" + }, + { + prop: "label", + type: "string", + defaultValue: '""', + description: "为 Checkbox、Radio、Switch 提供统一可读标签,减少页面自行拼接文案。" + }, + { + prop: "variant", + type: '"default" | "card"', + defaultValue: '"default"', + description: "让单项选择既能作为普通表单控件,也能作为卡片式筛选块出现。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接 Checkbox、Radio、Switch 等二元与组选项控件,统一选择状态、标签和排列密度。", + "当页面需要让用户启用功能、勾选筛选条件或在互斥选项中做决定时,应优先复用这组控件。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把大量标签筛选塞进 selection controls;当选项数量较多且需要折叠、搜索或多选反馈时应切到 MultiSelect。", + "不要让单个 Switch 承担复杂确认逻辑,涉及危险操作时仍应配合按钮、弹层或说明文案。" + ] + }, + { + title: "状态与变体", + body: [ + "当前示例覆盖 checked、unchecked、card-style 与 grouped controls 等高频状态,便于回归开关、单选与多选的视觉一致性。", + "同一组选择控件应共享标签密度、禁用态和焦点反馈,不在业务层额外定义不同交互语言。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例展示了通知开关、异常筛选 checkbox、汇总方式 radio 和卡片式选择块四种组合方式。", + "如果选择控件出现在设置页和筛选条两个场景,应优先复用同一套 label 与状态说明,避免术语漂移。" + ] + }, + { + title: "设计规范", + body: [ + "选择控件首先表达状态切换与范围边界,不要通过颜色或阴影单独创造新的语义。", + "当同一视图存在多组选项时,优先按用途分组并给出清晰标签,而不是靠视觉距离让用户自行猜测。" + ] + } + ] + }, + { + id: "navigation", + title: "Navigation", + chineseTitle: "导航组件", + summary: "Breadcrumb、Tabs、Pagination、Steps 等路径与流程组件。", + sectionIds: ["navigation-wayfinding"], + api: [ + { + prop: "Breadcrumb items", + type: "ReactNode[]", + defaultValue: "[]", + description: "按路径层级描述当前位置与返回入口,适合详情页和多层管理后台。" + }, + { + prop: "Tabs.defaultValue", + type: "string", + defaultValue: '"overview"', + description: "定义首屏展示的标签页,让局部视图切换有稳定默认态。" + }, + { + prop: "Pagination.page / total / pageSize", + type: "number", + defaultValue: "1 / 0 / 20", + description: "统一分页栏的页码、总量和每页数量结构,避免列表页各自定义分页规则。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于表达页面路径、内容分段、结果分页和任务流程,帮助用户理解自己当前所在的位置与下一步去向。", + "当页面需要在多个平级视图之间切换,或需要展示多步操作进度时,应优先复用这一组导航原语。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 Navigation 当成主要操作区来承载保存、删除等动作;导航负责定位与切换,不负责提交业务结果。", + "同一层级内容不要同时叠加 Tabs、Steps 和二级 Breadcrumb,重复路径信号会增加理解成本。" + ] + }, + { + title: "状态与变体", + body: [ + "当前常见变体包括 Breadcrumb 路径导航、Tabs 内容切换、Pagination 结果分页与 Steps 流程进度。", + "是否需要图标、数字、禁用态或完成态,应跟随组件职责,而不是为视觉丰富度额外增加状态。" + ] + }, + { + title: "交互示例", + body: [ + "管理后台详情页适合组合 Breadcrumb 与 Tabs:上层路径帮助返回列表,局部视图切换交由 Tabs 处理。", + "涉及多步配置流程时,可以用 Steps 展示进度,但每一步的主操作仍应留在正文或底部操作区。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:账号详情。", + "静态示例:概览活动。" + ] + }, + { + title: "设计规范", + body: [ + "导航组件首先服务于定位和切换,不应混入主操作按钮语义;主动作仍应留在工具栏或正文操作区。", + "同一页面里不要同时堆叠多个相同层级的导航模式,避免用户同时处理 breadcrumb、tabs 和 steps 的重复路径信号。" + ] + } + ] + }, + { + id: "feedback", + title: "Feedback", + chineseTitle: "反馈组件", + summary: "Tag、Badge、Alert、Progress、Skeleton 和 Spinner 等反馈状态。", + sectionIds: ["feedback-status"], + api: [ + { + prop: "variant", + type: '"success" | "warning" | "danger" | "info" | "brand"', + defaultValue: '"info"', + description: "统一 Badge、Tag、Alert 等反馈组件的语义色映射。" + }, + { + prop: "title", + type: "string", + defaultValue: '""', + description: "用于 Alert 等强提示组件的标题文案,帮助用户快速理解事件性质。" + }, + { + prop: "value", + type: "number", + defaultValue: "0", + description: "用于 Progress 等进度型反馈,表达任务完成比例与当前阶段。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于提示当前状态、异步进度、风险警告和加载占位,让用户及时理解系统是否成功响应了操作。", + "当页面需要补充状态密度但不想打断主流程时,优先使用 Badge、Tag 或 Progress;需要明确提醒时再升级到 Alert。" + ] + }, + { + title: "不适用场景", + body: [ + "不要用高优先级 Alert 展示每一条普通提示,否则真正的风险提醒会被淹没。", + "不要让 Skeleton 或 Spinner 长时间替代真实内容;如果加载超过合理时长,应补充明确说明或失败反馈。" + ] + }, + { + title: "状态与变体", + body: [ + "首批反馈文档覆盖 success、warning、danger、info 几类语义状态,以及 loading、empty、in-progress 等交互阶段。", + "Badge、Tag、Progress、Alert、Skeleton 和 Spinner 分别对应不同强度的反馈层级,选择时应先判断是否会打断用户主流程。" + ] + }, + { + title: "交互示例", + body: [ + "例如在 API Key 列表中,用 Badge 标识状态、用 Alert 呈现失败原因、用 Spinner 或 Skeleton 承接短暂加载。", + "涉及批量任务时,可以用 Progress 展示完成度,同时在任务结束后切换为明确的成功或失败文案。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:请在 3 天内完成续费。。", + "静态示例:运行中 组合展示状态。" + ] + }, + { + title: "设计规范", + body: [ + "反馈组件的颜色必须复用语义色 token,让 success、warning、danger 等状态在全站保持同一套认知映射。", + "Skeleton 和 Spinner 只用于短暂加载反馈,不应替代真正的空状态说明。" + ] + } + ] + } + ] + }, + { + id: "overlays-utilities", + title: "Overlays & Utilities", + chineseTitle: "弹层与工具型原语", + summary: "提供弹层、提示层、滚动区域和复制等辅助能力。", + overviewDescription: "这组能力主要解决复杂交互和信息补充,不改变主页面结构,但决定整个系统的细节完成度。", + sectionIds: ["overlays-utilities"], + components: [ + { + id: "overlay", + title: "Overlay", + chineseTitle: "弹层", + summary: "统一对话框、抽屉、聚焦管理和背景锁定。", + sectionIds: ["overlays-utilities"], + api: [ + { + prop: "title", + type: "string", + defaultValue: '""', + description: "为对话框或抽屉提供清晰标题,帮助用户理解当前弹层任务。" + }, + { + prop: "closeOnBackdrop", + type: "boolean", + defaultValue: "false", + description: "控制点击遮罩时是否允许关闭,适配轻量预览与需要强确认的两类场景。" + }, + { + prop: "onClose", + type: "() => void", + defaultValue: "required", + description: "统一弹层关闭出口,承接 Esc、关闭按钮和遮罩点击等收尾动作。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接对话框、抽屉等需要临时打断主流程的交互,同时统一 focus trap、背景锁定与 portal 挂载。", + "当页面需要补充配置、确认危险操作或展示分步信息时,应优先使用 Overlay 原语,而不是页面里手写 fixed panel。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把轻量提示或只读补充说明做成 Overlay;这类场景更适合 Tooltip、Popover 或页内提示块。", + "不要在同一时刻堆叠多个业务弹层,除非流程明确要求,否则会让焦点与关闭语义失控。" + ] + }, + { + title: "状态与变体", + body: [ + "当前真实示例覆盖 dialog 与 drawer 两种壳层,重点验证 focus trap、closeOnBackdrop 与统一 footer 行为。", + "弹层的宽度、关闭方式和 footer 组合应作为变体处理,但背景锁定和焦点管理必须保持同一套规则。" + ] + }, + { + title: "交互示例", + body: [ + "设计系统首页顶部已挂出打开对话框与打开抽屉两个真实入口,用于验证共享壳层而不是业务专属样式。", + "在弹层内部嵌入搜索、告警和键值摘要时,应确保 Tab 顺序与关闭行为仍然稳定。" + ] + }, + { + title: "设计规范", + body: [ + "弹层首先解决交互收束与焦点管理,不应承担页面级布局职责。", + "所有对话框和抽屉都应通过统一 overlay layer 渲染,避免每个业务模块各自实现 portal 与 scroll lock。" + ] + } + ] + }, + { + id: "tooltip-popover", + title: "Tooltip & Popover", + chineseTitle: "提示层与浮层", + summary: "承接轻量提示和补充操作面板。", + sectionIds: ["overlays-utilities"], + api: [ + { + prop: "TooltipContent", + type: "ReactNode", + defaultValue: "required", + description: "承载 hover 或 focus 后出现的简短说明文案。" + }, + { + prop: "PopoverContent", + type: "ReactNode", + defaultValue: "required", + description: "用于展示补充操作或上下文信息面板,内容可以比 tooltip 更丰富。" + }, + { + prop: "Trigger", + type: "interactive element", + defaultValue: "required", + description: "定义触发提示层或浮层的入口,保证 hover、focus 与 click 行为一致。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "Tooltip 用于承载简短解释、术语说明或图标按钮提示;Popover 用于展示补充操作面板和上下文设置。", + "当页面需要在不离开当前上下文的情况下补充说明或附加动作时,应优先考虑这一组轻量浮层。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把长篇文档、表单流程或危险确认放进 Tooltip 或 Popover;这类场景应升级为 Overlay 或独立页面。", + "Tooltip 不应用来承载必须阅读的信息,因为用户在移动端或键盘场景下不一定稳定触发悬停。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览重点覆盖 hover / focus 提示与 click 打开的快捷面板两类模式,帮助区分 Tooltip 和 Popover 的职责边界。", + "这组组件的关键变体不是颜色,而是内容密度、触发方式与是否允许继续操作。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例同时展示聚焦提示和快捷面板,便于验证 tooltip 文案、popover 操作区与触发器之间的距离感。", + "如果同一个图标既需要解释又需要动作,优先判断用户真正需要的是提示还是操作面板,避免两者叠加。" + ] + }, + { + title: "设计规范", + body: [ + "Tooltip 文案应短促直接,Popover 内容应围绕当前上下文,不要让用户在浮层里重新理解一套页面结构。", + "两类浮层都应复用统一的定位、层级和间距语言,避免每个业务入口呈现不同的悬浮体验。" + ] + } + ] + }, + { + id: "copy-utility", + title: "Copy utility", + chineseTitle: "复制工具", + summary: "统一命令、链接和片段复制反馈。", + sectionIds: ["overlays-utilities"], + api: [ + { + prop: "value", + type: "string", + defaultValue: '""', + description: "定义要复制到剪贴板的命令、链接或代码片段内容。" + }, + { + prop: "children", + type: "ReactNode", + defaultValue: '"复制"', + description: "控制按钮展示文案,让复制动作可以适配命令、链接和字段值等不同语境。" + }, + { + prop: "copiedLabel", + type: "string", + defaultValue: '"已复制"', + description: "复制成功后的即时反馈文案,帮助用户确认动作已经完成。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于命令行指令、API key 前缀、访问链接和代码片段复制,让用户在高频操作中得到统一反馈。", + "当页面包含需要重复复制的短文本时,应优先使用 Copy utility,而不是自己实现按钮与成功提示。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把长篇正文或整块富文本直接交给 Copy utility;这类场景更适合下载、导出或专门的代码块复制方案。", + "如果内容本身不可见或用户无法判断复制对象,也不应只放一个复制按钮而不补充上下文说明。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖测试命令复制、Playwright 命令复制和片段复制反馈,重点验证默认态与已复制态的切换。", + "复制工具的主要变体来自文案语境与所在容器,而不是重新设计一套独立按钮样式。" + ] + }, + { + title: "交互示例", + body: [ + "设计系统首页基础层与 Overlays & Utilities 区块都保留了复制命令按钮,方便验证不同容器中的反馈一致性。", + "如果复制动作出现在卡片、代码示例和工具栏中,应保持相同的成功提示和可聚焦行为。" + ] + }, + { + title: "设计规范", + body: [ + "复制按钮必须让用户知道将要复制什么内容,避免只显示抽象动词导致误操作。", + "复制反馈应短促、明确且不会打断主流程,优先使用同一套 success 文案和按钮状态切换。" + ] + } + ] + } + ] + } +]; diff --git a/apps/web/src/pages/design-system/designSystemPreviewParts.tsx b/apps/web/src/pages/design-system/designSystemPreviewParts.tsx new file mode 100644 index 0000000..0e86271 --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemPreviewParts.tsx @@ -0,0 +1,131 @@ +import type { ReactNode } from "react"; + +import type { WorkspaceTheme } from "../../app/appStore"; +import { Button } from "../../shared/button"; +import { CopyButton } from "../../shared/copy-button"; +import { designSystemSharedStyles } from "./designSystemStyles"; + +interface SwatchProps { + name: string; + hex: string; + varName: string; +} + +interface TokenRowProps { + label: string; + value: ReactNode; + hint: string; +} + +interface ThemeShowcaseCardProps { + description: string; + theme: WorkspaceTheme; + title: string; +} + +export function Swatch({ name, hex, varName }: SwatchProps) { + return ( +
+
+ ); +} + +export function TokenRow({ label, value, hint }: TokenRowProps) { + return ( +
+
{value}
+
+ {label} + {hint} +
+
+ ); +} + +export function ThemeShowcaseCard({ description, theme, title }: ThemeShowcaseCardProps) { + const isDark = theme === "dark"; + + return ( +
+ {title} +
+
+ {isDark ? "Dark surface" : "Light surface"} + + Accent + +
+ {description} +
+
+ ); +} + +export function DesignSystemQuickActions() { + return ( +
+ Quick actions +
+ 复制测试命令 + + 复制 e2e 命令 + +
+
+ ); +} + +export function PreviewActionButtons({ onOpenDialog, onOpenDrawer }: { onOpenDialog: () => void; onOpenDrawer: () => void }) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/pages/design-system/designSystemSections.tsx b/apps/web/src/pages/design-system/designSystemSections.tsx new file mode 100644 index 0000000..0786ef1 --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemSections.tsx @@ -0,0 +1,820 @@ +import { Bell, CircleAlert, Mail, ShieldCheck } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Alert } from "../../shared/alert"; +import { Avatar } from "../../shared/avatar"; +import { Badge } from "../../shared/badge"; +import { Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "../../shared/breadcrumb"; +import { Button } from "../../shared/button"; +import { Card, CardBody, CardFooter, CardHeader } from "../../shared/card"; +import { CopyButton } from "../../shared/copy-button"; +import { Divider } from "../../shared/divider"; +import { EmptyState } from "../../shared/empty-state"; +import { Checkbox, FormField, MultiSelect, Radio, SearchInput, SelectInput, TextareaInput } from "../../shared/form"; +import { Icon } from "../../shared/icon"; +import { KVList } from "../../shared/kv-list"; +import { MetricCard } from "../../shared/metric-card"; +import { Page, PageBody, PageHeader, PageMain, PageSidebar, PageToolbar } from "../../shared/page-layout"; +import { Pagination } from "../../shared/pagination"; +import { Popover, PopoverContent, PopoverTrigger } from "../../shared/popover"; +import { Progress } from "../../shared/progress"; +import { ScrollArea, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from "../../shared/scroll-area"; +import { Skeleton } from "../../shared/skeleton"; +import { Spinner } from "../../shared/spinner"; +import { StepItem, Steps, StepsList } from "../../shared/steps"; +import { Switch } from "../../shared/switch"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeaderCell, + TableRow +} from "../../shared/table"; +import { Tabs, TabsList, TabsPanel, TabsTrigger } from "../../shared/tabs"; +import { Tag } from "../../shared/tag"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../shared/tooltip"; +import { Code, Heading, Kbd, Label, Muted, Text } from "../../shared/typography"; +import { FilterBar, FilterBarActions, FilterBarSummary } from "../../shared/filter-bar"; +import { DesignSystemQuickActions, Swatch, ThemeShowcaseCard, TokenRow } from "./designSystemPreviewParts"; +import { designSystemExampleStyles, designSystemPageStyles, designSystemSharedStyles } from "./designSystemStyles"; +import type { DesignSystemSectionId } from "./designSystemContent"; + +export interface DesignSystemSection { + id: DesignSystemSectionId; + title: string; + chineseTitle: string; + sprint: string; + summary: string; + primitives: string[]; + coverage: string[]; + checklist: string[]; + preview: ReactNode; +} + +const demoMultiSelectOptions = [ + { label: "异常账号", value: "exceptions" }, + { label: "近 7 天活跃", value: "7d" }, + { label: "管理员创建", value: "admin" } +]; + +const demoKVListItems = [ + { key: "环境", value: "Prod" }, + { key: "区域", value: "APAC", hint: "默认" }, + { key: "健康度", value: "98.6%" } +]; + +export const designSystemSections: DesignSystemSection[] = [ + { + id: "foundations", + title: "Foundations", + chineseTitle: "基础层", + sprint: "基础", + summary: "锁定设计原则、发布节奏、预览锚点与文档入口,保证后续原语只做填充,不再重构页面结构。", + primitives: ["Design tokens", "Preview map", "Docs index", "Visual regression entry"], + coverage: ["设计原则", "Sprint 节奏", "预览分区锚点", "文档与回归约束"], + checklist: ["13 个 section id 固定", "README/CHANGELOG 对应同一分区地图", "light/dark 回归入口一致"], + preview: ( +
+
+ {[ + ["路由", "/design-system"], + ["当前目标", "Public showcase"], + ["系统范围", " 原语集成"], + ["视觉回归", "Playwright light/dark"] + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+
+ Usage rules +
    +
  • 原语统一走 token,不在业务层重新定义视觉变量。
  • +
  • 新增页面先从 `/design-system` 找已有模式,再决定是否扩原语。
  • +
  • 每个 section 都对应文档和回归锚点。
  • +
+
+ +
+
+ ) + }, + { + id: "color-theme", + title: "Color & Theme", + chineseTitle: "色彩与主题", + sprint: "已接入", + summary: "展示品牌色、语义色和 light/dark 主题入口,后续把真实组件的主题差异都收敛到这里校验。", + primitives: ["Brand tokens", "Semantic tokens", "Theme toggles"], + coverage: ["品牌色", "语义色", "主题切换", "对比度观察点"], + checklist: ["light/dark 均可截图", "token 名称与 docs 一致", "不依赖业务页颜色说明"], + preview: ( +
+
+ + + + +
+
+ + +
+
+ ) + }, + { + id: "layout-spacing", + title: "Layout & Spacing", + chineseTitle: "布局与间距", + sprint: "基础", + summary: "先把页面骨架、栅格与空间尺度摆清楚,为 PageLayout、Card 和表单节奏预留稳定容器。", + primitives: ["PageLayout", "Section spacing", "Container rhythm", "FilterBar"], + coverage: ["4px 网格", "双栏 section 容器", "信息密度层级"], + checklist: ["移动端自动折行", "分区元信息与预览区可独立扩展", "后续示例注入不改外层布局"], + preview: ( +
+
+ {[4, 8, 12, 16, 24, 32].map((value) => ( +
+ {`--space-${value / 4}`} +
+ ))} +
+
+ PageLayout preview +
+ + 导出} + description="统一页面头部、操作区与内容区节奏。" + kicker="账号中心" + title="账号列表" + /> + + + 搜索布局}> + + + 状态布局}> + + + + + 角色布局}> + + + + + + + + + + + + + 主内容区 + + + + + 侧栏摘要 + + + + +
+
+
+ ) + }, + { + id: "elevation-radius", + title: "Elevation & Radius", + chineseTitle: "阴影与圆角", + sprint: "基础", + summary: "卡片、表单、弹层的外观层级先在这里归档,避免每个组件各自解释阴影和圆角档位。", + primitives: ["Radius scale", "Elevation scale", "Surface transitions"], + coverage: ["6 档圆角", "5 档阴影", "卡片层级示意"], + checklist: ["token 名称与 preview 一致", "适用于 light/dark", "后续组件只引用 token"], + preview: ( +
+
+
+
+
+ Default + +
+
+ Focus ring + +
+
+ Hover intent + +
+
+ Disabled + +
+
+
+ ) + }, + { + id: "typography-content", + title: "Typography & Content", + chineseTitle: "排版与内容原子", + sprint: "计划中", + summary: "承接 Typography、Divider、Icon、Spinner、Skeleton 等原子,统一文案密度和内容骨架。", + primitives: ["Typography", "Divider", "Icon", "Spinner", "Skeleton"], + coverage: ["字号层级", "语义文字", "装饰原子", "加载占位"], + checklist: ["中英文混排稳定", "caption/code/kbd 有固定槽位", "Skeleton/Spinner 保留回归入口"], + preview: ( +
+
+ Scale preview +
+ + Design token preview + + + 原语文案与信息层级 + + 在这里统一验证 heading、body、muted、code 和快捷键标签的视觉节奏。 + 后续页面不再自己写标题字号和辅助说明 class。 +
+ --brand-500 + +
+
+
+
+ Atoms in use +
+
+ + + +
+ + + + + +
+
+
+ ) + }, + { + id: "buttons-actions", + title: "Buttons & Actions", + chineseTitle: "按钮与动作", + sprint: "迭代中", + summary: "按钮体系已经存在一部分,这里先固定动作位布局,后续再把 subtle、xs、copy 等增强态逐步接入。", + primitives: ["Button", "ButtonLink", "ButtonAnchor", "CopyButton"], + coverage: ["主要/次要动作", "链接态", "icon-only", "复制反馈"], + checklist: ["动作组顺序稳定", "禁用/加载态预留", "视觉回归不依赖业务数据"], + preview: ( +
+
+
+ Primary action +
+ + +
+
+
+ Lightweight action +
+ + +
+
+
+ Utility action +
+ + 复制命令 +
+
+
+ Danger +
+ + +
+
+
+
+ Size matrix +
+ + + + +
+
+
+ ) + }, + { + id: "form-inputs", + title: "Form Inputs", + chineseTitle: "输入控件", + sprint: "计划中", + summary: "TextInput、Select、Textarea 已有基础能力,但预览页先为 SearchInput、MultiSelect 和组合表单节奏预留固定区域。", + primitives: ["TextInput", "SelectInput", "TextareaInput", "FormField", "SearchInput", "MultiSelect", "FilterBar"], + coverage: ["字段容器", "标签/帮助文案", "前后缀输入", "搜索与多选"], + checklist: ["单字段与组合字段分开展示", "错误/帮助/禁用态预留", "后续真实示例不改 section id"], + preview: ( +
+
+
+ + + +
+
+ + + +
+
+ + + + + + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ FilterBar composition +
+ + + 搜索筛选条}> + + + 角色筛选条}> + + + + + + 状态筛选条}> + + + + + + + + + + 共 12 条结果 + +
+
+
+ ) + }, + { + id: "selection-controls", + title: "Selection Controls", + chineseTitle: "选择控件", + sprint: "计划中", + summary: "Switch 已有,Checkbox/Radio 还要补独立原子。这里先把单选、多选、开关拆成独立回归单元。", + primitives: ["Switch", "Checkbox", "Radio", "CheckboxField", "RadioGroupField"], + coverage: ["二元开关", "单项选择", "组选项", "状态排列"], + checklist: ["checked/unchecked/disabled 明确", "键盘交互后续可测", "语义标签位固定"], + preview: ( +
+
+
+ Switch + +
+
+ Checkbox + +
+
+ Radio + +
+
+ Grouped controls +
+ + +
+
+
+
+ ) + }, + { + id: "navigation-wayfinding", + title: "Navigation & Wayfinding", + chineseTitle: "导航与路径", + sprint: "计划中", + summary: "Breadcrumb、Pagination、Tabs、Steps 会影响多个页面骨架,这里先给这四类导航各留稳定位置。", + primitives: ["Breadcrumb", "Pagination", "Tabs", "Steps"], + coverage: ["分段导航", "路径层级", "分页", "流程步骤"], + checklist: ["可在单独 section 做视觉回归", "active/disabled/current 预留", "后续迁移旧导航时不改锚点"], + preview: ( +
+
+
+ Breadcrumb trail + + + + 工作台 + + + + 设计系统 + + + + 导航原语 + + + +
+
+ Segmented tabs + + + 概览 + Token + 状态 + + 同一套原语可直接承接顶栏和 landing 页的 tab 切换。 + Token 预览 + 状态矩阵 + +
+
+ Pagination bar +
+ undefined} page={2} pageSize={20} total={120} /> +
+
+
+ Steps tracker + + + + + + + +
+
+
+ ) + }, + { + id: "surfaces-cards", + title: "Surfaces & Cards", + chineseTitle: "卡片与容器", + sprint: "计划中", + summary: "用于承接 Card、EmptyState 以及页面级容器组合,明确 header/body/footer 和强调态容器的边界。", + primitives: ["Card", "CardHeader", "CardBody", "CardFooter", "EmptyState"], + coverage: ["基础卡片", "数据卡片", "状态卡片", "空状态容器"], + checklist: ["Card 变体与 padding 位置固定", "EmptyState 不抢其他 section 位置", "容器层级与 tokens 对齐"], + preview: ( +
+
+ + + + Base card + + 默认容器 + + + 用于设置页、摘要区、过滤器和普通信息分组。 + + + + + + + + Data card + + 2,184 + + + + 最近 7 天收到的邮件 + + + + + + 需要复核 + + + + 3 个账号连续 30 天无活动,建议清理或归档。 + + + + 新建筛选 + + } + description="当前筛选条件下还没有结果,调整状态或创建人后会在这里刷新。" + icon={} + title="暂无账号结果" + /> +
+
+ ) + }, + { + id: "data-display", + title: "Data Display & Charts", + chineseTitle: "数据展示", + sprint: "现有 + S5 补充", + summary: "Table、Chart 已经有基础原语,但预览页要额外承接 KVList、Avatar 和数据密度组合的回归面。", + primitives: ["Table", "Chart", "KVList", "Avatar", "MetricCard"], + coverage: ["表格容器", "图表主题", "键值列表", "头像与身份块"], + checklist: ["可离线渲染", "light/dark 数据配色稳定", "后续不会依赖真实接口数据"], + preview: ( +
+
+
+ Avatar stack +
+ + + +
+
+
+ KV list + +
+
+
+ + +
+
+ Table shell +
+ + + + + ID + 地址 + 状态 + + + + + acct_001 + ops@wemail.ai + + 启用 + + + + acct_002 + growth@wemail.ai + + 停用 + + + +
+
+
+
+
+ ) + }, + { + id: "feedback-status", + title: "Feedback & Status", + chineseTitle: "反馈与状态", + sprint: "规划中", + summary: "Tag、Badge、Progress、Alert、Spinner、Skeleton 等都会在这里汇总,避免状态型原语分散到多个页面。", + primitives: ["Tag", "Badge", "Progress", "Alert", "Spinner", "Skeleton"], + coverage: ["静态状态", "进度反馈", "页内提示", "加载骨架"], + checklist: ["语义色与 token 对齐", "成功/警告/错误态都有入口", "快照稳定后再提交基线"], + preview: ( +
+
+
+ Tag / Badge +
+ + 新版 + + } size="md" variant="info"> + 合规 + + + 启用 + + + 阻塞 + +
+
+
+ Alert + + design token 已切到共享入口后,建议优先走一轮 light / dark 截图核对。 + +
+
+ Progress + +
+
+ Loading states +
+ + + +
+
+
+
+ ) + }, + { + id: "overlays-utilities", + title: "Overlays & Utilities", + chineseTitle: "弹层与工具型原语", + sprint: "规划中", + summary: "Overlay 现有,Tooltip/Popover/ScrollArea 还未归档。这里作为所有弹层和工具型原语的统一回归面。", + primitives: ["Overlay", "Modal", "Drawer", "Tooltip", "Popover", "ScrollArea", "CopyButton"], + coverage: ["模态层", "抽屉", "悬浮提示", "滚动容器", "辅助动作"], + checklist: ["焦点管理后续单测/e2e 可挂载", "视觉回归截图只截稳定壳层", "工具型原语不侵入其他分区"], + preview: ( +
+
+
+ Tooltip / Popover +
+ + 聚焦提示 + 统一的 hover / focus 提示层。 + + + 打开快捷面板 + + 这里适合轻量筛选和补充配置,不适合长表单。 + + +
+
+
+ Scroll / Utility + + +
+ {Array.from({ length: 6 }, (_, index) => ( + + 滚动项 {index + 1} + + ))} +
+
+ + + +
+ + 复制回归命令 + +
+
+
+ + + + Dialog shell + + + + 预览页不直接挂打开态 modal,避免挡住整页;统一壳层由 Overlay 原语与 e2e 共同验证。 + + + + + + Drawer shell + + + + 抽屉、tooltip、popover 共用同一套层级 token 与交互语义,真实打开态放到单测和后续示例页。 + + +
+
+ ) + } +]; + +export function findDesignSystemSection(sectionId: DesignSystemSectionId): DesignSystemSection { + const section = designSystemSections.find((item) => item.id === sectionId); + + if (!section) { + throw new Error(`Unknown design system section: ${sectionId}`); + } + + return section; +} diff --git a/apps/web/src/pages/design-system/designSystemStyles.ts b/apps/web/src/pages/design-system/designSystemStyles.ts new file mode 100644 index 0000000..c6f938b --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemStyles.ts @@ -0,0 +1,253 @@ +import { type CSSProperties } from "react"; + +export const designSystemSharedStyles = { + stack: { + display: "grid", + gap: "16px" + } satisfies CSSProperties, + chipRow: { + display: "flex", + flexWrap: "wrap", + gap: "8px" + } satisfies CSSProperties, + chip: { + display: "inline-flex", + alignItems: "center", + gap: "6px", + borderRadius: "999px", + padding: "6px 10px", + fontSize: "12px", + lineHeight: 1.2, + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-secondary, rgba(15, 23, 42, 0.04))", + color: "var(--text, #111827)" + } satisfies CSSProperties, + previewCard: { + minWidth: 0, + overflowWrap: "anywhere", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + borderRadius: "16px", + padding: "16px", + background: "var(--surface-muted)", + display: "grid", + gap: "12px" + } satisfies CSSProperties +}; + +export const designSystemPageStyles = { + shell: { + maxWidth: "1440px", + width: "min(1440px, calc(100vw - 24px))", + margin: "0 auto", + padding: "84px 0 32px" + } satisfies CSSProperties, + hero: { + display: "grid", + gap: "20px", + gridTemplateColumns: "minmax(0, 1fr)", + alignItems: "start", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + sectionLayout: { + display: "grid", + gap: "20px", + gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", + alignItems: "start" + } satisfies CSSProperties, + metaGrid: { + display: "grid", + gap: "12px" + } satisfies CSSProperties, + sidebarShell: { + display: "grid", + gap: "20px", + alignSelf: "start", + position: "sticky", + top: "96px", + padding: "8px 0", + borderRadius: "24px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + sidebarNav: { + minHeight: 0, + padding: "10px 10px 22px 22px", + border: "none", + borderRadius: 0, + background: "transparent", + boxShadow: "none", + backdropFilter: "none" + } satisfies CSSProperties, + sidebarGroup: { + gap: "10px" + } satisfies CSSProperties, + sidebarGroupHeader: { + margin: 0, + padding: "0 2px" + } satisfies CSSProperties, + sidebarGroupList: { + gap: "10px" + } satisfies CSSProperties, + sidebarButton: { + cursor: "pointer" + } satisfies CSSProperties, + sidebarButtonActive: { + boxShadow: "0 12px 24px rgba(0, 0, 0, 0.16)" + } satisfies CSSProperties, + sidebarButtonMeta: { + fontSize: "12px" + } satisfies CSSProperties, + sidebarDot: { + width: "10px", + height: "10px", + borderRadius: "999px", + background: "currentColor", + display: "inline-block" + } satisfies CSSProperties, + emphasisChip: { + ...designSystemSharedStyles.chip, + background: "var(--brand-50, rgba(255, 122, 0, 0.12))", + borderColor: "var(--brand-200, rgba(255, 122, 0, 0.2))", + color: "var(--brand-600, #b45309)" + } satisfies CSSProperties, + denseList: { + margin: 0, + paddingLeft: "18px", + display: "grid", + gap: "8px", + color: "var(--text-muted, #667085)" + } satisfies CSSProperties, + subheading: { + margin: 0, + fontSize: "14px", + fontWeight: 700, + color: "var(--text, #111827)" + } satisfies CSSProperties +}; + +export const designSystemExampleStyles = { + previewPane: { + minWidth: 0, + display: "grid", + gap: "16px" + } satisfies CSSProperties, + previewGrid: { + minWidth: 0, + display: "grid", + gap: "16px" + } satisfies CSSProperties, + twoColumnGrid: { + minWidth: 0, + display: "grid", + gap: "12px", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 180px), 1fr))" + } satisfies CSSProperties, + denseGrid: { + minWidth: 0, + display: "grid", + gap: "10px", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 128px), 1fr))" + } satisfies CSSProperties, + fullWidthCard: { + minWidth: 0, + overflowX: "auto", + scrollbarGutter: "stable" + } satisfies CSSProperties +}; + +export const designSystemDocStyles = { + shell: { + display: "grid", + gap: "20px", + padding: "24px", + borderRadius: "24px", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + header: { + display: "grid", + gap: "12px", + paddingBottom: "16px", + borderBottom: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))" + } satisfies CSSProperties, + sectionList: { + display: "grid", + gap: "16px" + } satisfies CSSProperties, + section: { + display: "grid", + gap: "10px", + padding: "18px 20px", + borderRadius: "18px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + sectionHeading: { + margin: 0, + fontSize: "18px" + } satisfies CSSProperties, + paragraphGroup: { + display: "grid", + gap: "8px" + } satisfies CSSProperties, + exampleNote: { + padding: "14px 16px", + borderRadius: "14px", + background: "var(--surface-secondary, rgba(15, 23, 42, 0.04))", + color: "var(--text-muted, #667085)" + } satisfies CSSProperties, + guidanceNote: { + padding: "14px 16px", + borderRadius: "14px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-primary, rgba(255,255,255,0.9))" + } satisfies CSSProperties, + codeSampleList: { + display: "grid", + gap: "12px" + } satisfies CSSProperties, + codeSampleCard: { + display: "grid", + gap: "8px" + } satisfies CSSProperties, + codeSampleHeading: { + margin: 0, + fontSize: "15px", + color: "var(--text, #111827)" + } satisfies CSSProperties, + codeSamplePre: { + margin: 0, + padding: "14px 16px", + overflowX: "auto", + borderRadius: "14px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-secondary, rgba(15, 23, 42, 0.04))", + color: "var(--text, #111827)", + fontSize: "13px", + lineHeight: 1.6, + whiteSpace: "pre-wrap" + } satisfies CSSProperties +}; + +export function resolveDesignSystemSidebarLayoutStyle(viewportWidth?: number): CSSProperties { + const width = viewportWidth ?? (typeof window !== "undefined" ? window.innerWidth : 1280); + + if (width < 980) { + return { + display: "grid", + gap: "24px", + gridTemplateColumns: "minmax(0, 1fr)", + alignItems: "start" + }; + } + + return { + display: "grid", + gap: "28px", + gridTemplateColumns: "minmax(240px, 280px) minmax(0, 1fr)", + alignItems: "start" + }; +} diff --git a/apps/web/src/shared/README.md b/apps/web/src/shared/README.md index 51a0186..42e95cf 100644 --- a/apps/web/src/shared/README.md +++ b/apps/web/src/shared/README.md @@ -6,12 +6,12 @@ - API client - 样式 - 通用 hooks -- 通用 UI 基础组件 -- 通用按钮原语 -- 通用表单原语 -- 通用弹层原语 -- 通用表格原语 -- 通用开关原语 +- 通用 UI 基础组件与 design token +- 通用按钮、表单、导航、反馈、容器、数据展示原语 +- 通用页面级布局与筛选条原语 +- 通用统计卡与摘要型原语 +- 通用弹层与 shared layer 工具 +- 通用表格、开关、排版、状态与工具型原语 - 工具函数 ## 🚫 不放什么 diff --git a/apps/web/src/shared/alert/AlertPrimitives.tsx b/apps/web/src/shared/alert/AlertPrimitives.tsx new file mode 100644 index 0000000..3dc9f41 --- /dev/null +++ b/apps/web/src/shared/alert/AlertPrimitives.tsx @@ -0,0 +1,70 @@ +import { CircleAlert, Info, TriangleAlert, X, type LucideIcon } from "lucide-react"; +import { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from "react"; + +type AlertVariant = "info" | "success" | "warning" | "error"; +type AlertAppearance = "soft" | "outline"; + +type AlertProps = HTMLAttributes & { + actions?: ReactNode; + appearance?: AlertAppearance; + dismissLabel?: string; + icon?: ReactNode; + onClose?: ButtonHTMLAttributes["onClick"]; + title?: ReactNode; + variant?: AlertVariant; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +const iconMap: Record = { + error: CircleAlert, + info: Info, + success: Info, + warning: TriangleAlert +}; + +export const Alert = forwardRef(function Alert( + { + actions, + appearance = "soft", + children, + className, + dismissLabel = "关闭提示", + icon, + onClose, + role = "alert", + title, + variant = "info", + ...props + }, + ref +) { + const Icon = iconMap[variant]; + + return ( +
+ +
+ {title ?
{title}
: null} + {children ?
{children}
: null} + {actions ?
{actions}
: null} +
+ {onClose ? ( + + ) : null} +
+ ); + } +); diff --git a/apps/web/src/shared/alert/README.md b/apps/web/src/shared/alert/README.md new file mode 100644 index 0000000..f94e71d --- /dev/null +++ b/apps/web/src/shared/alert/README.md @@ -0,0 +1,18 @@ +# 🚨 shared/alert + +共享静态提示原语层。 + +## ✅ 放什么 +- `Alert` +- 信息 / 成功 / 警告 / 错误提示 +- 标题、正文、可选操作区与关闭按钮 + +## 🚫 不放什么 +- Toast 级自动消失逻辑 +- 业务错误映射与重试状态机 +- 页面级布局编排 + +## 可访问性 +- 默认 `role="alert"` +- 关闭按钮必须带 `dismissLabel` +- 图标默认 `aria-hidden` diff --git a/apps/web/src/shared/alert/alert.css b/apps/web/src/shared/alert/alert.css new file mode 100644 index 0000000..c66c984 --- /dev/null +++ b/apps/web/src/shared/alert/alert.css @@ -0,0 +1,65 @@ +.ui-alert { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 14px 16px; + border: 1px solid transparent; + border-radius: calc(var(--radius-card) - 8px); +} + +.ui-alert-soft.ui-alert-info { + background: color-mix(in srgb, var(--info-soft, rgba(59, 130, 246, 0.14)) 100%, transparent); + border-color: color-mix(in srgb, var(--info-500, #3b82f6) 18%, transparent); +} + +.ui-alert-soft.ui-alert-success { + background: color-mix(in srgb, var(--success-soft, rgba(32, 201, 151, 0.16)) 100%, transparent); + border-color: color-mix(in srgb, #20c997 18%, transparent); +} + +.ui-alert-soft.ui-alert-warning { + background: color-mix(in srgb, var(--warning-soft, rgba(255, 184, 77, 0.16)) 100%, transparent); + border-color: color-mix(in srgb, #ffb84d 24%, transparent); +} + +.ui-alert-soft.ui-alert-error { + background: color-mix(in srgb, rgba(239, 68, 68, 0.14) 100%, transparent); + border-color: color-mix(in srgb, #ef4444 24%, transparent); +} + +.ui-alert-outline { + background: var(--surface-strong); + border-color: var(--border); +} + +.ui-alert-copy { + display: grid; + gap: 6px; +} + +.ui-alert-title { + font-weight: 700; + color: var(--text); +} + +.ui-alert-body, +.ui-alert-actions { + color: var(--text-muted); +} + +.ui-alert-icon, +.ui-alert-close { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.ui-alert-close { + width: 28px; + height: 28px; + border: 0; + border-radius: 999px; + background: transparent; +} diff --git a/apps/web/src/shared/alert/index.ts b/apps/web/src/shared/alert/index.ts new file mode 100644 index 0000000..b734013 --- /dev/null +++ b/apps/web/src/shared/alert/index.ts @@ -0,0 +1 @@ +export { Alert } from "./AlertPrimitives"; diff --git a/apps/web/src/shared/avatar/AvatarPrimitives.tsx b/apps/web/src/shared/avatar/AvatarPrimitives.tsx new file mode 100644 index 0000000..05afeee --- /dev/null +++ b/apps/web/src/shared/avatar/AvatarPrimitives.tsx @@ -0,0 +1,65 @@ +import { forwardRef, useState, type HTMLAttributes, type ReactNode } from "react"; + +type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; +type AvatarShape = "circle" | "square"; + +type AvatarProps = HTMLAttributes & { + alt?: string; + fallback?: ReactNode; + name?: string; + shape?: AvatarShape; + size?: AvatarSize; + src?: string; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +function getInitials(name?: string, alt?: string) { + const source = name ?? alt ?? ""; + const parts = source + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2); + + if (parts.length === 0) return "?"; + return parts.map((part) => part.charAt(0).toUpperCase()).join(""); +} + +export const Avatar = forwardRef(function Avatar( + { + alt, + className, + fallback, + name, + shape = "circle", + size = "md", + src, + ...props + }, + ref +) { + const [hasImageError, setHasImageError] = useState(false); + const initials = fallback ?? getInitials(name, alt); + const canRenderImage = Boolean(src && !hasImageError); + + return ( + + {canRenderImage ? ( + {alt} setHasImageError(true)} src={src} /> + ) : ( + + )} + + ); + } +); diff --git a/apps/web/src/shared/avatar/README.md b/apps/web/src/shared/avatar/README.md new file mode 100644 index 0000000..0ebb90c --- /dev/null +++ b/apps/web/src/shared/avatar/README.md @@ -0,0 +1,18 @@ +# 👤 shared/avatar + +共享头像原语层。 + +## ✅ 放什么 +- `Avatar` +- 图片 / fallback initials 展示 +- 尺寸、形状和加载失败兜底 + +## 🚫 不放什么 +- 在线状态气泡 +- 业务级头像上传流程 +- 组织 / 成员权限逻辑 + +## 状态约定 +- `data-state="image" | "fallback"` +- `size`: `xs` / `sm` / `md` / `lg` / `xl` +- `shape`: `circle` / `square` diff --git a/apps/web/src/shared/avatar/avatar.css b/apps/web/src/shared/avatar/avatar.css new file mode 100644 index 0000000..d3b4978 --- /dev/null +++ b/apps/web/src/shared/avatar/avatar.css @@ -0,0 +1,60 @@ +.ui-avatar { + position: relative; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, var(--surface-muted)); + color: var(--text); + font-weight: 700; + user-select: none; +} + +.ui-avatar-square { + border-radius: calc(var(--radius-field) - 2px); +} + +.ui-avatar-xs { + width: 24px; + height: 24px; + font-size: 0.72rem; +} + +.ui-avatar-sm { + width: 32px; + height: 32px; + font-size: 0.82rem; +} + +.ui-avatar-md { + width: 40px; + height: 40px; + font-size: 0.92rem; +} + +.ui-avatar-lg { + width: 52px; + height: 52px; + font-size: 1.05rem; +} + +.ui-avatar-xl { + width: 64px; + height: 64px; + font-size: 1.2rem; +} + +.ui-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ui-avatar-fallback { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} diff --git a/apps/web/src/shared/avatar/index.ts b/apps/web/src/shared/avatar/index.ts new file mode 100644 index 0000000..7adb30c --- /dev/null +++ b/apps/web/src/shared/avatar/index.ts @@ -0,0 +1 @@ +export { Avatar } from "./AvatarPrimitives"; diff --git a/apps/web/src/shared/badge/BadgePrimitives.tsx b/apps/web/src/shared/badge/BadgePrimitives.tsx new file mode 100644 index 0000000..49807f8 --- /dev/null +++ b/apps/web/src/shared/badge/BadgePrimitives.tsx @@ -0,0 +1,43 @@ +import { forwardRef, type HTMLAttributes } from "react"; + +type BadgeVariant = "neutral" | "brand" | "info" | "success" | "warning" | "danger"; +type BadgeAppearance = "soft" | "solid"; +type BadgeSize = "sm" | "md"; +type BadgeStatusRole = "none" | "status"; + +type BadgeProps = HTMLAttributes & { + appearance?: BadgeAppearance; + size?: BadgeSize; + statusRole?: BadgeStatusRole; + variant?: BadgeVariant; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +export const Badge = forwardRef(function Badge( + { + className, + appearance = "soft", + size = "sm", + statusRole = "none", + variant = "neutral", + ...props + }, + ref +) { + return ( + + ); +}); diff --git a/apps/web/src/shared/badge/README.md b/apps/web/src/shared/badge/README.md new file mode 100644 index 0000000..04ac9bf --- /dev/null +++ b/apps/web/src/shared/badge/README.md @@ -0,0 +1,26 @@ +# 🎖️ shared/badge + +共享状态徽标原语层。 + +## ✅ 放什么 +- `Badge` +- 启用/停用、审核、风险、运行状态等 status 语义展示 +- `variant / appearance / size` 的统一视觉分层 +- 可选 `statusRole="status"`,用于需要被屏幕阅读器感知的即时状态 + +## 🚫 不放什么 +- 分类归档、主题标签、来源标记 +- 可选择 chip 或筛选器按钮 +- 包含复杂图标布局的业务状态卡 + +## 状态约定 +- `variant`: `neutral` / `brand` / `info` / `success` / `warning` / `danger` +- `appearance`: `soft` / `solid` +- `size`: `sm` / `md` +- `statusRole`: `none` / `status` + +## 使用约定 +- `Badge` 固定输出 `data-usage="status"`,明确其状态用途 +- 默认不启用 live region,只有动态状态反馈场景才传 `statusRole="status"` +- 如果内容表达的是分类或归属,不要用 `Badge`,改用 `shared/tag` +- 样式文件位于 `shared/badge/badge.css`,后续统一由样式 barrel 接入 diff --git a/apps/web/src/shared/badge/badge.css b/apps/web/src/shared/badge/badge.css new file mode 100644 index 0000000..42d9098 --- /dev/null +++ b/apps/web/src/shared/badge/badge.css @@ -0,0 +1,88 @@ +.ui-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + max-width: 100%; + border: 1px solid transparent; + border-radius: var(--radius-full); + font-size: var(--font-caption); + font-weight: 700; + line-height: var(--line-caption); + white-space: nowrap; +} + +.ui-badge-size-sm { + padding: 5px 10px; +} + +.ui-badge-size-md { + padding: 7px 12px; +} + +.ui-badge-neutral { + --ui-badge-bg-soft: color-mix(in srgb, var(--neutral-100) 72%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--neutral-300) 72%, transparent); + --ui-badge-fg-soft: var(--neutral-700); + --ui-badge-bg-solid: var(--neutral-700); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-brand { + --ui-badge-bg-soft: color-mix(in srgb, var(--brand-100) 76%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--brand-500) 24%, transparent); + --ui-badge-fg-soft: var(--brand-700); + --ui-badge-bg-solid: var(--brand-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-info { + --ui-badge-bg-soft: color-mix(in srgb, var(--info-100) 76%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--info-500) 22%, transparent); + --ui-badge-fg-soft: var(--info-600); + --ui-badge-bg-solid: var(--info-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-success { + --ui-badge-bg-soft: color-mix(in srgb, var(--success-100) 76%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--success-500) 22%, transparent); + --ui-badge-fg-soft: var(--success-600); + --ui-badge-bg-solid: var(--success-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-warning { + --ui-badge-bg-soft: color-mix(in srgb, var(--warning-100) 78%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--warning-500) 22%, transparent); + --ui-badge-fg-soft: var(--warning-600); + --ui-badge-bg-solid: var(--warning-500); + --ui-badge-fg-solid: var(--neutral-900); +} + +.ui-badge-danger { + --ui-badge-bg-soft: color-mix(in srgb, var(--danger-100) 78%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--danger-500) 22%, transparent); + --ui-badge-fg-soft: var(--danger-600); + --ui-badge-bg-solid: var(--danger-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-soft { + background: var(--ui-badge-bg-soft); + border-color: var(--ui-badge-border-soft); + color: var(--ui-badge-fg-soft); +} + +.ui-badge-solid { + background: var(--ui-badge-bg-solid); + border-color: transparent; + color: var(--ui-badge-fg-solid); + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--ui-badge-bg-solid) 78%, black); +} + +@media (prefers-reduced-motion: reduce) { + .ui-badge { + transition: none; + } +} diff --git a/apps/web/src/shared/badge/index.ts b/apps/web/src/shared/badge/index.ts new file mode 100644 index 0000000..08219ec --- /dev/null +++ b/apps/web/src/shared/badge/index.ts @@ -0,0 +1 @@ +export { Badge } from "./BadgePrimitives"; diff --git a/apps/web/src/shared/breadcrumb/BreadcrumbPrimitives.tsx b/apps/web/src/shared/breadcrumb/BreadcrumbPrimitives.tsx new file mode 100644 index 0000000..5822c81 --- /dev/null +++ b/apps/web/src/shared/breadcrumb/BreadcrumbPrimitives.tsx @@ -0,0 +1,87 @@ +import { + forwardRef, + type AnchorHTMLAttributes, + type HTMLAttributes, + type LiHTMLAttributes, + type OlHTMLAttributes, + type ReactNode +} from "react"; + +type BreadcrumbSeparatorProps = LiHTMLAttributes & { + children?: ReactNode; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +export const Breadcrumb = forwardRef>(function Breadcrumb( + { "aria-label": ariaLabel = "面包屑导航", className, ...props }, + ref +) { + return