@zmzai/theme 0.9.6 → 0.9.8

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.9.6",
3
+ "version": "0.9.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "zmzai 全品牌设计系统 — Radix UI + framer-motion + Tailwind v4 + MiSans",
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { cn } from "../../utils/cn";
4
+ import { Logo } from "../logo";
5
+ import { Wordmark } from "../wordmark";
6
+
7
+ /**
8
+ * BrandLockup — 全站统一的品牌锁标:云朵 Logo + ZMZAI wordmark + 子站标。
9
+ *
10
+ * 背景(2026-08-31):theme 有两套骨架,品牌区 API 不对称 ——
11
+ * Navbar 内置 Logo + Wordmark,AppShell 的 brand 只吃 {label, suffix} 文本。
12
+ * 结果走 Navbar 的站(Folio / Quill / Index / auth)有云朵标,
13
+ * 走 AppShell 的站(Tome / Plate / Margin)没有。抽成一处,谁都不许自造。
14
+ *
15
+ * 纪律(BRAND §7.7):
16
+ * · 云朵 Logo 只出现在品牌锁标里,不单独当装饰散落在页面各处
17
+ * · 子站标是站点对外的品牌名(书写系列),不是技术仓库名
18
+ *
19
+ * @example
20
+ * <BrandLockup sublabel="tome" /> // 白底(默认)
21
+ * <BrandLockup sublabel="auth" tone="dark" /> // 墨底叙事区
22
+ */
23
+ export interface BrandLockupProps {
24
+ /** 云朵标直径(px),默认 22 —— 全站统一,不要按场景私调 */
25
+ logoSize?: number;
26
+ /** wordmark 字号(px),默认 16 */
27
+ markSize?: number;
28
+ /** ZMZAI 后的子站标,如 "tome" / "quill" / "auth" */
29
+ sublabel?: string;
30
+ /** 墨底载体:云朵标反白、文字转暗底配色,默认 "light" */
31
+ tone?: "light" | "dark";
32
+ className?: string;
33
+ }
34
+
35
+ export function BrandLockup({
36
+ logoSize = 22,
37
+ markSize = 16,
38
+ sublabel,
39
+ tone = "light",
40
+ className,
41
+ }: BrandLockupProps) {
42
+ const dark = tone === "dark";
43
+ return (
44
+ <span
45
+ className={cn(
46
+ "inline-flex items-center gap-2 select-none",
47
+ dark && "text-dark-ink",
48
+ className,
49
+ )}
50
+ >
51
+ <Logo size={logoSize} inverted={dark} />
52
+ <Wordmark
53
+ size={markSize}
54
+ sublabel={sublabel}
55
+ // 墨底时压掉 Wordmark 子标自带的 text-ink-3(暗底上不可读)。
56
+ // 任意变体选择器特异性高于单类名,能可靠覆盖。
57
+ className={dark ? "[&>span]:text-dark-ink/45" : undefined}
58
+ />
59
+ </span>
60
+ );
61
+ }
@@ -0,0 +1,2 @@
1
+ // @zmzai/theme — Brand lockup barrel
2
+ export { BrandLockup, type BrandLockupProps } from "./BrandLockup";
@@ -1,3 +1,4 @@
1
1
  // @zmzai/theme — Brand assets barrel
2
2
  export * from "./logo";
3
3
  export * from "./wordmark";
4
+ export * from "./brand-lockup";
@@ -3,6 +3,8 @@
3
3
  import { useEffect, useMemo, useState } from "react";
4
4
  import type { ComponentType, ReactNode } from "react";
5
5
 
6
+ import { BrandLockup } from "../../brand/brand-lockup";
7
+
6
8
  import { Badge } from "../badge/Badge";
7
9
  import { Icon, type IconName } from "../icon/Icon";
8
10
  import { CommandPalette, type AppPaletteItem } from "./CommandPalette";
@@ -130,9 +132,11 @@ export function AppShell({
130
132
  }, [hasPalette]);
