# Stash UI Stash 的设计系统与组件库:**设计令牌 + 基础组件 + 组件展示站**。 独立于 `stash` 应用仓库,后续页面(控制台、详情页、后台)都从这里取组件拼装。 - 视觉标准来自 Alright 主题(与 `stash/ALRIGHT-DESIGN.md` 一致); - 零运行时依赖(只 peer 依赖 react / react-dom); - 样式用 CSS Modules + 设计令牌,换主题只改变量; - 自带一个可点开的展示站,每个组件都有变体、状态、props 与代码片段。 ## 快速开始 ```bash npm install npm run dev # 文档站:http://127.0.0.1:5199 # 整页示例:http://127.0.0.1:5199/demo.html(?scheme=dark 直接进深色) npm run typecheck # tsc --noEmit npm run build # 构建库:dist/stash-ui.js + dist/stash-ui.css + dist/types npm run build:site # 构建静态站点:dist-site/(index.html + demo.html) npm run lint # oxlint # 截图核对(需要本机 Chrome / Edge;playwright-core 不自带浏览器) node scripts/screenshot.mjs # 首屏 node scripts/screenshot.mjs http://127.0.0.1:5199/#buttons shots/buttons.png node scripts/screenshot.mjs http://127.0.0.1:5199/ shots/cards.png '#cards' # 只截某一节 ``` 环境要求:Node 24(`package.json` 里用 volta 固定了版本)。 ## 文档(先读这个) 四份文档构成组件库的「契约」,新增 / 修改组件全部以它们为准: | 文档 | 内容 | 什么时候看 | | --- | --- | --- | | [DESIGN.md](DESIGN.md) | **规格总纲**:分层结构、设计令牌、视觉规则、代码约定、验收清单、决策与已知坑 | 动手前、评审时 | | [docs/COMPONENTS.md](docs/COMPONENTS.md) | **组件目录与契约**:现有 42 个组件的 API 与边界、组合模式、候选组件、图标新增流程 | 想知道「用哪个 / 有没有 / 缺什么」 | | [docs/ADDING-A-COMPONENT.md](docs/ADDING-A-COMPONENT.md) | **新增组件手册**:12 步流程、代码模板、`Spinner` 完整示例、常见错误 | 要补一个组件时照着做 | | [docs/ALRIGHT-DECOMPOSITION.md](docs/ALRIGHT-DECOMPOSITION.md) | **参考站拆解**:实测令牌、组件尺寸、交互节奏、暗色映射、保留差异 | 想核对「这个值对不对」 | | [docs/STYLE-INTAKE.md](docs/STYLE-INTAKE.md) | **外部风格接入**:截图 → 组件/令牌映射表、决策矩阵、验收清单 | 新项目给了外部设计稿、和现有风格不一致时 | 缺组件时的标准动作:先在 COMPONENTS.md §5 找到它(或登记一行)→ 按 ADDING-A-COMPONENT.md 走流程 → 用 DESIGN.md §6 的清单验收。 ## 给 AI 用 仓库自带一个 skill,AI 不需要每次把五份文档读一遍: - [`skills/stash-ui/SKILL.md`](skills/stash-ui/SKILL.md) —— **入口**:任务分流(用 / 改 / 加)、硬约束、验证命令、已知坑 - [`AGENTS.md`](AGENTS.md) / [`CLAUDE.md`](CLAUDE.md) —— 仓库根指路,Codex / Claude Code / Cursor 会自动读到 - [docs/AI-USAGE.md](docs/AI-USAGE.md) —— 三种接入方式(直接读文件 / DSH 技能目录 / DSH 插件包)与维护规则 原则:**skill 只做路由和护栏,规则只写在文档里**,避免两处维护。 ## 主题(外部风格) - [`themes/example.css`](themes/example.css) —— 只改 CSS 变量的主题示例(换品牌色 / 圆角 / 阴影 / 控件高度); - 用法:`style.css` 之后引入主题文件,**禁止写选择器**; - 完整流程(截图 → 映射 → 决策 → 吸收)见 [DESIGN.md §11](DESIGN.md) 与 [docs/STYLE-INTAKE.md](docs/STYLE-INTAKE.md)。 ## 目录结构 ```text stash-ui/ ├── DESIGN.md # 规格总纲(分层/令牌/视觉/验收) ├── AGENTS.md / CLAUDE.md # AI 入口(指路到 skill 与文档) ├── skills/stash-ui/SKILL.md # AI skill:任务分流 + 约束 + 验证 ├── docs/ │ ├── COMPONENTS.md # 组件目录与契约 │ ├── ADDING-A-COMPONENT.md # 新增组件手册 │ ├── ALRIGHT-DECOMPOSITION.md# 参考站拆解(数值依据) │ └── AI-USAGE.md # AI 接入方式与 skill 维护 ├── src/ # 组件库(发布物只有这一层) │ ├── index.ts # 统一出口:组件 + 类型 + 样式 │ ├── theme/ │ │ ├── tokens.css # 设计令牌(颜色/间距/字号/圆角/阴影/动效) │ │ ├── icons.ts # 446 个图标名(自动生成) │ │ ├── iconfont.css # 图标字体与字形(自动生成) │ │ └── assets/iconfont.woff2 │ └── components/ # 51 个组件,每个 = Xxx.tsx + Xxx.module.css(见下方清单) ├── site/ # 展示站(不参与库构建) │ ├── index.html / demo.html # 文档站 / 整页示例 │ ├── App.tsx # 左侧目录 + 右侧示例 │ ├── app.css # 文档站自己的排版样式 │ ├── components/ # Section / Demo / PropsTable │ └── sections/ # 每个组件一节 ├── scripts/screenshot.mjs # 截图核对用 ├── vite.config.ts # 展示站构建 └── vite.lib.config.ts # 库构建 ``` ## 组件清单 | 组件 | 用途 | 关键 props | | --- | --- | --- | | `Button` | 操作按钮(圆角 / 胶囊,支持描边) | `variant` `size` `shape` `block` `loading` `icon` | | `IconButton` | 圆形图标按钮(28/36/44) | `label`(必填) `icon` `variant` `size` `spinning` | | `TextButton` | 文字按钮(行内操作,无外框) | `icon` `tone` `disabled` `title` `label` | | `Card` | 白色容器(极浅描边) | `as` `padding` `hoverable` | | `EntryRow` | 条目行(头像 + 标题 + 描述,整行可点) | `title` `description` `href` `avatar` `badge` `onOpen` | | `EntryTile` | 条目磁贴(上图下文白卡) | `title` `description` `cover` `ratio` `onOpen` | | `Brand` | 站点品牌(logo / 首字标记 + 名称) | `name` `mark` `logo` `href` | | `Ticker` | 无限滚动信息条 | `speed` `gap` `paused` `label` | | `SearchField` | 语义版搜索框 | `value` `onChange` `variant` `hotkey` `inputRef` | | `Cover` | 比例封面卡(2:3 / 4:3 / 16:9) | `ratio` `src` `caption` `lift` `onClick` | | `Image` | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` | | `FormField` | 表单字段排版(标签 + 提示 / 错误) | `label` `hint` `error` `required` | | `Typography` | 标题 / 文字 / 段落 | `level` `tone` `size` `lines` | | `Skeleton` | 骨架屏占位 | `variant` `lines` `width` `height` | | `Spinner` | 加载指示 | `size` `label` | | `Switch` | 即时开关 | `checked` `onChange` `size` `label` | | `ToggleRow` | 开关行(左标签 + 右开关) | `label` `checked` `onChange` `hint` `switchLabel` | | `Checkbox` | 复选框 | `checked` `onChange` `label` | | `RadioGroup` | 互斥单选组 | `options` `value` `onChange` `direction` | | `Select` | 受控下拉选择 | `options` `value` `onChange` `placeholder` | | `Segmented` | 视图形态切换 | `options` `value` `onChange` `size` `block` | | `Tabs` | 页内分区切换 | `items` `activeKey` `onChange` `variant` | | `Menu` | 侧栏两级菜单 | `items` `activeKey` `onSelect` | | `Caret` | 折叠箭头(6px 折角) | `open` `size` | | `Breadcrumb` | 路径导航 | `items` `separator` | | `Table` | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` | | `SortableList` | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` | | `Tag` | 分类 / 标签 / 状态(浅底 / 实底) | `tone` `variant` `shape` `icon` `dot` | | `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` | | `Badge` | 数字角标 | `count` `tone` `max` | | `Avatar` | 头像 / 封面占位 | `src` `fallback` `size` `shape` `verified` | | `Grid` | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` | | `Layout` | 页面骨架:页头 / 侧栏 / 内容 / 右栏 / 页脚 | `header` `sider` `aside` `footer` `maxWidth` `gap` | | `Space` | 元素间距 | `direction` `gap` `wrap` `align` `justify` | | `Divider` | 分隔线 | `orientation` `variant` `spacing` | | `Input` | 输入框 | `icon` `suffix` `clearable` `onClear` `shortcut` `inputRef` | | `Textarea` | 多行文本(Markdown / 简介) | `rows` `resize` `invalid` | | `PasswordInput` | 密码输入(锁图标 + 显隐切换) | `icon` `size` `toggle` | | `Notice` | 提示条 | `tone` `title` `action` | | `Empty` | 空态 | `text` `action` | | `Result` | 整块结果反馈 | `status` `title` `description` `actions` | | `Toast` | 命令式轻提示 | `toast()` `toast.success()` `` | | `Progress` | 进度条 | `percent` `size` `tone` `showLabel` | | `Steps` | 纵向步骤条 | `items` `current` | | `Modal` | 居中弹窗(原生 dialog) | `open` `onClose` `title` `footer` `size` | | `Drawer` | 侧滑抽屉(原生 dialog) | `open` `onClose` `title` `placement` `width` | | `Tooltip` | hover / 聚焦文字提示 | `content` `placement` | | `Popover` | 点击展开的小面板 | `content` `placement` | | `Pagination` | 分页 | `page` `total` `pageSize` `onChange` | | `ScrollTop` | 回到顶部 | `visible` `targetId` | | `Icon` | 图标(446 个字形) | `name` | ## 整页示例与深色主题 `site/demo.html` 是用这套组件拼出来的完整页面(三栏布局:资料卡 / 内容流 / 侧栏聚合), 只用了库里的组件 + 页面自己的布局样式: - `http://127.0.0.1:5199/demo.html` —— 浅色 - `http://127.0.0.1:5199/demo.html?scheme=dark` —— 深色 - 右上角按钮可以实时切换 深色主题由 `` 驱动(`dark` / `auto` / 不设置),**组件零改动**: 所有颜色走语义令牌,深色只覆盖 [tokens.css](src/theme/tokens.css) 末尾的变量块。 新增组件时不要直接写 `--su-gray-*`,否则深色下会串色——规则见 [DESIGN.md §7](DESIGN.md)。 ## 在应用里使用 库构建产物是标准 ESM 包,本地项目可以直接按路径引用: ```bash # 在应用目录里 npm install ../stash-ui # 或 "dependencies": { "@stash/ui": "file:../stash-ui" } ``` ```tsx // main.tsx:样式只引一次(令牌 + 图标字体 + 组件样式都在这份文件里) import '@stash/ui/style.css' import { Button, Card, Grid } from '@stash/ui' ``` 接入约定: - 只引一次 `style.css`,不要在页面里重复引令牌; - 应用自己的页面级样式(布局、页面专属类)写在应用里,不要加进组件库; - 需要改主题就覆盖 `--su-*` 变量,不要覆盖组件的内部类名。 ## 浏览器与响应式 **目标**:桌面 Chrome / Edge / Safari / Firefox 最新两个版本;iOS Safari 16+、Android Chrome 110+。 库只用这些基线能力:CSS 变量、CSS Modules、`:focus-visible`、`color-mix()`(吸顶页头)、 ``(Modal / Drawer)、`dvh`、`-webkit-line-clamp`(段落截断)。 响应式在组件内部处理,页面不用各写一套: | 断点 | 组件 | 行为 | | --- | --- | --- | | ≤1200px | `Layout` | 右栏堆到内容下方 | | ≤1100px | `Layout` / `Grid` | 侧栏堆到内容上方;网格降为 2 列 | | ≤720px | `Grid` | 网格降为 1 列 | 触屏相关的约定: - 交互只用 click,不依赖 hover;`Tooltip` 的 hover 样式包在 `@media (hover: hover)` 里; - 弹层宽度都有夹取(Modal `100vw - 32px`、Popover `min(320px, 100vw - 32px)`、Drawer `max-width: 100vw`); - 表格、标签栏、分段控件在窄屏横向滚动,不做压缩; - 移动端建议用 `sm(36)` / `md(44)` 的图标按钮,`xs(28)` 留给桌面密集场景。 已实测:文档站与示例页在 **390×844(手机)**、**820×1180(平板)**、**1440×900(桌面)** 下无横向溢出。 **尚未**在真机(iOS Safari 软键盘、`dvh`、`backdrop-filter`)上复核。 ## 约定(摘要,完整规则见 DESIGN.md) - **令牌先行**:组件里不允许出现硬编码色值 / 字号 / 间距,一律走 `--su-*`; - **CSS Modules**:每个组件一个 `Component.module.css`,类名短(`.root` / `.sm`)且不外泄; - **组件不带数据**:库里的组件只接收 props,不请求接口、不读全局状态; - **新增组件**:按 [ADDING-A-COMPONENT.md](docs/ADDING-A-COMPONENT.md) 的 12 步走, 并用 [DESIGN.md §6](DESIGN.md) 的清单验收(组件文件 → 导出 → 展示页 → 导航 → README 清单 → 契约 → typecheck/lint/build → 截图)。 ## 与 stash 应用的关系 `stash-web` 目前仍是自己的全局 CSS(`App.css`),本次接入是分阶段的: 1. **已具备**(本仓库):独立组件库 + 展示站,可单独构建与预览; 2. **下一步**:在 `stash-web` 里以 `file:` 依赖接入,先把侧栏 / 顶栏 / 空态 / 按钮换成库组件; 3. **再下一步**:把 `App.css` 按组件拆分,页面只保留布局样式;同步删掉库中重复的样式定义。 对照表(迁移时查): | stash 现有类 | 组件库对应 | | --- | --- | | `.card-box` | `Card` | | `.section-empty` | `Empty` | | `.notice` / `.notice-error` | `Notice`(`tone="info" / "error"`) | | `.header-info-tag` / `.item-tag` / `.service-pill` | `Tag` | | `.count-badge` | `Badge` | | `.search-bar` | `Input`(`icon="search"` + `clearable`) | | `.app-grid` / `.poster-grid` / `.article-grid` / `.ces-grid` | `Grid` | | `.back-to-top` | `ScrollTop` | | `.profile-avatar` / `.app-card-avatar` | `Avatar` | ## 构建注意(踩过的坑) - **展示站要显式 import 主题 CSS**:`site/main.tsx` 里直接引 `src/theme/tokens.css` 与 `src/theme/iconfont.css`。只靠 `src/index.ts` 的副作用引入时,生产构建会把 `iconfont.css` 摇掉(dev 正常、build 后图标全空)。 - **库产物的 CSS 内联了图标字体**:lib 模式下 Vite 会把 woff2 转成 data URI, 所以 `dist/stash-ui.css` 有 80KB+(gzip 56KB),换来「只引一个 CSS 文件」。 展示站走普通构建,字体仍是独立资源。 - **比例封面不要放进 `flex-direction: column` 的父容器**:纵向 flex 会把子项按内容高度压缩, 带 `aspect-ratio` 的封面会被裁掉一截(`site/app.css` 里纵向示例因此改用 grid)。 - **组件尺寸对齐**:`Input` 与 `Button size="md"` 都是 36px,并排成一行才是齐的。 ## 为什么不直接用 Storybook Storybook 能做的事我们这里只需要一小部分,而它要额外的依赖、端口和一套独立构建; 本项目是「本地小工具 + 单二进制发布」的配套,直接做成一个 Vite 站点更轻: - `site/` 就是文档站,dev 下改组件即时生效,build 后是纯静态页; - 展示用例直接用真实组件,不存在「story 与页面不一致」; - 截图脚本(`scripts/screenshot.mjs`)已经把视觉回归的基础打好了, 将来要接 Playwright 截图对比也不用换工具。 ## 后续可做 - 暗色主题:令牌已经收敛,补一份 `[data-theme='dark']` 覆盖即可; - Typography / Menu / Tabs / Modal 这类更上层的组件; - 展示站加「复制代码」与 props 表自动生成; - 视觉回归:在展示站上加 Playwright 截图对比(`scripts/screenshot.mjs` 已经是起点)。