dsh-design-studio 0.5.0 → 0.6.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/lib/system.js ADDED
@@ -0,0 +1,1360 @@
1
+ /**
2
+ * 设计系统:从用户自己的网站或代码里提一套品牌规范,存进设计系统库,以后做新设计都照它来。
3
+ *
4
+ * <库>/<名字>/tokens.css 设计变量(和 ui-design 技能的起手模板同一套名字,设计目录直接复制过去 link)
5
+ * <库>/<名字>/components.css 按钮、卡片、输入框、标签、文字样式,照网站原样,全用上面的变量
6
+ * <库>/<名字>/components.html 样张:色板、字号、组件(工作台里能打开看)
7
+ * <库>/<名字>/DESIGN.md 说明:每个颜色干什么用、字体、组件写法、注意事项(品牌气质由模型补)
8
+ * <库>/<名字>/system.json { name, sources, createdAt }
9
+ * <库>/<名字>/source/ 网站截图、组件截图;preview.jpg 是样张的缩略图
10
+ * <库>/index.json { default: 名字 }:做新设计默认用哪套
11
+ *
12
+ * 提取:网站地址 / 本地 HTML 用无头浏览器打开,在页面里量算出来的样式(颜色按面积、字按字数加权;字体、字号、圆角、阴影;
13
+ * 按钮、输入框、卡片、顶栏长什么样);项目文件夹先扫样式文件(CSS 变量、写死的颜色、字体、圆角、Tailwind 配置、用了哪个组件库),
14
+ * 里面有能直接打开的 HTML 也量一下。量到的数归成角色(背景、表面、正文、次要文字、主色、主色上的字、点缀、边框),
15
+ * 写成上面几个文件,交给模型核对、补说明。
16
+ */
17
+
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import { pathToFileURL } from 'node:url'
22
+
23
+ export const SYSTEMS_DIR = process.env.DSH_DESIGN_SYSTEMS_DIR || path.join(os.homedir(), '.dsh', 'design-systems') // 测试时用环境变量换到临时目录
24
+ export const MAX_SOURCES = 5
25
+ const MAX_CROPS = 4
26
+ const SCAN_EXT = /\.(css|scss|sass|less|styl|pcss|vue|svelte|astro|html?)$/i
27
+ const THEME_FILE = /(^|[\\/])(tailwind\.config\.[cm]?[jt]s|[\w.-]*theme[\w.-]*\.[cm]?[jt]s|tokens?\.[cm]?[jt]s|colors?\.[cm]?[jt]s)$/i
28
+ const SKIP_DIR = /^(node_modules|\.git|dist|build|out|\.next|\.nuxt|\.output|\.svelte-kit|\.cache|\.turbo|coverage|vendor|exports|refs|handoff|drafts)$|^\./
29
+ const MAX_SCAN_FILES = 1500
30
+ const MAX_SCAN_BYTES = 400 * 1024
31
+ const CJK_SANS = '"PingFang SC", "HarmonyOS Sans SC", "Microsoft YaHei", system-ui, sans-serif'
32
+ const CJK_SERIF = '"Songti SC", "Noto Serif SC", "Source Han Serif SC", serif'
33
+ const HAS_CJK_FONT = /PingFang|YaHei|Hiragino Sans GB|Noto (Sans|Serif) (SC|CJK)|Source Han|HarmonyOS|MiSans|Songti|SimSun|SimHei/i
34
+
35
+ // ── 颜色:CSS 写法 ↔ sRGB ↔ OKLab,对比度 ────────────────────────────────────────
36
+
37
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))
38
+ const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
39
+ const gam = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
40
+
41
+ export function toOklab([r, g, b]) {
42
+ const R = lin(r / 255), G = lin(g / 255), B = lin(b / 255)
43
+ const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B)
44
+ const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B)
45
+ const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B)
46
+ return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s]
47
+ }
48
+
49
+ export function fromOklab([L, a, b]) {
50
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
51
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
52
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
53
+ const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]
54
+ return rgb.map((c) => Math.round(clamp(gam(clamp(c, 0, 1)), 0, 1) * 255))
55
+ }
56
+
57
+ /** { L, C, H }:明度 0–1、彩度、色相(度) */
58
+ export function lch(rgb) {
59
+ const [L, a, b] = toOklab(rgb)
60
+ return { L, C: Math.hypot(a, b), H: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 }
61
+ }
62
+
63
+ /** 两个颜色差多少(OKLab 距离 × 100,差 2 以内肉眼几乎分不出) */
64
+ export function deltaE(a, b) {
65
+ const x = toOklab(a), y = toOklab(b)
66
+ return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]) * 100
67
+ }
68
+
69
+ const lum = ([r, g, b]) => 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255)
70
+ export function contrast(a, b) {
71
+ const x = lum(a), y = lum(b)
72
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
73
+ }
74
+
75
+ export const hexOf = (c) => '#' + c.slice(0, 3).map((v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0')).join('')
76
+
77
+ /** 两个颜色按 t(0–1,t 是 b 的份量)在 OKLab 里混 */
78
+ export function mix(a, b, t) {
79
+ const x = toOklab(a), y = toOklab(b)
80
+ return fromOklab(x.map((v, i) => v + (y[i] - v) * t))
81
+ }
82
+
83
+ function hsl2rgb(h, s, l) {
84
+ s = clamp(s, 0, 100) / 100
85
+ l = clamp(l, 0, 100) / 100
86
+ const k = (n) => (n + h / 30) % 12
87
+ const a = s * Math.min(l, 1 - l)
88
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
89
+ return [f(0), f(8), f(4)].map((x) => Math.round(x * 255))
90
+ }
91
+
92
+ const NAMED = { white: [255, 255, 255], black: [0, 0, 0], red: [255, 0, 0], blue: [0, 0, 255], green: [0, 128, 0], gray: [128, 128, 128], grey: [128, 128, 128], orange: [255, 165, 0] }
93
+
94
+ /** CSS 颜色 → [r, g, b, a](a 0–1);认不出、透明给 null。也认 shadcn 那种「222.2 47.4% 11.2%」的 HSL 三元组 */
95
+ export function parseColor(input) {
96
+ const s = String(input == null ? '' : input).trim().toLowerCase()
97
+ if (!s || s === 'transparent' || s === 'none' || s === 'currentcolor' || s === 'inherit') return null
98
+ if (NAMED[s]) return [...NAMED[s], 1]
99
+ let m = /^#([0-9a-f]{3,8})$/.exec(s)
100
+ if (m) {
101
+ const h = m[1]
102
+ if (h.length === 3 || h.length === 4) {
103
+ const v = [...h].map((x) => parseInt(x + x, 16))
104
+ return [v[0], v[1], v[2], h.length === 4 ? Math.round((v[3] / 255) * 100) / 100 : 1]
105
+ }
106
+ if (h.length === 6 || h.length === 8) {
107
+ const v = [0, 2, 4, 6].map((i) => parseInt(h.slice(i, i + 2), 16))
108
+ return [v[0], v[1], v[2], h.length === 8 ? Math.round((v[3] / 255) * 100) / 100 : 1]
109
+ }
110
+ return null
111
+ }
112
+ const parts = (body) => body.split(/[\s,/]+/).filter(Boolean)
113
+ const alpha = (v) => (v == null ? 1 : clamp(/%$/.test(v) ? parseFloat(v) / 100 : parseFloat(v), 0, 1))
114
+ m = /^rgba?\(([^)]*)\)$/.exec(s)
115
+ if (m) {
116
+ const p = parts(m[1])
117
+ if (p.length < 3) return null
118
+ const ch = (v) => clamp(/%$/.test(v) ? (parseFloat(v) * 255) / 100 : parseFloat(v), 0, 255)
119
+ const v = p.slice(0, 3).map(ch)
120
+ if (v.some((x) => !Number.isFinite(x))) return null
121
+ const a = alpha(p[3])
122
+ return a > 0 ? [...v.map(Math.round), a] : null
123
+ }
124
+ const hue = (v) => (/turn$/.test(v) ? parseFloat(v) * 360 : /rad$/.test(v) ? (parseFloat(v) * 180) / Math.PI : parseFloat(v))
125
+ m = /^hsla?\(([^)]*)\)$/.exec(s)
126
+ if (m) {
127
+ const p = parts(m[1])
128
+ if (p.length < 3) return null
129
+ const v = hsl2rgb(hue(p[0]), parseFloat(p[1]), parseFloat(p[2]))
130
+ if (v.some((x) => !Number.isFinite(x))) return null
131
+ const a = alpha(p[3])
132
+ return a > 0 ? [...v, a] : null
133
+ }
134
+ m = /^oklch\(([^)]*)\)$/.exec(s)
135
+ if (m) {
136
+ const p = parts(m[1])
137
+ if (p.length < 3) return null
138
+ const L = /%$/.test(p[0]) ? parseFloat(p[0]) / 100 : parseFloat(p[0])
139
+ const C = /%$/.test(p[1]) ? (parseFloat(p[1]) / 100) * 0.4 : parseFloat(p[1])
140
+ const H = p[2] === 'none' ? 0 : (hue(p[2]) * Math.PI) / 180
141
+ if (![L, C, H].every(Number.isFinite)) return null
142
+ const a = alpha(p[3])
143
+ return a > 0 ? [...fromOklab([L, C * Math.cos(H), C * Math.sin(H)]), a] : null
144
+ }
145
+ m = /^(-?[\d.]+)(deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+%?))?$/.exec(s)
146
+ if (m) {
147
+ const v = hsl2rgb(parseFloat(m[1]), parseFloat(m[3]), parseFloat(m[4]))
148
+ return v.every(Number.isFinite) ? [...v, alpha(m[5])] : null
149
+ }
150
+ return null
151
+ }
152
+
153
+ // ── 在页面里量(无头浏览器里跑,自成一体,不能引用外面的东西) ─────────────────────────
154
+
155
+ /* eslint-disable no-undef */
156
+ function collect() {
157
+ const W = innerWidth, H = Math.max(1, innerHeight)
158
+ const cv = document.createElement('canvas')
159
+ cv.width = cv.height = 1
160
+ const g = cv.getContext('2d', { willReadFrequently: true })
161
+ const memo = new Map()
162
+ // 任何 CSS 颜色(含 oklch、color-mix)都画一个像素读回来,统一成 sRGB
163
+ const col = (c) => {
164
+ if (!c || c === 'transparent' || c === 'none') return null
165
+ if (memo.has(c)) return memo.get(c)
166
+ let v = null
167
+ g.fillStyle = '#010203'
168
+ g.fillStyle = c
169
+ if (String(g.fillStyle) !== '#010203' || /^#010203$/i.test(c)) {
170
+ g.clearRect(0, 0, 1, 1)
171
+ g.fillRect(0, 0, 1, 1)
172
+ const d = g.getImageData(0, 0, 1, 1).data
173
+ if (d[3] > 12) v = [d[0], d[1], d[2], Math.round(d[3] / 2.55) / 100]
174
+ }
175
+ memo.set(c, v)
176
+ return v
177
+ }
178
+ const px = (v) => parseFloat(v) || 0
179
+ const own = (el) => {
180
+ let s = ''
181
+ for (const n of el.childNodes) if (n.nodeType === 3) s += n.nodeValue
182
+ return s.replace(/\s+/g, ' ').trim()
183
+ }
184
+ const keyOf = (c) => (c ? c.join(',') : '')
185
+ const bump = (m, k, w, extra) => {
186
+ if (!k) return
187
+ const e = m.get(k) || { w: 0, ...(extra || {}) }
188
+ e.w += w
189
+ m.set(k, e)
190
+ }
191
+ const vis = (el, s) => s.display !== 'none' && s.visibility !== 'hidden' && +s.opacity > 0.05
192
+ const sy = scrollY, sx = scrollX
193
+ const bgOf = (el) => {
194
+ for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
195
+ const c = col(getComputedStyle(e).backgroundColor)
196
+ if (c && c[3] > 0.5) return c
197
+ }
198
+ return col(getComputedStyle(document.documentElement).backgroundColor) || [255, 255, 255, 1]
199
+ }
200
+ const bgs = new Map(), texts = new Map(), borders = new Map(), sizes = new Map(), fams = new Map(), radii = new Map(), shadows = new Map(), links = new Map()
201
+ const all = document.body ? [...document.body.querySelectorAll('*')].slice(0, 6000) : []
202
+ const SKIP = /^(script|style|noscript|template|link|meta|br|wbr|title|head|path|g|defs|use|symbol|clippath|mask|lineargradient|radialgradient|stop|desc|circle|rect|line|polyline|polygon|ellipse|text|tspan)$/
203
+ const pageBg = bgOf(document.body || document.documentElement)
204
+ bump(bgs, keyOf(pageBg), 3, { c: pageBg, page: true })
205
+ for (const el of all) {
206
+ const tag = el.tagName.toLowerCase()
207
+ if (SKIP.test(tag)) continue
208
+ const s = getComputedStyle(el)
209
+ if (!vis(el, s)) continue
210
+ const r = el.getBoundingClientRect()
211
+ if (r.width < 1 || r.height < 1 || r.top + sy > 6 * H) continue
212
+ const a = (Math.min(r.width, W) * Math.min(r.height, 2 * H)) / (W * H)
213
+ const bg = col(s.backgroundColor)
214
+ if (bg && bg[3] > 0.5) bump(bgs, keyOf(bg), a, { c: bg, page: r.width >= W * 0.9 && r.height >= H * 0.25 })
215
+ const t = tag === 'svg' ? '' : own(el)
216
+ if (t) {
217
+ const fs = px(s.fontSize)
218
+ const w = (Math.min(t.length, 400) * fs) / 16
219
+ const c = col(s.color)
220
+ const head = /^h[1-3]$/.test(tag) || fs >= 28
221
+ if (c) bump(texts, keyOf(c), w, { c, head: 0 })
222
+ if (c && head) texts.get(keyOf(c)).head += w
223
+ bump(sizes, String(Math.round(fs)), w)
224
+ bump(fams, s.fontFamily, w, { head: 0 })
225
+ if (head) fams.get(s.fontFamily).head += w
226
+ }
227
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
228
+ if (px(s[`border${side}Width`]) < 1 || s[`border${side}Style`] === 'none') continue
229
+ const c = col(s[`border${side}Color`])
230
+ if (c) bump(borders, keyOf(c), side === 'Top' || side === 'Bottom' ? r.width : r.height, { c })
231
+ }
232
+ const rd = px(s.borderTopLeftRadius)
233
+ if (rd > 0 && tag !== 'svg' && tag !== 'img') bump(radii, String(Math.round(rd) >= Math.min(r.width, r.height) / 2 - 1 ? 999 : Math.round(rd)), 1)
234
+ if (s.boxShadow && s.boxShadow !== 'none') bump(shadows, s.boxShadow, 1)
235
+ }
236
+ const spec = (el) => {
237
+ const s = getComputedStyle(el)
238
+ const r = el.getBoundingClientRect()
239
+ const bw = px(s.borderTopWidth)
240
+ return {
241
+ bg: col(s.backgroundColor), fg: col(s.color), bd: bw >= 1 && s.borderTopStyle !== 'none' ? col(s.borderTopColor) : null, bw: Math.round(bw),
242
+ radius: Math.round(px(s.borderTopLeftRadius)), h: Math.round(r.height), w: Math.round(r.width),
243
+ py: Math.round(px(s.paddingTop)), px: Math.round(px(s.paddingLeft)), fs: Math.round(px(s.fontSize)), fw: String(s.fontWeight), ff: s.fontFamily,
244
+ tt: s.textTransform === 'none' ? '' : s.textTransform, ls: s.letterSpacing === 'normal' ? '' : s.letterSpacing,
245
+ shadow: s.boxShadow === 'none' ? '' : s.boxShadow, grad: /gradient/.test(s.backgroundImage), parent: bgOf(el.parentElement || el),
246
+ text: String(el.innerText || el.value || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim().slice(0, 24),
247
+ top: Math.round(r.top + sy),
248
+ }
249
+ }
250
+ const reps = []
251
+ const group = (els, sig, max) => {
252
+ const m = new Map()
253
+ for (const el of els) {
254
+ const s = getComputedStyle(el)
255
+ const r = el.getBoundingClientRect()
256
+ if (!vis(el, s) || r.width < 8 || r.height < 8 || r.top + sy > 6 * H) continue
257
+ const sp = spec(el)
258
+ const k = sig(sp)
259
+ const e = m.get(k)
260
+ if (e) { e.n++; if (sp.top < e.spec.top) { e.spec = sp; reps[e.rep] = el } } else {
261
+ reps.push(el)
262
+ m.set(k, { n: 1, spec: sp, rep: reps.length - 1 })
263
+ }
264
+ }
265
+ return [...m.values()].sort((a, b) => b.n - a.n).slice(0, max)
266
+ }
267
+ const same = (a, b) => a && b && Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) < 9
268
+ const BTN = 'button, [role=button], input[type=submit], input[type=button], a[class*=btn], a[class*=button], a[class*=Btn], a[class*=Button], a[class*=cta]'
269
+ const btnEls = new Set([...document.querySelectorAll(BTN)].slice(0, 200))
270
+ // 没写成按钮的「按钮」:有底色、有内边距的链接
271
+ for (const a of [...document.querySelectorAll('a')].slice(0, 400)) {
272
+ const s = getComputedStyle(a)
273
+ const c = col(s.backgroundColor)
274
+ if ((c && c[3] > 0.5 && !same(c, bgOf(a.parentElement || a))) || (px(s.borderTopWidth) >= 1 && px(s.paddingLeft) >= 10 && px(s.borderTopLeftRadius) > 0)) {
275
+ if (px(s.paddingLeft) >= 8) btnEls.add(a)
276
+ }
277
+ }
278
+ const buttons = group([...btnEls], (p) => [keyOf(p.bg && p.bg[3] > 0.5 && !same(p.bg, p.parent) ? p.bg : null), keyOf(p.fg), keyOf(p.bd), p.radius, Math.round(p.h / 6)].join('|'), 6)
279
+ const inputs = group([...document.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]):not([type=range]):not([type=file]), textarea, select')].slice(0, 80),
280
+ (p) => [keyOf(p.bg), keyOf(p.bd), p.radius, Math.round(p.h / 6)].join('|'), 3)
281
+ // 卡片:和外面底色不一样(或带阴影 / 一圈边框)、有圆角、不太大不太小、里面有字
282
+ const cardEls = []
283
+ for (const el of all) {
284
+ if (cardEls.length >= 300) break
285
+ if (btnEls.has(el) || /^(input|textarea|select|button|img|svg|a|span|label|li|ul|ol|p|h\d|header|nav|footer|html|body)$/.test(el.tagName.toLowerCase())) continue
286
+ const s = getComputedStyle(el)
287
+ if (!vis(el, s) || px(s.borderTopLeftRadius) < 2) continue
288
+ const r = el.getBoundingClientRect()
289
+ if (r.width < 120 || r.width > W * 0.96 || r.height < 60 || r.height > H * 1.6) continue
290
+ const c = col(s.backgroundColor)
291
+ const lifted = (c && c[3] > 0.5 && !same(c, bgOf(el.parentElement || el))) || (s.boxShadow && s.boxShadow !== 'none') || (px(s.borderTopWidth) >= 1 && px(s.borderBottomWidth) >= 1)
292
+ if (lifted && String(el.innerText || '').trim().length >= 8) cardEls.push(el)
293
+ }
294
+ const cards = group(cardEls, (p) => [keyOf(p.bg), p.radius, p.shadow.slice(0, 40), keyOf(p.bd)].join('|'), 3)
295
+ for (const a of [...document.querySelectorAll('a')].slice(0, 400)) {
296
+ if (btnEls.has(a)) continue
297
+ const s = getComputedStyle(a)
298
+ const r = a.getBoundingClientRect()
299
+ if (!vis(a, s) || r.width < 1 || !own(a)) continue
300
+ const c = col(s.color)
301
+ if (c) bump(links, keyOf(c), 1, { c, line: /underline/.test(s.textDecorationLine) ? 1 : 0, fw: s.fontWeight })
302
+ }
303
+ let header = null
304
+ for (const el of document.querySelectorAll('header, [role=banner], nav')) {
305
+ const s = getComputedStyle(el)
306
+ const r = el.getBoundingClientRect()
307
+ if (!vis(el, s) || r.height < 24 || r.top + sy > 240 || r.width < W * 0.6) continue
308
+ const bw = px(s.borderBottomWidth)
309
+ header = { h: Math.round(r.height), bg: bgOf(el), pos: s.position, line: bw >= 1 ? col(s.borderBottomColor) : null, shadow: s.boxShadow === 'none' ? '' : s.boxShadow, blur: /blur/.test(s.backdropFilter || s.webkitBackdropFilter || '') }
310
+ reps.push(el)
311
+ header.rep = reps.length - 1
312
+ break
313
+ }
314
+ const heads = {}
315
+ for (const tg of ['h1', 'h2', 'h3']) {
316
+ const el = [...document.querySelectorAll(tg)].find((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && own(e) })
317
+ if (!el) continue
318
+ const s = getComputedStyle(el)
319
+ heads[tg] = { ff: s.fontFamily, fs: Math.round(px(s.fontSize)), fw: String(s.fontWeight), lh: s.lineHeight, ls: s.letterSpacing === 'normal' ? '' : s.letterSpacing, c: col(s.color), tt: s.textTransform === 'none' ? '' : s.textTransform }
320
+ }
321
+ let body = null
322
+ let most = 0
323
+ for (const el of [...document.querySelectorAll('p, li')].slice(0, 300)) {
324
+ const t = own(el)
325
+ if (t.length <= most) continue
326
+ const s = getComputedStyle(el)
327
+ if (!vis(el, s)) continue
328
+ most = t.length
329
+ body = { ff: s.fontFamily, fs: Math.round(px(s.fontSize)), fw: String(s.fontWeight), lh: s.lineHeight, c: col(s.color) }
330
+ }
331
+ let logo = null
332
+ for (const el of document.querySelectorAll('header img, header svg, [class*=logo] img, [class*=logo] svg, [id*=logo] img, [id*=logo] svg, img[alt*=logo i], a[class*=logo], [class*=brand] img, [class*=brand] svg')) {
333
+ const r = el.getBoundingClientRect()
334
+ if (r.width < 12 || r.height < 8 || r.top + sy > 300) continue
335
+ reps.push(el)
336
+ logo = { rep: reps.length - 1, w: Math.round(r.width), h: Math.round(r.height) }
337
+ break
338
+ }
339
+ const vars = []
340
+ const seen = new Set()
341
+ const rootCs = getComputedStyle(document.documentElement)
342
+ let dark = false
343
+ const scan = (rules) => {
344
+ for (const r of rules) {
345
+ if (r.media && /prefers-color-scheme\s*:\s*dark/i.test(r.media.mediaText || '')) { dark = true; continue }
346
+ if (r.cssRules && !r.selectorText) { try { scan(r.cssRules) } catch { /* 读不了 */ } continue }
347
+ if (!r.style || !r.selectorText) continue
348
+ if (/\[data-theme=["']?dark|\.dark\b/.test(r.selectorText)) dark = true
349
+ if (!/(^|,)\s*(:root|html)\s*(,|$)/.test(r.selectorText)) continue
350
+ for (let i = 0; i < r.style.length && vars.length < 200; i++) {
351
+ const n = r.style[i]
352
+ if (!n.startsWith('--') || seen.has(n)) continue
353
+ seen.add(n)
354
+ const v = (rootCs.getPropertyValue(n) || r.style.getPropertyValue(n)).trim()
355
+ if (v && v.length <= 120) vars.push([n, v])
356
+ }
357
+ }
358
+ }
359
+ for (const sheet of document.styleSheets) { try { scan(sheet.cssRules) } catch { /* 别的源的样式表读不了 */ } }
360
+ const fontsLoaded = [...new Set([...(document.fonts || [])].filter((f) => f.status === 'loaded').map((f) => f.family.replace(/^["']|["']$/g, '')))].slice(0, 12)
361
+ const fontLinks = [...document.querySelectorAll('link[rel=stylesheet][href]')].map((l) => l.href).filter((h) => /fonts\.(googleapis|loli|font\.im)|typekit|fonts\.bunny|fontshare/i.test(h)).slice(0, 4)
362
+ const tc = document.querySelector('meta[name=theme-color]')
363
+ // 品牌名:og:site_name / application-name,或者标志上的字
364
+ const metaName = document.querySelector('meta[property="og:site_name"], meta[name="application-name"]')
365
+ let brand = metaName ? String(metaName.getAttribute('content') || '').trim().slice(0, 30) : ''
366
+ if (!brand) {
367
+ for (const el of document.querySelectorAll('[class*=logo], [id*=logo], [class*=brand], header a[href="/"], header a[href="./"]')) {
368
+ const t = String(el.innerText || el.getAttribute('aria-label') || (el.querySelector('img[alt]') || {}).alt || '').replace(/\s+/g, ' ').trim()
369
+ if (t && t.length <= 24 && !/logo/i.test(t)) { brand = t; break }
370
+ }
371
+ }
372
+ window.__dsx = reps
373
+ const list = (m) => [...m.values()].sort((a, b) => b.w - a.w).slice(0, 24)
374
+ return {
375
+ url: location.href, title: document.title, lang: document.documentElement.lang || '', w: W, h: H,
376
+ page: Math.max(document.documentElement.scrollHeight, H), n: all.length, pageBg,
377
+ bgs: list(bgs), texts: list(texts), borders: list(borders),
378
+ sizes: [...sizes].map(([k, v]) => [+k, v.w]).sort((a, b) => b[1] - a[1]).slice(0, 30),
379
+ fams: [...fams].map(([k, v]) => ({ ff: k, w: v.w, head: v.head })).sort((a, b) => b.w - a.w).slice(0, 8),
380
+ radii: [...radii].map(([k, v]) => [+k, v.w]).sort((a, b) => b[1] - a[1]).slice(0, 12),
381
+ shadows: [...shadows].map(([k, v]) => [k, v.w]).sort((a, b) => b[1] - a[1]).slice(0, 6),
382
+ links: list(links).slice(0, 6), buttons, inputs, cards, header, heads, body, logo, vars, dark, brand, bodyColor: col(getComputedStyle(document.body).color),
383
+ headerText: String((document.querySelector('header, [role=banner]') || {}).innerText || '').replace(/\s+/g, ' ').slice(0, 200),
384
+ fontsLoaded, fontLinks, themeColor: tc ? String(tc.getAttribute('content') || '') : '',
385
+ }
386
+ }
387
+ /* eslint-enable no-undef */
388
+
389
+ const COLLECT = `(async () => {
390
+ try { await Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 3000))]) } catch (e) { /* 没有字体接口 */ }
391
+ await new Promise((r) => setTimeout(r, 500))
392
+ return (${collect})()
393
+ })()`
394
+
395
+ // 截一个量到的元素(按钮、卡片……):滚到屏幕中间,四周留 12px
396
+ const CROP = (i) => `(() => {
397
+ const el = (window.__dsx || [])[${i}]
398
+ if (!el) return null
399
+ el.scrollIntoView({ block: 'center', inline: 'nearest' })
400
+ const r = el.getBoundingClientRect()
401
+ const pad = 12
402
+ const x0 = Math.max(0, r.left - pad), y0 = Math.max(0, r.top - pad)
403
+ const x1 = Math.min(innerWidth, r.right + pad), y1 = Math.min(innerHeight, r.bottom + pad)
404
+ if (x1 - x0 < 4 || y1 - y0 < 4) return null
405
+ return { x: x0 + scrollX, y: y0 + scrollY, width: x1 - x0, height: y1 - y0 }
406
+ })()`
407
+
408
+ /** 开一页量:样式数据、顶上一屏的截图、几个组件的截图 */
409
+ async function renderPage(browser, url, label) {
410
+ return browser.withPage({ url, w: 1280, h: 800 }, async (page) => {
411
+ const data = await page.eval(COLLECT)
412
+ const shots = [{ key: `${label}-top`, label: `${label} 顶上一屏`, jpg: await page.shot() }]
413
+ const picks = []
414
+ const btn = data.buttons.find((b) => b.spec.bg && b.spec.bg[3] > 0.5) || data.buttons[0]
415
+ if (btn) picks.push(['button', btn.rep])
416
+ const btn2 = data.buttons.find((b) => b !== btn)
417
+ if (btn2) picks.push(['button-2', btn2.rep])
418
+ if (data.cards[0]) picks.push(['card', data.cards[0].rep])
419
+ if (data.inputs[0]) picks.push(['input', data.inputs[0].rep])
420
+ if (data.logo) picks.unshift(['logo', data.logo.rep])
421
+ for (const [what, i] of picks.slice(0, MAX_CROPS)) {
422
+ try {
423
+ const clip = await page.eval(CROP(i))
424
+ if (clip) shots.push({ key: `${label}-${what}`, label: `${label} ${CROP_NAME[what]}`, jpg: await page.shot({ clip }), what })
425
+ } catch { /* 截不了就算了 */ }
426
+ }
427
+ return { url, label, data, shots }
428
+ })
429
+ }
430
+
431
+ const CROP_NAME = { logo: '标志', button: '主按钮', 'button-2': '另一种按钮', card: '卡片', input: '输入框' }
432
+
433
+ // ── 扫项目文件夹(不开浏览器也有的东西) ──────────────────────────────────────
434
+
435
+ const LIBS = [
436
+ ['tailwindcss', 'Tailwind CSS'], ['antd', 'Ant Design'], ['@mui/material', 'MUI'], ['element-plus', 'Element Plus'], ['element-ui', 'Element UI'],
437
+ ['vant', 'Vant'], ['@chakra-ui/react', 'Chakra UI'], ['bootstrap', 'Bootstrap'], ['@arco-design/web-react', 'Arco Design'], ['@arco-design/web-vue', 'Arco Design'],
438
+ ['naive-ui', 'Naive UI'], ['@nextui-org/react', 'NextUI'], ['@radix-ui/themes', 'Radix Themes'], ['@mantine/core', 'Mantine'], ['tdesign-react', 'TDesign'], ['tdesign-vue-next', 'TDesign'],
439
+ ['vuetify', 'Vuetify'], ['quasar', 'Quasar'], ['daisyui', 'daisyUI'], ['@headlessui/react', 'Headless UI'], ['styled-components', 'styled-components'], ['@emotion/react', 'Emotion'],
440
+ ]
441
+
442
+ /** 项目里的样式:CSS 变量、写死的颜色、字体、圆角(按出现次数)、Tailwind 主题、组件库、能直接打开的 HTML */
443
+ export function scanProject(root) {
444
+ const out = { root, files: 0, vars: new Map(), colors: new Map(), fonts: new Map(), radii: new Map(), libs: [], tailwind: '', entries: [], fontLinks: new Set(), name: '' }
445
+ const files = []
446
+ const walk = (dir, depth) => {
447
+ if (depth > 6 || files.length >= MAX_SCAN_FILES) return
448
+ let ents
449
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
450
+ for (const e of ents) {
451
+ if (files.length >= MAX_SCAN_FILES) return
452
+ const p = path.join(dir, e.name)
453
+ if (e.isDirectory()) { if (!SKIP_DIR.test(e.name)) walk(p, depth + 1) } else if (e.isFile() && (SCAN_EXT.test(e.name) || THEME_FILE.test(p) || (depth === 0 && (e.name === 'package.json' || e.name === 'components.json')))) files.push({ p, depth })
454
+ }
455
+ }
456
+ walk(root, 0)
457
+ const bump = (m, k, extra) => { const e = m.get(k) || { n: 0, ...(extra || {}) }; e.n++; m.set(k, e) }
458
+ for (const { p, depth } of files) {
459
+ let text
460
+ try {
461
+ if (fs.statSync(p).size > MAX_SCAN_BYTES) continue
462
+ text = fs.readFileSync(p, 'utf8')
463
+ } catch { continue }
464
+ out.files++
465
+ const base = path.basename(p)
466
+ if (base === 'package.json') {
467
+ try {
468
+ const pkg = JSON.parse(text)
469
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
470
+ out.libs = LIBS.filter(([k]) => deps[k]).map(([, v]) => v).filter((v, i, a) => a.indexOf(v) === i)
471
+ if (typeof pkg.name === 'string' && depth === 0) out.name = pkg.name.replace(/^@[^/]+\//, '')
472
+ } catch { /* 不是 JSON */ }
473
+ continue
474
+ }
475
+ if (base === 'components.json') { if (/shadcn|"style"\s*:/.test(text) && !out.libs.includes('shadcn/ui')) out.libs.push('shadcn/ui'); continue }
476
+ if (/^tailwind\.config\./.test(base)) {
477
+ const i = text.search(/\btheme\s*:/)
478
+ out.tailwind = (i >= 0 ? text.slice(i) : text).slice(0, 3000)
479
+ }
480
+ if (/\.html?$/i.test(base)) {
481
+ const bodyText = text.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
482
+ if (depth <= 2 && bodyText.length >= 160) out.entries.push(p)
483
+ for (const m of text.matchAll(/href=["'](https?:\/\/fonts\.(?:googleapis|loli)\.[^"']+)["']/g)) out.fontLinks.add(m[1].replace(/&amp;/g, '&'))
484
+ }
485
+ for (const m of text.matchAll(/(--[A-Za-z0-9_-]+)\s*:\s*([^;{}\n]+?)\s*(?:;|}|\n)/g)) {
486
+ const v = m[2].trim()
487
+ if (v.length <= 120 && !/^var\(/.test(v)) bump(out.vars, m[1], { v })
488
+ }
489
+ for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?|oklch)\([^)]{4,60}\)/g)) {
490
+ const c = parseColor(m[0])
491
+ if (c && c[3] > 0.5) bump(out.colors, hexOf(c), { c })
492
+ }
493
+ for (const m of text.matchAll(/font-family\s*:\s*([^;}\n]+)/gi)) bump(out.fonts, m[1].trim())
494
+ for (const m of text.matchAll(/border-radius\s*:\s*([\d.]+)(px|rem)\b/gi)) bump(out.radii, String(Math.round(parseFloat(m[1]) * (m[2].toLowerCase() === 'rem' ? 16 : 1))))
495
+ }
496
+ out.fontLinks = [...out.fontLinks]
497
+ return out
498
+ }
499
+
500
+ // ── 归成角色 ──────────────────────────────────────────────────────────────
501
+
502
+ /** 颜色按「看起来是同一个」归堆:权重大的先占位,差不到 3.5 的并进去 */
503
+ function clusters(entries) {
504
+ const out = []
505
+ for (const e of [...entries].sort((a, b) => b.w - a.w)) {
506
+ if (!e.c || e.w <= 0) continue
507
+ const hit = out.find((k) => deltaE(k.c, e.c) < 3.5)
508
+ if (hit) {
509
+ hit.w += e.w
510
+ for (const [k, v] of Object.entries(e.as || {})) hit.as[k] = (hit.as[k] || 0) + v
511
+ } else out.push({ c: e.c.slice(0, 3), w: e.w, as: { ...(e.as || {}) } })
512
+ }
513
+ return out
514
+ }
515
+
516
+ const NAME_HINT = {
517
+ primary: /^--(?:color-)?(?:primary|brand)(?:-color|-500|-600|-base|-main)?$/,
518
+ bg: /^--(?:color-)?(?:background|bg|body-bg|page-bg|bg-page|base-100)$/,
519
+ text: /^--(?:color-)?(?:foreground|text|body-color|text-color|fg|base-content)$/,
520
+ line: /^--(?:color-)?(?:border|line|divider|border-color)$/,
521
+ }
522
+
523
+ /** 网站 / 项目里按名字就能认出角色的变量(--primary、--background …)→ { 角色: [r, g, b] } */
524
+ function hinted(vars) {
525
+ const out = {}
526
+ for (const [n, v] of vars) {
527
+ for (const [role, re] of Object.entries(NAME_HINT)) {
528
+ if (out[role] || !re.test(n.toLowerCase())) continue
529
+ const c = parseColor(v)
530
+ if (c && c[3] > 0.8) out[role] = c.slice(0, 3)
531
+ }
532
+ }
533
+ return out
534
+ }
535
+
536
+ const roundTo = (v, step = 1) => Math.round(v / step) * step
537
+ const familyOf = (ff) => String(ff || '').split(',')[0].trim().replace(/^["']|["']$/g, '')
538
+ const isSerif = (ff) => /(^|,)\s*serif\s*$/i.test(String(ff || '')) || /serif|song|ming|georgia|times|garamond|playfair|lora|merriweather|noto serif/i.test(familyOf(ff))
539
+
540
+ /** 字体栈:没有中文字体就补上(标题是衬线就补宋体) */
541
+ export function withCjk(ff) {
542
+ const s = String(ff || '').trim().replace(/\s*,\s*/g, ', ')
543
+ if (!s) return ''
544
+ if (HAS_CJK_FONT.test(s)) return s
545
+ const serif = isSerif(s)
546
+ return s.replace(/,?\s*(sans-serif|serif|system-ui|-apple-system|BlinkMacSystemFont)(,\s*(sans-serif|serif|system-ui|-apple-system|BlinkMacSystemFont))*\s*$/i, '') + ', ' + (serif ? CJK_SERIF : CJK_SANS)
547
+ }
548
+
549
+ const TEXT_STEPS = [['xs', 12], ['sm', 14], ['md', 16], ['lg', 20], ['xl', 24], ['2xl', 32], ['3xl', 40], ['4xl', 56]]
550
+
551
+ /** 量到的字号(字号 → 权重)+ 正文字号 → 八档字号阶梯:每档挑离模板比例最近的真实字号(差 12% 以内),没有就按比例算 */
552
+ export function typeScale(sizes, base = 16, display = 0) {
553
+ const md = clamp(Math.round(base) || 16, 13, 20)
554
+ const total = sizes.reduce((m, [, w]) => m + w, 0) || 1
555
+ const real = sizes.filter(([, w]) => w / total >= 0.01).map(([s]) => s)
556
+ const out = {}
557
+ let prev = 0
558
+ for (const [name, t] of TEXT_STEPS) {
559
+ const want = Math.round((t * md) / 16)
560
+ let v = name === 'md' ? md : real.filter((s) => Math.abs(s - want) / want <= 0.12).sort((a, b) => Math.abs(a - want) - Math.abs(b - want))[0] || want
561
+ if (v <= prev) v = prev + (prev < 20 ? 1 : 2)
562
+ out[name] = v
563
+ prev = v
564
+ }
565
+ if (display > out['4xl']) out['4xl'] = display
566
+ return out
567
+ }
568
+
569
+ /** 计算样式里的阴影 'rgba(0, 0, 0, 0.1) 0px 4px 12px 0px' → 模糊半径 */
570
+ const blurOf = (sh) => { const m = /(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/.exec(sh); return m ? +m[3] : 0 }
571
+
572
+ /**
573
+ * 量到的数 → 一套设计系统。
574
+ * pages:页面里量到的(collect 的结果);scan:扫项目的结果(可以没有)
575
+ */
576
+ export function synthesize(pages = [], scan = null, opts = {}) {
577
+ const notes = []
578
+ const entries = []
579
+ const add = (c, w, as) => { if (c && c[3] > 0.85 && w > 0) entries.push({ c: c.slice(0, 3), w, as: { [as]: w } }) }
580
+ const vars = []
581
+ for (const p of pages) {
582
+ for (const b of p.bgs) add(b.c, b.w * (b.page ? 2 : 1), b.page ? 'page' : 'bg')
583
+ const tw = p.texts.reduce((m, x) => m + x.w, 0) || 1
584
+ for (const t of p.texts) add(t.c, (t.w / tw) * 1.5, 'text')
585
+ const bw = p.borders.reduce((m, x) => m + x.w, 0) || 1
586
+ for (const b of p.borders) add(b.c, (b.w / bw) * 0.3, 'border')
587
+ for (const b of p.buttons) {
588
+ const s = b.spec
589
+ if (s.bg && s.bg[3] > 0.5 && !(s.parent && deltaE(s.bg, s.parent) < 3)) add(s.bg, 0.4 * b.n, 'button')
590
+ if (s.fg) add(s.fg, 0.05 * b.n, 'buttonText')
591
+ }
592
+ for (const l of p.links) add(l.c, 0.08 * l.w, 'link')
593
+ vars.push(...p.vars)
594
+ }
595
+ if (scan) {
596
+ const total = [...scan.colors.values()].reduce((m, x) => m + x.n, 0) || 1
597
+ for (const e of scan.colors.values()) add(e.c, (e.n / total) * (pages.length ? 0.5 : 3), 'literal')
598
+ for (const [n, e] of scan.vars) vars.push([n, e.v])
599
+ }
600
+ const cl = clusters(entries)
601
+ const by = (as) => cl.filter((k) => k.as[as]).sort((a, b) => b.as[as] - a.as[as])
602
+ const hint = hinted(vars)
603
+ const C = (k) => k && k.c
604
+ const L = (c) => lch(c).L
605
+
606
+ // 背景:整块铺满的底色;没量到就看变量名,再不行白色
607
+ let bg = hint.bg || C(by('page')[0]) || C(by('bg')[0]) || C(by('literal').find((k) => lch(k.c).C < 0.03 && (L(k.c) > 0.9 || L(k.c) < 0.2))) || [255, 255, 255]
608
+ const darkBg = L(bg) < 0.5
609
+ // 正文:字最多的颜色(和背景对比要够),次要文字:另一个够得着的中性色
610
+ const texts = by('text').length ? by('text') : by('literal').filter((k) => contrast(k.c, bg) >= 4)
611
+ // 段落常用次要灰、标题和导航用深色:<body> 的颜色在常用的几种里、对比够就用它;不然取常用的中性色里对比最高的
612
+ const wOf = (k) => k.as.text || k.as.literal || 0
613
+ const topW = texts.length ? wOf(texts[0]) : 0
614
+ const usual = texts.filter((k) => wOf(k) >= topW * 0.12 && contrast(k.c, bg) >= 4.5)
615
+ const bodyC = pages.map((p) => p.bodyColor).find((c) => c && c[3] > 0.85)
616
+ const bodyHit = bodyC && usual.find((k) => deltaE(k.c, bodyC) < 3)
617
+ const darkest = usual.filter((k) => lch(k.c).C < 0.06).sort((a, b) => contrast(b.c, bg) - contrast(a.c, bg))[0]
618
+ let text = hint.text || C(bodyHit) || C(darkest) || C(texts.find((k) => contrast(k.c, bg) >= 3)) || (darkBg ? [240, 240, 240] : [24, 24, 27])
619
+ let muted = C(texts.find((k) => k.c !== text && deltaE(k.c, text) > 8 && lch(k.c).C < 0.08 && contrast(k.c, bg) >= 2 && contrast(k.c, bg) < contrast(text, bg)))
620
+ if (!muted) { muted = mix(text, bg, 0.38); notes.push('次要文字的颜色是按正文和背景混出来的(网站上没量到明显的第二种文字色)') }
621
+ if (contrast(muted, bg) < 4.5) notes.push(`次要文字 ${hexOf(muted)} 对背景只有 ${contrast(muted, bg).toFixed(1)}:1,小字(正文大小)别用它,至少要 4.5:1`)
622
+ // 表面:卡片、弹层的底;和背景接近但不一样的中性色
623
+ const surfaces = by('bg').filter((k) => deltaE(k.c, bg) > 1.5 && lch(k.c).C < 0.05 && Math.abs(L(k.c) - L(bg)) < 0.12)
624
+ const surface = C(surfaces[0]) || (darkBg ? mix(bg, [255, 255, 255], 0.06) : L(bg) > 0.97 ? [255, 255, 255] : mix(bg, [255, 255, 255], 0.6))
625
+ const surface2 = C(surfaces.find((k) => deltaE(k.c, surface) > 1.5)) || mix(bg, text, darkBg ? 0.08 : 0.04)
626
+ // 主色:最常见的实心按钮的底色;没有按钮就看变量名、链接色、最常见的彩色
627
+ const btns = by('button').filter((k) => deltaE(k.c, bg) > 6)
628
+ const chroma = (k) => lch(k.c).C
629
+ let primary = hint.primary || C(btns.find((k) => chroma(k) >= 0.04)) || C(btns[0]) ||
630
+ C(by('link').find((k) => chroma(k) >= 0.05)) ||
631
+ C(cl.filter((k) => chroma(k) >= 0.06 && deltaE(k.c, bg) > 6).sort((a, b) => b.w * chroma(b) - a.w * chroma(a))[0])
632
+ if (!primary) { primary = text; notes.push('网站基本没有彩色(黑白灰为主),主色就用了正文色:主按钮是深色实心的') }
633
+ // 主色上的字:主按钮上的字;没有就挑对比更高的黑 / 白
634
+ let ink = null
635
+ for (const p of pages) {
636
+ const b = p.buttons.find((x) => x.spec.bg && deltaE(x.spec.bg, primary) < 4)
637
+ if (b && b.spec.fg) { ink = b.spec.fg.slice(0, 3); break }
638
+ }
639
+ if (!ink || contrast(ink, primary) < 3) ink = contrast([255, 255, 255], primary) >= contrast([17, 17, 17], primary) ? [255, 255, 255] : [17, 17, 17]
640
+ if (contrast(ink, primary) < 4.5) notes.push(`主色上的字对比度只有 ${contrast(ink, primary).toFixed(1)}:1,主按钮的字用大一点、粗一点(≥ 18px 或 ≥ 14px 粗体)`)
641
+ // 点缀:和主色色相差得开的另一个彩色,少用
642
+ const hp = lch(primary).H
643
+ const accentK = cl.filter((k) => chroma(k) >= 0.06 && deltaE(k.c, primary) > 10 && deltaE(k.c, bg) > 6 && Math.min(Math.abs(lch(k.c).H - hp), 360 - Math.abs(lch(k.c).H - hp)) >= 35).sort((a, b) => b.w - a.w)[0]
644
+ const accent = C(accentK) || primary
645
+ // 边框:最常见的边框色(彩色的不算)
646
+ const line = hint.line || C(by('border').find((k) => chroma(k) < 0.06 && deltaE(k.c, bg) > 2)) || mix(bg, text, darkBg ? 0.16 : 0.1)
647
+ // 链接:和主色、正文都不一样就单列
648
+ let link = null
649
+ for (const p of pages) for (const l of p.links) if (!link && l.c && deltaE(l.c, primary) > 6 && deltaE(l.c, text) > 6 && chroma({ c: l.c }) >= 0.05) link = l.c.slice(0, 3)
650
+ const colors = { bg, surface, 'surface-2': surface2, text, muted, line, primary, 'primary-ink': ink, accent }
651
+ if (link) colors.link = link
652
+
653
+ // 用量:主色、点缀在页面上大概占多少(给 DESIGN.md)
654
+ const share = (c) => { const k = cl.find((x) => deltaE(x.c, c) < 3.5); const all = cl.reduce((m, x) => m + x.w, 0) || 1; return k ? Math.round((k.w / all) * 1000) / 10 : 0 }
655
+
656
+ // ── 字体 ──
657
+ const fams = new Map()
658
+ for (const p of pages) for (const f of p.fams) { const e = fams.get(f.ff) || { ff: f.ff, w: 0, head: 0 }; e.w += f.w; e.head += f.head; fams.set(f.ff, e) }
659
+ const famList = [...fams.values()]
660
+ let bodyFf = (pages.find((p) => p.body) || {}).body?.ff || famList.sort((a, b) => (b.w - b.head) - (a.w - a.head))[0]?.ff || ''
661
+ let headFf = (pages.find((p) => p.heads && p.heads.h1) || {}).heads?.h1?.ff || famList.sort((a, b) => b.head - a.head)[0]?.ff || bodyFf
662
+ if (!bodyFf && scan && scan.fonts.size) {
663
+ const top = [...scan.fonts].sort((a, b) => b[1].n - a[1].n)
664
+ bodyFf = top[0][0]
665
+ headFf = (top.find(([ff]) => isSerif(ff) !== isSerif(bodyFf)) || top[0])[0]
666
+ }
667
+ const sans = withCjk(bodyFf) || '-apple-system, BlinkMacSystemFont, ' + CJK_SANS
668
+ const display = familyOf(headFf) && familyOf(headFf) !== familyOf(bodyFf) ? withCjk(headFf) : ''
669
+ const loaded = [...new Set(pages.flatMap((p) => p.fontsLoaded || []))]
670
+ const fontLinks = [...new Set([...pages.flatMap((p) => p.fontLinks || []), ...((scan && scan.fontLinks) || [])])].slice(0, 3)
671
+ const named = [familyOf(bodyFf), familyOf(headFf)].filter(Boolean)
672
+ const selfHosted = named.filter((f) => loaded.includes(f) && !fontLinks.some((l) => l.toLowerCase().includes(f.toLowerCase().replace(/\s+/g, '+'))))
673
+ if (selfHosted.length) notes.push(`网站用了自己托管的字体「${[...new Set(selfHosted)].join('、')}」,设计稿里没有这个字体文件时会退回到后面的字体(要一模一样,把字体文件放进设计目录、加 @font-face)`)
674
+
675
+ // ── 字号 ──
676
+ const sizeW = new Map()
677
+ for (const p of pages) for (const [s, w] of p.sizes) sizeW.set(s, (sizeW.get(s) || 0) + w)
678
+ const bodyFs = (pages.find((p) => p.body) || {}).body?.fs || [...sizeW].sort((a, b) => b[1] - a[1]).map(([s]) => s).find((s) => s >= 13 && s <= 20) || 16
679
+ const h1 = (pages.find((p) => p.heads && p.heads.h1) || {}).heads?.h1 || null
680
+ const text_ = typeScale([...sizeW], bodyFs, h1 ? h1.fs : 0)
681
+ const heads = {}
682
+ for (const tg of ['h1', 'h2', 'h3']) { const h = (pages.find((p) => p.heads && p.heads[tg]) || {}).heads?.[tg]; if (h) heads[tg] = h }
683
+ const bodySpec = (pages.find((p) => p.body) || {}).body || null
684
+
685
+ // ── 圆角、阴影 ──
686
+ const firstSpec = (what) => { for (const p of pages) if (p[what] && p[what][0]) return p[what][0].spec; return null }
687
+ const btnSpec = (() => { for (const p of pages) { const b = p.buttons.find((x) => x.spec.bg && deltaE(x.spec.bg, primary) < 6) || p.buttons[0]; if (b) return b.spec } return null })()
688
+ const btn2Spec = (() => { for (const p of pages) { const b = p.buttons.find((x) => x.spec !== btnSpec && !(x.spec.bg && deltaE(x.spec.bg, primary) < 6)); if (b) return b.spec } return null })()
689
+ const inputSpec = firstSpec('inputs')
690
+ const cardSpec = firstSpec('cards')
691
+ const radW = new Map()
692
+ for (const p of pages) for (const [r, w] of p.radii) radW.set(r, (radW.get(r) || 0) + w)
693
+ if (scan) for (const [r, e] of scan.radii) radW.set(+r, (radW.get(+r) || 0) + e.n * 0.5)
694
+ const common = [...radW].filter(([r]) => r > 0 && r < 100).sort((a, b) => b[1] - a[1]).map(([r]) => r)
695
+ const pill = !!(btnSpec && btnSpec.radius >= btnSpec.h / 2 - 1)
696
+ const sharp = !!(btnSpec && btnSpec.radius === 0 && (!cardSpec || cardSpec.radius === 0))
697
+ let radius
698
+ if (sharp) radius = { sm: 0, md: 0, lg: 0, xl: 0 }
699
+ else {
700
+ const md = btnSpec && !pill && btnSpec.radius > 0 ? btnSpec.radius : common[0] || 10
701
+ const sm = inputSpec && inputSpec.radius > 0 && inputSpec.radius <= md ? inputSpec.radius : Math.min(md, [...common].sort((a, b) => a - b)[0] || Math.round(md * 0.66))
702
+ const lg = cardSpec && cardSpec.radius >= md ? cardSpec.radius : Math.max(md, [...common].filter((r) => r > md).sort((a, b) => a - b)[0] || Math.round(md * 1.4))
703
+ const xl = Math.max(lg, [...common].filter((r) => r > lg).sort((a, b) => a - b)[0] || Math.round(lg * 1.5))
704
+ radius = { sm, md, lg, xl }
705
+ if (sm > md) radius.sm = md
706
+ }
707
+ if (sharp) notes.push('网站的按钮、卡片都是直角:设计稿里也别加圆角')
708
+ if (pill) notes.push('主按钮是胶囊形(两头全圆),用 --radius-full')
709
+ const shadowList = []
710
+ for (const p of pages) for (const [s] of p.shadows) if (!shadowList.includes(s)) shadowList.push(s)
711
+ const sorted = [...shadowList].sort((a, b) => blurOf(a) - blurOf(b))
712
+ const shadowMd = (cardSpec && cardSpec.shadow) || sorted[sorted.length - 1] || ''
713
+ const shadowSm = sorted.find((s) => blurOf(s) < blurOf(shadowMd)) || (btnSpec && btnSpec.shadow) || ''
714
+ if (!shadowList.length && pages.length) notes.push('网站几乎不用阴影(靠底色和边框分层):--shadow-sm / --shadow-md 是 none,设计稿里也别加大阴影')
715
+
716
+ const brand = String(opts.name || '').trim()
717
+ return {
718
+ name: brand,
719
+ colors,
720
+ share: { primary: share(primary), accent: accent === primary ? 0 : share(accent) },
721
+ dark: pages.some((p) => p.dark),
722
+ font: { sans, display, loaded, links: fontLinks },
723
+ text: text_,
724
+ heads,
725
+ body: bodySpec,
726
+ radius,
727
+ pill,
728
+ shadow: { sm: shadowSm || (pages.length ? 'none' : ''), md: shadowMd || (pages.length ? 'none' : '') },
729
+ comps: { button: btnSpec, button2: btn2Spec, input: inputSpec, card: cardSpec, header: (pages.find((p) => p.header) || {}).header || null },
730
+ vars: vars.slice(0, 60),
731
+ libs: (scan && scan.libs) || [],
732
+ tailwind: (scan && scan.tailwind) || '',
733
+ notes,
734
+ }
735
+ }
736
+
737
+ // ── 写成文件 ──────────────────────────────────────────────────────────────
738
+
739
+ const ROLE_NOTE = {
740
+ bg: '页面背景', surface: '卡片、弹层', 'surface-2': '次一级表面、分组背景', text: '正文', muted: '次要文字', line: '边框、分割线',
741
+ primary: '主色:关键按钮、选中态', 'primary-ink': '主色上的文字', accent: '点睛色,少用', link: '文字链接',
742
+ }
743
+
744
+ /** 颜色值 → 设计变量名(等于某个角色就用 var()) */
745
+ function colorRef(sys, c) {
746
+ if (!c) return ''
747
+ for (const [k, v] of Object.entries(sys.colors)) if (deltaE(v, c) < 2) return `var(--color-${k})`
748
+ return c[3] != null && c[3] < 1 ? `rgb(${c.slice(0, 3).join(' ')} / ${c[3]})` : hexOf(c)
749
+ }
750
+ const radiusRef = (sys, r) => {
751
+ if (r == null) return 'var(--radius-md)'
752
+ for (const k of ['sm', 'md', 'lg', 'xl']) if (sys.radius[k] === r) return `var(--radius-${k})`
753
+ return r >= 100 ? 'var(--radius-full)' : `${r}px`
754
+ }
755
+ const sizeRef = (sys, fs) => {
756
+ for (const [k, v] of Object.entries(sys.text)) if (v === fs) return `var(--text-${k})`
757
+ return `${fs}px`
758
+ }
759
+
760
+ /**
761
+ * 网页上量来的值写进 CSS 前过一遍:带 ; { } < > \、注释、换行,或引号不配对的不要(换成默认),
762
+ * 免得网页故意给个怪值把样式表写坏、塞进别的规则
763
+ */
764
+ export function cssVal(v, fb = '') {
765
+ const s = String(v == null ? '' : v).trim()
766
+ if (!s || /[;{}<>\\\n\r]|\/\*|\*\//.test(s)) return fb
767
+ if ((s.match(/"/g) || []).length % 2 || (s.match(/'/g) || []).length % 2) return fb
768
+ return s
769
+ }
770
+
771
+ /** 写进 CSS 注释的字(名字、来源):*\/ 拆开、换行换成空格,别提前把注释关掉 */
772
+ export const cmt = (v) => String(v == null ? '' : v).replace(/\*\//g, '* /').replace(/[\r\n]+/g, ' ')
773
+
774
+ export function tokensCss(sys, meta = {}) {
775
+ const c = sys.colors
776
+ const line = (k, note) => ` --color-${k}: ${hexOf(c[k])};${' '.repeat(Math.max(1, 12 - k.length))}/* ${note} */`
777
+ const dark = (() => {
778
+ const h = Math.round(lch(c.primary).H)
779
+ return [` --color-bg: oklch(18% 0.01 ${h});`, ` --color-surface: oklch(22% 0.012 ${h});`, ` --color-surface-2: oklch(26% 0.012 ${h});`, ' --color-text: oklch(95% 0.005 85);', ' --color-muted: oklch(72% 0.01 85);', ` --color-line: oklch(32% 0.012 ${h});`]
780
+ })()
781
+ return [
782
+ `/* 设计系统「${cmt(sys.name)}」的设计变量(从 ${cmt(meta.source || '网站')} 提取${meta.date ? ',' + cmt(meta.date) : ''})。复制到设计目录,每屏 <link rel="stylesheet" href="tokens.css"> */`,
783
+ ':root {',
784
+ ' /* 颜色:按角色定义 */',
785
+ ...Object.keys(c).map((k) => line(k, ROLE_NOTE[k] || '')),
786
+ ' --color-success: oklch(62% 0.14 150);',
787
+ ' --color-warning: oklch(75% 0.15 75);',
788
+ ' --color-danger: oklch(58% 0.2 25);',
789
+ '',
790
+ ' /* 字体 */',
791
+ ` --font-sans: ${cssVal(sys.font.sans, CJK_SANS)};`,
792
+ ` --font-display: ${cssVal(sys.font.display, 'var(--font-sans)')};`,
793
+ ' --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;',
794
+ '',
795
+ ' /* 字号阶梯 */',
796
+ ...Object.entries(sys.text).map(([k, v]) => ` --text-${k}: ${v}px;`),
797
+ '',
798
+ ' /* 间距(4 / 8 的倍数) */',
799
+ ...[4, 8, 12, 16, 24, 32, 48, 64].map((v, i) => ` --space-${i + 1}: ${v}px;`),
800
+ '',
801
+ ' /* 圆角 */',
802
+ ...['sm', 'md', 'lg', 'xl'].map((k) => ` --radius-${k}: ${sys.radius[k]}px;`),
803
+ ' --radius-full: 999px;',
804
+ '',
805
+ ' /* 阴影 */',
806
+ ` --shadow-sm: ${cssVal(sys.shadow.sm, '0 1px 2px rgb(0 0 0 / 0.06)')};`,
807
+ ` --shadow-md: ${cssVal(sys.shadow.md, '0 6px 20px rgb(0 0 0 / 0.08)')};`,
808
+ '',
809
+ ' /* 动效 */',
810
+ ' --ease: cubic-bezier(.2, .8, .2, 1);',
811
+ ' --dur: 200ms;',
812
+ '}',
813
+ '',
814
+ `/* 深色版(${sys.dark ? '网站有深色模式,下面是按主色色相配的,核对后再用' : '网站没有深色模式,下面是按主色色相配的起点'}):<html data-theme="dark"> */`,
815
+ '[data-theme="dark"] {',
816
+ ...dark,
817
+ '}',
818
+ '',
819
+ '@media (prefers-reduced-motion: reduce) {',
820
+ ' :root { --dur: 0ms; }',
821
+ '}',
822
+ '',
823
+ ].join('\n')
824
+ }
825
+
826
+ export function componentsCss(sys) {
827
+ const b = sys.comps.button || {}
828
+ const b2 = sys.comps.button2 || null
829
+ const inp = sys.comps.input || {}
830
+ const card = sys.comps.card || {}
831
+ const h = sys.heads
832
+ const pad = (s, dy, dx) => `${s && s.py != null ? s.py : dy}px ${s && s.px != null ? s.px : dx}px`
833
+ const tt = (s) => (s && cssVal(s.tt) ? `\n text-transform: ${cssVal(s.tt)};` : '') + (s && cssVal(s.ls) ? `\n letter-spacing: ${cssVal(s.ls)};` : '')
834
+ const textStyle = (cls, hh, fallback) => {
835
+ if (!hh) return `.${cls} {\n font: ${fallback};\n}`
836
+ return `.${cls} {\n font-family: ${cls === 'body' ? 'var(--font-sans)' : 'var(--font-display)'};\n font-size: ${sizeRef(sys, hh.fs)};\n font-weight: ${cssVal(hh.fw, '400')};\n line-height: ${/px$/.test(hh.lh) ? Math.round((parseFloat(hh.lh) / hh.fs) * 100) / 100 : hh.lh === 'normal' ? 1.2 : cssVal(hh.lh, '1.4')};${cssVal(hh.ls) ? `\n letter-spacing: ${cssVal(hh.ls)};` : ''}${cssVal(hh.tt) ? `\n text-transform: ${cssVal(hh.tt)};` : ''}\n color: var(--color-text);\n}`
837
+ }
838
+ const secondary = b2
839
+ ? `.btn-secondary {\n background: ${b2.bg && b2.bg[3] > 0.5 ? colorRef(sys, b2.bg) : 'transparent'};\n color: ${colorRef(sys, b2.fg) || 'var(--color-text)'};\n border-color: ${b2.bd ? colorRef(sys, b2.bd) : 'transparent'};\n border-radius: ${radiusRef(sys, b2.radius >= b2.h / 2 - 1 ? 999 : b2.radius)};\n}`
840
+ : '.btn-secondary {\n background: transparent;\n color: var(--color-text);\n border-color: var(--color-line);\n}'
841
+ return `/* 设计系统「${cmt(sys.name)}」的组件样式:照网站上的按钮、输入框、卡片量出来的,全用 tokens.css 里的变量。复制到设计目录,在 tokens.css 后面 link */
842
+
843
+ .btn {
844
+ display: inline-flex;
845
+ align-items: center;
846
+ justify-content: center;
847
+ gap: 8px;
848
+ min-height: ${b.h ? Math.max(32, b.h) : 44}px;
849
+ padding: ${pad(b, 10, 20)};
850
+ border: ${b.bw || 1}px solid ${b.bd ? colorRef(sys, b.bd) : 'transparent'};
851
+ border-radius: ${sys.pill ? 'var(--radius-full)' : radiusRef(sys, b.radius)};
852
+ background: var(--color-primary);
853
+ color: var(--color-primary-ink);
854
+ font: ${cssVal(b.fw, '600')} ${b.fs ? sizeRef(sys, b.fs) : 'var(--text-md)'}/1.2 var(--font-sans);${tt(b)}${cssVal(b.shadow) ? `\n box-shadow: ${cssVal(b.shadow)};` : ''}
855
+ cursor: pointer;
856
+ transition: filter var(--dur) var(--ease), transform var(--dur) var(--ease);
857
+ }
858
+ .btn:hover { filter: brightness(1.06); }
859
+ .btn:active { transform: translateY(1px); }
860
+ .btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
861
+ .btn:disabled { opacity: .45; cursor: not-allowed; }
862
+ ${secondary}
863
+ .btn-ghost {
864
+ background: transparent;
865
+ color: var(--color-primary);
866
+ border-color: transparent;
867
+ }
868
+
869
+ .input {
870
+ min-height: ${inp.h ? Math.max(32, inp.h) : 44}px;
871
+ padding: ${pad(inp, 10, 12)};
872
+ border: ${inp.bw || 1}px solid ${inp.bd ? colorRef(sys, inp.bd) : 'var(--color-line)'};
873
+ border-radius: ${radiusRef(sys, inp.radius != null ? inp.radius : sys.radius.sm)};
874
+ background: ${inp.bg && inp.bg[3] > 0.5 ? colorRef(sys, inp.bg) : 'var(--color-surface)'};
875
+ color: var(--color-text);
876
+ font: 400 ${inp.fs ? sizeRef(sys, inp.fs) : 'var(--text-md)'}/1.4 var(--font-sans);
877
+ }
878
+ .input::placeholder { color: var(--color-muted); }
879
+ .input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 22%, transparent); }
880
+
881
+ .card {
882
+ background: ${card.bg && card.bg[3] > 0.5 ? colorRef(sys, card.bg) : 'var(--color-surface)'};
883
+ border-radius: ${radiusRef(sys, card.radius != null ? card.radius : sys.radius.lg)};
884
+ border: ${card.bd ? `${card.bw || 1}px solid ${colorRef(sys, card.bd)}` : 'none'};
885
+ box-shadow: ${card.shadow ? 'var(--shadow-md)' : 'none'};
886
+ padding: ${card.py != null ? Math.max(12, card.py) : 24}px ${card.px != null ? Math.max(12, card.px) : 24}px;
887
+ }
888
+
889
+ .tag {
890
+ display: inline-flex;
891
+ align-items: center;
892
+ height: 24px;
893
+ padding: 0 10px;
894
+ border-radius: ${sys.pill ? 'var(--radius-full)' : 'var(--radius-sm)'};
895
+ background: color-mix(in oklab, var(--color-primary) 12%, var(--color-surface));
896
+ color: var(--color-primary);
897
+ font: 600 var(--text-xs)/1 var(--font-sans);
898
+ }
899
+
900
+ .link {
901
+ color: var(--color-${sys.colors.link ? 'link' : 'primary'});
902
+ text-decoration: none;
903
+ }
904
+ .link:hover { text-decoration: underline; }
905
+
906
+ /* 文字样式 */
907
+ ${textStyle('display', h.h1, '700 var(--text-4xl)/1.1 var(--font-display)')}
908
+ ${textStyle('h2', h.h2, '700 var(--text-2xl)/1.25 var(--font-display)')}
909
+ ${textStyle('h3', h.h3, '600 var(--text-lg)/1.35 var(--font-display)')}
910
+ ${textStyle('body', sys.body ? { ...sys.body, fs: sys.body.fs } : null, '400 var(--text-md)/1.65 var(--font-sans)')}
911
+ .caption {
912
+ font: 400 var(--text-sm)/1.5 var(--font-sans);
913
+ color: var(--color-muted);
914
+ }
915
+ `
916
+ }
917
+
918
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
919
+
920
+ /** 样张:色板、字号、组件(放进设计工作台、或浏览器里看) */
921
+ export function specimenHtml(sys, meta = {}) {
922
+ const c = sys.colors
923
+ const sw = Object.keys(c).map((k) => {
924
+ const ratio = contrast(c[k], k === 'primary-ink' ? c.primary : k === 'bg' ? c.text : c.bg)
925
+ return `<figure class="sw"><i style="background:var(--color-${k})"></i><figcaption><b>${esc(ROLE_NOTE[k] || k)}</b><code>--color-${k}</code><span>${hexOf(c[k])} · ${ratio.toFixed(1)}:1</span></figcaption></figure>`
926
+ }).join('\n ')
927
+ const steps = Object.entries(sys.text).slice().reverse().map(([k, v]) => `<div class="step"><code>--text-${k} · ${v}px</code><span style="font-size:var(--text-${k})">${v >= 32 ? '好设计,一眼看得懂' : '好设计一眼看得懂,Good design is obvious'}</span></div>`).join('\n ')
928
+ const radii = ['sm', 'md', 'lg', 'xl', 'full'].map((k) => `<div class="rd"><i style="border-radius:var(--radius-${k})"></i><code>${k}</code></div>`).join('')
929
+ return `<!doctype html>
930
+ <html lang="zh-CN">
931
+ <head>
932
+ <meta charset="utf-8">
933
+ <meta name="viewport" content="width=device-width, initial-scale=1">
934
+ <meta name="dsh-design" content="web">
935
+ <title>${esc(sys.name)} · 设计系统</title>
936
+ ${(sys.font.links || []).map((l) => `<link rel="stylesheet" href="${esc(l)}">`).join('\n')}
937
+ <link rel="stylesheet" href="tokens.css">
938
+ <link rel="stylesheet" href="components.css">
939
+ <style>
940
+ body { margin: 0; background: var(--color-bg); color: var(--color-text); font: 400 var(--text-md)/1.6 var(--font-sans); }
941
+ main { max-width: 1120px; margin: 0 auto; padding: 56px 40px 80px; }
942
+ header.top { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--color-line); padding-bottom: 24px; margin-bottom: 40px; }
943
+ header.top p { margin: 0; color: var(--color-muted); font-size: var(--text-sm); }
944
+ section { margin: 0 0 48px; }
945
+ h2.sec { font: 600 var(--text-sm)/1 var(--font-sans); letter-spacing: .08em; text-transform: uppercase; color: var(--color-muted); margin: 0 0 16px; }
946
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
947
+ .sw { margin: 0; border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; background: var(--color-surface); }
948
+ .sw i { display: block; height: 72px; border-bottom: 1px solid var(--color-line); }
949
+ .sw figcaption { display: grid; gap: 2px; padding: 10px 12px; font-size: var(--text-xs); }
950
+ .sw b { font-size: var(--text-sm); }
951
+ .sw code, .sw span { color: var(--color-muted); }
952
+ .step { display: grid; grid-template-columns: 150px 1fr; align-items: baseline; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--color-line); }
953
+ .step code { color: var(--color-muted); font-size: var(--text-xs); }
954
+ .step span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; }
955
+ .row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
956
+ .rd { display: grid; justify-items: center; gap: 6px; font-size: var(--text-xs); color: var(--color-muted); }
957
+ .rd i { width: 64px; height: 64px; background: color-mix(in oklab, var(--color-primary) 16%, var(--color-surface)); border: 1px solid var(--color-line); }
958
+ .two { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start; }
959
+ .fonts p { margin: 0 0 8px; }
960
+ .sh { display: flex; gap: 24px; }
961
+ .sh div { width: 120px; height: 72px; border-radius: var(--radius-md); background: var(--color-surface); display: grid; place-items: center; font-size: var(--text-xs); color: var(--color-muted); }
962
+ @media (max-width: 720px) { main { padding: 32px 18px 48px; } .two { grid-template-columns: 1fr; } .step { grid-template-columns: 1fr; gap: 4px; } }
963
+ </style>
964
+ </head>
965
+ <body>
966
+ <main>
967
+ <header class="top">
968
+ <div><p>设计系统</p><h1 class="display" style="margin:4px 0 0">${esc(sys.name)}</h1></div>
969
+ <p>${esc(meta.source || '')}${meta.date ? ' · ' + esc(meta.date) : ''}</p>
970
+ </header>
971
+
972
+ <section>
973
+ <h2 class="sec">颜色</h2>
974
+ <div class="grid">
975
+ ${sw}
976
+ </div>
977
+ </section>
978
+
979
+ <section class="two">
980
+ <div>
981
+ <h2 class="sec">字体</h2>
982
+ <div class="fonts">
983
+ <p class="display" style="margin-bottom:12px">标题 Display Aa</p>
984
+ <p class="h2">二级标题 Heading</p>
985
+ <p class="h3">三级标题 Subheading</p>
986
+ <p class="body">正文:好的界面让人不用想就知道下一步做什么。The quick brown fox jumps over the lazy dog.</p>
987
+ <p class="caption">说明文字 · Caption · 2026</p>
988
+ </div>
989
+ </div>
990
+ <div>
991
+ <h2 class="sec">组件</h2>
992
+ <div class="row" style="margin-bottom:16px">
993
+ <button class="btn">${esc((sys.comps.button && sys.comps.button.text) || '立即开始')}</button>
994
+ <button class="btn btn-secondary">${esc((sys.comps.button2 && sys.comps.button2.text) || '了解更多')}</button>
995
+ <button class="btn btn-ghost">文字按钮</button>
996
+ <span class="tag">标签</span>
997
+ </div>
998
+ <input class="input" placeholder="输入框占位文字" style="width:100%;margin-bottom:16px">
999
+ <div class="card"><p class="h3" style="margin:0 0 6px">卡片标题</p><p class="caption" style="margin:0">卡片里的说明文字,两行以内。<a class="link" href="#">文字链接</a></p></div>
1000
+ </div>
1001
+ </section>
1002
+
1003
+ <section>
1004
+ <h2 class="sec">字号阶梯</h2>
1005
+ ${steps}
1006
+ </section>
1007
+
1008
+ <section class="two">
1009
+ <div>
1010
+ <h2 class="sec">圆角</h2>
1011
+ <div class="row">${radii}</div>
1012
+ </div>
1013
+ <div>
1014
+ <h2 class="sec">阴影</h2>
1015
+ <div class="sh"><div style="box-shadow:var(--shadow-sm)">--shadow-sm</div><div style="box-shadow:var(--shadow-md)">--shadow-md</div></div>
1016
+ </div>
1017
+ </section>
1018
+ </main>
1019
+ </body>
1020
+ </html>
1021
+ `
1022
+ }
1023
+
1024
+ const fmtColor = (sys, k) => `\`${hexOf(sys.colors[k])}\``
1025
+
1026
+ export function designMd(sys, meta = {}) {
1027
+ const c = sys.colors
1028
+ const rows = Object.keys(c).map((k) => {
1029
+ const on = k === 'primary-ink' ? c.primary : k === 'bg' ? c.text : c.bg
1030
+ return `| ${ROLE_NOTE[k] || k} | \`--color-${k}\` | ${fmtColor(sys, k)} | ${contrast(c[k], on).toFixed(1)}:1 |`
1031
+ })
1032
+ const comp = (label, s, extra = '') => {
1033
+ if (!s) return ''
1034
+ const parts = []
1035
+ if (s.bg && s.bg[3] > 0.5) parts.push(`底色 ${colorRef(sys, s.bg)}`)
1036
+ if (s.fg) parts.push(`字 ${colorRef(sys, s.fg)}`)
1037
+ if (s.bd) parts.push(`边框 ${s.bw || 1}px ${colorRef(sys, s.bd)}`)
1038
+ parts.push(`圆角 ${s.radius >= s.h / 2 - 1 ? '全圆(胶囊)' : s.radius + 'px'}`)
1039
+ if (s.h) parts.push(`高 ${s.h}px`)
1040
+ if (s.py != null) parts.push(`内边距 ${s.py}px ${s.px}px`)
1041
+ if (s.fs) parts.push(`字 ${s.fs}px / ${s.fw}`)
1042
+ if (s.tt) parts.push(`文字 ${s.tt}`)
1043
+ if (s.shadow) parts.push('有阴影')
1044
+ if (s.grad) parts.push('底色是渐变')
1045
+ return `- **${label}**:${parts.join(',')}${s.text ? `(网站上写的是「${s.text}」)` : ''}${extra}`
1046
+ }
1047
+ const hd = sys.comps.header
1048
+ const head = (tg) => { const x = sys.heads[tg]; return x ? `${tg.toUpperCase()} ${x.fs}px / ${x.fw}${x.ls ? `,字距 ${x.ls}` : ''}${x.tt ? `,${x.tt}` : ''}` : '' }
1049
+ return `# 「${sys.name}」设计系统
1050
+
1051
+ 来源:${meta.source || '—'}${meta.date ? `(${meta.date} 提取)` : ''}
1052
+
1053
+ > 做新设计时:把这个目录里的 \`tokens.css\`、\`components.css\` 复制进设计目录,每屏都 link 这两个文件(先 tokens 后 components)。颜色、字体、圆角、阴影、按钮输入框卡片都照这里来,不要另起一套配色。
1054
+ > 样张:\`components.html\`(色板、字号、组件放在一起看)。
1055
+
1056
+ ## 品牌气质
1057
+
1058
+ (待补:两三句话说清这个品牌给人什么感觉、设计时该保持什么、该避免什么。看 source/ 里的网站截图写。)
1059
+
1060
+ ## 颜色
1061
+
1062
+ | 角色 | 变量 | 值 | 对比度 |
1063
+ | --- | --- | --- | --- |
1064
+ ${rows.join('\n')}
1065
+
1066
+ 主色在网站上大约占 ${sys.share.primary}% 的面积${sys.share.accent ? `,点缀色约 ${sys.share.accent}%` : ''}:主色只用在关键按钮、选中态和少量强调,大面积还是背景和表面色。
1067
+
1068
+ ## 字体
1069
+
1070
+ - 正文:\`${sys.font.sans}\`
1071
+ - 标题:${sys.font.display ? `\`${sys.font.display}\`` : '和正文同一款'}
1072
+ ${sys.font.links.length ? `- 网站从这里载入字体:${sys.font.links.map((l) => `\`${l}\``).join('、')}(样张已经 link 了,设计稿要用同样的字体也 link 它)\n` : ''}- 标题:${['h1', 'h2', 'h3'].map(head).filter(Boolean).join(';') || '没量到'}
1073
+ - 正文:${sys.body ? `${sys.body.fs}px,行高 ${sys.body.lh}` : '没量到'}
1074
+ - 字号阶梯:${Object.entries(sys.text).map(([k, v]) => `${k} ${v}`).join(' · ')}(px)
1075
+
1076
+ ## 形状与层次
1077
+
1078
+ - 圆角:sm ${sys.radius.sm}px · md ${sys.radius.md}px · lg ${sys.radius.lg}px · xl ${sys.radius.xl}px${sys.pill ? ' · 按钮用全圆' : ''}
1079
+ - 阴影:sm \`${sys.shadow.sm || 'none'}\`;md \`${sys.shadow.md || 'none'}\`
1080
+
1081
+ ## 组件(components.css 里有现成的类)
1082
+
1083
+ ${[comp('主按钮 `.btn`', sys.comps.button), comp('次按钮 `.btn-secondary`', sys.comps.button2), comp('输入框 `.input`', sys.comps.input), comp('卡片 `.card`', sys.comps.card)].filter(Boolean).join('\n') || '- 没量到按钮、输入框、卡片(项目里没有能直接打开的页面);components.css 按设计变量给了一套默认写法'}
1084
+ ${hd ? `- **顶栏**:高 ${hd.h}px,底色 ${colorRef(sys, hd.bg)}${hd.pos === 'fixed' || hd.pos === 'sticky' ? ',滚动时固定在顶上' : ''}${hd.line ? `,底边一条 ${colorRef(sys, hd.line)} 的线` : ''}${hd.shadow ? ',带阴影' : ''}${hd.blur ? ',毛玻璃' : ''}` : ''}
1085
+ - 文字样式:\`.display\` \`.h2\` \`.h3\` \`.body\` \`.caption\`;标签 \`.tag\`;文字链接 \`.link\`
1086
+
1087
+ ## 注意
1088
+
1089
+ ${sys.notes.length ? sys.notes.map((n) => `- ${n}`).join('\n') : '- 没有'}
1090
+ ${sys.libs.length ? `\n项目用了:${sys.libs.join('、')}。在这个项目里写代码时,优先用它的组件,样式按上面的变量改。\n` : ''}
1091
+ ## 原始数据(提取时量到的,核对用)
1092
+
1093
+ ${sys.vars.length ? `网站 / 项目里的 CSS 变量(前 ${Math.min(sys.vars.length, 40)} 个):\n\n\`\`\`css\n${sys.vars.slice(0, 40).map(([n, v]) => `${n}: ${v};`).join('\n')}\n\`\`\`\n` : '没有 CSS 变量。\n'}${sys.tailwind ? `\nTailwind 配置(theme 部分):\n\n\`\`\`js\n${sys.tailwind.slice(0, 2000)}\n\`\`\`\n` : ''}`
1094
+ }
1095
+
1096
+ // ── 设计系统库:列出、默认用哪套 ─────────────────────────────────────────────
1097
+
1098
+ /** 库里的文件夹得是真文件夹:是软链接(或文件)就不往里写,免得写到库外面去 */
1099
+ function realDir(d) {
1100
+ let st = null
1101
+ try { st = fs.lstatSync(d) } catch { fs.mkdirSync(d) }
1102
+ if (st && !st.isDirectory()) throw new Error(`${d} 不是普通文件夹(是软链接或文件),先挪走再提取`)
1103
+ }
1104
+
1105
+ /** 是普通文件(不是软链接) */
1106
+ const isFile = (f) => { try { return fs.lstatSync(f).isFile() } catch { return false } }
1107
+
1108
+ /** 写文件:原来是软链接就先删掉链接本身(不碰它指的文件),再写成普通文件 */
1109
+ function writeOwn(file, data) {
1110
+ try { if (fs.lstatSync(file).isSymbolicLink()) fs.unlinkSync(file) } catch { /* 没有 */ }
1111
+ fs.writeFileSync(file, data)
1112
+ }
1113
+
1114
+ const safeName = (s) => String(s || '').replace(/[^\w一-鿿-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
1115
+
1116
+ function readJson(file) {
1117
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
1118
+ }
1119
+
1120
+ /**
1121
+ * tokens.css 第一段 :root 里的变量(按括号配对找这段的结尾,一行写完、最后一条没分号的也认)。
1122
+ * 先把注释、字符串遮住(等长)再找:字符串里的 { } ; 不算,值从原文里取
1123
+ */
1124
+ export function tokensOf(text) {
1125
+ const css = String(text || '')
1126
+ const masked = css.replace(/\/\*[\s\S]*?\*\/|"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'/g, (x) => (x.startsWith('/*') ? ' ' : '_').repeat(x.length))
1127
+ const out = {}
1128
+ const m = /:root\s*\{/.exec(masked)
1129
+ if (!m) return out
1130
+ const from = m.index + m[0].length
1131
+ let depth = 1
1132
+ let i = from
1133
+ for (; i < masked.length && depth; i++) {
1134
+ if (masked[i] === '{') depth++
1135
+ else if (masked[i] === '}') depth--
1136
+ }
1137
+ const to = depth ? i : i - 1
1138
+ for (const x of masked.slice(from, to).matchAll(/(--[\w-]+)\s*:\s*([^;{}]+?)\s*(?:;|$)/gd)) {
1139
+ const [a, b] = x.indices[2]
1140
+ out[x[1]] = css.slice(from + a, from + b).trim()
1141
+ }
1142
+ return out
1143
+ }
1144
+
1145
+ export function defaultSystem(dir = SYSTEMS_DIR) {
1146
+ const idx = readJson(path.join(dir, 'index.json'))
1147
+ const id = idx && typeof idx.default === 'string' ? idx.default : ''
1148
+ return id && fs.existsSync(path.join(dir, id, 'tokens.css')) ? id : ''
1149
+ }
1150
+
1151
+ export function setDefault(dir = SYSTEMS_DIR, id = '') {
1152
+ if (id && !fs.existsSync(path.join(dir, id, 'tokens.css'))) throw new Error(`没有这套设计系统:${id}`)
1153
+ fs.mkdirSync(dir, { recursive: true })
1154
+ const idx = readJson(path.join(dir, 'index.json')) || {}
1155
+ idx.default = id || ''
1156
+ writeOwn(path.join(dir, 'index.json'), JSON.stringify(idx, null, 2))
1157
+ return idx.default
1158
+ }
1159
+
1160
+ /** 库里的每一套:名字、来源、几个主要颜色、字体(右侧栏列出来;技能里告诉模型) */
1161
+ export function listSystems(dir = SYSTEMS_DIR) {
1162
+ let ents = []
1163
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return [] }
1164
+ const def = defaultSystem(dir)
1165
+ const out = []
1166
+ for (const e of ents) {
1167
+ if (!e.isDirectory() || e.name.startsWith('.')) continue
1168
+ const root = path.join(dir, e.name)
1169
+ let tok
1170
+ try { tok = tokensOf(fs.readFileSync(path.join(root, 'tokens.css'), 'utf8')) } catch { continue }
1171
+ const meta = readJson(path.join(root, 'system.json')) || {}
1172
+ const colors = ['bg', 'surface', 'text', 'muted', 'primary', 'accent', 'line'].map((k) => [k, tok[`--color-${k}`]]).filter(([, v]) => v)
1173
+ out.push({
1174
+ id: e.name,
1175
+ name: String(meta.name || e.name),
1176
+ sources: Array.isArray(meta.sources) ? meta.sources.slice(0, 5) : [],
1177
+ createdAt: meta.createdAt || 0,
1178
+ dir: root,
1179
+ colors: Object.fromEntries(colors),
1180
+ font: familyOf(tok['--font-display'] && !/^var\(/.test(tok['--font-display']) ? tok['--font-display'] : tok['--font-sans']),
1181
+ radius: tok['--radius-md'] || '',
1182
+ preview: fs.existsSync(path.join(root, 'source', 'preview.jpg')),
1183
+ default: e.name === def,
1184
+ })
1185
+ }
1186
+ return out.sort((a, b) => (b.default - a.default) || (b.createdAt - a.createdAt))
1187
+ }
1188
+
1189
+ /** 给 ui-design 技能补的一段:用户有哪些设计系统、默认用哪套、怎么用 */
1190
+ export function skillSection(dir = SYSTEMS_DIR) {
1191
+ const list = listSystems(dir)
1192
+ const def = list.find((x) => x.default)
1193
+ const others = list.filter((x) => !x.default)
1194
+ const head = '\n\n## 设计系统(用户自己的品牌)\n\n'
1195
+ const how = '用户给了自己的网站地址或项目目录、说「照我们的品牌来」:调 `extract_design_system` 提一套(网站地址最准;项目目录会扫样式文件),提完核对 DESIGN.md、补上「品牌气质」,再 `preview_page` 打开它的 `components.html` 给用户看一眼。'
1196
+ if (!list.length) return head + how + '\n'
1197
+ const line = (x) => `「${x.name}」:${x.dir}${x.sources.length ? `(来自 ${x.sources[0]})` : ''}`
1198
+ let s = head
1199
+ if (def) {
1200
+ s += `**默认设计系统**:${line(def)}\n\n做新设计时照它来:\n\n` +
1201
+ '- 先读它的 `DESIGN.md`,把它的 `tokens.css`、`components.css` 复制进设计目录(不用技能自带的起手模板),每屏都 link 这两个文件,按钮、输入框、卡片直接用里面的类;\n' +
1202
+ '- 第 0 步不问「想要什么感觉」、不推风格卡:品牌已经定了;只问给谁用、要哪几屏、必须有的内容。三个方向用同一套配色和字体,在版式和信息重点上拉开;\n' +
1203
+ '- 用户点了风格卡、或者说这次不用品牌(「换个风格」「别照官网」)就不用它。\n'
1204
+ } else s += '用户有设计系统,但没设默认:用户点名时(「用 XX 的设计系统」)照下面那套来,做法同上:复制 tokens.css、components.css,读 DESIGN.md。\n'
1205
+ if (others.length) s += `\n${def ? '其他' : '库里'}的设计系统(用户点名时用):\n\n${others.slice(0, 8).map((x) => `- ${line(x)}`).join('\n')}\n`
1206
+ s += '\n' + how + '\n'
1207
+ return s
1208
+ }
1209
+
1210
+ // ── 提取:解析来源、量、写文件 ────────────────────────────────────────────────
1211
+
1212
+ /** 来源 → { kind: 'url' | 'file' | 'dir', url?, path?, label } */
1213
+ export function resolveSource(input, cwd = os.homedir()) {
1214
+ const s = String(input || '').trim()
1215
+ if (!s) throw new Error('没给来源:网站地址、HTML 文件或项目文件夹')
1216
+ if (/^https?:\/\//i.test(s)) {
1217
+ const u = new URL(s)
1218
+ return { kind: 'url', url: u.href, label: u.hostname.replace(/^www\./, '') }
1219
+ }
1220
+ if (/^[\w-]+(\.[\w-]+)+(\/\S*)?$/.test(s) && !fs.existsSync(path.resolve(cwd, s))) {
1221
+ const u = new URL('https://' + s)
1222
+ return { kind: 'url', url: u.href, label: u.hostname.replace(/^www\./, '') }
1223
+ }
1224
+ const p = path.resolve(cwd, s.replace(/^~(?=$|[\\/])/, os.homedir()))
1225
+ let st
1226
+ try { st = fs.statSync(p) } catch { throw new Error(`找不到 ${p}`) }
1227
+ if (st.isDirectory()) return { kind: 'dir', path: p, label: path.basename(p) }
1228
+ if (/\.html?$/i.test(p)) return { kind: 'file', path: p, url: pathToFileURL(p).href, label: path.basename(p) }
1229
+ throw new Error(`${p} 不是 HTML 文件,也不是文件夹`)
1230
+ }
1231
+
1232
+ function guessName(rendered, scans, srcs) {
1233
+ for (const r of rendered) if (r.data.brand) return r.data.brand
1234
+ for (const r of rendered) {
1235
+ // 标题「品牌 | 口号」「页面 - 品牌」:哪段在页面的标志 / 顶栏里出现就是哪段,不然取第一段
1236
+ const t = String(r.data.title || '').split(/\s+[|\-–—·::_]\s+|\s*[||–—·:]\s*/).map((x) => x.trim()).filter((x) => x && x.length <= 24)
1237
+ if (t.length) return t.find((x) => r.data.headerText && r.data.headerText.includes(x)) || t[0]
1238
+ }
1239
+ for (const s of scans) if (s.name) return s.name
1240
+ const u = srcs.find((x) => x.kind === 'url')
1241
+ if (u) return u.label.split('.').slice(0, -1).join('.') || u.label
1242
+ return srcs[0] ? srcs[0].label.replace(/\.html?$/i, '') : '我的品牌'
1243
+ }
1244
+
1245
+ function mergeScans(list) {
1246
+ if (!list.length) return null
1247
+ if (list.length === 1) return list[0]
1248
+ const out = { ...list[0], vars: new Map(), colors: new Map(), fonts: new Map(), radii: new Map(), libs: [], entries: [], fontLinks: [] }
1249
+ for (const s of list) {
1250
+ for (const k of ['vars', 'colors', 'fonts', 'radii']) for (const [a, b] of s[k]) { const e = out[k].get(a); if (e) e.n += b.n; else out[k].set(a, { ...b }) }
1251
+ out.libs.push(...s.libs.filter((x) => !out.libs.includes(x)))
1252
+ out.entries.push(...s.entries)
1253
+ out.fontLinks.push(...s.fontLinks)
1254
+ if (!out.tailwind && s.tailwind) out.tailwind = s.tailwind
1255
+ }
1256
+ return out
1257
+ }
1258
+
1259
+ /**
1260
+ * 提一套设计系统,写进库里。
1261
+ * @returns { id, name, dir, sys, text, images: [{ key, label, jpg }], replaced }
1262
+ */
1263
+ export async function extractSystem({ browser, sources, name = '', cwd = os.homedir(), dir = SYSTEMS_DIR, makeDefault = true, now = new Date() }) {
1264
+ const srcs = []
1265
+ const bad = []
1266
+ for (const s of (Array.isArray(sources) ? sources : [sources]).filter(Boolean).slice(0, MAX_SOURCES)) {
1267
+ try { srcs.push(resolveSource(s, cwd)) } catch (e) { bad.push(e.message) }
1268
+ }
1269
+ if (!srcs.length) throw new Error(bad[0] || '没给来源')
1270
+ const scans = []
1271
+ const toRender = []
1272
+ for (const s of srcs) {
1273
+ if (s.kind === 'dir') {
1274
+ const sc = scanProject(s.path)
1275
+ scans.push(sc)
1276
+ for (const f of sc.entries.slice(0, 2)) toRender.push({ url: pathToFileURL(f).href, label: path.relative(s.path, f).replace(/\.html?$/i, '').replace(/[\\/]+/g, '-') })
1277
+ } else toRender.push({ url: s.url, label: s.label.replace(/\.html?$/i, '') })
1278
+ }
1279
+ const rendered = []
1280
+ const errors = [...bad]
1281
+ const canRender = browser && browser.available()
1282
+ if (canRender) {
1283
+ for (const t of toRender.slice(0, MAX_SOURCES)) {
1284
+ try { rendered.push(await renderPage(browser, t.url, safeName(t.label) || 'page')) } catch (e) { errors.push(`${t.url}:${String((e && e.message) || e).slice(0, 120)}`) }
1285
+ }
1286
+ } else if (toRender.length) errors.push('这台电脑上没找到 Chrome / Edge,网页没法打开量,只扫了项目里的样式文件')
1287
+ const scan = mergeScans(scans)
1288
+ const scanned = scan && (scan.colors.size || scan.vars.size || scan.fonts.size)
1289
+ if (!rendered.length && !scanned) throw new Error(`没量到东西:${errors.join(';') || '项目里没找到样式文件'}`)
1290
+ const nm = String(name || '').trim() || guessName(rendered, scans, srcs)
1291
+ const sys = synthesize(rendered.map((r) => r.data), scan, { name: nm })
1292
+ const id = safeName(nm) || 'brand'
1293
+ fs.mkdirSync(dir, { recursive: true })
1294
+ const root = path.join(dir, id)
1295
+ realDir(root)
1296
+ const replaced = isFile(path.join(root, 'tokens.css'))
1297
+ if (replaced) {
1298
+ // 重新提取同一套:原来的几个文件留一份(DESIGN.md 里模型补过的说明)
1299
+ const prev = path.join(root, '.prev')
1300
+ realDir(prev)
1301
+ for (const f of ['tokens.css', 'components.css', 'DESIGN.md']) { if (isFile(path.join(root, f))) writeOwn(path.join(prev, f), fs.readFileSync(path.join(root, f))) }
1302
+ }
1303
+ realDir(path.join(root, 'source'))
1304
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
1305
+ const source = srcs.map((s) => s.url && s.kind === 'url' ? s.url : s.path).join('、')
1306
+ const meta = { source, date }
1307
+ writeOwn(path.join(root, 'tokens.css'), tokensCss(sys, meta))
1308
+ writeOwn(path.join(root, 'components.css'), componentsCss(sys))
1309
+ writeOwn(path.join(root, 'components.html'), specimenHtml(sys, meta))
1310
+ writeOwn(path.join(root, 'DESIGN.md'), designMd(sys, meta))
1311
+ writeOwn(path.join(root, 'system.json'), JSON.stringify({ name: nm, sources: srcs.map((s) => s.url && s.kind === 'url' ? s.url : s.path), createdAt: now.getTime(), version: 1 }, null, 2))
1312
+ const images = []
1313
+ for (const r of rendered) {
1314
+ for (const s of r.shots) {
1315
+ writeOwn(path.join(root, 'source', `${s.key}.jpg`), s.jpg)
1316
+ images.push(s)
1317
+ }
1318
+ }
1319
+ // 样张截一张(右侧栏的缩略图;也给模型看提出来的长什么样)
1320
+ if (canRender) {
1321
+ try {
1322
+ const jpg = await browser.withPage({ url: pathToFileURL(path.join(root, 'components.html')).href, w: 1280, h: 800 }, async (page) => {
1323
+ await page.eval('Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 2500))]).then(() => true)')
1324
+ return page.shot()
1325
+ })
1326
+ writeOwn(path.join(root, 'source', 'preview.jpg'), jpg)
1327
+ images.push({ key: 'specimen', label: '提出来的设计系统(样张)', jpg })
1328
+ } catch (e) { errors.push(`样张没截成:${String((e && e.message) || e).slice(0, 80)}`) }
1329
+ }
1330
+ // 明说了不设默认就不动默认(哪怕现在还没有默认)
1331
+ if (makeDefault) setDefault(dir, id)
1332
+ const isDefault = defaultSystem(dir) === id
1333
+ return { id, name: nm, dir: root, sys, images: images.slice(0, 8), replaced, isDefault, text: summaryText({ sys, root, nm, rendered, scan, errors, replaced, isDefault, images }) }
1334
+ }
1335
+
1336
+ function summaryText({ sys, root, nm, rendered, scan, errors, replaced, isDefault, images }) {
1337
+ const c = sys.colors
1338
+ const lines = [
1339
+ `${replaced ? '重新提取并覆盖了' : '提好了'}设计系统「${nm}」:${root}${isDefault ? '(已设为默认:以后做新设计都照它来)' : ''}`,
1340
+ `量了:${rendered.map((r) => r.url).join('、') || '(没开网页)'}${scan ? `;扫了项目里 ${scan.files} 个文件` : ''}`,
1341
+ '',
1342
+ '颜色:' + Object.keys(c).map((k) => `${k} ${hexOf(c[k])}`).join(' · '),
1343
+ `字体:正文 ${familyOf(sys.font.sans)}${sys.font.display ? `,标题 ${familyOf(sys.font.display)}` : ''};字号阶梯 ${Object.values(sys.text).join(' / ')}px`,
1344
+ `圆角:${['sm', 'md', 'lg', 'xl'].map((k) => sys.radius[k]).join(' / ')}px${sys.pill ? '(按钮全圆)' : ''};阴影:${sys.shadow.md && sys.shadow.md !== 'none' ? '有' : '基本不用'}`,
1345
+ `组件:${[sys.comps.button && '主按钮', sys.comps.button2 && '次按钮', sys.comps.input && '输入框', sys.comps.card && '卡片', sys.comps.header && '顶栏'].filter(Boolean).join('、') || '没量到(components.css 用的默认写法)'}`,
1346
+ ]
1347
+ if (sys.libs.length) lines.push(`项目用了:${sys.libs.join('、')}`)
1348
+ if (sys.vars.length) lines.push(`网站 / 项目里有 ${sys.vars.length} 个 CSS 变量(DESIGN.md 末尾列着,角色猜得不对时对照它改)`)
1349
+ if (sys.notes.length) lines.push('', '注意:', ...sys.notes.map((n) => `- ${n}`))
1350
+ if (errors.length) lines.push('', '没量成的:', ...errors.map((e) => `- ${e}`))
1351
+ lines.push('',
1352
+ '文件:tokens.css(设计变量)、components.css(按钮 / 输入框 / 卡片 / 文字样式)、components.html(样张)、DESIGN.md(说明)、source/(截图)。',
1353
+ images.length ? `附了 ${images.length} 张图:网站截图、组件截图${images.some((x) => x.key === 'specimen') ? ',最后一张是提出来的样张' : ''}。` : '',
1354
+ '接下来:',
1355
+ '1. 对照网站截图看样张:角色猜错了(比如主色其实是另一个颜色、背景不对)就改 tokens.css,components.css 跟着变量走;',
1356
+ '2. 把 DESIGN.md 的「品牌气质」补上两三句(看截图写:给人什么感觉、该保持什么、该避免什么);',
1357
+ '3. 用 preview_page 打开 components.html 给用户看一眼,说清提出来的主色、字体、圆角;',
1358
+ '4. 以后做新设计:复制这套 tokens.css、components.css 进设计目录(ui-design 技能的「设计系统」一节有写)。')
1359
+ return lines.filter((x, i, a) => !(x === '' && a[i - 1] === '')).join('\n')
1360
+ }