dsh-visualkit 0.0.1 → 0.0.2

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/lib/style.js CHANGED
@@ -1,688 +1,688 @@
1
- /**
2
- * style.js — design_style 的实现:风格约束的**正交两层**(骨架 × 皮肤)。
3
- *
4
- * ── 为什么拆层(2026-11-07 第二次修订)──────────────────────────
5
- * 上一版是 4 个"预设",实测发现它们其实是「1 套骨架 + 1 套字号 + 3 种配色」:
6
- * 字号阶梯四个预设 100% 相同、语义色色相几乎一致、骨架都是五段式。
7
- * 等于换色号不换骨,约束反而制造了新的单一审美。
8
- *
9
- * 现在拆成两个正交维度:
10
- * SKELETONS 信息结构(顶部条/首屏/指标/表格/收口 怎么排)——决定"页面是干什么的"
11
- * SKINS 视觉皮肤(色相/字体/字号/圆角/阴影/密度)——决定"看起来像什么"
12
- * 6 × 8 = 48 种组合,且两者可独立扩展:加骨架不影响皮肤,加皮肤不影响骨架。
13
- *
14
- * 每个皮肤自带 typeScale(不再全局共用一套字号),且都必须守住
15
- * 「4 档 + 最大跳变 ≥1.6×」这条质量底线(selftest 对全部皮肤断言)。
16
- *
17
- * 皮肤可声明 exempt:该皮肤**有意**违反的风格规则(如 playful 的大圆角)。
18
- * 配合 ui_taste 的 .design-intent.json 豁免机制,让"有意的越界"变成数据而非口头理由。
19
- */
20
-
21
- /** 皮肤:视觉语言。typeScale 四档,micro/body/head/display。 */
22
- const SKINS = [
23
- {
24
- id: "industrial-editorial",
25
- label: "工业编辑",
26
- useWhen: "技术汇报、系统记录、数据看板、工程报告",
27
- tone: "纸感中性底 + 墨黑文字 + 信号黄只做结论标记",
28
- defaultSkeleton: "archive-ops",
29
- color: {
30
- paper: "#E8E8E2",
31
- surface: "#F1F1EB",
32
- ink: "#101110",
33
- inkSoft: "#3A3D39",
34
- muted: "#6B6D68",
35
- line: "#C9C9C1",
36
- accent: "#C8B400",
37
- ok: "#4A6B2A",
38
- warn: "#A8701C",
39
- danger: "#B4402B"
40
- },
41
- font: { body: '"Microsoft YaHei","Noto Sans SC",sans-serif', mono: '"Consolas","IBM Plex Mono",monospace' },
42
- typeScale: { micro: "11px", body: "14px", head: "24px", display: "34px" },
43
- radius: { "1": "2px", "2": "4px" },
44
- shadow: "0 1px 2px rgba(16,17,16,.06)",
45
- density: "紧凑",
46
- exempt: []
47
- },
48
- {
49
- id: "tactical-archive",
50
- label: "战术档案",
51
- useWhen: "安全/应急、项目冲刺、密级报告、行动简报",
52
- tone: "深色舞台 + 信号红/琥珀只做状态与警示",
53
- defaultSkeleton: "archive-ops",
54
- color: {
55
- paper: "#0B0D12",
56
- surface: "#171A20",
57
- ink: "#ECEDE9",
58
- inkSoft: "#B9BDBA",
59
- muted: "#7E8489",
60
- line: "#2C3138",
61
- accent: "#B7372E",
62
- ok: "#5F7F52",
63
- warn: "#C98A24",
64
- danger: "#D8453A"
65
- },
66
- font: { body: '"Microsoft YaHei","Noto Sans SC",sans-serif', mono: '"Consolas","IBM Plex Mono",monospace' },
67
- typeScale: { micro: "10px", body: "13px", head: "22px", display: "30px" },
68
- radius: { "1": "2px", "2": "3px" },
69
- shadow: "0 1px 0 rgba(255,255,255,.04)",
70
- density: "极紧凑",
71
- exempt: []
72
- },
73
- {
74
- id: "retro-archive",
75
- label: "复古档案",
76
- useWhen: "品牌/文化、年度复盘、收藏叙事、正式归档",
77
- tone: "深色舞台 + 旧纸衬线 + 铁锈红点缀",
78
- defaultSkeleton: "narrative",
79
- color: {
80
- paper: "#0B0A08",
81
- surface: "#141210",
82
- ink: "#E9DCCD",
83
- inkSoft: "#C4B4A2",
84
- muted: "#8C7B6B",
85
- line: "#3A322A",
86
- accent: "#B55829",
87
- ok: "#6E7A55",
88
- warn: "#B08A3E",
89
- danger: "#A93B2C"
90
- },
91
- font: { body: '"Georgia","SimSun",serif', mono: '"Consolas",monospace' },
92
- typeScale: { micro: "12px", body: "16px", head: "26px", display: "38px" },
93
- radius: { "1": "0px", "2": "0px" },
94
- shadow: "none",
95
- density: "舒展",
96
- exempt: []
97
- },
98
- {
99
- id: "studio-grid",
100
- label: "网格工作室",
101
- useWhen: "通用商业提案、客户报价、中性交付、需要易改的场合",
102
- tone: "纸白 + 墨黑 + 朱砂,细线网格,字重分层",
103
- defaultSkeleton: "document",
104
- color: {
105
- paper: "#FAFAF7",
106
- surface: "#F2F1EC",
107
- ink: "#14140F",
108
- inkSoft: "#3C3C35",
109
- muted: "#6E6E64",
110
- line: "#CFCEC5",
111
- accent: "#C0392B",
112
- ok: "#4F6B3A",
113
- warn: "#A8741C",
114
- danger: "#A93226"
115
- },
116
- font: { body: '"Microsoft YaHei","Noto Sans SC",sans-serif', mono: '"Consolas",monospace' },
117
- typeScale: { micro: "12px", body: "15px", head: "25px", display: "36px" },
118
- radius: { "1": "2px", "2": "4px" },
119
- shadow: "0 1px 3px rgba(20,20,15,.07)",
120
- density: "中等",
121
- exempt: []
122
- },
123
- {
124
- id: "swiss-minimal",
125
- label: "瑞士极简",
126
- useWhen: "严肃产品、机构站点、作品集、需要长时间耐看且不抢内容的界面",
127
- tone: "纯白底 + 墨黑字 + 极小面积瑞士红;靠字号、字距和网格说话,零装饰",
128
- defaultSkeleton: "landing",
129
- color: {
130
- paper: "#FFFFFF",
131
- surface: "#F4F4F4",
132
- ink: "#111111",
133
- inkSoft: "#3A3A3A",
134
- muted: "#767676",
135
- line: "#D8D8D8",
136
- accent: "#E4002B",
137
- ok: "#1D6B3A",
138
- warn: "#8A5A00",
139
- danger: "#C1121F"
140
- },
141
- font: { body: '"Helvetica Neue","Arial","Microsoft YaHei",sans-serif', mono: '"Consolas",monospace' },
142
- // 字距拉开、标题远大于正文:瑞士风格的核心是"层级靠尺度不靠装饰"
143
- typeScale: { micro: "11px", body: "15px", head: "28px", display: "44px" },
144
- radius: { "1": "0px", "2": "0px" },
145
- shadow: "none",
146
- density: "大留白",
147
- exempt: []
148
- },
149
- {
150
- id: "brutalist",
151
- label: "粗野主义",
152
- useWhen: "强表达的产品页、活动页、独立工具、要「不像模板」的场合",
153
- tone: "纯黑白 + 3px 硬边框 + 大色块 + 超大标题;刻意反精致",
154
- defaultSkeleton: "landing",
155
- color: {
156
- paper: "#FFFFFF",
157
- surface: "#F0F0F0",
158
- ink: "#000000",
159
- inkSoft: "#222222",
160
- muted: "#555555",
161
- line: "#000000",
162
- accent: "#FF3B00",
163
- ok: "#007A3D",
164
- warn: "#B36B00",
165
- danger: "#CC0000"
166
- },
167
- font: { body: '"Arial Black","Microsoft YaHei",sans-serif', mono: '"Consolas",monospace' },
168
- typeScale: { micro: "12px", body: "16px", head: "30px", display: "52px" },
169
- radius: { "1": "0px", "2": "0px" },
170
- shadow: "none",
171
- density: "紧凑",
172
- // 粗野主义刻意用高饱和撞色与硬边框,与「色相克制」规则天然冲突——这是有意的
173
- exempt: ["hue-sprawl"]
174
- },
175
- {
176
- id: "quiet-luxe",
177
- label: "静奢",
178
- useWhen: "高端品牌、酒店/地产/金融、需要「贵而不吵」的界面",
179
- tone: "暖白底 + 低饱和棕灰 + 细字重 + 极大留白;靠质感与留白建立层级",
180
- defaultSkeleton: "narrative",
181
- color: {
182
- paper: "#F7F5F2",
183
- surface: "#F1EEE9",
184
- ink: "#2A2A28",
185
- inkSoft: "#4A4A46",
186
- muted: "#8A8580",
187
- line: "#DDD8D1",
188
- accent: "#8C7A66",
189
- ok: "#5E7358",
190
- warn: "#9A7B3C",
191
- danger: "#8E3B33"
192
- },
193
- font: { body: '"Georgia","Songti SC","SimSun",serif', mono: '"Consolas",monospace' },
194
- typeScale: { micro: "10px", body: "14px", head: "23px", display: "34px" },
195
- radius: { "1": "0px", "2": "2px" },
196
- shadow: "none",
197
- density: "极大留白",
198
- exempt: []
199
- },
200
- {
201
- id: "playful-geometric",
202
- label: "几何趣味",
203
- useWhen: "消费类产品、儿童/教育、社区、需要亲和力的 C 端界面",
204
- tone: "暖白底 + 大圆角 + 几何色块;鲜艳但每屏只用 1–2 个强调色",
205
- defaultSkeleton: "landing",
206
- color: {
207
- paper: "#FFF8F0",
208
- surface: "#FFFFFF",
209
- ink: "#1F1A17",
210
- inkSoft: "#4A423C",
211
- muted: "#857A72",
212
- line: "#EADFD4",
213
- accent: "#FF6B35",
214
- ok: "#2C9E6A",
215
- warn: "#E0A100",
216
- danger: "#E03B3B"
217
- },
218
- font: { body: '"PingFang SC","Microsoft YaHei",sans-serif', mono: '"Consolas",monospace' },
219
- typeScale: { micro: "12px", body: "16px", head: "28px", display: "40px" },
220
- radius: { "1": "8px", "2": "16px" },
221
- shadow: "0 2px 8px rgba(31,26,23,.08)",
222
- density: "舒展",
223
- // C 端产品用大圆角是合理的,不是"AI 味"——声明豁免
224
- exempt: ["radius-blob"]
225
- }
226
- ];
227
-
228
- /** 骨架:信息结构。与皮肤正交(任何皮肤都能套任何骨架)。 */
229
- const SKELETONS = [
230
- {
231
- id: "archive-ops",
232
- label: "档案作业",
233
- useWhen: "日/周报、任务队列、运行状态、可归档的记录型界面",
234
- steps: [
235
- "顶部 rail(高 44px):左品牌/系统名,右时间与运行编号——跨页恒定参照物",
236
- "首屏:左 8 列放一句真实结论 + 支撑事实;右 4 列放元数据戳(RUN / WINDOW / OWNER)",
237
- "指标行:4 列,细线分隔;数字用 tabular-nums,单位缩小一号;不做卡片",
238
- "主体:左 8 列真表格(长名字换行、编号独立行),右 4 列观察记录 + kv 列表",
239
- "收口 footer:左版本号,右页码,每页同一位置"
240
- ]
241
- },
242
- {
243
- id: "narrative",
244
- label: "叙事长页",
245
- useWhen: "年度复盘、品牌故事、项目回顾、需要阅读节奏的内容页",
246
- steps: [
247
- "开场:大标题 + 一句摘要 + 时间范围,不放下拉菜单",
248
- "章节接力:每章一个编号(01/02/03)+ 小标题,编号用等宽——读者靠编号导航",
249
- "图文交替:左文右图 / 左图右文交替,避免所有图都居中对齐",
250
- "引文块:关键结论单独成块,细竖线 + 小一号字,与正文拉开距离",
251
- "结语:回到开场摘要,给一句收口判断 + 归档信息"
252
- ]
253
- },
254
- {
255
- id: "dashboard",
256
- label: "仪表盘",
257
- useWhen: "实时监控、运营后台、数据产品的主界面",
258
- steps: [
259
- "左侧固定导航(宽 200–240px)+ 顶部筛选条,内容区只在框内滚动",
260
- "KPI 行:4–6 个关键数字,同尺寸同基线,超出每行的指标折到第二行而非缩小",
261
- "图表区:主图占 8 列、辅图占 4 列;图表必须有标题与单位,不留无标题空壳",
262
- "明细表:默认显示最近 N 行 + 总量提示,不假装能显示全部",
263
- "右下角固定操作区:刷新时间 + 手动重跑,不要飘浮工具栏"
264
- ]
265
- },
266
- {
267
- id: "landing",
268
- label: "着陆页",
269
- useWhen: "产品介绍、活动页、下载页、需要转化但不靠营销口号的页面",
270
- steps: [
271
- "首屏:真实价值主张(说清「对谁、解决什么」)+ 一个可信的数字证据,不写「让 X 更懂 Y」",
272
- "特性分段:3–4 段,每段一个具体能力 + 截图/示意,不用 icon+标题+描述三件套",
273
- "社会证明:客户名/数据/引用,必须有出处,不允许编造 logo 墙",
274
- "价格或规格:真表格,写清包含什么、不包含什么",
275
- "收口 CTA:一个主按钮 + 一行说明「下一步会发生什么」,不做两个抢焦点的按钮"
276
- ]
277
- },
278
- {
279
- id: "document",
280
- label: "文档型",
281
- useWhen: "规范手册、提案、合同附件、需要打印或归档的长文档",
282
- steps: [
283
- "标题区:文档类型 + 版本 + 日期 + 责任人,四行以内说清身份",
284
- "目录:章节 + 页码,超过 3 页必须有",
285
- "正文层级:只用两级标题(章 / 节),正文首行不缩进、段间留白",
286
- "边注:定义、出处、例外放右侧窄栏,不打断正文阅读线",
287
- "收口:签署/生效/附件清单块,明确文档状态(草案/生效/废止)"
288
- ]
289
- },
290
- {
291
- id: "editor",
292
- label: "编辑器型",
293
- useWhen: "画布工具、设计器、代码编辑器、需要长期停留的操作界面",
294
- steps: [
295
- "顶部工具栏:高频操作在左,视图控制在右;图标必须带 tooltip 文本",
296
- "中央画布:占满剩余空间,四周留 24px 安全边距,不叠加装饰",
297
- "右侧属性面板:固定宽度 280–320px,分组折叠,按「选中对象」动态变化",
298
- "底部状态栏:坐标/缩放/保存状态,等宽字体,一眼可读",
299
- "空态与选中态必须都有明确视觉,不能只靠鼠标指针变化"
300
- ]
301
- }
302
- ];
303
-
304
- const SKIN_BY_ID = new Map(SKINS.map((skin) => [skin.id, skin]));
305
- const SKELETON_BY_ID = new Map(SKELETONS.map((skeleton) => [skeleton.id, skeleton]));
306
-
307
- /**
308
- * 全局禁令清单:按 scope 标注,让模型知道哪些是底线、哪些是风格取向。
309
- * 底线(quality)不该为风格让步;风格(style)允许在有理由时声明豁免。
310
- *
311
- * **每条必须带 id**,且 id 与 rules.js 的规则 id 同源:
312
- * 皮肤的 exempt(如 brutalist 的 ["hue-sprawl"])要靠这个 id 映射到具体条目,
313
- * 否则模型看到「有意偏离 hue-sprawl」却在禁令清单里找不到它在说什么。
314
- */
315
- const BANS = [
316
- { id: "emoji-icon", scope: "quality", text: "emoji 当图标(→ icon action=find|emit 取真 SVG)" },
317
- { id: "type-flat", scope: "quality", text: "字号碎阶:最多 4 档,最大档 ≥ 正文 ×1.6" },
318
- { id: "spacing-offgrid", scope: "quality", text: "间距手调值(落 4px 网格,2px 仅用于微调)" },
319
- { id: "no-states", scope: "quality", text: "可点元素缺 hover / focus-visible / disabled" },
320
- { id: "card-uniform", scope: "quality", text: "整页 icon+标题+描述的同构卡片网格" },
321
- { id: "no-breath", scope: "quality", text: "页面级与控件级留白同量级(应有 3:1 差)" },
322
- { id: "purple-gradient", scope: "style", text: "紫/indigo/violet 渐变(品牌主色确需时声明豁免)" },
323
- { id: "gradient-text", scope: "style", text: "渐变文字 background-clip:text" },
324
- { id: "glassmorphism", scope: "style", text: "毛玻璃 backdrop-filter、大面积半透明白" },
325
- { id: "shadow-glow", scope: "style", text: "发光阴影、彩色投影(最多 1 层中性投影)" },
326
- { id: "radius-blob", scope: "style", text: "全站 rounded-xl/2xl(数据密集型 ≤8px;消费类可放宽)" },
327
- { id: "hue-sprawl", scope: "style", text: "色相乱跑:同屏多个无关联高饱和色(一屏只留 1 个强调色)" },
328
- { id: "centered-hero", scope: "style", text: "居中 hero + 口号 + CTA 的营销首屏(产品界面)" },
329
- { id: "fake-stat", scope: "content", text: "128K+/99.9% 这类不可算的假大数、Lorem/敬请期待" },
330
- { id: "nest-boxes", scope: "style", text: "框里再套框(三层以上圆角容器),层级靠套娃而不是间距" },
331
- { id: "divider-sprawl", scope: "style", text: "分割线泛滥:每个区块之间都画一条线,靠线分区而不是靠留白" },
332
- { id: "big-black-tight", scope: "style", text: "标题又大又黑又挤(≥30px + 700+ + 行高 ≤1.25),靠放大加粗硬撑层次" },
333
- { id: "dead-button", scope: "quality", text: "按钮看着能点却没有结果(无 submit / 点击绑定 / data-action)" },
334
- { id: "icon-tile-uniform", scope: "style", text: "每个卡片的图标都套同尺寸同色的圆角块(渐变方块尤甚)" },
335
- { id: "eyebrow-spam", scope: "style", text: "每个区块都拿小号大字距标签当开场(四段以上)" },
336
- { id: "ai-copy", scope: "content", text: "CTA 里的 → 箭头、“立即/一键/轻松/极致/解锁/赋能”与“XX之旅”开场白,以及无缝/闭环/抓手这类黑话" }
337
- ];
338
-
339
- function cssOf(skin, skeleton) {
340
- const lines = [`:root {`];
341
- lines.push(` /* ${skin.label} × ${skeleton.label} — ${skin.tone} */`);
342
- for (const [key, value] of Object.entries(skin.color)) lines.push(` --c-${key}: ${value};`);
343
- for (const [key, value] of Object.entries(skin.font)) lines.push(` --font-${key}: ${value};`);
344
- for (const [key, value] of Object.entries(skin.typeScale)) lines.push(` --text-${key}: ${value};`);
345
- for (const [key, value] of Object.entries(SPACE)) lines.push(` --space-${key}: ${value};`);
346
- for (const [key, value] of Object.entries(skin.radius)) lines.push(` --radius-${key}: ${value};`);
347
- lines.push(` --shadow-1: ${skin.shadow};`);
348
- lines.push(` --line: ${skin.border ?? "1px"} solid var(--c-line);`);
349
- lines.push(` --border-w: ${skin.border ?? "1px"};`);
350
- // 机制层变量:即使没有值也输出空声明,让使用方知道可以消费这些钩子
351
- for (const [key, varName] of Object.entries(MECH_KEYS)) {
352
- const value = skin.mech?.[key];
353
- if (value !== undefined) lines.push(` ${varName}: ${value};`);
354
- }
355
- lines.push(`}`);
356
- return lines.join("\n");
357
- }
358
-
359
- function jsonOf(skin, skeleton) {
360
- return JSON.stringify(
361
- {
362
- skin: { id: skin.id, label: skin.label, useWhen: skin.useWhen, tone: skin.tone, density: skin.density },
363
- skeleton: { id: skeleton.id, label: skeleton.label, useWhen: skeleton.useWhen, steps: skeleton.steps },
364
- color: skin.color,
365
- font: skin.font,
366
- type: skin.typeScale,
367
- space: SPACE,
368
- radius: skin.radius,
369
- shadow: skin.shadow,
370
- // 机制层钩子:json 格式也要给出,否则消费方(例如按 token 生成 HTML 的构建脚本)
371
- // 拿不到 pattern/clip/organic 这些值,机制等于白加。
372
- ...(skin.mech === undefined ? {} : { mech: skin.mech }),
373
- exempt: skin.exempt,
374
- bans: BANS
375
- },
376
- null,
377
- 2
378
- );
379
- }
380
-
381
- function tailwindOf(skin, skeleton) {
382
- const colors = Object.entries(skin.color)
383
- .map(([key, value]) => ` "${key}": "${value}"`)
384
- .join(",\n");
385
- const fontList = (value) => value.split(",").map((f) => `"${f.trim().replace(/^"|"$/g, "")}"`).join(", ");
386
- // 输出**完整的 tailwind.config.js**,而不是 `extend: {…}` 片段:
387
- // 片段要么缺外层 module.exports/theme 包裹、要么把 extend 键本身也包含进去,
388
- // 模型直接粘进项目就是语法错。完整文件可以直接 `node --check` 验证(测试里有这条)。
389
- return [
390
- `// tailwind.config.js — 由 dsh-visualkit 生成(${skin.label} × ${skeleton.label})`,
391
- "module.exports = {",
392
- " theme: {",
393
- " extend: {",
394
- " colors: {",
395
- colors,
396
- " },",
397
- ` borderRadius: { sm: "${skin.radius["1"]}", md: "${skin.radius["2"]}" },`,
398
- ` boxShadow: { "1": "${skin.shadow}" },`,
399
- " fontSize: {",
400
- ...Object.entries(skin.typeScale).map(([key, value]) => ` ${key}: "${value}",`),
401
- " },",
402
- " fontFamily: {",
403
- ` sans: [${fontList(skin.font.body)}],`,
404
- ` mono: [${fontList(skin.font.mono)}]`,
405
- " }",
406
- " }",
407
- " }",
408
- "};",
409
- ""
410
- ].join("\n");
411
- }
412
-
413
- /* ---------------------------- 交互层(motion) ---------------------------- */
414
-
415
- /**
416
- * 交互层兜底值:只在皮肤没给对应机制 token 时用。
417
- *
418
- * 全部是**中性**取值,不指向任何风格——时长、缓动、缩放幅度这些一旦带风格倾向,
419
- * 就又变成了「预设动效包」。挂哪个 `data-motion` 原语、给哪个元素挂,由使用者决定;
420
- * 这里只保证「挂了就有物理反馈,且反馈可调」。
421
- */
422
- const MOTION_FALLBACK = {
423
- dur: "160ms",
424
- ease: "cubic-bezier(.4, 0, .2, 1)",
425
- press: "scale(.96)",
426
- lift: "translateY(-2px)",
427
- focus: "2px solid var(--c-accent, currentColor)",
428
- ripple: "currentColor",
429
- stagger: "40ms",
430
- reveal: "12px",
431
- shimmer: "rgb(255 255 255 / .28)"
432
- };
433
-
434
- /**
435
- * 交互层产出:一份**可选的、按元素挂载**的 CSS 原语,不产出任何具体动效风格。
436
- *
437
- * 为什么它是工具而不是模板:
438
- * - 每个原语(press/lift/focus/ripple/enter/stagger/reveal/shimmer)独立,可以只挑需要的;
439
- * - 全部读 `:root` 变量,一套产物换个项目只改变量;
440
- * - 选择器一律走 `[data-motion~="…"]` 属性钩子,不占用使用者的 class 命名空间;
441
- * - 风格差异(弹性/线性/缓入缓出、快慢、位移幅度)由变量的**取值**表达,不由我们预设几套。
442
- *
443
- * 唯一不可协商的是末尾的 `prefers-reduced-motion` 兜底:它是无障碍底线,不是风格。
444
- */
445
- function motionOf(skin, skeleton) {
446
- const mech = skin.mech ?? {};
447
- const val = (key) => mech[key] ?? MOTION_FALLBACK[key];
448
- const lines = [
449
- `/* 交互层 — ${skin.label} × ${skeleton.label}(dsh-visualkit 生成)`,
450
- ` * 原语层:给元素挂 data-motion="press lift focus" 才生效,挂哪些、挂给谁由你决定。`,
451
- ` * 时长/缓动/幅度全走变量,换项目只改这一块 :root。 */`,
452
- `:root {`,
453
- ` /* 时长阶梯:一个基准推快慢两档,避免满文件硬编码 120/240/300ms */`,
454
- ` --dur: ${val("dur")};`,
455
- ` --ease: ${val("ease")};`,
456
- ` --t-fast: calc(var(--dur) * .6);`,
457
- ` --t-base: var(--dur);`,
458
- ` --t-slow: calc(var(--dur) * 1.6);`,
459
- ` /* 机制变量(皮肤/机制 token 覆盖过就用覆盖值) */`
460
- ];
461
- for (const key of ["press", "lift", "focus", "ripple", "stagger", "reveal", "shimmer"]) {
462
- lines.push(` ${MECH_KEYS[key]}: ${val(key)};`);
463
- }
464
- lines.push(` --focus-offset: 2px;`);
465
- lines.push(`}`, ``);
466
-
467
- lines.push(
468
- `/* 1) 过渡只加在挂载元素上,不污染全站 */`,
469
- `[data-motion] {`,
470
- ` transition: transform var(--t-fast) var(--ease), opacity var(--t-fast) var(--ease),`,
471
- ` background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease),`,
472
- ` box-shadow var(--t-fast) var(--ease);`,
473
- `}`,
474
- ``,
475
- `/* 2) 按压::active 期间贴合,松手回弹(触屏 300ms 内也够快) */`,
476
- `[data-motion~="press"]:active { transform: var(--press); }`,
477
- ``,
478
- `/* 3) 悬停抬升:只位移不做阴影,阴影量由使用者自己的 hover 规则给 */`,
479
- `[data-motion~="lift"]:hover { transform: var(--lift); }`,
480
- `[data-motion~="lift"]:active { transform: none; }`,
481
- ``,
482
- `/* 4) 焦点环::focus-visible 而非 :focus,鼠标点击不弹环;outline 不占布局 */`,
483
- `[data-motion~="focus"]:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); }`,
484
- ``,
485
- `/* 5) 波纹:原点取 --mx/--my(无 JS 时落在中心),clip-path 扩张不依赖元素尺寸 */`,
486
- `[data-motion~="ripple"] { position: relative; overflow: hidden; isolation: isolate; }`,
487
- `[data-motion~="ripple"]::after {`,
488
- ` content: ""; position: absolute; inset: 0; pointer-events: none;`,
489
- ` background: var(--ripple); opacity: 0;`,
490
- ` clip-path: circle(0% at var(--mx, 50%) var(--my, 50%));`,
491
- `}`,
492
- `[data-motion~="ripple"]:active::after,`,
493
- `[data-motion~="ripple"].is-rippling::after { animation: artkit-ripple var(--t-base) var(--ease) forwards; }`,
494
- `@keyframes artkit-ripple {`,
495
- ` from { opacity: .24; clip-path: circle(0% at var(--mx, 50%) var(--my, 50%)); }`,
496
- ` to { opacity: 0; clip-path: circle(140% at var(--mx, 50%) var(--my, 50%)); }`,
497
- `}`,
498
- ``,
499
- `/* 6) 入场:单个元素用 enter,列表容器用 stagger(子元素读 --i 序号错峰) */`,
500
- `@keyframes artkit-enter {`,
501
- ` from { opacity: 0; transform: translate3d(0, var(--reveal), 0); }`,
502
- ` to { opacity: 1; transform: none; }`,
503
- `}`,
504
- `[data-motion~="enter"] { animation: artkit-enter var(--t-slow) var(--ease) both; }`,
505
- `[data-motion~="stagger"] > * {`,
506
- ` animation: artkit-enter var(--t-slow) var(--ease) both;`,
507
- ` animation-delay: calc(var(--stagger) * var(--i, 0));`,
508
- `}`,
509
- ``,
510
- `/* 7) 滚动显现:初始不可见,加 .is-in 才播(配合 IntersectionObserver) */`,
511
- `[data-motion~="reveal"] { opacity: 0; }`,
512
- `[data-motion~="reveal"].is-in { animation: artkit-enter var(--t-slow) var(--ease) both; }`,
513
- ``,
514
- `/* 8) 骨架屏扫光:只画在元素自己的背景层上,不改文字 */`,
515
- `@keyframes artkit-shimmer { from { background-position: 140% 0; } to { background-position: -40% 0; } }`,
516
- `[data-motion~="shimmer"] {`,
517
- ` background-image: linear-gradient(100deg, transparent 20%, var(--shimmer) 50%, transparent 80%);`,
518
- ` background-size: 200% 100%;`,
519
- ` animation: artkit-shimmer calc(var(--t-slow) * 6) linear infinite;`,
520
- `}`,
521
- ``,
522
- `/* 配套行为钩子(可选,各一行;CSS 表达不了指针原点/可见性)`,
523
- ` * 波纹原点:el.onpointerdown = e => { const r = el.getBoundingClientRect();`,
524
- ` * el.style.setProperty('--mx', e.clientX - r.left + 'px');`,
525
- ` * el.style.setProperty('--my', e.clientY - r.top + 'px'); };`,
526
- ` * 滚动显现:new IntersectionObserver(es => es.forEach(e => e.isIntersecting && e.target.classList.add('is-in')),`,
527
- ` * { threshold: .15 }).observe(el); */`,
528
- ``,
529
- `/* 9) 无障碍兜底(底线,不是风格):不能因为「动效好看」就把前庭敏感用户挡在门外 */`,
530
- `@media (prefers-reduced-motion: reduce) {`,
531
- ` [data-motion], [data-motion]::after, [data-motion] > * {`,
532
- ` animation-duration: .01ms !important;`,
533
- ` animation-iteration-count: 1 !important;`,
534
- ` transition-duration: .01ms !important;`,
535
- ` }`,
536
- ` /* reveal 默认 opacity:0,兜底必须补回可见,否则内容永久消失 */`,
537
- ` [data-motion~="reveal"] { opacity: 1; }`,
538
- ` [data-motion~="shimmer"] { animation: none; }`,
539
- `}`
540
- );
541
- return lines.join("\n");
542
- }
543
-
544
- /** 间距六档,全在 4px 网格(与皮肤无关,属于质量底线)。 */
545
- const SPACE = { "1": "4px", "2": "8px", "3": "12px", "4": "16px", "6": "24px", "8": "32px", "12": "48px" };
546
-
547
- /**
548
- * 渲染一次风格约束。
549
- * @param {{skin?:string, skeleton?:string, name?:string, format?:"css"|"json"|"tailwind"}} args
550
- * name 是 skin 的旧参数名,保留兼容;给的 skeleton 缺省时取皮肤的 defaultSkeleton。
551
- */
552
- /**
553
- * 解析自定义 token 覆盖:`ground=#0B0B0C;ink=#F5F5F4;accent=#C6F24E;radius=0;border=3`
554
- *
555
- * **为什么要有这个**:`skin` 是封闭枚举,只能表达我们内置的 8 种视觉语言。
556
- * 但插件的定位是「给框架不给目录」——风格应该能被描述,而不是被我们在源码里枚举。
557
- * 有了 token 覆盖,任何配色/圆角/字体/边框组合都能落到同一个骨架体系上:
558
- * 扁平、Y2K、Art Deco、有机自然这些没有内置 skin 的方向,也能用一组 token 表达出来。
559
- *
560
- * 用**字符串**而不是嵌套对象:嵌套 schema 的每个字段都是每请求常驻 token,
561
- * 而 key=value 串一个参数就能表达二十来个维度。
562
- */
563
- // 机制层 token:不描述「什么风格」,只描述「用哪种实现手段」。
564
- // 这层的存在理由:把市面上的风格拆开看,绝大多数只是少数几种原语的组合——
565
- // 渐变填充、图案层、任意形状、描边、双线、动效档位、栅格配方。
566
- // 这些原语是**通用的**,项目规范各不相同但手段相同,所以补原语而不是补风格。
567
- const MECH_KEYS = {
568
- pattern: "--pattern", // 图案层:repeating-*-gradient / radial-gradient 拼波点斜纹网格
569
- clip: "--clip", // 任意形状:polygon() 斜切角、六边形、箭头
570
- organic: "--radius-organic", // 有机圆角:八角写法 30% 70% 60% 40% / 40% 30% 70% 60%
571
- double: "--double-line", // 双线边框(Art Deco):0 0 0 1px c, 0 0 0 3px bg, 0 0 0 4px c
572
- stroke: "--text-stroke", // 描边字:
573
- tracking: "--tracking", // 字距
574
- grid: "--grid", // 栅格配方:bento / magazine / symmetric
575
- // —— 交互层(format=motion 消费;视觉层不读取,但它们同样是「手段」而非「风格」)——
576
- dur: "--dur", // 动效基准时长(dur=160 自动补 ms)
577
- ease: "--ease", // 缓动曲线
578
- press: "--press", // 按压反馈:scale(.96) / translateY(1px) / 无
579
- lift: "--lift", // 悬停位移:translateY(-2px)
580
- focus: "--focus-ring", // 焦点环:2px solid <主色>,用 :focus-visible 触发
581
- ripple: "--ripple", // 波纹色(currentColor 或半透明主色)
582
- stagger: "--stagger", // 列表错峰步长(stagger=40 自动补 ms)
583
- reveal: "--reveal", // 入场/滚动显现的位移距离
584
- shimmer: "--shimmer" // 骨架屏扫光高光色
585
- };
586
-
587
- const TOKEN_KEYS = {
588
- ground: ["color", "paper"], surface: ["color", "surface"], ink: ["color", "ink"],
589
- inkSoft: ["color", "inkSoft"], muted: ["color", "muted"], line: ["color", "line"],
590
- accent: ["color", "accent"], ok: ["color", "ok"], warn: ["color", "warn"], danger: ["color", "danger"],
591
- font: ["font", "body"], mono: ["font", "mono"],
592
- micro: ["typeScale", "micro"], body: ["typeScale", "body"], head: ["typeScale", "head"], display: ["typeScale", "display"],
593
- radius: ["radius", "2"], shadow: ["shadow"], border: ["border"], density: ["density"]
594
- };
595
-
596
- function parseTokens(raw) {
597
- if (typeof raw !== "string" || raw.trim() === "") return null;
598
- const patch = { color: {}, font: {}, typeScale: {}, radius: {}, mech: {} };
599
- let touched = false;
600
- for (const piece of raw.split(";")) {
601
- const at = piece.indexOf("=");
602
- if (at === -1) continue;
603
- const key = piece.slice(0, at).trim();
604
- const value = piece.slice(at + 1).trim();
605
- if (value === "") continue;
606
- // 归一化必须在分流前算:机制 token(dur/stagger/radius…)走 mech 分支,
607
- // 若把归一化留在下面,`dur=90` 会原样写成 `--dur: 90`,calc 静默失效。
608
- // 尺寸类 token 自动补 px:写 radius=18 和 radius=18px 都当 18px,
609
- // 否则会输出 `--radius-2: 18` 这种在 CSS 里无效的值(实测踩过)。
610
- // 时间类同理补 ms。
611
- const needsUnit = key === "radius" || key === "border";
612
- const needsTime = key === "dur" || key === "stagger";
613
- const bare = /^\d+(\.\d+)?$/;
614
- const normalized = needsUnit && bare.test(value) ? `${value}px` : needsTime && bare.test(value) ? `${value}ms` : value;
615
- const path = TOKEN_KEYS[key];
616
- if (path === undefined) {
617
- if (MECH_KEYS[key] !== undefined) {
618
- touched = true;
619
- patch.mech[key] = normalized;
620
- continue;
621
- }
622
- throw new Error(
623
- `unknown token "${key}"(可用:${Object.keys(TOKEN_KEYS).join(", ")} | 机制:${Object.keys(MECH_KEYS).join(", ")})`
624
- );
625
- }
626
- touched = true;
627
- if (path.length === 1) patch[path[0]] = normalized;
628
- else patch[path[0]][path[1]] = normalized;
629
- }
630
- if (!touched) return null;
631
- // radius 同时派生 radius.1 / radius.3,避免只改一档时其余档位还是原 skin 的值
632
- if (patch.radius["2"] !== undefined) {
633
- const base = patch.radius["2"];
634
- patch.radius["1"] = base === "0" ? "0" : `calc(${base} * 0.5)`;
635
- patch.radius["3"] = patch.radius["3"] ?? base;
636
- }
637
- return patch;
638
- }
639
-
640
- function renderStyle(args = {}) {
641
- const skinId = args.skin ?? args.name;
642
- if (skinId === undefined || skinId === "") {
643
- throw new Error(`需要 skin(可用:${SKINS.map((s) => s.id).join(", ")})`);
644
- }
645
- const baseSkin = SKIN_BY_ID.get(skinId);
646
- if (baseSkin === undefined) {
647
- throw new Error(`unknown skin "${skinId}"(可用:${SKINS.map((s) => s.id).join(", ")})`);
648
- }
649
- // token 覆盖:在内置 skin 的基础上改配色/圆角/字体/边框,不改结构
650
- const patch = parseTokens(args.tokens);
651
- const skin =
652
- patch === null
653
- ? baseSkin
654
- : {
655
- ...baseSkin,
656
- ...(args.label === undefined ? {} : { label: String(args.label) }),
657
- color: { ...baseSkin.color, ...patch.color },
658
- font: { ...baseSkin.font, ...patch.font },
659
- typeScale: { ...baseSkin.typeScale, ...patch.typeScale },
660
- radius: { ...baseSkin.radius, ...patch.radius },
661
- ...(patch.shadow === undefined ? {} : { shadow: patch.shadow }),
662
- ...(patch.border === undefined ? {} : { border: patch.border }),
663
- ...(patch.density === undefined ? {} : { density: patch.density }),
664
- ...(Object.keys(patch.mech).length === 0 ? {} : { mech: patch.mech })
665
- };
666
- const skeletonId = args.skeleton ?? skin.defaultSkeleton;
667
- const skeleton = SKELETON_BY_ID.get(skeletonId);
668
- if (skeleton === undefined) {
669
- throw new Error(`unknown skeleton "${skeletonId}"(可用:${SKELETONS.map((s) => s.id).join(", ")})`);
670
- }
671
- const format = args.format ?? "css";
672
- const renderer = { css: cssOf, json: jsonOf, tailwind: tailwindOf, motion: motionOf }[format];
673
- if (renderer === undefined) throw new Error(`unknown format "${format}"(可用:css, json, tailwind, motion)`);
674
- return {
675
- skin: patch === null ? skin.id : `${skin.id}+tokens`,
676
- skeleton: skeleton.id,
677
- label: `${skin.label} × ${skeleton.label}`,
678
- useWhen: `${skin.useWhen};结构上适合:${skeleton.useWhen}`,
679
- tone: skin.tone,
680
- density: skin.density,
681
- code: renderer(skin, skeleton),
682
- steps: skeleton.steps,
683
- bans: BANS,
684
- exempt: skin.exempt
685
- };
686
- }
687
-
688
- export { BANS, SKELETONS, SKINS, SPACE, renderStyle };
1
+ /**
2
+ * style.js — design_style 的实现:风格约束的**正交两层**(骨架 × 皮肤)。
3
+ *
4
+ * ── 为什么拆层(2026-11-07 第二次修订)──────────────────────────
5
+ * 上一版是 4 个"预设",实测发现它们其实是「1 套骨架 + 1 套字号 + 3 种配色」:
6
+ * 字号阶梯四个预设 100% 相同、语义色色相几乎一致、骨架都是五段式。
7
+ * 等于换色号不换骨,约束反而制造了新的单一审美。
8
+ *
9
+ * 现在拆成两个正交维度:
10
+ * SKELETONS 信息结构(顶部条/首屏/指标/表格/收口 怎么排)——决定"页面是干什么的"
11
+ * SKINS 视觉皮肤(色相/字体/字号/圆角/阴影/密度)——决定"看起来像什么"
12
+ * 6 × 8 = 48 种组合,且两者可独立扩展:加骨架不影响皮肤,加皮肤不影响骨架。
13
+ *
14
+ * 每个皮肤自带 typeScale(不再全局共用一套字号),且都必须守住
15
+ * 「4 档 + 最大跳变 ≥1.6×」这条质量底线(selftest 对全部皮肤断言)。
16
+ *
17
+ * 皮肤可声明 exempt:该皮肤**有意**违反的风格规则(如 playful 的大圆角)。
18
+ * 配合 ui_taste 的 .design-intent.json 豁免机制,让"有意的越界"变成数据而非口头理由。
19
+ */
20
+
21
+ /** 皮肤:视觉语言。typeScale 四档,micro/body/head/display。 */
22
+ const SKINS = [
23
+ {
24
+ id: "industrial-editorial",
25
+ label: "工业编辑",
26
+ useWhen: "技术汇报、系统记录、数据看板、工程报告",
27
+ tone: "纸感中性底 + 墨黑文字 + 信号黄只做结论标记",
28
+ defaultSkeleton: "archive-ops",
29
+ color: {
30
+ paper: "#E8E8E2",
31
+ surface: "#F1F1EB",
32
+ ink: "#101110",
33
+ inkSoft: "#3A3D39",
34
+ muted: "#6B6D68",
35
+ line: "#C9C9C1",
36
+ accent: "#C8B400",
37
+ ok: "#4A6B2A",
38
+ warn: "#A8701C",
39
+ danger: "#B4402B"
40
+ },
41
+ font: { body: '"Microsoft YaHei","Noto Sans SC",sans-serif', mono: '"Consolas","IBM Plex Mono",monospace' },
42
+ typeScale: { micro: "11px", body: "14px", head: "24px", display: "34px" },
43
+ radius: { "1": "2px", "2": "4px" },
44
+ shadow: "0 1px 2px rgba(16,17,16,.06)",
45
+ density: "紧凑",
46
+ exempt: []
47
+ },
48
+ {
49
+ id: "tactical-archive",
50
+ label: "战术档案",
51
+ useWhen: "安全/应急、项目冲刺、密级报告、行动简报",
52
+ tone: "深色舞台 + 信号红/琥珀只做状态与警示",
53
+ defaultSkeleton: "archive-ops",
54
+ color: {
55
+ paper: "#0B0D12",
56
+ surface: "#171A20",
57
+ ink: "#ECEDE9",
58
+ inkSoft: "#B9BDBA",
59
+ muted: "#7E8489",
60
+ line: "#2C3138",
61
+ accent: "#B7372E",
62
+ ok: "#5F7F52",
63
+ warn: "#C98A24",
64
+ danger: "#D8453A"
65
+ },
66
+ font: { body: '"Microsoft YaHei","Noto Sans SC",sans-serif', mono: '"Consolas","IBM Plex Mono",monospace' },
67
+ typeScale: { micro: "10px", body: "13px", head: "22px", display: "30px" },
68
+ radius: { "1": "2px", "2": "3px" },
69
+ shadow: "0 1px 0 rgba(255,255,255,.04)",
70
+ density: "极紧凑",
71
+ exempt: []
72
+ },
73
+ {
74
+ id: "retro-archive",
75
+ label: "复古档案",
76
+ useWhen: "品牌/文化、年度复盘、收藏叙事、正式归档",
77
+ tone: "深色舞台 + 旧纸衬线 + 铁锈红点缀",
78
+ defaultSkeleton: "narrative",
79
+ color: {
80
+ paper: "#0B0A08",
81
+ surface: "#141210",
82
+ ink: "#E9DCCD",
83
+ inkSoft: "#C4B4A2",
84
+ muted: "#8C7B6B",
85
+ line: "#3A322A",
86
+ accent: "#B55829",
87
+ ok: "#6E7A55",
88
+ warn: "#B08A3E",
89
+ danger: "#A93B2C"
90
+ },
91
+ font: { body: '"Georgia","SimSun",serif', mono: '"Consolas",monospace' },
92
+ typeScale: { micro: "12px", body: "16px", head: "26px", display: "38px" },
93
+ radius: { "1": "0px", "2": "0px" },
94
+ shadow: "none",
95
+ density: "舒展",
96
+ exempt: []
97
+ },
98
+ {
99
+ id: "studio-grid",
100
+ label: "网格工作室",
101
+ useWhen: "通用商业提案、客户报价、中性交付、需要易改的场合",
102
+ tone: "纸白 + 墨黑 + 朱砂,细线网格,字重分层",
103
+ defaultSkeleton: "document",
104
+ color: {
105
+ paper: "#FAFAF7",
106
+ surface: "#F2F1EC",
107
+ ink: "#14140F",
108
+ inkSoft: "#3C3C35",
109
+ muted: "#6E6E64",
110
+ line: "#CFCEC5",
111
+ accent: "#C0392B",
112
+ ok: "#4F6B3A",
113
+ warn: "#A8741C",
114
+ danger: "#A93226"
115
+ },
116
+ font: { body: '"Microsoft YaHei","Noto Sans SC",sans-serif', mono: '"Consolas",monospace' },
117
+ typeScale: { micro: "12px", body: "15px", head: "25px", display: "36px" },
118
+ radius: { "1": "2px", "2": "4px" },
119
+ shadow: "0 1px 3px rgba(20,20,15,.07)",
120
+ density: "中等",
121
+ exempt: []
122
+ },
123
+ {
124
+ id: "swiss-minimal",
125
+ label: "瑞士极简",
126
+ useWhen: "严肃产品、机构站点、作品集、需要长时间耐看且不抢内容的界面",
127
+ tone: "纯白底 + 墨黑字 + 极小面积瑞士红;靠字号、字距和网格说话,零装饰",
128
+ defaultSkeleton: "landing",
129
+ color: {
130
+ paper: "#FFFFFF",
131
+ surface: "#F4F4F4",
132
+ ink: "#111111",
133
+ inkSoft: "#3A3A3A",
134
+ muted: "#767676",
135
+ line: "#D8D8D8",
136
+ accent: "#E4002B",
137
+ ok: "#1D6B3A",
138
+ warn: "#8A5A00",
139
+ danger: "#C1121F"
140
+ },
141
+ font: { body: '"Helvetica Neue","Arial","Microsoft YaHei",sans-serif', mono: '"Consolas",monospace' },
142
+ // 字距拉开、标题远大于正文:瑞士风格的核心是"层级靠尺度不靠装饰"
143
+ typeScale: { micro: "11px", body: "15px", head: "28px", display: "44px" },
144
+ radius: { "1": "0px", "2": "0px" },
145
+ shadow: "none",
146
+ density: "大留白",
147
+ exempt: []
148
+ },
149
+ {
150
+ id: "brutalist",
151
+ label: "粗野主义",
152
+ useWhen: "强表达的产品页、活动页、独立工具、要「不像模板」的场合",
153
+ tone: "纯黑白 + 3px 硬边框 + 大色块 + 超大标题;刻意反精致",
154
+ defaultSkeleton: "landing",
155
+ color: {
156
+ paper: "#FFFFFF",
157
+ surface: "#F0F0F0",
158
+ ink: "#000000",
159
+ inkSoft: "#222222",
160
+ muted: "#555555",
161
+ line: "#000000",
162
+ accent: "#FF3B00",
163
+ ok: "#007A3D",
164
+ warn: "#B36B00",
165
+ danger: "#CC0000"
166
+ },
167
+ font: { body: '"Arial Black","Microsoft YaHei",sans-serif', mono: '"Consolas",monospace' },
168
+ typeScale: { micro: "12px", body: "16px", head: "30px", display: "52px" },
169
+ radius: { "1": "0px", "2": "0px" },
170
+ shadow: "none",
171
+ density: "紧凑",
172
+ // 粗野主义刻意用高饱和撞色与硬边框,与「色相克制」规则天然冲突——这是有意的
173
+ exempt: ["hue-sprawl"]
174
+ },
175
+ {
176
+ id: "quiet-luxe",
177
+ label: "静奢",
178
+ useWhen: "高端品牌、酒店/地产/金融、需要「贵而不吵」的界面",
179
+ tone: "暖白底 + 低饱和棕灰 + 细字重 + 极大留白;靠质感与留白建立层级",
180
+ defaultSkeleton: "narrative",
181
+ color: {
182
+ paper: "#F7F5F2",
183
+ surface: "#F1EEE9",
184
+ ink: "#2A2A28",
185
+ inkSoft: "#4A4A46",
186
+ muted: "#8A8580",
187
+ line: "#DDD8D1",
188
+ accent: "#8C7A66",
189
+ ok: "#5E7358",
190
+ warn: "#9A7B3C",
191
+ danger: "#8E3B33"
192
+ },
193
+ font: { body: '"Georgia","Songti SC","SimSun",serif', mono: '"Consolas",monospace' },
194
+ typeScale: { micro: "10px", body: "14px", head: "23px", display: "34px" },
195
+ radius: { "1": "0px", "2": "2px" },
196
+ shadow: "none",
197
+ density: "极大留白",
198
+ exempt: []
199
+ },
200
+ {
201
+ id: "playful-geometric",
202
+ label: "几何趣味",
203
+ useWhen: "消费类产品、儿童/教育、社区、需要亲和力的 C 端界面",
204
+ tone: "暖白底 + 大圆角 + 几何色块;鲜艳但每屏只用 1–2 个强调色",
205
+ defaultSkeleton: "landing",
206
+ color: {
207
+ paper: "#FFF8F0",
208
+ surface: "#FFFFFF",
209
+ ink: "#1F1A17",
210
+ inkSoft: "#4A423C",
211
+ muted: "#857A72",
212
+ line: "#EADFD4",
213
+ accent: "#FF6B35",
214
+ ok: "#2C9E6A",
215
+ warn: "#E0A100",
216
+ danger: "#E03B3B"
217
+ },
218
+ font: { body: '"PingFang SC","Microsoft YaHei",sans-serif', mono: '"Consolas",monospace' },
219
+ typeScale: { micro: "12px", body: "16px", head: "28px", display: "40px" },
220
+ radius: { "1": "8px", "2": "16px" },
221
+ shadow: "0 2px 8px rgba(31,26,23,.08)",
222
+ density: "舒展",
223
+ // C 端产品用大圆角是合理的,不是"AI 味"——声明豁免
224
+ exempt: ["radius-blob"]
225
+ }
226
+ ];
227
+
228
+ /** 骨架:信息结构。与皮肤正交(任何皮肤都能套任何骨架)。 */
229
+ const SKELETONS = [
230
+ {
231
+ id: "archive-ops",
232
+ label: "档案作业",
233
+ useWhen: "日/周报、任务队列、运行状态、可归档的记录型界面",
234
+ steps: [
235
+ "顶部 rail(高 44px):左品牌/系统名,右时间与运行编号——跨页恒定参照物",
236
+ "首屏:左 8 列放一句真实结论 + 支撑事实;右 4 列放元数据戳(RUN / WINDOW / OWNER)",
237
+ "指标行:4 列,细线分隔;数字用 tabular-nums,单位缩小一号;不做卡片",
238
+ "主体:左 8 列真表格(长名字换行、编号独立行),右 4 列观察记录 + kv 列表",
239
+ "收口 footer:左版本号,右页码,每页同一位置"
240
+ ]
241
+ },
242
+ {
243
+ id: "narrative",
244
+ label: "叙事长页",
245
+ useWhen: "年度复盘、品牌故事、项目回顾、需要阅读节奏的内容页",
246
+ steps: [
247
+ "开场:大标题 + 一句摘要 + 时间范围,不放下拉菜单",
248
+ "章节接力:每章一个编号(01/02/03)+ 小标题,编号用等宽——读者靠编号导航",
249
+ "图文交替:左文右图 / 左图右文交替,避免所有图都居中对齐",
250
+ "引文块:关键结论单独成块,细竖线 + 小一号字,与正文拉开距离",
251
+ "结语:回到开场摘要,给一句收口判断 + 归档信息"
252
+ ]
253
+ },
254
+ {
255
+ id: "dashboard",
256
+ label: "仪表盘",
257
+ useWhen: "实时监控、运营后台、数据产品的主界面",
258
+ steps: [
259
+ "左侧固定导航(宽 200–240px)+ 顶部筛选条,内容区只在框内滚动",
260
+ "KPI 行:4–6 个关键数字,同尺寸同基线,超出每行的指标折到第二行而非缩小",
261
+ "图表区:主图占 8 列、辅图占 4 列;图表必须有标题与单位,不留无标题空壳",
262
+ "明细表:默认显示最近 N 行 + 总量提示,不假装能显示全部",
263
+ "右下角固定操作区:刷新时间 + 手动重跑,不要飘浮工具栏"
264
+ ]
265
+ },
266
+ {
267
+ id: "landing",
268
+ label: "着陆页",
269
+ useWhen: "产品介绍、活动页、下载页、需要转化但不靠营销口号的页面",
270
+ steps: [
271
+ "首屏:真实价值主张(说清「对谁、解决什么」)+ 一个可信的数字证据,不写「让 X 更懂 Y」",
272
+ "特性分段:3–4 段,每段一个具体能力 + 截图/示意,不用 icon+标题+描述三件套",
273
+ "社会证明:客户名/数据/引用,必须有出处,不允许编造 logo 墙",
274
+ "价格或规格:真表格,写清包含什么、不包含什么",
275
+ "收口 CTA:一个主按钮 + 一行说明「下一步会发生什么」,不做两个抢焦点的按钮"
276
+ ]
277
+ },
278
+ {
279
+ id: "document",
280
+ label: "文档型",
281
+ useWhen: "规范手册、提案、合同附件、需要打印或归档的长文档",
282
+ steps: [
283
+ "标题区:文档类型 + 版本 + 日期 + 责任人,四行以内说清身份",
284
+ "目录:章节 + 页码,超过 3 页必须有",
285
+ "正文层级:只用两级标题(章 / 节),正文首行不缩进、段间留白",
286
+ "边注:定义、出处、例外放右侧窄栏,不打断正文阅读线",
287
+ "收口:签署/生效/附件清单块,明确文档状态(草案/生效/废止)"
288
+ ]
289
+ },
290
+ {
291
+ id: "editor",
292
+ label: "编辑器型",
293
+ useWhen: "画布工具、设计器、代码编辑器、需要长期停留的操作界面",
294
+ steps: [
295
+ "顶部工具栏:高频操作在左,视图控制在右;图标必须带 tooltip 文本",
296
+ "中央画布:占满剩余空间,四周留 24px 安全边距,不叠加装饰",
297
+ "右侧属性面板:固定宽度 280–320px,分组折叠,按「选中对象」动态变化",
298
+ "底部状态栏:坐标/缩放/保存状态,等宽字体,一眼可读",
299
+ "空态与选中态必须都有明确视觉,不能只靠鼠标指针变化"
300
+ ]
301
+ }
302
+ ];
303
+
304
+ const SKIN_BY_ID = new Map(SKINS.map((skin) => [skin.id, skin]));
305
+ const SKELETON_BY_ID = new Map(SKELETONS.map((skeleton) => [skeleton.id, skeleton]));
306
+
307
+ /**
308
+ * 全局禁令清单:按 scope 标注,让模型知道哪些是底线、哪些是风格取向。
309
+ * 底线(quality)不该为风格让步;风格(style)允许在有理由时声明豁免。
310
+ *
311
+ * **每条必须带 id**,且 id 与 rules.js 的规则 id 同源:
312
+ * 皮肤的 exempt(如 brutalist 的 ["hue-sprawl"])要靠这个 id 映射到具体条目,
313
+ * 否则模型看到「有意偏离 hue-sprawl」却在禁令清单里找不到它在说什么。
314
+ */
315
+ const BANS = [
316
+ { id: "emoji-icon", scope: "quality", text: "emoji 当图标(→ icon action=find|emit 取真 SVG)" },
317
+ { id: "type-flat", scope: "quality", text: "字号碎阶:最多 4 档,最大档 ≥ 正文 ×1.6" },
318
+ { id: "spacing-offgrid", scope: "quality", text: "间距手调值(落 4px 网格,2px 仅用于微调)" },
319
+ { id: "no-states", scope: "quality", text: "可点元素缺 hover / focus-visible / disabled" },
320
+ { id: "card-uniform", scope: "quality", text: "整页 icon+标题+描述的同构卡片网格" },
321
+ { id: "no-breath", scope: "quality", text: "页面级与控件级留白同量级(应有 3:1 差)" },
322
+ { id: "purple-gradient", scope: "style", text: "紫/indigo/violet 渐变(品牌主色确需时声明豁免)" },
323
+ { id: "gradient-text", scope: "style", text: "渐变文字 background-clip:text" },
324
+ { id: "glassmorphism", scope: "style", text: "毛玻璃 backdrop-filter、大面积半透明白" },
325
+ { id: "shadow-glow", scope: "style", text: "发光阴影、彩色投影(最多 1 层中性投影)" },
326
+ { id: "radius-blob", scope: "style", text: "全站 rounded-xl/2xl(数据密集型 ≤8px;消费类可放宽)" },
327
+ { id: "hue-sprawl", scope: "style", text: "色相乱跑:同屏多个无关联高饱和色(一屏只留 1 个强调色)" },
328
+ { id: "centered-hero", scope: "style", text: "居中 hero + 口号 + CTA 的营销首屏(产品界面)" },
329
+ { id: "fake-stat", scope: "content", text: "128K+/99.9% 这类不可算的假大数、Lorem/敬请期待" },
330
+ { id: "nest-boxes", scope: "style", text: "框里再套框(三层以上圆角容器),层级靠套娃而不是间距" },
331
+ { id: "divider-sprawl", scope: "style", text: "分割线泛滥:每个区块之间都画一条线,靠线分区而不是靠留白" },
332
+ { id: "big-black-tight", scope: "style", text: "标题又大又黑又挤(≥30px + 700+ + 行高 ≤1.25),靠放大加粗硬撑层次" },
333
+ { id: "dead-button", scope: "quality", text: "按钮看着能点却没有结果(无 submit / 点击绑定 / data-action)" },
334
+ { id: "icon-tile-uniform", scope: "style", text: "每个卡片的图标都套同尺寸同色的圆角块(渐变方块尤甚)" },
335
+ { id: "eyebrow-spam", scope: "style", text: "每个区块都拿小号大字距标签当开场(四段以上)" },
336
+ { id: "ai-copy", scope: "content", text: "CTA 里的 → 箭头、“立即/一键/轻松/极致/解锁/赋能”与“XX之旅”开场白,以及无缝/闭环/抓手这类黑话" }
337
+ ];
338
+
339
+ function cssOf(skin, skeleton) {
340
+ const lines = [`:root {`];
341
+ lines.push(` /* ${skin.label} × ${skeleton.label} — ${skin.tone} */`);
342
+ for (const [key, value] of Object.entries(skin.color)) lines.push(` --c-${key}: ${value};`);
343
+ for (const [key, value] of Object.entries(skin.font)) lines.push(` --font-${key}: ${value};`);
344
+ for (const [key, value] of Object.entries(skin.typeScale)) lines.push(` --text-${key}: ${value};`);
345
+ for (const [key, value] of Object.entries(SPACE)) lines.push(` --space-${key}: ${value};`);
346
+ for (const [key, value] of Object.entries(skin.radius)) lines.push(` --radius-${key}: ${value};`);
347
+ lines.push(` --shadow-1: ${skin.shadow};`);
348
+ lines.push(` --line: ${skin.border ?? "1px"} solid var(--c-line);`);
349
+ lines.push(` --border-w: ${skin.border ?? "1px"};`);
350
+ // 机制层变量:即使没有值也输出空声明,让使用方知道可以消费这些钩子
351
+ for (const [key, varName] of Object.entries(MECH_KEYS)) {
352
+ const value = skin.mech?.[key];
353
+ if (value !== undefined) lines.push(` ${varName}: ${value};`);
354
+ }
355
+ lines.push(`}`);
356
+ return lines.join("\n");
357
+ }
358
+
359
+ function jsonOf(skin, skeleton) {
360
+ return JSON.stringify(
361
+ {
362
+ skin: { id: skin.id, label: skin.label, useWhen: skin.useWhen, tone: skin.tone, density: skin.density },
363
+ skeleton: { id: skeleton.id, label: skeleton.label, useWhen: skeleton.useWhen, steps: skeleton.steps },
364
+ color: skin.color,
365
+ font: skin.font,
366
+ type: skin.typeScale,
367
+ space: SPACE,
368
+ radius: skin.radius,
369
+ shadow: skin.shadow,
370
+ // 机制层钩子:json 格式也要给出,否则消费方(例如按 token 生成 HTML 的构建脚本)
371
+ // 拿不到 pattern/clip/organic 这些值,机制等于白加。
372
+ ...(skin.mech === undefined ? {} : { mech: skin.mech }),
373
+ exempt: skin.exempt,
374
+ bans: BANS
375
+ },
376
+ null,
377
+ 2
378
+ );
379
+ }
380
+
381
+ function tailwindOf(skin, skeleton) {
382
+ const colors = Object.entries(skin.color)
383
+ .map(([key, value]) => ` "${key}": "${value}"`)
384
+ .join(",\n");
385
+ const fontList = (value) => value.split(",").map((f) => `"${f.trim().replace(/^"|"$/g, "")}"`).join(", ");
386
+ // 输出**完整的 tailwind.config.js**,而不是 `extend: {…}` 片段:
387
+ // 片段要么缺外层 module.exports/theme 包裹、要么把 extend 键本身也包含进去,
388
+ // 模型直接粘进项目就是语法错。完整文件可以直接 `node --check` 验证(测试里有这条)。
389
+ return [
390
+ `// tailwind.config.js — 由 dsh-visualkit 生成(${skin.label} × ${skeleton.label})`,
391
+ "module.exports = {",
392
+ " theme: {",
393
+ " extend: {",
394
+ " colors: {",
395
+ colors,
396
+ " },",
397
+ ` borderRadius: { sm: "${skin.radius["1"]}", md: "${skin.radius["2"]}" },`,
398
+ ` boxShadow: { "1": "${skin.shadow}" },`,
399
+ " fontSize: {",
400
+ ...Object.entries(skin.typeScale).map(([key, value]) => ` ${key}: "${value}",`),
401
+ " },",
402
+ " fontFamily: {",
403
+ ` sans: [${fontList(skin.font.body)}],`,
404
+ ` mono: [${fontList(skin.font.mono)}]`,
405
+ " }",
406
+ " }",
407
+ " }",
408
+ "};",
409
+ ""
410
+ ].join("\n");
411
+ }
412
+
413
+ /* ---------------------------- 交互层(motion) ---------------------------- */
414
+
415
+ /**
416
+ * 交互层兜底值:只在皮肤没给对应机制 token 时用。
417
+ *
418
+ * 全部是**中性**取值,不指向任何风格——时长、缓动、缩放幅度这些一旦带风格倾向,
419
+ * 就又变成了「预设动效包」。挂哪个 `data-motion` 原语、给哪个元素挂,由使用者决定;
420
+ * 这里只保证「挂了就有物理反馈,且反馈可调」。
421
+ */
422
+ const MOTION_FALLBACK = {
423
+ dur: "160ms",
424
+ ease: "cubic-bezier(.4, 0, .2, 1)",
425
+ press: "scale(.96)",
426
+ lift: "translateY(-2px)",
427
+ focus: "2px solid var(--c-accent, currentColor)",
428
+ ripple: "currentColor",
429
+ stagger: "40ms",
430
+ reveal: "12px",
431
+ shimmer: "rgb(255 255 255 / .28)"
432
+ };
433
+
434
+ /**
435
+ * 交互层产出:一份**可选的、按元素挂载**的 CSS 原语,不产出任何具体动效风格。
436
+ *
437
+ * 为什么它是工具而不是模板:
438
+ * - 每个原语(press/lift/focus/ripple/enter/stagger/reveal/shimmer)独立,可以只挑需要的;
439
+ * - 全部读 `:root` 变量,一套产物换个项目只改变量;
440
+ * - 选择器一律走 `[data-motion~="…"]` 属性钩子,不占用使用者的 class 命名空间;
441
+ * - 风格差异(弹性/线性/缓入缓出、快慢、位移幅度)由变量的**取值**表达,不由我们预设几套。
442
+ *
443
+ * 唯一不可协商的是末尾的 `prefers-reduced-motion` 兜底:它是无障碍底线,不是风格。
444
+ */
445
+ function motionOf(skin, skeleton) {
446
+ const mech = skin.mech ?? {};
447
+ const val = (key) => mech[key] ?? MOTION_FALLBACK[key];
448
+ const lines = [
449
+ `/* 交互层 — ${skin.label} × ${skeleton.label}(dsh-visualkit 生成)`,
450
+ ` * 原语层:给元素挂 data-motion="press lift focus" 才生效,挂哪些、挂给谁由你决定。`,
451
+ ` * 时长/缓动/幅度全走变量,换项目只改这一块 :root。 */`,
452
+ `:root {`,
453
+ ` /* 时长阶梯:一个基准推快慢两档,避免满文件硬编码 120/240/300ms */`,
454
+ ` --dur: ${val("dur")};`,
455
+ ` --ease: ${val("ease")};`,
456
+ ` --t-fast: calc(var(--dur) * .6);`,
457
+ ` --t-base: var(--dur);`,
458
+ ` --t-slow: calc(var(--dur) * 1.6);`,
459
+ ` /* 机制变量(皮肤/机制 token 覆盖过就用覆盖值) */`
460
+ ];
461
+ for (const key of ["press", "lift", "focus", "ripple", "stagger", "reveal", "shimmer"]) {
462
+ lines.push(` ${MECH_KEYS[key]}: ${val(key)};`);
463
+ }
464
+ lines.push(` --focus-offset: 2px;`);
465
+ lines.push(`}`, ``);
466
+
467
+ lines.push(
468
+ `/* 1) 过渡只加在挂载元素上,不污染全站 */`,
469
+ `[data-motion] {`,
470
+ ` transition: transform var(--t-fast) var(--ease), opacity var(--t-fast) var(--ease),`,
471
+ ` background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease),`,
472
+ ` box-shadow var(--t-fast) var(--ease);`,
473
+ `}`,
474
+ ``,
475
+ `/* 2) 按压::active 期间贴合,松手回弹(触屏 300ms 内也够快) */`,
476
+ `[data-motion~="press"]:active { transform: var(--press); }`,
477
+ ``,
478
+ `/* 3) 悬停抬升:只位移不做阴影,阴影量由使用者自己的 hover 规则给 */`,
479
+ `[data-motion~="lift"]:hover { transform: var(--lift); }`,
480
+ `[data-motion~="lift"]:active { transform: none; }`,
481
+ ``,
482
+ `/* 4) 焦点环::focus-visible 而非 :focus,鼠标点击不弹环;outline 不占布局 */`,
483
+ `[data-motion~="focus"]:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); }`,
484
+ ``,
485
+ `/* 5) 波纹:原点取 --mx/--my(无 JS 时落在中心),clip-path 扩张不依赖元素尺寸 */`,
486
+ `[data-motion~="ripple"] { position: relative; overflow: hidden; isolation: isolate; }`,
487
+ `[data-motion~="ripple"]::after {`,
488
+ ` content: ""; position: absolute; inset: 0; pointer-events: none;`,
489
+ ` background: var(--ripple); opacity: 0;`,
490
+ ` clip-path: circle(0% at var(--mx, 50%) var(--my, 50%));`,
491
+ `}`,
492
+ `[data-motion~="ripple"]:active::after,`,
493
+ `[data-motion~="ripple"].is-rippling::after { animation: artkit-ripple var(--t-base) var(--ease) forwards; }`,
494
+ `@keyframes artkit-ripple {`,
495
+ ` from { opacity: .24; clip-path: circle(0% at var(--mx, 50%) var(--my, 50%)); }`,
496
+ ` to { opacity: 0; clip-path: circle(140% at var(--mx, 50%) var(--my, 50%)); }`,
497
+ `}`,
498
+ ``,
499
+ `/* 6) 入场:单个元素用 enter,列表容器用 stagger(子元素读 --i 序号错峰) */`,
500
+ `@keyframes artkit-enter {`,
501
+ ` from { opacity: 0; transform: translate3d(0, var(--reveal), 0); }`,
502
+ ` to { opacity: 1; transform: none; }`,
503
+ `}`,
504
+ `[data-motion~="enter"] { animation: artkit-enter var(--t-slow) var(--ease) both; }`,
505
+ `[data-motion~="stagger"] > * {`,
506
+ ` animation: artkit-enter var(--t-slow) var(--ease) both;`,
507
+ ` animation-delay: calc(var(--stagger) * var(--i, 0));`,
508
+ `}`,
509
+ ``,
510
+ `/* 7) 滚动显现:初始不可见,加 .is-in 才播(配合 IntersectionObserver) */`,
511
+ `[data-motion~="reveal"] { opacity: 0; }`,
512
+ `[data-motion~="reveal"].is-in { animation: artkit-enter var(--t-slow) var(--ease) both; }`,
513
+ ``,
514
+ `/* 8) 骨架屏扫光:只画在元素自己的背景层上,不改文字 */`,
515
+ `@keyframes artkit-shimmer { from { background-position: 140% 0; } to { background-position: -40% 0; } }`,
516
+ `[data-motion~="shimmer"] {`,
517
+ ` background-image: linear-gradient(100deg, transparent 20%, var(--shimmer) 50%, transparent 80%);`,
518
+ ` background-size: 200% 100%;`,
519
+ ` animation: artkit-shimmer calc(var(--t-slow) * 6) linear infinite;`,
520
+ `}`,
521
+ ``,
522
+ `/* 配套行为钩子(可选,各一行;CSS 表达不了指针原点/可见性)`,
523
+ ` * 波纹原点:el.onpointerdown = e => { const r = el.getBoundingClientRect();`,
524
+ ` * el.style.setProperty('--mx', e.clientX - r.left + 'px');`,
525
+ ` * el.style.setProperty('--my', e.clientY - r.top + 'px'); };`,
526
+ ` * 滚动显现:new IntersectionObserver(es => es.forEach(e => e.isIntersecting && e.target.classList.add('is-in')),`,
527
+ ` * { threshold: .15 }).observe(el); */`,
528
+ ``,
529
+ `/* 9) 无障碍兜底(底线,不是风格):不能因为「动效好看」就把前庭敏感用户挡在门外 */`,
530
+ `@media (prefers-reduced-motion: reduce) {`,
531
+ ` [data-motion], [data-motion]::after, [data-motion] > * {`,
532
+ ` animation-duration: .01ms !important;`,
533
+ ` animation-iteration-count: 1 !important;`,
534
+ ` transition-duration: .01ms !important;`,
535
+ ` }`,
536
+ ` /* reveal 默认 opacity:0,兜底必须补回可见,否则内容永久消失 */`,
537
+ ` [data-motion~="reveal"] { opacity: 1; }`,
538
+ ` [data-motion~="shimmer"] { animation: none; }`,
539
+ `}`
540
+ );
541
+ return lines.join("\n");
542
+ }
543
+
544
+ /** 间距六档,全在 4px 网格(与皮肤无关,属于质量底线)。 */
545
+ const SPACE = { "1": "4px", "2": "8px", "3": "12px", "4": "16px", "6": "24px", "8": "32px", "12": "48px" };
546
+
547
+ /**
548
+ * 渲染一次风格约束。
549
+ * @param {{skin?:string, skeleton?:string, name?:string, format?:"css"|"json"|"tailwind"}} args
550
+ * name 是 skin 的旧参数名,保留兼容;给的 skeleton 缺省时取皮肤的 defaultSkeleton。
551
+ */
552
+ /**
553
+ * 解析自定义 token 覆盖:`ground=#0B0B0C;ink=#F5F5F4;accent=#C6F24E;radius=0;border=3`
554
+ *
555
+ * **为什么要有这个**:`skin` 是封闭枚举,只能表达我们内置的 8 种视觉语言。
556
+ * 但插件的定位是「给框架不给目录」——风格应该能被描述,而不是被我们在源码里枚举。
557
+ * 有了 token 覆盖,任何配色/圆角/字体/边框组合都能落到同一个骨架体系上:
558
+ * 扁平、Y2K、Art Deco、有机自然这些没有内置 skin 的方向,也能用一组 token 表达出来。
559
+ *
560
+ * 用**字符串**而不是嵌套对象:嵌套 schema 的每个字段都是每请求常驻 token,
561
+ * 而 key=value 串一个参数就能表达二十来个维度。
562
+ */
563
+ // 机制层 token:不描述「什么风格」,只描述「用哪种实现手段」。
564
+ // 这层的存在理由:把市面上的风格拆开看,绝大多数只是少数几种原语的组合——
565
+ // 渐变填充、图案层、任意形状、描边、双线、动效档位、栅格配方。
566
+ // 这些原语是**通用的**,项目规范各不相同但手段相同,所以补原语而不是补风格。
567
+ const MECH_KEYS = {
568
+ pattern: "--pattern", // 图案层:repeating-*-gradient / radial-gradient 拼波点斜纹网格
569
+ clip: "--clip", // 任意形状:polygon() 斜切角、六边形、箭头
570
+ organic: "--radius-organic", // 有机圆角:八角写法 30% 70% 60% 40% / 40% 30% 70% 60%
571
+ double: "--double-line", // 双线边框(Art Deco):0 0 0 1px c, 0 0 0 3px bg, 0 0 0 4px c
572
+ stroke: "--text-stroke", // 描边字:
573
+ tracking: "--tracking", // 字距
574
+ grid: "--grid", // 栅格配方:bento / magazine / symmetric
575
+ // —— 交互层(format=motion 消费;视觉层不读取,但它们同样是「手段」而非「风格」)——
576
+ dur: "--dur", // 动效基准时长(dur=160 自动补 ms)
577
+ ease: "--ease", // 缓动曲线
578
+ press: "--press", // 按压反馈:scale(.96) / translateY(1px) / 无
579
+ lift: "--lift", // 悬停位移:translateY(-2px)
580
+ focus: "--focus-ring", // 焦点环:2px solid <主色>,用 :focus-visible 触发
581
+ ripple: "--ripple", // 波纹色(currentColor 或半透明主色)
582
+ stagger: "--stagger", // 列表错峰步长(stagger=40 自动补 ms)
583
+ reveal: "--reveal", // 入场/滚动显现的位移距离
584
+ shimmer: "--shimmer" // 骨架屏扫光高光色
585
+ };
586
+
587
+ const TOKEN_KEYS = {
588
+ ground: ["color", "paper"], surface: ["color", "surface"], ink: ["color", "ink"],
589
+ inkSoft: ["color", "inkSoft"], muted: ["color", "muted"], line: ["color", "line"],
590
+ accent: ["color", "accent"], ok: ["color", "ok"], warn: ["color", "warn"], danger: ["color", "danger"],
591
+ font: ["font", "body"], mono: ["font", "mono"],
592
+ micro: ["typeScale", "micro"], body: ["typeScale", "body"], head: ["typeScale", "head"], display: ["typeScale", "display"],
593
+ radius: ["radius", "2"], shadow: ["shadow"], border: ["border"], density: ["density"]
594
+ };
595
+
596
+ function parseTokens(raw) {
597
+ if (typeof raw !== "string" || raw.trim() === "") return null;
598
+ const patch = { color: {}, font: {}, typeScale: {}, radius: {}, mech: {} };
599
+ let touched = false;
600
+ for (const piece of raw.split(";")) {
601
+ const at = piece.indexOf("=");
602
+ if (at === -1) continue;
603
+ const key = piece.slice(0, at).trim();
604
+ const value = piece.slice(at + 1).trim();
605
+ if (value === "") continue;
606
+ // 归一化必须在分流前算:机制 token(dur/stagger/radius…)走 mech 分支,
607
+ // 若把归一化留在下面,`dur=90` 会原样写成 `--dur: 90`,calc 静默失效。
608
+ // 尺寸类 token 自动补 px:写 radius=18 和 radius=18px 都当 18px,
609
+ // 否则会输出 `--radius-2: 18` 这种在 CSS 里无效的值(实测踩过)。
610
+ // 时间类同理补 ms。
611
+ const needsUnit = key === "radius" || key === "border";
612
+ const needsTime = key === "dur" || key === "stagger";
613
+ const bare = /^\d+(\.\d+)?$/;
614
+ const normalized = needsUnit && bare.test(value) ? `${value}px` : needsTime && bare.test(value) ? `${value}ms` : value;
615
+ const path = TOKEN_KEYS[key];
616
+ if (path === undefined) {
617
+ if (MECH_KEYS[key] !== undefined) {
618
+ touched = true;
619
+ patch.mech[key] = normalized;
620
+ continue;
621
+ }
622
+ throw new Error(
623
+ `unknown token "${key}"(可用:${Object.keys(TOKEN_KEYS).join(", ")} | 机制:${Object.keys(MECH_KEYS).join(", ")})`
624
+ );
625
+ }
626
+ touched = true;
627
+ if (path.length === 1) patch[path[0]] = normalized;
628
+ else patch[path[0]][path[1]] = normalized;
629
+ }
630
+ if (!touched) return null;
631
+ // radius 同时派生 radius.1 / radius.3,避免只改一档时其余档位还是原 skin 的值
632
+ if (patch.radius["2"] !== undefined) {
633
+ const base = patch.radius["2"];
634
+ patch.radius["1"] = base === "0" ? "0" : `calc(${base} * 0.5)`;
635
+ patch.radius["3"] = patch.radius["3"] ?? base;
636
+ }
637
+ return patch;
638
+ }
639
+
640
+ function renderStyle(args = {}) {
641
+ const skinId = args.skin ?? args.name;
642
+ if (skinId === undefined || skinId === "") {
643
+ throw new Error(`需要 skin(可用:${SKINS.map((s) => s.id).join(", ")})`);
644
+ }
645
+ const baseSkin = SKIN_BY_ID.get(skinId);
646
+ if (baseSkin === undefined) {
647
+ throw new Error(`unknown skin "${skinId}"(可用:${SKINS.map((s) => s.id).join(", ")})`);
648
+ }
649
+ // token 覆盖:在内置 skin 的基础上改配色/圆角/字体/边框,不改结构
650
+ const patch = parseTokens(args.tokens);
651
+ const skin =
652
+ patch === null
653
+ ? baseSkin
654
+ : {
655
+ ...baseSkin,
656
+ ...(args.label === undefined ? {} : { label: String(args.label) }),
657
+ color: { ...baseSkin.color, ...patch.color },
658
+ font: { ...baseSkin.font, ...patch.font },
659
+ typeScale: { ...baseSkin.typeScale, ...patch.typeScale },
660
+ radius: { ...baseSkin.radius, ...patch.radius },
661
+ ...(patch.shadow === undefined ? {} : { shadow: patch.shadow }),
662
+ ...(patch.border === undefined ? {} : { border: patch.border }),
663
+ ...(patch.density === undefined ? {} : { density: patch.density }),
664
+ ...(Object.keys(patch.mech).length === 0 ? {} : { mech: patch.mech })
665
+ };
666
+ const skeletonId = args.skeleton ?? skin.defaultSkeleton;
667
+ const skeleton = SKELETON_BY_ID.get(skeletonId);
668
+ if (skeleton === undefined) {
669
+ throw new Error(`unknown skeleton "${skeletonId}"(可用:${SKELETONS.map((s) => s.id).join(", ")})`);
670
+ }
671
+ const format = args.format ?? "css";
672
+ const renderer = { css: cssOf, json: jsonOf, tailwind: tailwindOf, motion: motionOf }[format];
673
+ if (renderer === undefined) throw new Error(`unknown format "${format}"(可用:css, json, tailwind, motion)`);
674
+ return {
675
+ skin: patch === null ? skin.id : `${skin.id}+tokens`,
676
+ skeleton: skeleton.id,
677
+ label: `${skin.label} × ${skeleton.label}`,
678
+ useWhen: `${skin.useWhen};结构上适合:${skeleton.useWhen}`,
679
+ tone: skin.tone,
680
+ density: skin.density,
681
+ code: renderer(skin, skeleton),
682
+ steps: skeleton.steps,
683
+ bans: BANS,
684
+ exempt: skin.exempt
685
+ };
686
+ }
687
+
688
+ export { BANS, SKELETONS, SKINS, SPACE, renderStyle };