@zmzai/theme 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ # 设计约定(Design Conventions)
2
+
3
+ > 2026-08-27 起。整合:交互取择表(源自 hallmark microinteractions 纪律)、反 AI 味检查清单、hallmark skill 的使用边界。
4
+ > 与 `prefer-theme-components` 原则(UI 一律 @zmzai/theme 组件)配套执行。
5
+
6
+ ## 一、交互取择表(Tool-UI 场景)
7
+
8
+ 写交互时的默认选择——左边被否决时才允许用右边:
9
+
10
+ | 默认采用 ✅ | 而不是 ❌ | 适用 |
11
+ | --- | --- | --- |
12
+ | 静默成功(状态自然变化) | 庆祝型 toast / 弹窗 | 所有操作反馈 |
13
+ | 乐观更新 + Undo | 事前确认对话框 | 可逆操作(删除入回收站、重命名) |
14
+ | 事前确认 | 乐观更新 | **不可逆**操作(真删数据、对外发布)——Undo 救不了的必须问 |
15
+ | 就近内联错误(字段旁/卡片内) | 顶部全局 toast 报错 | 表单与工具卡 |
16
+ | hover 提示延迟 800ms / focus 0ms | 两者都 0ms | Tooltip(防扫过闪烁) |
17
+ | `prefers-reduced-motion: reduce` 全局降级 | 只做动画不管偏好 | 所有动效 |
18
+ | 只动 `transform` / `opacity` | 动 layout 属性(width/top/margin) | 所有动效 |
19
+ | `--ease-out`(入场)/ `--ease-in`(退场) | 浏览器默认 `ease`、bounce/overshoot | 所有过渡 |
20
+ | 动效时长 ≤200ms(状态) / ≤400ms(进出场) | 更长 | 工具 UI 节奏 |
21
+ | 裁剪:删掉不损失信息的动画就删 | 多多益善 | Review 时逐个问「删了会丢信息吗」 |
22
+
23
+ **harness/工作台类界面专项**:消息流自动跟随滚动但不锁底(用户上翻即停,近底再恢复);长任务用 terminal_read 式游标增量而非整段重渲染。
24
+
25
+ ## 二、反 AI 味检查清单(并入 design-review)
26
+
27
+ 每次 UI 批次过 review 时逐项检查,任一命中即退回:
28
+
29
+ 1. **斜体标题**——headings 一律 roman(`font-style: normal`);强调用字重/accent 色/下划线,不用 `<em>`。
30
+ 2. **编造数据**——页面上的任何数字(用户数、百分比、"10×")必须有真实来源;没有就放 `—` 占位并标注"数据待确认",或者干脆换结构。
31
+ 3. **手绘假 chrome**——不画假浏览器栏(URL pill + 红绿灯)、假手机框、假 IDE 窗口包 `<pre>`;要么用真实截图(hairline 边框的 `<figure>`),要么裸内容。
32
+ 4. **模板节奏**——同仓两页不许共享 hero→3 特性→CTA→footer 的同一韵律;工具型界面豁免(一致性优先)。
33
+ 5. **未经 token 的即兴取值**——CSS 里出现内联 hex/oklch/rgb 或绕过 token 块的 `font-family: "某字体"`,一律改为引用命名 token。
34
+ 6. **假占位图冒充成品**——stock 图必须标 placeholder,不得当作最终交付。
35
+
36
+ ## 三、hallmark skill 使用边界(拍板 2026-08-27)
37
+
38
+ | 场景 | 用法 | 约束 |
39
+ | --- | --- | --- |
40
+ | **叙事型新页面**(落地页、官网、产品介绍) | `hallmark redesign` / default Design flow 可用 | **token 只准指向 @zmzai/theme**;禁走 catalog 主题自选(会引入第二套 OKLCH + 字体配对,与 theme 冲突);macrostructure 轮换照常 |
41
+ | **工具型界面**(工作台、后台、harness) | 只用 audit / 反 AI 味清单 | 不进 Design flow(工具 UI 要一致性不要多样性) |
42
+ | **任何 UI 的质检** | `hallmark audit` | 输出进 design-review 流程,与可用性审查并列 |
43
+ | **参考站拆解** | `hallmark study` | 产出的 DNA 回写 theme 时走正常 token 变更(主应用 v0.6.0 荧光绿即 study 产物) |
44
+
45
+ **权威序**:`@zmzai/theme` tokens > 各产品 globals.css 内联 token > 页面级样式。hallmark 在任何环节都不得越过第一级。
46
+
47
+ ## 四、违反后果
48
+
49
+ - push main 前的约定检查(见 `prefer-theme-components` 记忆)把本文件第一、二节纳入检查范围;
50
+ - 新组件 PR 缺 8 态矩阵行(states-discipline.md)不予合并。
@@ -0,0 +1,36 @@
1
+ # 组件状态纪律(States Discipline)
2
+
3
+ > 来源:hallmark skill 的 8-state discipline,经 2026-08-27 评审吸收进 @zmzai/theme。
4
+ > 自 v0.6.1 起执行。适用范围:**所有交互组件**(theme 仓内 + 各产品仓自建控件)。
5
+
6
+ ## 验收标准(8 态清单)
7
+
8
+ 每个交互组件必须对以下状态有**明确的设计决定**——要么有样式,要么有书面豁免理由(如纯装饰元素)。缺态 = 验收不通过。
9
+
10
+ | 状态 | 载体 | 硬性要求 |
11
+ | --- | --- | --- |
12
+ | default | — | 基线样式 |
13
+ | hover | `:hover` | 必须有可感知反馈(边框/背景/阴影任一),纯 `cursor` 不算 |
14
+ | focus-visible | `:focus-visible` | **≥3:1 对比度的 ring,立即可见、禁止动画**。用 `ring-2 ring-ink ring-offset-2 ring-offset-bg`;绝不能用 `:focus` 代替(避免鼠标点击也出 ring) |
15
+ | active | `:active` | 按压反馈统一 `active:scale-[0.98]`(按钮族);不可缩的用背景加深 |
16
+ | disabled | `disabled:` | `pointer-events-none opacity-40`(按钮)/ `cursor-not-allowed bg-surface text-ink-3`(输入族) |
17
+ | loading | `data-state` / 组件内状态 | 由 `StatefulButton` 承担(spinner + 防重复点击) |
18
+ | error | `aria-invalid` | 输入族:边框转 `border-danger`;按钮族:错误不落在按钮上,落在就近的错误文案区 |
19
+ | success | `data-state` | 静默优先——成功不必庆祝,需要确认感的操作用 `StatefulButton` 的 checkmark 过渡(1.5s 自复位) |
20
+
21
+ ## 现状矩阵(v0.6.1 基线)
22
+
23
+ | 组件 | default | hover | focus-visible | active | disabled | loading | error | success |
24
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- |
25
+ | Button | ✅ | ✅ | ✅ 0.6.1 补 | ✅ 0.6.1 补齐 secondary/ghost | ✅ | via StatefulButton | 豁免(就近文案) | via StatefulButton |
26
+ | Input / Textarea | ✅ | ✅ 0.6.1 补 | ✅ 0.6.1 补 | 豁免(输入不按压) | ✅ 0.6.1 补 | 豁免 | ✅ 0.6.1 补(aria-invalid) | 豁免 |
27
+ | SelectTrigger | ✅ | ✅ | ✅ 0.6.1 补 | 豁免 | ✅ 0.6.1 补 | 豁免 | ✅ 0.6.1 补(aria-invalid) | 豁免 |
28
+ | StatefulButton | ✅ | ✅ | ✅(继承 Button) | ✅ | ✅ | ✅ | ⚠️ **待补**:当前 catch 静默回 idle | ✅ |
29
+
30
+ **待办**:StatefulButton 的 error 呈现(失败态停留 + 重试入口,不自动复位)——下个版本。
31
+
32
+ ## 新组件自查流程
33
+
34
+ 1. 写完组件后对照矩阵逐格打勾,打不了勾的写豁免理由进组件头注释;
35
+ 2. playground 里过一遍键盘 Tab 流(只看 focus-visible 是否全程可见);
36
+ 3. `aria-invalid` / `data-state` 用真实表单校验驱动,不要手动塞 class。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmzai/theme",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "zmzai 全品牌设计系统 — Radix UI + framer-motion + Tailwind v4 + MiSans",
@@ -56,9 +56,16 @@
56
56
  },
