dsh-design-studio 0.4.1 → 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/handoff.js ADDED
@@ -0,0 +1,555 @@
1
+ /**
2
+ * 转成代码:把设计目录整理成一份交接包,交给模型做成真项目的代码;做完一屏用 compare_design 和设计稿对一遍。
3
+ *
4
+ * <设计目录>/handoff/HANDOFF.md 做什么、做到哪、用什么框架;每屏的文件、建议路由、截图、跳到哪;设计变量;
5
+ * 几屏共用的部分(该抽成组件的);交互和状态;滑块;素材;做法和验收
6
+ * <设计目录>/handoff/shots/<屏>-<n>.jpg 每屏按设计尺寸从上到下的截图(网页再加一张手机宽的)
7
+ *
8
+ * compare_design:设计稿的一屏和做出来的页面(本机地址)按同样尺寸各截一张,在无头浏览器里逐像素比,
9
+ * 回一张「设计稿 | 做出来的 | 差异」拼图、差了多少,和差得最多的几块分别是哪个元素。
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { pathToFileURL } from 'node:url'
15
+ import { prepare } from './look.js'
16
+ import { listScreens } from './core.js'
17
+ import { readTweaks, shownValue } from './tweaks.js'
18
+ import { tokensOf } from './system.js'
19
+
20
+ export const HANDOFF_DIR = 'handoff'
21
+ const MAX_SCREENS = 12
22
+ const MAX_PARTS = 3 // 每屏最多截几段
23
+
24
+ export const FRAMEWORKS = {
25
+ react: { label: 'React + Vite(TypeScript)', create: 'npm create vite@latest {name} -- --template react-ts', router: 'react-router-dom' },
26
+ vue: { label: 'Vue 3 + Vite(TypeScript)', create: 'npm create vite@latest {name} -- --template vue-ts', router: 'vue-router' },
27
+ next: { label: 'Next.js(App Router,TypeScript)', create: 'npx create-next-app@latest {name} --ts --app --use-npm', router: '' },
28
+ html: { label: '纯 HTML / CSS(不用框架)', create: '', router: '' },
29
+ }
30
+
31
+ const FW_DEPS = [['next', 'next', 'Next.js'], ['nuxt', 'vue', 'Nuxt'], ['@sveltejs/kit', 'svelte', 'SvelteKit'], ['svelte', 'svelte', 'Svelte'], ['astro', 'astro', 'Astro'], ['@angular/core', 'angular', 'Angular'], ['solid-js', 'solid', 'Solid'], ['vue', 'vue', 'Vue'], ['react', 'react', 'React']]
32
+
33
+ /** 目录是不是个前端项目、用的什么:{ dir, pkg, name, framework, label, tailwind, ts, pm } */
34
+ export function detectProject(dir) {
35
+ const out = { dir, pkg: false, name: path.basename(dir || ''), framework: '', label: '', tailwind: false, ts: false, pm: 'npm' }
36
+ let pkg
37
+ try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) } catch { return out }
38
+ out.pkg = true
39
+ if (typeof pkg.name === 'string' && pkg.name) out.name = pkg.name
40
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
41
+ const hit = FW_DEPS.find(([d]) => deps[d])
42
+ if (hit) { out.framework = hit[1]; out.label = hit[2] + (hit[0] === 'react' && deps.vite ? ' + Vite' : hit[0] === 'vue' && deps.vite ? ' + Vite' : '') }
43
+ out.tailwind = !!deps.tailwindcss
44
+ out.ts = !!deps.typescript || fs.existsSync(path.join(dir, 'tsconfig.json'))
45
+ out.pm = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : fs.existsSync(path.join(dir, 'yarn.lock')) ? 'yarn' : fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock')) ? 'bun' : 'npm'
46
+ return out
47
+ }
48
+
49
+ /** 真实路径:不存在的部分接在最近一级存在的上级目录的真实路径后面(新建的目录也能按真实位置比) */
50
+ export function realish(p) {
51
+ let cur = path.resolve(p)
52
+ const rest = []
53
+ for (;;) {
54
+ try { return path.join(fs.realpathSync.native(cur), ...rest) } catch { /* 还不存在,往上找 */ }
55
+ const up = path.dirname(cur)
56
+ if (up === cur) return path.resolve(p)
57
+ rest.unshift(path.basename(cur))
58
+ cur = up
59
+ }
60
+ }
61
+
62
+ // mac、Windows 的磁盘默认不分大小写
63
+ const fold = (p) => (process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p)
64
+
65
+ /** p 是不是 root 本身或在它里面(按真实路径,软链接、大小写都算上) */
66
+ export function within(p, root) {
67
+ const a = fold(realish(p))
68
+ const b = fold(realish(root))
69
+ return a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep)
70
+ }
71
+
72
+ /**
73
+ * 代码默认放哪:会话目录是个前端项目、又不在设计目录里 → 就放会话目录;
74
+ * 否则新建 <设计名>-app:设计目录在会话目录下就建在会话目录里,不然建在设计目录旁边(别塞进设计目录)
75
+ */
76
+ export function defaultDest(root, cwd, project) {
77
+ const inside = !!root && within(cwd, root)
78
+ if (project && project.pkg && !inside) return cwd
79
+ const base = (root ? path.basename(root) : 'app').replace(/[^\w-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'app'
80
+ const name = base === 'app' || base.endsWith('-app') ? `${base}-code` : `${base}-app`
81
+ const under = !!root && !inside && within(root, cwd)
82
+ return path.join(root && !under ? path.dirname(root) : cwd, name)
83
+ }
84
+
85
+ /** 文件名 → 建议路由:index / home → /,别的 /名字 */
86
+ export function routeOf(key) {
87
+ const b = String(key).replace(/\.html?$/i, '').toLowerCase()
88
+ return b === 'index' || b === 'home' ? '/' : '/' + b.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
89
+ }
90
+
91
+ const CLASS_TAGS = /^(header|nav|footer|aside|section|div|a|button|ul|ol|li|form|article|main|figure)$/
92
+
93
+ /** 几屏共用的 class(≥ 2 屏都有,多半该抽成组件):[{ cls, n, screens }] */
94
+ export function sharedParts(htmlByKey) {
95
+ const seen = new Map()
96
+ for (const [key, html] of htmlByKey) {
97
+ const mine = new Set()
98
+ for (const m of String(html).matchAll(/<([a-z][a-z0-9-]*)\b[^>]*?\bclass\s*=\s*("([^"]*)"|'([^']*)')/gi)) {
99
+ if (!CLASS_TAGS.test(m[1].toLowerCase())) continue
100
+ for (const c of (m[3] || m[4] || '').split(/\s+/)) if (c && c.length <= 40 && !/^(is-|has-|active|on|sel|selected|current|hidden|show|open)$/.test(c)) mine.add(c)
101
+ }
102
+ for (const c of mine) { const e = seen.get(c) || { cls: c, n: 0, screens: [] }; e.n++; e.screens.push(key); seen.set(c, e) }
103
+ }
104
+ const all = [...seen.values()].filter((e) => e.n >= 2)
105
+ // 外层的名字(.tabbar)比里面的(.tabbar-item)更像一个组件:前缀是别的共用 class 的就不单列
106
+ const names = new Set(all.map((e) => e.cls))
107
+ return all.filter((e) => ![...names].some((n) => n !== e.cls && (e.cls.startsWith(n + '-') || e.cls.startsWith(n + '__'))))
108
+ .sort((a, b) => b.n - a.n || a.cls.localeCompare(b.cls)).slice(0, 14)
109
+ }
110
+
111
+ const pascal = (s) => s.replace(/(^|[-_]+)([a-z0-9])/gi, (_m, _a, c) => c.toUpperCase())
112
+
113
+ /** 截图 + 每屏的标题、跳转:每屏在无头浏览器里按设计尺寸开一次,从上到下截几段 */
114
+ async function shootScreens(browser, screens, size, kind, dir) {
115
+ const [w, h] = size
116
+ const out = []
117
+ await Promise.all(screens.map(async (sc, i) => {
118
+ const row = { key: sc.key, label: sc.label || sc.key, title: '', links: [], shots: [], page: 0, error: '' }
119
+ try {
120
+ await browser.withPage({ url: sc.url, w, h }, async (page) => {
121
+ const { info } = await prepare(page, false)
122
+ row.title = info && info.title ? String(info.title).slice(0, 40) : ''
123
+ row.page = info ? info.page : 0
124
+ row.links = await page.eval('window.__dshDesign.links ? window.__dshDesign.links() : []').catch(() => [])
125
+ const s0 = await page.eval('window.__dshDesign.scroll(0)')
126
+ const of = Math.min(MAX_PARTS, (s0 && s0.of) || 1)
127
+ for (let p = 0; p < of; p++) {
128
+ if (p) await page.eval(`window.__dshDesign.scroll(${p})`)
129
+ const name = `${sc.key.replace(/\.html?$/i, '')}-${p + 1}.jpg`
130
+ fs.writeFileSync(path.join(dir, name), await page.shot())
131
+ row.shots.push(name)
132
+ }
133
+ if ((s0 && s0.of) > MAX_PARTS) row.more = s0.of - MAX_PARTS
134
+ })
135
+ // 网页再截一张手机宽的顶上一屏(看响应式怎么排)
136
+ if (kind === 'web' && w > 600) {
137
+ await browser.withPage({ url: sc.url, w: 390, h: 844 }, async (page) => {
138
+ await prepare(page, false)
139
+ const name = `${sc.key.replace(/\.html?$/i, '')}-mobile.jpg`
140
+ fs.writeFileSync(path.join(dir, name), await page.shot())
141
+ row.mobile = name
142
+ })
143
+ }
144
+ } catch (e) {
145
+ row.error = String((e && e.message) || e).slice(0, 120)
146
+ }
147
+ out[i] = row
148
+ }))
149
+ return out
150
+ }
151
+
152
+ /** 是软链接就先删掉链接本身(不碰它指的文件),再写的时候写成普通文件 */
153
+ export function unlinkIfLink(file) {
154
+ try { if (fs.lstatSync(file).isSymbolicLink()) fs.unlinkSync(file) } catch { /* 没有 */ }
155
+ }
156
+
157
+ /**
158
+ * 生成交接包。screens:[{ key, url, label }](预览服务器上的地址);root:设计目录。
159
+ * @returns { file, dir, md, shots, rows }
160
+ */
161
+ export async function buildHandoff(browser, { root, screens, kind = 'web', size = [1280, 800], framework = 'react', dest = '', project = null, system = '', now = new Date() }) {
162
+ const dir = path.join(root, HANDOFF_DIR)
163
+ const shotDir = path.join(dir, 'shots')
164
+ // handoff/、handoff/shots/ 得是设计目录里真的文件夹:是软链接就可能删到、写到别处去
165
+ for (const d of [dir, shotDir]) {
166
+ let st = null
167
+ try { st = fs.lstatSync(d) } catch { fs.mkdirSync(d) }
168
+ if (st && !st.isDirectory()) throw new Error(`${path.relative(root, d)} 不是普通文件夹(是软链接或文件),先挪走再交接`)
169
+ }
170
+ for (const f of fs.readdirSync(shotDir)) if (/\.jpg$/i.test(f)) fs.rmSync(path.join(shotDir, f), { force: true })
171
+ const list = screens.slice(0, MAX_SCREENS)
172
+ const rows = browser && browser.available() ? await shootScreens(browser, list, size, kind, shotDir) : list.map((sc) => ({ key: sc.key, label: sc.label, title: '', links: [], shots: [], error: '没有 Chrome / Edge,没截图' }))
173
+ const htmlByKey = new Map()
174
+ for (const sc of list) { try { htmlByKey.set(sc.key, fs.readFileSync(path.join(root, sc.key), 'utf8')) } catch { /* 读不了 */ } }
175
+ const md = handoffMd({ root, rows, kind, size, framework, dest, project, system, htmlByKey, more: Math.max(0, screens.length - MAX_SCREENS), now })
176
+ const file = path.join(dir, 'HANDOFF.md')
177
+ unlinkIfLink(file)
178
+ fs.writeFileSync(file, md)
179
+ return { file, dir, md, shots: rows.reduce((m, r) => m + r.shots.length + (r.mobile ? 1 : 0), 0), rows }
180
+ }
181
+
182
+ /** 交接说明 */
183
+ export function handoffMd({ root, rows, kind, size, framework, dest, project, system, htmlByKey, more = 0, now = new Date() }) {
184
+ const fw = FRAMEWORKS[framework] || null
185
+ const same = framework === 'same' && project && project.framework
186
+ const fwLabel = same ? `${project.label}(跟项目一样)` : fw ? fw.label : framework
187
+ const name = path.basename(root)
188
+ const keys = rows.map((r) => r.key)
189
+ const route = (k) => routeOf(k)
190
+ const keyOfLink = (p) => String(p || '').replace(/^\/+/, '').split(/[?#]/)[0]
191
+ const lines = []
192
+ lines.push(`# 交接:${name} → ${fwLabel}`, '')
193
+ lines.push(`> 给写代码的模型看:照这份把设计稿做成真页面。设计稿就是源码——每屏一个 HTML,样式在 tokens.css 等样式表里。先读源文件,截图只用来对照,别凭截图猜数值。`, '')
194
+ lines.push('## 做什么', '')
195
+ lines.push(`- 设计目录:\`${root}\`(${rows.length} 屏${more ? `,另有 ${more} 屏没列进来` : ''},${kind} ${size[0]}×${size[1]})`)
196
+ if (project && project.pkg) lines.push(`- 做到:\`${dest || project.dir}\`(现有项目 ${project.name}:${project.label || '没认出框架'}${project.tailwind ? ' + Tailwind' : ''}${project.ts ? ',TypeScript' : ''},包管理 ${project.pm})`)
197
+ else if (fw && fw.create) lines.push(`- 做到:\`${dest}\`(新建项目:\`${fw.create.replace('{name}', path.basename(dest || name + '-app'))}\`,在 \`${path.dirname(dest || root)}\` 里跑)`)
198
+ else lines.push(`- 做到:\`${dest}\``)
199
+ lines.push(`- 框架:${fwLabel}`)
200
+ if (system) lines.push(`- 设计系统:${system}(设计用的是它的 tokens.css / components.css,项目里也照它)`)
201
+ lines.push(`- 生成时间:${now.toISOString().slice(0, 16).replace('T', ' ')}`, '')
202
+
203
+ lines.push('## 屏幕和路由', '', '| 屏 | 设计文件 | 建议路由 | 截图(handoff/shots/) | 跳到 |', '| --- | --- | --- | --- | --- |')
204
+ for (const r of rows) {
205
+ const to = [...new Set((r.links || []).map((l) => keyOfLink(l.path)).filter((k) => k && k !== r.key))]
206
+ const shots = r.error ? `没截成(${r.error})` : [...r.shots, r.mobile].filter(Boolean).join('、') + (r.more ? `(还有 ${r.more} 段没截)` : '')
207
+ lines.push(`| ${r.title || r.label} | ${r.key} | \`${route(r.key)}\` | ${shots} | ${to.map((k) => (keys.includes(k) ? k.replace(/\.html?$/i, '') : `${k}(没有这一屏)`)).join('、') || '—'} |`)
208
+ }
209
+ lines.push('')
210
+
211
+ // 设计变量
212
+ let tok = {}
213
+ let hasTokens = false
214
+ try { tok = tokensOf(fs.readFileSync(path.join(root, 'tokens.css'), 'utf8')); hasTokens = true } catch { tok = {} }
215
+ const tn = Object.keys(tok)
216
+ lines.push('## 设计变量', '')
217
+ if (tn.length) {
218
+ const colors = tn.filter((n) => /^--colou?r-/.test(n))
219
+ lines.push(`\`tokens.css\` 里 ${tn.length} 个变量(颜色 ${colors.length} 个:${colors.slice(0, 10).map((n) => `\`${n}: ${tok[n]}\``).join('、')}${colors.length > 10 ? ' …' : ''})。`, '')
220
+ lines.push(project && project.tailwind
221
+ ? '- 把 tokens.css 原样放进项目的全局样式并在入口引入;Tailwind 的 theme.extend 里把颜色、圆角、字号、阴影映射到这些变量(如 `primary: \'var(--color-primary)\'`),类名里别写死色值。'
222
+ : '- 把 tokens.css 原样放进项目的全局样式(如 `src/styles/tokens.css`)并在入口引入;组件样式里一律用 `var(--…)`,别写死色值、字号。')
223
+ } else if (hasTokens) lines.push('- `tokens.css` 的第一段 `:root` 里没读到变量:先打开看看,把各屏用到的颜色、字号收成 `:root` 上的变量再动手。')
224
+ else lines.push('- 设计目录里没有 tokens.css:先把各屏 `<style>` 里 `:root` 上的变量收成一份 tokens.css 再动手。')
225
+ const sheets = (() => { try { return fs.readdirSync(root).filter((n) => /\.css$/i.test(n) && n !== 'tokens.css') } catch { return [] } })()
226
+ if (sheets.length) lines.push(`- 别的样式表:${sheets.map((n) => `\`${n}\``).join('、')}(公共样式,拆进对应组件或全局样式)`)
227
+ lines.push('')
228
+
229
+ // 共用部分
230
+ const parts = sharedParts(htmlByKey)
231
+ lines.push('## 几屏共用的部分(做成组件)', '')
232
+ if (parts.length) for (const p of parts) lines.push(`- \`.${p.cls}\`:${p.n} 屏都有(${p.screens.map((k) => k.replace(/\.html?$/i, '')).join('、')})→ \`${pascal(p.cls)}\` 组件`)
233
+ else lines.push('- 没找到几屏共用的结构;按页面拆就行。')
234
+ if (fs.existsSync(path.join(root, 'icons.svg'))) {
235
+ let n = 0
236
+ try { n = (fs.readFileSync(path.join(root, 'icons.svg'), 'utf8').match(/<symbol\b/g) || []).length } catch { n = 0 }
237
+ lines.push(`- \`icons.svg\`:${n} 个图标(<symbol id>)→ 一个 \`Icon\` 组件按 id 取,或拆成单个 SVG 组件`)
238
+ }
239
+ lines.push('')
240
+
241
+ // 交互和状态
242
+ lines.push('## 交互和状态', '')
243
+ const inter = []
244
+ let hover = 0
245
+ for (const n of [...sheets, 'tokens.css']) { try { hover += (fs.readFileSync(path.join(root, n), 'utf8').match(/:(hover|focus-visible|focus|active)\b/g) || []).length } catch { /* 没有 */ } }
246
+ for (const [key, html] of htmlByKey) {
247
+ const scripts = [...html.matchAll(/<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi)].filter((m) => m[1].trim()).length
248
+ const forms = (html.match(/<(input|textarea|select)\b/gi) || []).length
249
+ hover += (html.match(/:(hover|focus-visible|focus|active)\b/g) || []).length
250
+ const bits = []
251
+ if (scripts) bits.push(`${scripts} 段脚本(改成框架里的状态和事件)`)
252
+ if (forms) bits.push(`${forms} 个输入控件(做成受控组件、加校验和出错状态)`)
253
+ if (/data-href=/.test(html)) bits.push('用 data-href 跳转的元素(改成路由跳转)')
254
+ if (bits.length) inter.push(`- ${key}:${bits.join(';')}`)
255
+ }
256
+ lines.push(...(inter.length ? inter : ['- 设计稿里没有脚本和表单,纯展示。']))
257
+ if (hover) lines.push(`- 悬停 / 聚焦 / 按下的样式 ${hover} 处:照搬,别丢。`)
258
+ if ([...htmlByKey.values()].some((h) => /prefers-color-scheme\s*:\s*dark|data-theme=["']?dark/.test(h)) || /\[data-theme=["']?dark/.test(Object.keys(tok).length ? fs.readFileSync(path.join(root, 'tokens.css'), 'utf8') : '')) lines.push('- 有深色样式:项目里也要能切(跟系统,或 `<html data-theme="dark">`)。')
259
+ const tw = readTweaks(root)
260
+ if (tw && tw.list.length) lines.push(`- 调节滑块(tweaks.json):${tw.list.map((k) => `${k.label}(${k.var || k.attr},现在是 ${shownValue(k, k.value)})`).join('、')}。最终值已经写在样式里;要在产品里留着可调,就做成主题设置(改同一个 CSS 变量 / <html> 属性)。`)
261
+ lines.push('')
262
+
263
+ // 素材
264
+ const assets = []
265
+ const walk = (d, rel, depth) => {
266
+ if (depth > 2 || assets.length >= 40) return
267
+ let ents = []
268
+ try { ents = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
269
+ for (const e of ents) {
270
+ if (e.name.startsWith('.') || /^(exports|refs|handoff|drafts|node_modules)$/.test(e.name)) continue
271
+ const r = rel ? `${rel}/${e.name}` : e.name
272
+ if (e.isDirectory()) walk(path.join(d, e.name), r, depth + 1)
273
+ else if (/\.(png|jpe?g|webp|gif|avif|svg|woff2?|ttf|otf|mp4|webm)$/i.test(e.name) && e.name !== 'icons.svg') assets.push(r)
274
+ }
275
+ }
276
+ walk(root, '', 0)
277
+ lines.push('## 图片和字体', '')
278
+ lines.push(assets.length ? `- ${assets.slice(0, 30).map((a) => `\`${a}\``).join('、')}${assets.length > 30 ? ' …' : ''}:照原样复制进项目的静态资源目录(public/ 或 src/assets/)` : '- 没有单独的图片、字体文件(图形都写在 HTML / SVG 里)。', '')
279
+
280
+ // 做法
281
+ const create = !(project && project.pkg) && fw && fw.create ? fw.create.replace('{name}', path.basename(dest || name + '-app')) : ''
282
+ lines.push('## 做法和验收', '')
283
+ lines.push(`1. 搭骨架:${create ? `新建项目 \`${create}\`,装好依赖${fw.router ? `和 ${fw.router}` : ''}` : framework === 'html' ? '整理成一个静态站点目录(每屏一个页面,公共样式一份)' : `在现有项目里加页面和路由(看清它现有的目录结构、样式写法、组件库再动手)`};把 tokens.css${sheets.length ? '(和公共样式表)' : ''}搬进去。`)
284
+ lines.push('2. 一屏一个页面,路由照上面的表;先抽几屏共用的组件,再拼页面。')
285
+ lines.push('3. 结构、文案、数字、图标照设计稿的 HTML 搬;class 名可以改成项目的习惯,样式值(间距、字号、颜色、圆角、阴影)一律用设计变量。')
286
+ lines.push('4. 每做完一屏:把开发服务器跑起来,调 `compare_design`(screen 填设计文件名,url 填这一屏在开发服务器上的地址),照差异图把不一样的地方改掉,直到差异在 3% 以内,或者剩下的差别说得出理由(比如真实数据、系统字体)。')
287
+ lines.push('5. 全部做完:每屏再对一遍;告诉用户做了哪些页面、怎么跑起来(命令和地址)、还有哪里和设计稿不一样。')
288
+ lines.push('', '别做:改设计(配色、文案、布局)、加设计稿里没有的功能、引入新的 UI 组件库(项目已经在用的除外)。', '')
289
+ return lines.join('\n')
290
+ }
291
+
292
+ // ── compare_design:设计稿 vs 做出来的页面 ─────────────────────────────────────
293
+
294
+ // 页面上看得见的「块」(有字的、图片、按钮、有底色或边框的),差异区域按它说是哪个元素
295
+ const BLOCKS = `(() => {
296
+ const out = []
297
+ const W = innerWidth, H = innerHeight
298
+ for (const el of document.body ? document.body.querySelectorAll('*') : []) {
299
+ if (out.length >= 600) break
300
+ const r = el.getBoundingClientRect()
301
+ if (r.width < 4 || r.height < 4 || r.bottom < 0 || r.top > H || r.right < 0 || r.left > W) continue
302
+ const s = getComputedStyle(el)
303
+ if (s.visibility === 'hidden' || +s.opacity < 0.05 || s.display === 'none') continue
304
+ let text = ''
305
+ for (const n of el.childNodes) if (n.nodeType === 3) text += n.nodeValue
306
+ text = text.replace(/\\s+/g, ' ').trim()
307
+ const tag = el.tagName.toLowerCase()
308
+ if (/^(script|style|path|g|use|defs|symbol)$/.test(tag)) continue
309
+ const leaf = text || /^(img|svg|button|input|select|textarea|video|canvas|picture)$/.test(tag)
310
+ const box = s.backgroundColor !== 'rgba(0, 0, 0, 0)' || s.boxShadow !== 'none' || parseFloat(s.borderTopWidth) > 0
311
+ if (!leaf && !box) continue
312
+ const cls = typeof el.className === 'string' ? el.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : []
313
+ out.push({ d: tag + (el.id ? '#' + el.id : '') + cls.map((c) => '.' + c).join(''), t: (text || el.getAttribute('alt') || el.getAttribute('aria-label') || '').slice(0, 24), x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) })
314
+ }
315
+ return out
316
+ })()`
317
+
318
+ // 等单页应用画出东西来:body 里有字 / 有图,最多等 4 秒;再等字体
319
+ const SETTLE = `new Promise((res) => {
320
+ const t0 = Date.now()
321
+ ;(function w() {
322
+ const b = document.body
323
+ if ((b && (b.innerText.trim().length > 0 || b.querySelector('img,svg,canvas'))) || Date.now() - t0 > 4000) {
324
+ Promise.race([document.fonts ? document.fonts.ready : null, new Promise((r) => setTimeout(r, 2500))]).then(() => setTimeout(() => res(true), 500))
325
+ } else setTimeout(w, 100)
326
+ })()
327
+ })`
328
+
329
+ // 滚到底:页面本身和里面能滚的区域(App 常把内容放在一个能滚的 main 里)都滚到底,再等新露出来的东西画好
330
+ const SCROLL_END = `new Promise((res) => {
331
+ const se = document.scrollingElement || document.documentElement
332
+ scrollTo({ left: 0, top: se.scrollHeight, behavior: 'instant' })
333
+ const all = document.body ? document.body.getElementsByTagName('*') : []
334
+ for (let i = 0, n = 0; i < all.length && n < 20; i++) {
335
+ const el = all[i]
336
+ if (el.scrollHeight <= el.clientHeight + 1 || el.clientHeight < 40 || (el.id || '').startsWith('__dsh')) continue
337
+ if (/auto|scroll|overlay/.test(getComputedStyle(el).overflowY)) { el.scrollTo({ left: el.scrollLeft, top: el.scrollHeight, behavior: 'instant' }); n++ }
338
+ }
339
+ Promise.race([document.fonts ? document.fonts.ready : null, new Promise((r) => setTimeout(r, 1500))]).then(() => setTimeout(() => res(true), 300))
340
+ })`
341
+
342
+ // 做出来的页面切深色:和设计稿那边(picker 的 dark)一模一样——样式里有 [data-theme="dark"] / .dark / [data-mode="dark"]
343
+ // 或 prefers-color-scheme: dark 的规则,就在 <html> 上打上属性,再在最后补一段 color-scheme: dark 和那些媒体规则
344
+ const IMPL_DARK = `new Promise((res) => {
345
+ const media = []
346
+ let attr = false
347
+ const scan = (list) => {
348
+ for (const r of list) {
349
+ if (r.media && /prefers-color-scheme\\s*:\\s*dark/i.test(r.media.mediaText || '')) {
350
+ for (const x of r.cssRules) media.push(x.cssText)
351
+ continue
352
+ }
353
+ if (r.selectorText && /\\[data-theme=["']?dark|\\.dark\\b|\\[data-mode=["']?dark/i.test(r.selectorText)) attr = true
354
+ if (r.cssRules && !r.media) { try { scan(r.cssRules) } catch (e) { /* 读不了 */ } }
355
+ }
356
+ }
357
+ for (const s of document.styleSheets) { try { scan(s.cssRules) } catch (e) { /* 别的源 */ } }
358
+ if (!attr && !media.length) return res(false)
359
+ const de = document.documentElement
360
+ de.setAttribute('data-theme', 'dark')
361
+ de.setAttribute('data-mode', 'dark')
362
+ de.classList.add('dark')
363
+ const tag = document.createElement('style')
364
+ tag.id = '__dsh_design_dark__'
365
+ tag.textContent = ':root{color-scheme:dark}\\n' + media.join('\\n')
366
+ ;(document.head || de).appendChild(tag)
367
+ setTimeout(() => res(true), 400)
368
+ })`
369
+
370
+ /** 做出来的页面只许开本机的:localhost / 127.0.0.1 / ::1 的 http(s)、本地文件(跳转、脚本改地址也一样查) */
371
+ export function localOnly(u) {
372
+ let x
373
+ try { x = new URL(u) } catch { return false }
374
+ if (/^(file|about|data|blob):$/.test(x.protocol)) return true
375
+ return /^https?:$/.test(x.protocol) && /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$/i.test(x.hostname)
376
+ }
377
+
378
+ /* eslint-disable no-undef */
379
+ /** 在空白页里比两张无损截图:逐像素,每个点三个通道差加起来超过 24 就算不一样 → 差异比例、差得多的几块、拼图 */
380
+ async function diffIn(a, b, labels) {
381
+ const load = (src) => new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error('图片载不出来')); i.src = 'data:image/png;base64,' + src })
382
+ const [A, B] = await Promise.all([load(a), load(b)])
383
+ const w = A.width, h = A.height
384
+ const c = document.createElement('canvas')
385
+ c.width = w
386
+ c.height = h
387
+ const g = c.getContext('2d', { willReadFrequently: true })
388
+ g.drawImage(A, 0, 0)
389
+ const da = g.getImageData(0, 0, w, h).data
390
+ g.clearRect(0, 0, w, h)
391
+ g.drawImage(B, 0, 0, w, h)
392
+ const db = g.getImageData(0, 0, w, h).data
393
+ const CELL = 16
394
+ const cw = Math.ceil(w / CELL), ch = Math.ceil(h / CELL)
395
+ const cells = new Float32Array(cw * ch)
396
+ const heat = g.createImageData(w, h)
397
+ let bad = 0
398
+ for (let y = 0; y < h; y++) {
399
+ for (let x = 0; x < w; x++) {
400
+ const i = (y * w + x) * 4
401
+ const d = Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2])
402
+ const hit = d > 24
403
+ const l = (da[i] * 0.3 + da[i + 1] * 0.59 + da[i + 2] * 0.11) * 0.3 + 175
404
+ if (hit) { bad++; cells[((y / CELL) | 0) * cw + ((x / CELL) | 0)]++ }
405
+ heat.data[i] = hit ? 232 : l
406
+ heat.data[i + 1] = hit ? 56 : l
407
+ heat.data[i + 2] = hit ? 48 : l
408
+ heat.data[i + 3] = 255
409
+ }
410
+ }
411
+ // 差得多的格子连成块,按差的像素数排
412
+ const on = (i) => cells[i] / (CELL * CELL) > 0.12
413
+ const seen = new Uint8Array(cw * ch)
414
+ const boxes = []
415
+ for (let i = 0; i < cw * ch; i++) {
416
+ if (seen[i] || !on(i)) continue
417
+ const q = [i]
418
+ seen[i] = 1
419
+ let x0 = cw, y0 = ch, x1 = 0, y1 = 0, score = 0
420
+ while (q.length) {
421
+ const j = q.pop()
422
+ const x = j % cw, y = (j / cw) | 0
423
+ score += cells[j]
424
+ x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y)
425
+ for (const [nx, ny] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
426
+ if (nx < 0 || ny < 0 || nx >= cw || ny >= ch) continue
427
+ const k = ny * cw + nx
428
+ if (!seen[k] && on(k)) { seen[k] = 1; q.push(k) }
429
+ }
430
+ }
431
+ if (score >= 120) boxes.push({ x: x0 * CELL, y: y0 * CELL, w: Math.min(w, (x1 + 1) * CELL) - x0 * CELL, h: Math.min(h, (y1 + 1) * CELL) - y0 * CELL, score })
432
+ }
433
+ boxes.sort((p, q) => q.score - p.score)
434
+ const top = boxes.slice(0, 6)
435
+ // 拼图:设计稿 | 做出来的 | 差异,差得多的几块标上号
436
+ const PAD = 20, HEAD = 44
437
+ const s = Math.min(1, 2400 / (w * 3 + PAD * 4))
438
+ const out = document.createElement('canvas')
439
+ out.width = Math.round((w * 3 + PAD * 4) * s)
440
+ out.height = Math.round((h + HEAD + PAD) * s)
441
+ const o = out.getContext('2d')
442
+ o.scale(s, s)
443
+ o.fillStyle = '#eef0f3'
444
+ o.fillRect(0, 0, w * 3 + PAD * 4, h + HEAD + PAD)
445
+ const hc = document.createElement('canvas')
446
+ hc.width = w
447
+ hc.height = h
448
+ hc.getContext('2d').putImageData(heat, 0, 0)
449
+ const pct = (bad / (w * h)) * 100
450
+ ;[[A, labels[0]], [B, labels[1]], [hc, labels[2].replace('{pct}', pct.toFixed(1))]].forEach(([img, label], k) => {
451
+ const x = PAD + k * (w + PAD)
452
+ o.fillStyle = '#1f2329'
453
+ o.font = '600 22px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
454
+ o.fillText(label, x, 30)
455
+ o.drawImage(img, x, HEAD)
456
+ o.strokeStyle = 'rgba(0,0,0,.12)'
457
+ o.lineWidth = 1
458
+ o.strokeRect(x - 0.5, HEAD - 0.5, w + 1, h + 1)
459
+ if (k === 0) return
460
+ top.forEach((bx, n) => {
461
+ o.strokeStyle = '#e8382f'
462
+ o.lineWidth = 3
463
+ o.strokeRect(x + bx.x, HEAD + bx.y, bx.w, bx.h)
464
+ o.fillStyle = '#e8382f'
465
+ o.fillRect(x + bx.x, HEAD + bx.y - 22 < HEAD ? HEAD + bx.y : HEAD + bx.y - 22, 22, 22)
466
+ o.fillStyle = '#fff'
467
+ o.font = '700 15px -apple-system, sans-serif'
468
+ o.fillText(String(n + 1), x + bx.x + 6, (HEAD + bx.y - 22 < HEAD ? HEAD + bx.y : HEAD + bx.y - 22) + 16)
469
+ })
470
+ })
471
+ return { jpg: out.toDataURL('image/jpeg', 0.84).split(',')[1], pct, boxes: top, w, h }
472
+ }
473
+ /* eslint-enable no-undef */
474
+
475
+ /** 差异区域最像哪个元素:重叠面积 / 两者里大的那个,取最大的 */
476
+ export function blockAt(blocks, bx) {
477
+ let best = null
478
+ let score = 0
479
+ for (const b of blocks || []) {
480
+ const ix = Math.max(0, Math.min(bx.x + bx.w, b.x + b.w) - Math.max(bx.x, b.x))
481
+ const iy = Math.max(0, Math.min(bx.y + bx.h, b.y + b.h) - Math.max(bx.y, b.y))
482
+ const inter = ix * iy
483
+ if (!inter) continue
484
+ const s = inter / Math.max(bx.w * bx.h, b.w * b.h)
485
+ if (s > score) { score = s; best = b }
486
+ }
487
+ return best
488
+ }
489
+
490
+ const describe = (b) => (b ? `${b.d}${b.t ? `「${b.t}」` : ''}` : '(空白处)')
491
+
492
+ /** 做出来的页面地址:本机的 http(s),或本地 HTML 文件 */
493
+ export function implUrl(input, cwd) {
494
+ const s = String(input || '').trim()
495
+ if (/^\d{2,5}$/.test(s)) return `http://localhost:${s}/`
496
+ if (/^https?:\/\//i.test(s)) {
497
+ const u = new URL(s)
498
+ if (!/^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$/i.test(u.hostname)) throw new Error('只比本机的页面(localhost 地址或本地 HTML 文件)')
499
+ return u.href
500
+ }
501
+ if (/^(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(s)) return 'http://' + s
502
+ const p = path.resolve(cwd || '.', s)
503
+ if (!fs.existsSync(p)) throw new Error(`找不到 ${p}`)
504
+ return pathToFileURL(p).href
505
+ }
506
+
507
+ /**
508
+ * 比一屏:设计稿(预览服务器上的地址,带设计台脚本)vs 做出来的页面,同样尺寸、同样滚动位置。
509
+ * @returns { pct, boxes: [{ x, y, w, h, design, impl }], jpg, text }
510
+ */
511
+ export async function compareScreens(browser, { design, impl, size, at = 'top', dark = false }) {
512
+ const [w, h] = size
513
+ // 两边一样准备:等画好 → 深色 → 滚到同一个位置(页面和里面能滚的区域都滚)→ 无损截图
514
+ let blocked = []
515
+ const shoot = (url, isDesign) => browser.withPage({ url, w, h, dark, allow: isDesign ? null : localOnly }, async (page) => {
516
+ if (!isDesign && page.blocked) blocked = page.blocked
517
+ if (isDesign) await prepare(page, dark)
518
+ else {
519
+ await page.eval(SETTLE)
520
+ // 跳外网被拦下后停在了出错页:比它没意义
521
+ if (blocked.length && (await page.eval('location.protocol').catch(() => '')) === 'chrome-error:') throw new Error('blocked')
522
+ if (dark) await page.eval(IMPL_DARK)
523
+ }
524
+ if (at === 'bottom') await page.eval(SCROLL_END)
525
+ const png = await page.shot({ format: 'png' })
526
+ const blocks = await page.eval(BLOCKS).catch(() => [])
527
+ const pageH = await page.eval('Math.max(document.documentElement.scrollHeight, innerHeight)').catch(() => 0)
528
+ return { png, blocks, pageH, blocked: page.blocked ? page.blocked.slice() : [] }
529
+ })
530
+ // 一个接一个截:同时开两个页面时系统字体(中文)偶尔还没画好就截了,差异会虚高
531
+ const A = await shoot(design.url, true)
532
+ const B = await shoot(impl, false).catch((e) => {
533
+ // 页面自己跳到外网被拦下、截图时页面没了:说清楚是跳走了
534
+ if (blocked.length) throw new Error(`做出来的页面跳到了本机以外的地址,拦下了:${blocked[0].slice(0, 120)}`)
535
+ throw e
536
+ })
537
+ const labels = ['设计稿', '做出来的', '差异 {pct}%']
538
+ const r = await browser.withPage({ url: 'about:blank', w: 200, h: 200 }, (page) => page.eval(`(${diffIn})(${JSON.stringify(A.png.toString('base64'))}, ${JSON.stringify(B.png.toString('base64'))}, ${JSON.stringify(labels)})`))
539
+ const boxes = r.boxes.map((bx) => ({ ...bx, design: blockAt(A.blocks, bx), impl: blockAt(B.blocks, bx) }))
540
+ const lines = [`${design.label || design.key}(${w}×${h}${at === 'bottom' ? ',滚到底' : ',顶上一屏'}):设计稿 vs ${impl}`]
541
+ lines.push(`差异 ${r.pct.toFixed(1)}%(逐像素无损比)${r.pct < 3 ? ':基本一致' : r.pct < 10 ? ':大体对上了,下面几块要改' : ':差得比较多'}`)
542
+ if (B.blocked.length) lines.push(`做出来的页面想跳到本机以外的地址,拦下了:${B.blocked.slice(0, 3).join('、')}`)
543
+ if (A.pageH && B.pageH && Math.abs(A.pageH - B.pageH) > 24) lines.push(`页面高度:设计稿 ${A.pageH}px,做出来的 ${B.pageH}px(差 ${B.pageH - A.pageH > 0 ? '+' : ''}${B.pageH - A.pageH}px,多半是间距 / 字号 / 行高不一样)`)
544
+ if (boxes.length) {
545
+ lines.push('差得最多的几块(图里红框的号):')
546
+ boxes.forEach((b, i) => lines.push(`${i + 1}. 位置 ${b.x},${b.y}(${b.w}×${b.h}):设计稿是 ${describe(b.design)},做出来的是 ${describe(b.impl)}`))
547
+ lines.push('整块挪了位置先查上面的间距和高度;颜色不对查设计变量;字的宽度不一样查字体、字号、字重。')
548
+ } else lines.push('没有成片的差异。')
549
+ return { pct: r.pct, boxes, jpg: Buffer.from(r.jpg, 'base64'), text: lines.join('\n') }
550
+ }
551
+
552
+ /** 设计目录里的几屏(交接、对比时按文件名找) */
553
+ export function screensOf(root) {
554
+ return listScreens(root)
555
+ }
package/lib/history.js CHANGED
@@ -12,7 +12,7 @@ import path from 'node:path'
12
12
  import crypto from 'node:crypto'
13
13
 
14
14
  const KEEP_EXT = /\.(html?|css|m?js|json|svg|txt|md|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf)$/i
15
- const SKIP_DIR = /^(node_modules|\.git|\.next|\.cache|\.turbo|dist|build|exports|refs)$|^\./
15
+ const SKIP_DIR = /^(node_modules|\.git|\.next|\.cache|\.turbo|dist|build|exports|refs|handoff)$|^\./
16
16
  const MAX_FILE = 2 * 1024 * 1024
17
17
  const MAX_TOTAL = 30 * 1024 * 1024
18
18
  const MAX_FILES = 300