@zmzai/theme 0.6.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmzai/theme",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "zmzai 全品牌设计系统 — Radix UI + framer-motion + Tailwind v4 + MiSans",
@@ -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",
@@ -26,7 +26,8 @@ export const buttonVariants = cva(
26
26
  variants: {
27
27
  variant: {
28
28
  primary:
29
- "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]",
30
31
  secondary:
31
32
  "border border-line text-ink hover:border-ink hover:bg-surface-2 active:scale-[0.98]",
32
33
  ghost:
@@ -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";
@@ -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
  }
@@ -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"