57
57
  "files": [
58
58
  "src",
59
+ "docs",
59
60
  "README.md",
60
61
  "LICENSE"
61
62
  ],
63
+ "scripts": {
64
+ "build": "tsc -p tsconfig.json --noEmit",
65
+ "typecheck": "tsc --noEmit",
66
+ "playground": "vite playground --port 5174",
67
+ "prepublishOnly": "pnpm typecheck"
68
+ },
62
69
  "peerDependencies": {
63
70
  "react": "^19.0.0",
64
71
  "react-dom": "^19.0.0",
@@ -98,9 +105,5 @@
98
105
  "remark-gfm": "^4.0.1",
99
106
  "tailwind-merge": "^2.5.0"
100
107
  },
101
- "scripts": {
102
- "build": "tsc -p tsconfig.json --noEmit",
103
- "typecheck": "tsc --noEmit",
104
- "playground": "vite playground --port 5174"
105
- }
106
- }
108
+ "packageManager": "pnpm@10.34.5+sha512.a4ee05f2f73658255bd6a89859c065a45c28a57daefae2c893a168ee2b73168c37b91e83e57ea67654ad03f03031746430e8bce38e362e042605fb8abc80192e"
109
+ }
@@ -0,0 +1,211 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import type { ComponentType, ReactNode } from "react";
5
+
6
+ import { Badge } from "../badge/Badge";
7
+ import { Icon, type IconName } from "../icon/Icon";
8
+ import { CommandPalette, type AppPaletteItem } from "./CommandPalette";
9
+
10
+ export type AppNavItem = { label: string; href: string; icon: IconName; keywords?: string };
11
+ export type AppNavSection = { label: string; items: AppNavItem[] };
12
+ export type { AppPaletteItem } from "./CommandPalette";
13
+
14
+ /** 消费端注入的链接组件(Next.js 传 next/link 以保持 SPA 导航;缺省用 <a>)。 */
15
+ export type AppShellLink = ComponentType<{
16
+ href: string;
17
+ className?: string;
18
+ children?: ReactNode;
19
+ onClick?: () => void;
20
+ "aria-current"?: "page" | undefined;
21
+ }>;
22
+
23
+ export function appShellIsActive(pathname: string, href: string): boolean {
24
+ return pathname === href || pathname.startsWith(`${href}/`);
25
+ }
26
+
27
+ /** 无 SPA 需求时的缺省链接:普通 <a>。 */
28
+ function DefaultAnchor({ href, className, children, onClick, "aria-current": ariaCurrent }: { href: string; className?: string; children?: ReactNode; onClick?: () => void; "aria-current"?: "page" }) {
29
+ return (
30
+ <a href={href} className={className} onClick={onClick} aria-current={ariaCurrent}>
31
+ {children}
32
+ </a>
33
+ );
34
+ }
35
+
36
+ function SidebarSections({
37
+ sections,
38
+ pathname,
39
+ link: Link,
40
+ onNavigate,
41
+ }: {
42
+ sections: AppNavSection[];
43
+ pathname: string;
44
+ link: AppShellLink;
45
+ onNavigate?: () => void;
46
+ }) {
47
+ return (
48
+ <>
49
+ {sections.map((section) => (
50
+ <div key={section.label} className="flex flex-col gap-0.5">
51
+ <p className="px-2 pb-1 font-mono text-[10px] uppercase tracking-wider text-ink-3">{section.label}</p>
52
+ {section.items.map((item) => {
53
+ const active = appShellIsActive(pathname, item.href);
54
+ return (
55
+ <Link
56
+ key={item.href}
57
+ href={item.href}
58
+ onClick={onNavigate}
59
+ aria-current={active ? "page" : undefined}
60
+ className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors ${
61
+ active ? "bg-surface font-medium text-accent-readable" : "text-muted hover:bg-surface hover:text-accent"
62
+ }`}
63
+ >
64
+ <Icon name={item.icon} size={13} />
65
+ {item.label}
66
+ </Link>
67
+ );
68
+ })}
69
+ </div>
70
+ ))}
71
+ </>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * 全站统一应用外壳(OpenRouter 式交互内核):
77
+ * 全高左侧 sidebar(分组导航)+ 轻顶栏(标题 + ⌘K)+ 侧栏底部账户块。
78
+ * 站点无关:导航分组、品牌区、账户块、链接组件均由调用方注入;
79
+ * relay 的 RelayShell / sandbox 的 SandboxShell 是它的薄封装。
80
+ */
81
+ export function AppShell({
82
+ brand,
83
+ sections,
84
+ pathname,
85
+ link,
86
+ account = null,
87
+ headerExtras = null,
88
+ paletteItems,
89
+ children,
90
+ }: {
91
+ /** 品牌区:侧栏顶部与顶栏兜底标题 */
92
+ brand: { label: string; suffix?: string; href?: string };
93
+ /** 侧栏分组导航 */
94
+ sections: AppNavSection[];
95
+ /** 当前路径(消费端传 usePathname() ?? "/";theme 不依赖 next) */
96
+ pathname: string;
97
+ /** 链接组件(Next.js 站传 next/link) */
98
+ link?: AppShellLink;
99
+ /** 侧栏底部账户块(登录按钮 / 用户信息) */
100
+ account?: ReactNode;
101
+ /** 顶栏右侧扩展(余额、状态等) */
102
+ headerExtras?: ReactNode;
103
+ /** ⌘K 命令面板条目;为空时顶栏不显示搜索按钮 */
104
+ paletteItems?: AppPaletteItem[];
105
+ children: ReactNode;
106
+ }) {
107
+ const Link = (link ?? DefaultAnchor) as AppShellLink;
108
+ const hasPalette = Boolean(paletteItems?.length);
109
+ const [drawerOpen, setDrawerOpen] = useState(false);
110
+ const [paletteOpen, setPaletteOpen] = useState(false);
111
+
112
+ // 顶栏标题:当前激活项的 label(取最长匹配,/admin/models 优先于 /admin)
113
+ const currentLabel = useMemo(() => {
114
+ const matches = sections.flatMap((s) => s.items).filter((item) => appShellIsActive(pathname, item.href));
115
+ if (!matches.length) return brand.label;
116
+ return matches.sort((a, b) => b.href.length - a.href.length)[0].label;
117
+ }, [pathname, sections, brand.label]);
118
+
119
+ // ⌘K / Ctrl+K 全局快捷键
120
+ useEffect(() => {
121
+ if (!hasPalette) return;
122
+ const onKey = (event: KeyboardEvent) => {
123
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
124
+ event.preventDefault();
125
+ setPaletteOpen((open) => !open);
126
+ }
127
+ };
128
+ window.addEventListener("keydown", onKey);
129
+ return () => window.removeEventListener("keydown", onKey);
130
+ }, [hasPalette]);
131
+
132
+ const brandBlock = (
133
+ <Link href={brand.href ?? "/"} className="flex items-baseline gap-1.5 px-2">
134
+ <span className="text-sm font-semibold tracking-tight">{brand.label}</span>
135
+ {brand.suffix ? <span className="font-mono text-[11px] text-ink-3">{brand.suffix}</span> : null}
136
+ </Link>
137
+ );
138
+
139
+ return (
140
+ <div className="min-h-dvh bg-bg">
141
+ {hasPalette ? <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} items={paletteItems!} /> : null}
142
+
143
+ {/* 移动端抽屉 */}
144
+ {drawerOpen ? (
145
+ <div className="fixed inset-0 z-40 lg:hidden" role="presentation">
146
+ <div className="absolute inset-0 bg-black/30" onClick={() => setDrawerOpen(false)} />
147
+ <nav className="absolute inset-y-0 left-0 flex w-64 flex-col gap-5 overflow-y-auto border-r border-line bg-bg px-3 py-4">
148
+ {brandBlock}
149
+ <SidebarSections sections={sections} pathname={pathname} link={Link} onNavigate={() => setDrawerOpen(false)} />
150
+ {account}
151
+ </nav>
152
+ </div>
153
+ ) : null}
154
+
155
+ <div className="lg:grid lg:grid-cols-[13.5rem_minmax(0,1fr)]">
156
+ {/* 桌面固定侧栏 */}
157
+ <aside className="sticky top-0 hidden h-dvh flex-col gap-5 overflow-y-auto border-r border-line px-3 py-4 lg:flex">
158
+ {brandBlock}
159
+ <SidebarSections sections={sections} pathname={pathname} link={Link} />
160
+ {account}
161
+ </aside>
162
+
163
+ <div className="flex min-h-dvh min-w-0 flex-col">
164
+ {/* 轻顶栏 */}
165
+ <header className="sticky top-0 z-30 flex items-center gap-3 border-b border-line bg-bg/95 px-4 py-2.5 backdrop-blur lg:px-8">
166
+ <button
167
+ type="button"
168
+ aria-label="打开导航"
169
+ onClick={() => setDrawerOpen(true)}
170
+ className="rounded-md p-1 text-muted hover:bg-surface hover:text-accent lg:hidden"
171
+ >
172
+ <Icon name="menu" size={16} />
173
+ </button>
174
+ <span className="text-sm font-medium text-ink-2">{currentLabel}</span>
175
+ <div className="ml-auto flex items-center gap-3">
176
+ {headerExtras}
177
+ {hasPalette ? (
178
+ <button
179
+ type="button"
180
+ onClick={() => setPaletteOpen(true)}
181
+ className="flex items-center gap-2 rounded-md border border-line px-2.5 py-1 font-mono text-[11px] text-muted transition-colors hover:border-accent hover:text-accent"
182
+ aria-label="打开命令面板"
183
+ >
184
+ <Icon name="search" size={12} />
185
+ <span className="hidden sm:inline">搜索</span>
186
+ <kbd className="hidden sm:inline">⌘K</kbd>
187
+ </button>
188
+ ) : null}
189
+ </div>
190
+ </header>
191
+
192
+ {/* 内容区 */}
193
+ <main className="page-shell flex-1 py-8">{children}</main>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ /** 侧栏账户块通用样式辅助:登录后的用户信息行。 */
201
+ export function AppShellAccountRow({ name, badge, children }: { name: string; badge?: string; children?: ReactNode }) {
202
+ return (
203
+ <div className="mt-auto flex flex-col gap-2 border-t border-line px-2 pt-3">
204
+ <div className="flex items-center justify-between gap-2">
205
+ <span className="truncate font-mono text-xs text-ink-2">{name}</span>
206
+ {badge ? <Badge variant="outline" size="sm">{badge}</Badge> : null}
207
+ </div>
208
+ {children ? <div className="flex items-center justify-between gap-2 font-mono text-[11px]">{children}</div> : null}
209
+ </div>
210
+ );
211
+ }
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+
5
+ import { Icon, type IconName } from "../icon/Icon";
6
+
7
+ export type AppPaletteItem = {
8
+ label: string;
9
+ hint?: string;
10
+ group: string;
11
+ icon?: IconName;
12
+ keywords?: string;
13
+ run: () => void;
14
+ };
15
+
16
+ /**
17
+ * ⌘K / Ctrl+K 命令面板:纯手写 overlay(无新依赖)。
18
+ * 键盘:↑↓ 选择、Enter 确认、Esc 关闭;输入即时过滤(label/hint/keywords 子串匹配)。
19
+ */
20
+ export function CommandPalette({ open, onClose, items, placeholder = "搜索页面或执行动作…" }: { open: boolean; onClose: () => void; items: AppPaletteItem[]; placeholder?: string }) {
21
+ const [query, setQuery] = useState("");
22
+ const [cursor, setCursor] = useState(0);
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+ const listRef = useRef<HTMLDivElement>(null);
25
+
26
+ const filtered = useMemo(() => {
27
+ const q = query.trim().toLowerCase();
28
+ if (!q) return items;
29
+ return items.filter((item) => `${item.label} ${item.hint ?? ""} ${item.keywords ?? ""}`.toLowerCase().includes(q));
30
+ }, [items, query]);
31
+
32
+ useEffect(() => {
33
+ if (open) {
34
+ setQuery("");
35
+ setCursor(0);
36
+ // 等一帧让 overlay 挂载后再聚焦,避免被浏览器焦点还原打断
37
+ requestAnimationFrame(() => inputRef.current?.focus());
38
+ }
39
+ }, [open]);
40
+
41
+ useEffect(() => setCursor(0), [query]);
42
+
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ const onKey = (event: KeyboardEvent) => {
46
+ if (event.key === "Escape") {
47
+ event.preventDefault();
48
+ onClose();
49
+ } else if (event.key === "ArrowDown") {
50
+ event.preventDefault();
51
+ setCursor((c) => Math.min(c + 1, filtered.length - 1));
52
+ } else if (event.key === "ArrowUp") {
53
+ event.preventDefault();
54
+ setCursor((c) => Math.max(c - 1, 0));
55
+ } else if (event.key === "Enter") {
56
+ event.preventDefault();
57
+ const item = filtered[cursor];
58
+ if (item) {
59
+ onClose();
60
+ item.run();
61
+ }
62
+ }
63
+ };
64
+ window.addEventListener("keydown", onKey);
65
+ return () => window.removeEventListener("keydown", onKey);
66
+ }, [open, filtered, cursor, onClose]);
67
+
68
+ useEffect(() => {
69
+ listRef.current?.querySelector<HTMLElement>(`[data-index="${cursor}"]`)?.scrollIntoView({ block: "nearest" });
70
+ }, [cursor]);
71
+
72
+ if (!open) return null;
73
+
74
+ const execute = (index: number) => {
75
+ const item = filtered[index];
76
+ if (!item) return;
77
+ onClose();
78
+ item.run();
79
+ };
80
+
81
+ return (
82
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 px-4 pt-[12vh]" onClick={onClose} role="presentation">
83
+ <div
84
+ className="w-full max-w-xl overflow-hidden rounded-xl border border-line bg-bg shadow-lg"
85
+ onClick={(event) => event.stopPropagation()}
86
+ role="dialog"
87
+ aria-modal="true"
88
+ aria-label="命令面板"
89
+ >
90
+ <div className="flex items-center gap-2 border-b border-line px-4 py-3">
91
+ <Icon name="search" size={14} className="text-muted" />
92
+ <input
93
+ ref={inputRef}
94
+ value={query}
95
+ onChange={(event) => setQuery(event.target.value)}
96
+ placeholder={placeholder}
97
+ className="w-full bg-transparent text-sm outline-none placeholder:text-ink-3"
98
+ />
99
+ <kbd className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-ink-3">esc</kbd>
100
+ </div>
101
+ <div ref={listRef} className="max-h-[50vh] overflow-y-auto py-1.5">
102
+ {filtered.length === 0 ? (
103
+ <p className="px-4 py-6 text-center font-mono text-xs text-ink-3">没有匹配结果</p>
104
+ ) : (
105
+ filtered.map((item, index) => (
106
+ <button
107
+ key={`${item.group}-${item.label}`}
108
+ type="button"
109
+ data-index={index}
110
+ onClick={() => execute(index)}
111
+ onMouseEnter={() => setCursor(index)}
112
+ className={`flex w-full items-center gap-2.5 px-4 py-2 text-left text-sm transition-colors ${
113
+ index === cursor ? "bg-surface text-accent-readable" : "text-ink-2"
114
+ }`}
115
+ >
116
+ {item.icon ? <Icon name={item.icon} size={13} className="shrink-0 text-muted" /> : null}
117
+ <span className="min-w-0 flex-1 truncate">{item.label}</span>
118
+ {item.hint ? <span className="shrink-0 font-mono text-[11px] text-ink-3">{item.hint}</span> : null}
119
+ </button>
120
+ ))
121
+ )}
122
+ </div>
123
+ </div>
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,4 @@
1
+ export { AppShell, AppShellAccountRow, appShellIsActive } from "./AppShell";
2
+ export type { AppNavItem, AppNavSection, AppShellLink } from "./AppShell";
3
+ export { CommandPalette } from "./CommandPalette";
4
+ export type { AppPaletteItem } from "./CommandPalette";
@@ -9,7 +9,7 @@ export const badgeVariants = cva(
9
9
  {
10
10
  variants: {
11
11
  variant: {
12
- solid: "bg-ink text-white",
12
+ solid: "bg-ink text-bg",
13
13
  outline: "border border-line text-ink-2",
14
14
  success: "border border-success/30 bg-success/10 text-success",
15
15
  warning: "border border-warning/30 bg-warning/10 text-warning",
@@ -4,22 +4,34 @@ import { cva } from "class-variance-authority";
4
4
  * Button variant styles.
5
5
  *
6
6
  * Design:
7
- * - 锐角 2px(v0.2 暖纸风),hover 轻微缩放
7
+ * - 锐角 2px(v0.2 暖纸风沿用的锐利语言),hover 阴影 / active 微缩
8
8
  * - primary: black fill, white text, hover scale + shadow
9
9
  * - secondary: transparent, border, hover fill
10
10
  * - ghost: no border, subtle hover bg
11
+ * - danger: red fill
12
+ *
13
+ * States discipline (v0.6.1 起,见 docs/states-discipline.md):
14
+ * - focus-visible: 2px ink ring + offset,立即可见不做动画(键盘可达性硬性要求)
15
+ * - active: 所有可按压 variant 统一 scale 反馈(此前仅 primary/danger 有)
16
+ * - disabled: opacity-40 + pointer-events-none
17
+ * - loading/error/success 语义态由 StatefulButton 承担(error 呈现待补)
11
18
  */
12
19
  export const buttonVariants = cva(
13
- "inline-flex cursor-pointer items-center justify-center gap-2 rounded-sm font-semibold transition-colors transition-transform disabled:pointer-events-none disabled:opacity-40 select-none",
20
+ [
21
+ "inline-flex cursor-pointer items-center justify-center gap-2 rounded-sm font-semibold transition-colors transition-transform select-none",
22
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-bg",
23
+ "disabled:pointer-events-none disabled:opacity-40",
24
+ ].join(" "),
14
25
  {
15
26
  variants: {
16
27
  variant: {
17
28
  primary:
18
- "bg-ink text-white hover:shadow-md active:scale-[0.98]",
29
+ // 反色按钮:bg-ink 上用 bg 色作文字(浅色=白、深色=黑),硬编码 text-white 在深色主题下不可读
30
+ "bg-ink text-bg hover:shadow-md active:scale-[0.98]",
19
31
  secondary:
20
- "border border-line text-ink hover:border-ink hover:bg-surface-2",
32
+ "border border-line text-ink hover:border-ink hover:bg-surface-2 active:scale-[0.98]",
21
33
  ghost:
22
- "text-ink-2 hover:bg-surface-2 hover:text-ink",
34
+ "text-ink-2 hover:bg-surface-2 hover:text-ink active:scale-[0.98]",
23
35
  danger:
24
36
  "bg-danger text-white hover:shadow-md active:scale-[0.98]",
25
37
  },
@@ -1,6 +1,7 @@
1
1
  // @zmzai/theme — Components barrel (54 components)
2
2
 
3
3
  // === Basic ===
4
+ export * from "./app-shell";
4
5
  export * from "./button";
5
6
  export * from "./input";
6
7
  export * from "./textarea";
@@ -4,18 +4,26 @@ import { cva } from "class-variance-authority";
4
4
  * Input variant styles.
5
5
  *
6
6
  * Design:
7
- * - default: light border, focus turns border to ink
8
- * - brutal: 2px ink border + hard offset shadow (neobrutalism), focus shifts
7
+ * - default: light border, hover 提示可交互,focus turns border to ink
8
+ * - brutal: 2px ink border + hard offset shadow (neobrutalism)
9
+ *
10
+ * States discipline (v0.6.1 起,见 docs/states-discipline.md):
11
+ * - disabled: 灰化 + not-allowed 光标
12
+ * - aria-invalid: 边框转 danger(与 Radix/shadcn 惯例一致,业务层无需传额外 prop)
9
13
  */
10
14
  export const inputVariants = cva(
11
- "w-full bg-bg font-sans text-ink placeholder:text-ink-3 outline-none transition-all",
15
+ [
16
+ "w-full bg-bg font-sans text-ink placeholder:text-ink-3 outline-none transition-all",
17
+ "focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-bg disabled:cursor-not-allowed disabled:bg-surface disabled:text-ink-3",
18
+ "aria-invalid:border-danger aria-invalid:focus:border-danger",
19
+ ].join(" "),
12
20
  {
13
21
  variants: {
14
22
  variant: {
15
23
  default:
16
- "h-10 border border-line rounded-sm px-4 text-sm focus:border-ink",
24
+ "h-10 border border-line rounded-sm px-4 text-sm hover:border-ink/40 focus:border-ink focus-visible:ring-ink",
17
25
  brutal:
18
- "h-10 border-2 border-ink rounded-sm px-4 text-sm",
26
+ "h-10 border-2 border-ink rounded-sm px-4 text-sm focus-visible:ring-line-strong",
19
27
  },
20
28
  size: {
21
29
  sm: "h-8 text-xs px-3",
@@ -9,7 +9,7 @@ import { Icon } from "../icon/Icon";
9
9
  /**
10
10
  * navItemClass — 导航项统一样式(全域导航一致)。
11
11
  *
12
- * active 时深墨 pill 高亮(bg-ink text-white),其余 hover 浮现。
12
+ * active 时深墨 pill 高亮(bg-ink text-bg,深浅主题自适应),其余 hover 浮现。
13
13
  * 链接本身由调用方渲染(Next.js 用 next/link,保持 SPA 导航),
14
14
  * 只要都套这个 className,任何站点的导航观感就完全一致。
15
15
  */
@@ -17,7 +17,7 @@ export function navItemClass(active: boolean): string {
17
17
  return cn(
18
18
  "inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
19
19
  active
20
- ? "bg-ink text-white"
20
+ ? "bg-ink text-bg"
21
21
  : "text-ink-2 hover:bg-surface-2 hover:text-ink"
22
22
  );
23
23
  }
@@ -20,6 +20,8 @@ export function SelectTrigger({
20
20
  className={cn(
21
21
  "inline-flex h-10 items-center justify-between gap-2 rounded-sm border border-line bg-bg px-4 text-sm font-medium text-ink outline-none transition-colors",
22
22
  "hover:border-ink/40 focus:border-ink data-[placeholder]:text-ink-3",
23
+ "disabled:cursor-not-allowed disabled:bg-surface disabled:text-ink-3",
24
+ "aria-invalid:border-danger focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:ring-ink",
23
25
  className
24
26
  )}
25
27
  >
@@ -16,7 +16,7 @@ export interface StickyBannerProps extends HTMLAttributes<HTMLDivElement> {
16
16
 
17
17
  const variantStyles: Record<BannerVariant, string> = {
18
18
  info: "bg-surface text-ink border-line",
19
- warning: "bg-ink text-white border-ink",
19
+ warning: "bg-ink text-bg border-ink",
20
20
  danger: "bg-bg text-ink border-ink border-2",
21
21
  };
22
22
 
@@ -145,7 +145,7 @@ export function VanishInput({
145
145
  <button
146
146
  type="submit"
147
147
  aria-label="提交"
148
- className="ml-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ink text-white transition-transform hover:scale-105 active:scale-95"
148
+ className="ml-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ink text-bg transition-transform hover:scale-105 active:scale-95"
149
149
  >
150
150
  <svg
151
151
  className="h-3.5 w-3.5"