131
133
 
132
134
  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}
135
+ <Link href={brand.href ?? "/"} className="flex flex-col gap-1 px-2">
136
+ {/* 品牌区统一走 BrandLockup(2026-08-31 抽出,与 Navbar 共用一份锁标)。
137
+ label 同时是顶栏兜底标题与子站标,两处语义一致。 */}
138
+ <BrandLockup sublabel={brand.label} />
139
+ {brand.suffix ? <span className="font-mono text-[10px] text-ink-3">{brand.suffix}</span> : null}
136
140
  </Link>
137
141
  );
138
142
 
@@ -2,8 +2,7 @@
2
2
 
3
3
  import { type ReactNode, useEffect, useState } from "react";
4
4
  import { cn } from "../../utils/cn";
5
- import { Logo } from "../../brand/logo";
6
- import { Wordmark } from "../../brand/wordmark";
5
+ import { BrandLockup } from "../../brand/brand-lockup";
7
6
  import { Icon } from "../icon/Icon";
8
7
 
9
8
  /**
@@ -66,12 +65,8 @@ export function Navbar({ sublabel, brand, children, actions, badge, brandHref, m
66
65
  return () => window.removeEventListener("keydown", onKey);
67
66
  }, [open]);
68
67
 
69
- const brandNode = brand ?? (
70
- <span className="inline-flex items-center gap-2">
71
- <Logo size={22} />
72
- <Wordmark size={16} sublabel={sublabel} />
73
- </span>
74
- );
68
+ // 品牌区统一走 BrandLockup(2026-08-31 抽出,与 AppShell 共用一份锁标)
69
+ const brandNode = brand ?? <BrandLockup sublabel={sublabel} />;
75
70
  const nav = children ? (
76
71
  <nav
77
72
  className="flex min-w-0 items-center gap-1 overflow-x-auto"
@@ -15,6 +15,13 @@
15
15
  * · 描述统一 text-sm text-ink-2 —— ink-3 只有 3.69:1,副文案不用它
16
16
  *
17
17
  * v0.9.5 追加 centered(居中 Hero,/quill 首页用)。
18
+ *
19
+ * v0.9.7 page 变体改版(用户定案 2026-08-31,与 BRAND 戒律 14 同步):
20
+ * · 眉标改 mono 小写间距体(原 sans semibold)—— 等宽小标是全站通用的小节语言
21
+ * · 标题从 text-2xl 提到 text-3xl,font-semibold → font-bold —— 衬线的视觉
22
+ * 体量比无衬线小,不放大反而显得比原来弱
23
+ * · 图标从 text-ink 降到 text-ink-2 —— 图标是辅助信息,不该跟标题同权重
24
+ * · 描述加 max-w-2xl + leading-7 —— 衬线大标题下的 sans 正文要更松才不挤
18
25
  */
19
26
 
20
27
  import type { ReactNode } from "react";
@@ -93,19 +100,19 @@ export function PageHeader({
93
100
  <header className={`flex flex-wrap items-end justify-between gap-3 ${className}`}>
94
101
  <div className="min-w-0">
95
102
  {eyebrow ? (
96
- <small className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-3">
103
+ <small className="mb-1.5 block font-mono text-[11px] uppercase tracking-[0.16em] text-ink-3">
97
104
  {eyebrow}
98
105
  </small>
99
106
  ) : null}
100
- <div className="flex min-w-0 items-center gap-2">
101
- {icon ? <Icon name={icon} size={20} className="shrink-0 text-ink" /> : null}
102
- <h1
103
- className={`font-serif text-2xl font-semibold tracking-tight text-ink ${titleClassName}`}
104
- >
107
+ <div className="flex min-w-0 items-center gap-2.5">
108
+ {icon ? <Icon name={icon} size={20} strokeWidth={1.5} className="shrink-0 text-ink-2" /> : null}
109
+ <h1 className={`font-serif text-3xl font-bold leading-[1.2] tracking-tight text-ink ${titleClassName}`}>
105
110
  {title}
106
111
  </h1>
107
112
  </div>
108
- {description ? <p className="mt-2 text-sm leading-6 text-ink-2">{description}</p> : null}
113
+ {description ? (
114
+ <p className="mt-2.5 max-w-2xl text-sm leading-7 text-ink-2">{description}</p>
115
+ ) : null}
109
116
  </div>
110
117
  {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
111
118
  </header>
@@ -1,16 +1,17 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useEffect, useRef, useState } from "react";
4
4
  import { AnimatePresence, motion } from "framer-motion";
5
5
 
6
6
  import { Icon } from "../../components/icon/Icon";
7
7
  import "./reasoning.css";
8
8
 
9
9
  /**
10
- * Reasoning — 思考过程折叠块。
10
+ * Reasoning — 思考过程折叠块(v0.9.8 计时)。
11
11
  *
12
+ * 标签行显示思考时长:文本持续增长时「思考中 Ns」,停止增长 1.5s 后
13
+ * 「已思考 Ns」——流式体验与主流(Claude / o3 的 Thought for Xs)对齐。
12
14
  * 运行中自动展开(呼吸圆点),完成后收起为标签行,点击回看。
13
- * 展开内容渐显(AnimatePresence)。
14
15
  *
15
16
  * @example
16
17
  * <Reasoning text={chainOfThought} active={isStreaming} />
@@ -18,11 +19,44 @@ import "./reasoning.css";
18
19
  export function Reasoning({ text, active = false }: { text: string; active?: boolean }) {
19
20
  const [open, setOpen] = useState(false);
20
21
  const expanded = active || open;
22
+
23
+ // 思考计时:startedAt = 首次收到文本;lastChangeAt = 最近一次文本变化。
24
+ // 「正在思考」= active,或 1.5s 内文本仍在变化(delta 流)。
25
+ const startedAtRef = useRef<number | null>(null);
26
+ const lastChangeRef = useRef<number>(0);
27
+ const [thinking, setThinking] = useState(false);
28
+ const [seconds, setSeconds] = useState(0);
29
+
30
+ useEffect(() => {
31
+ if (!text) return;
32
+ if (startedAtRef.current === null) startedAtRef.current = Date.now();
33
+ lastChangeRef.current = Date.now();
34
+ setThinking(true);
35
+ }, [text]);
36
+
37
+ useEffect(() => {
38
+ if (!thinking) return;
39
+ const timer = setInterval(() => {
40
+ const now = Date.now();
41
+ const settled = now - lastChangeRef.current > 1500 && !active;
42
+ const secs = Math.max(1, Math.round((now - (startedAtRef.current ?? now)) / 1000));
43
+ setSeconds(secs);
44
+ if (settled) setThinking(false);
45
+ }, 500);
46
+ return () => clearInterval(timer);
47
+ }, [thinking, active]);
48
+
49
+ const label = thinking
50
+ ? `思考中 ${seconds ? `${seconds}s` : "…"}`
51
+ : seconds > 0
52
+ ? `已思考 ${seconds}s`
53
+ : "思考过程";
54
+
21
55
  return (
22
56
  <div className="zmz-reasoning">
23
57
  <button type="button" className="zmz-reasoning-toggle" aria-expanded={expanded} onClick={() => setOpen((value) => !value)}>
24
- <span className={`zmz-reasoning-dot ${active ? "live" : ""}`} aria-hidden />
25
- <span>思考过程</span>
58
+ <span className={`zmz-reasoning-dot ${active || thinking ? "live" : ""}`} aria-hidden />
59
+ <span>{label}</span>
26
60
  <Icon name="chevron-down" size={11} className={expanded ? "zmz-chevron open" : "zmz-chevron"} />
27
61
  </button>
28
62
  <AnimatePresence initial={false}>
@@ -4,13 +4,15 @@ import { useState } from "react";
4
4
 
5
5
  import { Badge } from "../../components/badge";
6
6
  import { Icon } from "../../components/icon/Icon";
7
- import { formatToolInput, toolDuration, type ToolCall } from "./types";
7
+ import { formatToolInput, formatToolSummary, toolDuration, type ToolCall } from "./types";
8
8
  import "./tool-card.css";
9
9
 
10
10
  /**
11
11
  * ToolCard — 单次工具调用卡:状态色左边条 + 展开/收起的输入输出详情。
12
12
  *
13
- * 运行中卡常开;进入终态后收起,点标题可再看输出。
13
+ * 标题行显示人类可读摘要(v0.9.8 formatToolSummary:`Read src/foo.ts`、
14
+ * `Bash pnpm build`),引擎提供的 state.title 优先;JSON 结构化输入只出现在
15
+ * 展开区。运行中卡常开;进入终态后收起,点标题可再看输出。
14
16
  * `sessionIdle=true` 时非终态工具渲染为「失败(中断)」——
15
17
  * 会话空闲却停在 running 是崩溃/重启的遗留,不该永远转圈。
16
18
  *
@@ -22,7 +24,9 @@ export function ToolCard({ call, sessionIdle = false }: { call: ToolCall; sessio
22
24
  const state = call.state;
23
25
  const running = state.status === "running" || state.status === "pending";
24
26
  const interrupted = running && sessionIdle;
25
- const title = state.status === "completed" ? state.title : state.status === "running" ? (state.title ?? call.tool) : call.tool;
27
+ const summary = formatToolSummary(call.tool, state.input);
28
+ const title = ("title" in state && state.title) || summary || call.tool;
29
+ const isSummary = !("title" in state && state.title) && !!summary; // 摘要是路径/命令 → 等宽更可读
26
30
  const output = state.status === "completed" ? state.output : state.status === "error" ? state.error : null;
27
31
  const statusClass = state.status === "completed" ? "completed" : interrupted ? "failed" : state.status === "error" ? "failed" : "running";
28
32
  const isOpen = running || expanded;
@@ -31,7 +35,7 @@ export function ToolCard({ call, sessionIdle = false }: { call: ToolCall; sessio
31
35
  <div className={`tool-card ${statusClass}`}>
32
36
  <button type="button" className="tool-card-trigger" aria-expanded={isOpen} onClick={() => setExpanded((value) => !value)}>
33
37
  <span className="tool-card-glyph" aria-hidden><Icon name={state.status === "completed" ? "check" : interrupted || state.status === "error" ? "cross" : "chevron-down"} size={12} /></span>
34
- <span className="tool-card-label">{title}</span>
38
+ <span className={isSummary ? "tool-card-label tool-card-label-mono" : "tool-card-label"}>{title}</span>
35
39
  {(state.status === "error" || interrupted) && <Badge variant="danger" size="sm">失败</Badge>}
36
40
  {running && !interrupted && <Badge variant="live" size="sm">运行中</Badge>}
37
41
  {toolDuration(call) && <span className="tool-card-duration">{toolDuration(call)}</span>}
@@ -75,11 +79,9 @@ export function ToolGroup({ calls, sessionIdle = false }: { calls: ToolCall[]; s
75
79
  const glyph = failed.length > 0 ? "cross" : running.length > 0 ? "chevron-down" : "check";
76
80
  const single = calls.length === 1 ? calls[0] : undefined;
77
81
  const singleTitle = single
78
- ? single.state.status === "completed"
79
- ? (single.state.title ?? single.tool)
80
- : single.state.status === "error"
81
- ? single.state.error
82
- : single.tool
82
+ ? ("title" in single.state && single.state.title) ||
83
+ formatToolSummary(single.tool, single.state.input) ||
84
+ (single.state.status === "error" ? single.state.error : single.tool)
83
85
  : null;
84
86
  const summary = [
85
87
  running.length > 0 && `${running.length} 个进行中`,
@@ -12,6 +12,8 @@
12
12
  .tool-card.completed .tool-card-glyph { color: var(--color-success); }
13
13
  .tool-card.failed .tool-card-glyph { color: var(--color-danger); }
14
14
  .tool-card-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.75rem; }
15
+ /* 摘要行(formatToolSummary):路径/命令用等宽,与 JSON 展开区视觉连续 */
16
+ .tool-card-label-mono { font-family: var(--font-mono); font-size: 0.7rem; }
15
17
  .tool-card-duration { flex: 0 0 auto; color: var(--color-muted); font-family: var(--font-mono); font-size: 0.6rem; }
16
18
  .tool-card-chevron { flex: 0 0 auto; color: var(--color-muted); transition: transform 0.15s var(--ease-out); }
17
19
  .tool-card-chevron.open { transform: rotate(180deg); }
@@ -39,3 +39,51 @@ export function formatToolInput(input: unknown): string {
39
39
  return String(input);
40
40
  }
41
41
  }
42
+
43
+ /** 工具输入摘要(v0.9.8):从 input 的关键字段生成人类可读一行 ——
44
+ * `Read src/foo.ts`、`Edit src/bar.ts`、`Bash pnpm build`、`Grep "pat" in src/`。
45
+ * 字段名与 agent-framework 工具 schema 对齐(path/command/pattern/description…),
46
+ * 未知工具尽力提取,提取不到返回 null(调用方回落工具名)。 */
47
+ export function formatToolSummary(tool: string, input: unknown): string | null {
48
+ if (input === null || input === undefined) return null;
49
+ if (typeof input !== "object") return typeof input === "string" ? oneLine(input) : null;
50
+ const o = input as Record<string, unknown>;
51
+ const str = (k: string): string | undefined => (typeof o[k] === "string" && (o[k] as string).trim() ? (o[k] as string).trim() : undefined);
52
+ const path = str("path") ?? str("file_path") ?? str("filePath") ?? str("notebook_path");
53
+ const cmd = str("command") ?? str("cmd");
54
+ const pattern = str("pattern") ?? str("query") ?? str("q");
55
+ const url = str("url");
56
+ const desc = str("description") ?? str("prompt") ?? str("task");
57
+ const cap = (s: string): string => (s.length > 80 ? `${s.slice(0, 80)}…` : s);
58
+ const verb = tool.charAt(0).toUpperCase() + tool.slice(1);
59
+ switch (tool) {
60
+ case "read":
61
+ return path ? `Read ${cap(path)}` : null;
62
+ case "glob":
63
+ return pattern ? `Glob ${cap(pattern)}${path ? ` in ${cap(path)}` : ""}` : path ? `Glob ${cap(path)}` : null;
64
+ case "grep":
65
+ return pattern ? `Grep ${cap(pattern)}${path ? ` in ${cap(path)}` : ""}` : path ? `Grep ${cap(path)}` : null;
66
+ case "edit":
67
+ case "write":
68
+ return path ? `${tool === "edit" ? "Edit" : "Write"} ${cap(path)}` : null;
69
+ case "bash":
70
+ case "terminal":
71
+ return cmd ? `Bash ${cap(oneLine(cmd))}` : null;
72
+ case "webfetch":
73
+ case "web_fetch":
74
+ return url ? `Fetch ${cap(url)}` : null;
75
+ case "task":
76
+ return desc ? `Task ${cap(oneLine(desc))}` : null;
77
+ default: {
78
+ if (path) return `${cap(tool)} ${cap(path)}`;
79
+ if (cmd) return `${cap(tool)} ${cap(oneLine(cmd))}`;
80
+ if (url) return `${cap(tool)} ${cap(url)}`;
81
+ if (desc) return `${cap(tool)} ${cap(oneLine(desc))}`;
82
+ return null;
83
+ }
84
+ }
85
+ }
86
+
87
+ function oneLine(s: string): string {
88
+ return s.replace(/\s+/g, " ").trim();
89
+ }