dsh-design-studio 0.5.0 → 0.6.1

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/index.js CHANGED
@@ -54,6 +54,9 @@ import { qrMatrix } from './qr.js'
54
54
  import { makePdf, makePptx, jpegInfo } from './doc.js'
55
55
  import { createBrowser } from './browser.js'
56
56
  import { designOf, metaOfFile, sizeOf, pickScreens, measureAll, shootAll, cropIssues, formatMeasure, issueSnapshot, formatShots, whereOf, MAX_SCREENS, MAX_SIZES, MAX_SHOTS, MAX_CROPS } from './look.js'
57
+ import { SYSTEMS_DIR, extractSystem, listSystems, setDefault, skillSection } from './system.js'
58
+ import { readTweaks, applyTweaks, lintTweaks } from './tweaks.js'
59
+ import { FRAMEWORKS, detectProject, buildHandoff, compareScreens, implUrl, defaultDest, within } from './handoff.js'
57
60
 
58
61
  export const name = 'dsh-design-studio'
59
62
  export const inject = ['webServer']
@@ -86,8 +89,9 @@ export const designSkill = {
86
89
  name: 'ui-design',
87
90
  description:
88
91
  '做网页、App 界面、平板界面、海报 / 社媒图、幻灯片、邮件、组件这类界面设计之前先加载:' +
89
- '配合右侧「设计工作台」画布的做法(一屏一个文件、设计变量、类型标记)、现代设计原则、中文排版、各类型画幅要点、避免 AI 味、设计自查清单。',
90
- whenToUse: '用户要做或改任何看得见的界面 / 视觉设计时',
92
+ '配合右侧「设计工作台」画布的做法(一屏一个文件、设计变量、类型标记)、现代设计原则、中文排版、各类型画幅要点、避免 AI 味、设计自查清单。' +
93
+ '只是把回答做成一页给用户看或用(对比、表格、清单、图表、计算器这类)不算界面设计:有 show_page 工具就直接用它,不加载这个技能。',
94
+ whenToUse: '用户要做或改网站 / App / 海报 / 幻灯片这类界面或视觉设计时(把回答做成一页、有 show_page 工具时用 show_page)',
91
95
  invocation: { modelInvocable: true, userInvocable: true },
92
96
  provider: 'dsh-design-studio',
93
97
  source: 'bundled',
@@ -101,7 +105,10 @@ export const skillProvider = {
101
105
  list: () => Promise.resolve([designSkill]),
102
106
  async get() {
103
107
  const { name, description, whenToUse, invocation, provider, source, resourceBase } = designSkill
104
- return { name, description, whenToUse, invocation, provider, source, resourceBase, content: await fs.promises.readFile(designSkill.locator, 'utf8') }
108
+ // 用户的设计系统(默认用哪套、库里有哪些)每次现读,提了新的下次加载技能就知道
109
+ let extra = ''
110
+ try { extra = skillSection() } catch (e) { studio.log(e) }
111
+ return { name, description, whenToUse, invocation, provider, source, resourceBase, content: (await fs.promises.readFile(designSkill.locator, 'utf8')) + extra }
105
112
  },
106
113
  }
107
114
 
@@ -111,6 +118,7 @@ export const previewTool = {
111
118
  description:
112
119
  '在右侧栏「设计工作台」里实时打开一个页面,用户能边看边点选要改的地方。' +
113
120
  '做网页、App 界面、海报、幻灯片、邮件这类界面设计时用:写好第一个 HTML 文件、或启动开发服务器后调一次就行,之后改文件会自动刷新,不用再调。' +
121
+ '只是把回答做成一页(对比、表格、清单、图表、计算器)给用户看或用时,有 show_page 工具就用它,不用这里。' +
114
122
  '本地 HTML 写文件路径;开发服务器写地址或端口(如 http://localhost:5173)。只能预览本机页面。' +
115
123
  'kind 说明是哪类设计,右侧按它的画幅显示(App 套手机外框、幻灯片 16:9 …)。' +
116
124
  '动手设计前先加载 ui-design 技能(设计流程、规范和自查清单)。' +
@@ -248,6 +256,7 @@ export const checkTool = {
248
256
  while (lastChecks.size > 20) lastChecks.delete(lastChecks.keys().next().value)
249
257
  if (want.bad.length) text += `\n认不出的尺寸(没量):${want.bad.join('、')},写成 375x667 或 375`
250
258
  if (want.more) text += `\n尺寸太多,只量了前 ${MAX_SIZES} 种`
259
+ text += tweakNotes(id)
251
260
  return kept && kept.images ? { text, images: kept.images } : { text }
252
261
  }
253
262
  studio.log(new Error(`check_design 无头浏览器没量成:${rows[0] && rows[0].error}`))
@@ -261,10 +270,20 @@ export const checkTool = {
261
270
  const note = want.sizes.length || a.dark ? '(这台电脑上没找到能用的 Chrome / Edge,没法按指定尺寸 / 深色量;下面是右侧栏按它现在显示的尺寸量的)\n' : ''
262
271
  if (!r) return { text: '右侧栏「设计工作台」现在没开着,量不了。照 ui-design 技能里的「自查」清单自己过一遍。' }
263
272
  if (!r.error && !r.measured) return { text: '右侧栏开着,但预览页面没回应,这次没量成(可能还在加载)。过一会儿再调一次,或照 ui-design 技能里的「自查」清单自己过一遍。' }
264
- return { text: note + (r.error || r.text) }
273
+ return { text: note + (r.error || r.text) + tweakNotes(id) }
265
274
  },
266
275
  }
267
276
 
277
+ /** 调节滑块(tweaks.json)有没有真接上:check_design 末尾附上 */
278
+ function tweakNotes(id) {
279
+ let list = []
280
+ try {
281
+ const t = studio.tracker.stateOf(id).target
282
+ if (t && t.kind === 'file') list = lintTweaks(t.root)
283
+ } catch (e) { studio.log(e) }
284
+ return list.length ? `\n\n调节滑块(tweaks.json):\n${list.map((x) => `- ${x}`).join('\n')}` : ''
285
+ }
286
+
268
287
  /**
269
288
  * 浏览器给的规则(样式表地址 + 选择器 + 同一文件里第几条)→ 设计目录里的文件和行号。
270
289
  * 只认这个会话正在预览的本地目录里的文件(≤ 2MB);别的地址、插件自己的、跑出目录的给 null
@@ -359,6 +378,166 @@ export const lookTool = {
359
378
  },
360
379
  }
361
380
 
381
+ /** extract_design_system 工具:从用户的网站 / 项目里提一套设计系统,放进库里,以后做新设计都照它来 */
382
+ export const systemTool = {
383
+ name: 'extract_design_system',
384
+ description:
385
+ '从用户自己的网站或项目里提取一套设计系统(品牌的颜色、字体、字号阶梯、圆角、阴影,和按钮 / 输入框 / 卡片的样子),存进用户的设计系统库,默认设为以后做新设计都照它来。' +
386
+ '用户给了网站地址、项目文件夹或 HTML 文件,说「照我们官网的风格」「用我们的品牌」时用。网站地址最准:在无头浏览器里打开量真实渲染的样式、截组件;项目文件夹会扫 CSS / Tailwind 配置 / 主题文件,有 HTML 入口也会打开量。' +
387
+ '返回提出来的配色、字体、圆角、组件,和网站截图、组件截图、样张截图;文件(tokens.css、components.css、components.html 样张、DESIGN.md)在 ~/.dsh/design-systems/<名字>/ 里。' +
388
+ '提完对照截图检查角色猜得对不对(不对就改 tokens.css),把 DESIGN.md 的「品牌气质」补上,再用 preview_page 打开 components.html 给用户看。',
389
+ parameters: specToJsonSchema({
390
+ sources: { type: 'array', items: { type: 'string' }, required: true, description: '来源,1–5 个:网站地址(https://example.com,可以给同一个站的几页)、项目文件夹或 HTML 文件的路径' },
391
+ name: { type: 'string', description: '这套设计系统叫什么(品牌名),可选;不填从网站标题 / package.json 里猜' },
392
+ default: { type: 'boolean', description: '设为默认(以后做新设计都照它来),默认 true' },
393
+ }),
394
+ output: {
395
+ schema: {
396
+ type: 'object',
397
+ additionalProperties: false,
398
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
399
+ required: ['text'],
400
+ },
401
+ render: (_args, value) => withImages(value),
402
+ },
403
+ async execute(args, exec, ctx) {
404
+ const session = exec && exec.agent && exec.agent.session
405
+ const id = (session && session.id) || ''
406
+ const a = args || {}
407
+ const sources = (Array.isArray(a.sources) ? a.sources : [a.sources]).filter((x) => typeof x === 'string' && x.trim())
408
+ if (!sources.length) return { text: '没给来源:sources 写网站地址、项目文件夹或 HTML 文件。' }
409
+ const cwd = studio.tracker && id ? studio.tracker.cwdOf(id) : os.homedir()
410
+ let r
411
+ try {
412
+ r = await extractSystem({ browser: studio.browser, sources, name: typeof a.name === 'string' ? a.name : '', cwd, makeDefault: a.default !== false })
413
+ } catch (e) {
414
+ return { text: `没提成:${String((e && e.message) || e).slice(0, 300)}` }
415
+ }
416
+ if (!r.images.length) return { text: r.text }
417
+ const kept = await keepImages(ctx, id || 'system', r.images, true)
418
+ if (kept.images) return { text: r.text, images: kept.images }
419
+ return { text: `${r.text}\n(截图存在 ${path.join(r.dir, 'source')})` }
420
+ },
421
+ }
422
+
423
+ /** 设计目录里的 tokens.css 是不是从库里哪套设计系统复制来的(文件头写着「设计系统「名字」」) */
424
+ function systemOfDir(root) {
425
+ try {
426
+ const head = fs.readFileSync(path.join(root, 'tokens.css'), 'utf8').slice(0, 400)
427
+ const m = /设计系统「([^」]{1,40})」/.exec(head)
428
+ return m ? m[1] : ''
429
+ } catch {
430
+ return ''
431
+ }
432
+ }
433
+
434
+ /** 路径里的 ~ 换成家目录,相对路径按会话的目录算 */
435
+ const resolvePath = (p, cwd) => path.resolve(cwd, String(p).trim().replace(/^~(?=$|[\\/])/, os.homedir()))
436
+
437
+ /** handoff_design 工具:把设计整理成交接包(说明 + 每屏截图),照它做成真项目的代码 */
438
+ export const handoffTool = {
439
+ name: 'handoff_design',
440
+ description:
441
+ '把右侧「设计工作台」里的设计(本地 HTML 那几屏)整理成交接包,准备做成真项目的代码:写好 <设计目录>/handoff/HANDOFF.md(每屏的文件、建议路由、跳转、设计变量、几屏共用该抽成组件的部分、交互和状态、调节滑块、素材、做法和验收),和每屏从上到下的截图。' +
442
+ '用户说「转成代码」「做成真的项目」「用 React / Vue 做出来」时先调它,读 HANDOFF.md 照着做;每做完一屏用 compare_design 和设计稿比一遍,差异降到 3% 以下再做下一屏。' +
443
+ 'framework:same 跟目标目录现有项目一样(有 package.json 时默认)、react、vue、next、html;dest:代码放哪个目录(不填放在会话目录里新建)。要先用 preview_page 打开过设计。',
444
+ parameters: specToJsonSchema({
445
+ framework: { type: 'string', enum: ['same', ...Object.keys(FRAMEWORKS)], description: 'same 跟现有项目一样;react(React + Vite)、vue(Vue 3 + Vite)、next(Next.js)、html(纯 HTML / CSS)。不填:目标目录有项目就 same,没有就 react' },
446
+ dest: { type: 'string', description: '代码放哪:现有项目的目录,或要新建的项目目录(绝对路径或相对会话目录)。不填:会话目录有项目就放进去,没有就在会话目录下新建' },
447
+ screens: { type: 'array', items: { type: 'string' }, description: '只转哪几屏(文件名);不填全部' },
448
+ }),
449
+ output: {
450
+ schema: {
451
+ type: 'object',
452
+ additionalProperties: false,
453
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
454
+ required: ['text'],
455
+ },
456
+ render: (_args, value) => withImages(value),
457
+ },
458
+ async execute(args, exec, ctx) {
459
+ const id = toolSession(exec, '转成代码')
460
+ const a = args || {}
461
+ const snap = studio.tracker.snapshot(id)
462
+ if (!snap.url) return { text: '还没有在预览的设计:先用 preview_page 打开设计文件。' }
463
+ const t = studio.tracker.stateOf(id).target
464
+ if (!t || t.kind !== 'file') return { text: '现在预览的是开发服务器上的页面,它已经是代码了:直接在那个项目里改;要比对设计稿就先用 preview_page 打开设计的 HTML 文件。' }
465
+ const { kind, size } = designFor(id)
466
+ const pick = pickScreens(snap, a.screens, 12)
467
+ if (!pick.list.length) return { text: `没找到要转的屏:${pick.missing.join('、')}。现在有:${(snap.screens || []).map((x) => x.key).join('、')}` }
468
+ const cwd = studio.tracker.cwdOf(id)
469
+ // 先定下最终放哪(没给就和右侧栏建议的一样),再按真实路径查是不是在设计目录里
470
+ const dest = typeof a.dest === 'string' && a.dest.trim() ? resolvePath(a.dest, cwd) : defaultDest(t.root, cwd, detectProject(cwd))
471
+ if (within(dest, t.root)) return { text: `代码别放进设计目录(${t.root})里:设计稿留着做对比。换一个目录。` }
472
+ const project = detectProject(dest)
473
+ const framework = typeof a.framework === 'string' && (a.framework === 'same' || FRAMEWORKS[a.framework]) ? a.framework : project.pkg && project.framework ? 'same' : 'react'
474
+ if (framework === 'same' && !(project.pkg && project.framework)) return { text: `${dest} 里没有认得出的前端项目(package.json 里没有 React / Vue / Next …):framework 选 react、vue、next 或 html。` }
475
+ let r
476
+ try {
477
+ r = await buildHandoff(studio.browser, { root: t.root, screens: pick.list, kind, size, framework, dest, project, system: systemOfDir(t.root) })
478
+ } catch (e) {
479
+ return { text: `没写成交接包:${String((e && e.message) || e).slice(0, 200)}` }
480
+ }
481
+ const head = `交接包写好了:${r.file}(${r.shots} 张截图在 handoff/shots/)。下面是全文,照「做法和验收」一屏一屏做,每屏做完用 compare_design 比。\n\n`
482
+ // 每屏顶上一张附上,直接看到要做成什么样
483
+ const firsts = r.rows.filter((row) => row.shots.length).slice(0, 4).map((row) => ({ key: row.key, jpg: fs.readFileSync(path.join(r.dir, 'shots', row.shots[0])) }))
484
+ const kept = firsts.length ? await keepImages(ctx, id, firsts) : {}
485
+ return kept.images ? { text: head + r.md, images: kept.images } : { text: head + r.md }
486
+ },
487
+ }
488
+
489
+ /** compare_design 工具:设计稿的一屏和做出来的页面按同样尺寸截图,逐像素比 */
490
+ export const compareTool = {
491
+ name: 'compare_design',
492
+ description:
493
+ '把做出来的页面和右侧「设计工作台」里的设计稿比一比:按同样尺寸在无头浏览器里各截一张,逐像素比,回一张「设计稿 | 做出来的 | 差异」拼图(差得多的地方红框标号)、差了百分之几,和每块差异在两边分别是哪个元素。' +
494
+ '照设计稿写代码时每做完一屏调一次(url 给开发服务器上那一页的地址,如 http://localhost:5173/login),照差异改到 3% 以下。at=bottom 比滚到底的样子。要先用 preview_page 打开过设计。',
495
+ parameters: specToJsonSchema({
496
+ url: { type: 'string', required: true, description: '做出来的页面:本机地址(http://localhost:5173/login,或端口 5173)或本地 HTML 文件' },
497
+ screen: { type: 'string', description: '和设计稿的哪一屏比(文件名,如 login 或 login.html);不填用右侧正在看的那屏' },
498
+ size: { type: 'string', description: '尺寸,如 375x667 或只写宽度;不填按设计的尺寸' },
499
+ at: { type: 'string', enum: ['top', 'bottom'], description: 'top 顶上一屏(默认)、bottom 滚到底' },
500
+ dark: { type: 'boolean', description: '比深色' },
501
+ }),
502
+ output: {
503
+ schema: {
504
+ type: 'object',
505
+ additionalProperties: false,
506
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
507
+ required: ['text'],
508
+ },
509
+ render: (_args, value) => withImages(value),
510
+ },
511
+ async execute(args, exec, ctx) {
512
+ const id = toolSession(exec, '比对')
513
+ const a = args || {}
514
+ const snap = studio.tracker.snapshot(id)
515
+ if (!snap.url) return { text: '还没有在预览的设计稿:先用 preview_page 打开设计文件,再比。' }
516
+ const b = studio.browser
517
+ if (!b || !b.available()) return { text: '这台电脑上没找到 Chrome / Edge(比对要用无头浏览器截图),比不了。' }
518
+ const { kind, size } = designFor(id)
519
+ const want = a.size ? sizeOf(a.size, kind) : size
520
+ if (!want) return { text: `认不出尺寸「${String(a.size).slice(0, 20)}」:写成 375x667,或只写宽度 375。` }
521
+ // 指定了哪屏就只认那屏,找不到就说(别悄悄拿第一屏比,结果对不上号);没指定才用正在看的 / 第一屏
522
+ const asked = typeof a.screen === 'string' && a.screen.trim()
523
+ const pick = pickScreens(snap, asked ? [a.screen] : [snap.focus], 1)
524
+ const sc = pick.list[0] || (asked ? null : pickScreens(snap, null, 1).list[0])
525
+ if (!sc) return { text: `没找到设计稿的这一屏:${asked ? a.screen : '(没有屏)'}。现在有:${(snap.screens || []).map((x) => x.key).join('、')}` }
526
+ let impl
527
+ try { impl = implUrl(a.url, studio.tracker.cwdOf(id)) } catch (e) { return { text: e.message } }
528
+ if (snap.origin && impl.startsWith(snap.origin + '/')) return { text: '这个地址是设计稿自己(设计工作台的预览),给做出来的那个项目的地址。' }
529
+ let r
530
+ try {
531
+ r = await compareScreens(b, { design: sc, impl, size: want, at: a.at === 'bottom' ? 'bottom' : 'top', dark: !!a.dark })
532
+ } catch (e) {
533
+ return { text: `没比成:${String((e && e.message) || e).slice(0, 200)}(做出来的页面打得开吗?开发服务器起来了吗?)` }
534
+ }
535
+ const kept = await keepImages(ctx, id, [{ key: `compare-${sc.key}`, jpg: r.jpg }], true)
536
+ if (kept.images) return { text: r.text, images: kept.images }
537
+ return { text: `${r.text}\n(这个 DSH 版本没法把图直接给你,存成了文件:${kept.files.join('、')})` }
538
+ },
539
+ }
540
+
362
541
  /** 工具结果显示成:文字 + 一张张图 */
363
542
  function withImages(value) {
364
543
  const blocks = [{ type: 'text', text: value.text }]
@@ -563,7 +742,80 @@ export function apply(ctx) {
563
742
  const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
564
743
  const ph = phone.status(session)
565
744
  const c = checks.get(session)
566
- json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 }, ...(c ? { checkReq: c.id } : {}) })
745
+ // 调节滑块:tweaks.json 改了(模型写的 / 写回的)右侧栏跟着变
746
+ const t = tracker.stateOf(session).target
747
+ let tweaks = null
748
+ if (t && t.kind === 'file') { try { tweaks = readTweaks(t.root) } catch (e) { log(e) } }
749
+ json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 }, tweaks, ...(c ? { checkReq: c.id } : {}) })
750
+ }
751
+
752
+ /** 调节滑块写回文件:values = { id: 值 } */
753
+ async function tweakRoute(req, res) {
754
+ if (!admit(req, res)) return
755
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
756
+ let body
757
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
758
+ const values = body.values && typeof body.values === 'object' && !Array.isArray(body.values) ? body.values : {}
759
+ try {
760
+ const r = tracker.editDir(String(body.session || ''), (root) => applyTweaks(root, values), (x) => `调节:${x.changed.map((c) => `${c.label} ${c.to}`).join('、')}`.slice(0, 60), String(body.root || ''))
761
+ json(res, 200, r)
762
+ } catch (e) {
763
+ json(res, 500, { error: e.message })
764
+ }
765
+ }
766
+
767
+ /** 设计系统库:GET 列出来;POST { id, default: true / false } 设 / 取消默认 */
768
+ async function systemsRoute(req, res) {
769
+ if (!admit(req, res)) return
770
+ if (req.method === 'POST') {
771
+ let body
772
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
773
+ const id = String(body.id || '')
774
+ // 只认库里列得出来的那几套(id 就是库目录下的文件夹名,别让 ../ 之类指到外面)
775
+ const sys = listSystems().find((x) => x.id === id)
776
+ if (!sys) return json(res, 404, { error: 'no such system' })
777
+ try {
778
+ if (body.default === false) { if (sys.default) setDefault(SYSTEMS_DIR, '') }
779
+ else setDefault(SYSTEMS_DIR, id)
780
+ } catch (e) { return json(res, 400, { error: e.message }) }
781
+ }
782
+ json(res, 200, { dir: SYSTEMS_DIR, systems: listSystems() })
783
+ }
784
+
785
+ /** 设计系统的缩略图(样张截图,没有就用第一张网站截图) */
786
+ function systhumbRoute(req, res) {
787
+ if (!admit(req, res)) return
788
+ const id = new URL(req.url, 'http://x').searchParams.get('id') || ''
789
+ const sys = listSystems().find((x) => x.id === id)
790
+ if (!sys) return json(res, 404, { error: 'no such system' })
791
+ const src = path.join(sys.dir, 'source')
792
+ // 只给 source/ 里真的图片文件:软链接、文件夹都不认(别借缩略图读到库外面的文件)
793
+ const own = (f) => { try { return fs.lstatSync(f).isFile() } catch { return false } }
794
+ let srcOk = false
795
+ try { srcOk = fs.lstatSync(src).isDirectory() } catch { srcOk = false }
796
+ if (!srcOk) return json(res, 404, { error: 'no preview' })
797
+ let file = path.join(src, 'preview.jpg')
798
+ if (!own(file)) {
799
+ let first = ''
800
+ try { first = fs.readdirSync(src).find((f) => /\.jpg$/i.test(f) && own(path.join(src, f))) || '' } catch { first = '' }
801
+ if (!first) return json(res, 404, { error: 'no preview' })
802
+ file = path.join(src, first)
803
+ }
804
+ res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'no-store' })
805
+ fs.createReadStream(file).on('error', () => res.end()).pipe(res)
806
+ }
807
+
808
+ /** 转成代码前:会话目录是不是个前端项目(右侧栏默认选「跟项目一样」、填好放哪) */
809
+ function projectRoute(req, res) {
810
+ if (!admit(req, res)) return
811
+ const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
812
+ const cwd = tracker.cwdOf(session)
813
+ const p = detectProject(cwd)
814
+ const t = tracker.stateOf(session).target
815
+ const root = t && t.kind === 'file' ? t.root : ''
816
+ // root 一起回去:右侧栏按它认这份建议是不是给现在这套设计的;project 按最终放的目录认(默认框架跟着它选)
817
+ const dest = defaultDest(root, cwd, p)
818
+ json(res, 200, { cwd, root, project: dest === cwd ? p : detectProject(dest), dest, frameworks: Object.fromEntries(Object.entries(FRAMEWORKS).map(([k, v]) => [k, v.label])) })
567
819
  }
568
820
 
569
821
  async function checkRoute(req, res) {
@@ -857,7 +1109,7 @@ export function apply(ctx) {
857
1109
  json(res, 200, st.on ? { ...st, qr: qrMatrix(st.url).map((row) => row.map((c) => (c ? 1 : 0)).join('')) } : st)
858
1110
  }
859
1111
 
860
- const routes = { state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, style: styleRoute, bundle: bundleRoute, ref: refRoute, export: exportRoute, doc: docRoute, reveal: revealRoute, phone: phoneRoute, check: checkRoute, locate: locateRoute }
1112
+ const routes = { state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, style: styleRoute, bundle: bundleRoute, ref: refRoute, export: exportRoute, doc: docRoute, reveal: revealRoute, phone: phoneRoute, check: checkRoute, locate: locateRoute, tweak: tweakRoute, systems: systemsRoute, systhumb: systhumbRoute, project: projectRoute }
861
1113
  for (const [key, handler] of Object.entries(routes)) {
862
1114
  ctx.effect(() => ctx.webServer.register({
863
1115
  kind: 'exact',
package/lib/look.js CHANGED
@@ -101,7 +101,7 @@ export const scaleFor = (w, h) => Math.max(1, Math.min(2, Math.round((1568 / Mat
101
101
  const READY = 'new Promise((r) => { const t = Date.now(); (function w() { if (window.__dshDesign || Date.now() - t > 4000) r(!!window.__dshDesign); else setTimeout(w, 50) })() })'
102
102
 
103
103
  /** 开好一屏:等 picker 装上、字体图片载完;要深色就切过去 */
104
- async function prepare(page, dark) {
104
+ export async function prepare(page, dark) {
105
105
  if (!(await page.eval(READY))) throw new Error('页面没装上设计台脚本(不是 HTML,或者载入出错)')
106
106
  await page.eval('window.__dshDesign.settle()')
107
107
  let darkOk = true
package/lib/picker.js CHANGED
@@ -2340,6 +2340,38 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2340
2340
  }
2341
2341
  }
2342
2342
 
2343
+ // 调节滑块(右侧栏拖的时候):CSS 变量写在 <html> 的行内样式上(带 !important,样式表里 !important 的默认值也压得住),
2344
+ // 属性直接改 <html> 上的;原来的行内值(连同优先级)、属性值都记着,撤掉时原样还原。不动文件
2345
+ const tweakVars = new Map() // 变量 → 原来的行内值 { v, p }(v 为空 = 原来没有)
2346
+ const tweakAttrs = new Map() // 属性 → 原来的值(null = 原来没有)
2347
+ function applyTweak(vars, attrs) {
2348
+ const de = document.documentElement
2349
+ const nv = vars && typeof vars === 'object' ? vars : {}
2350
+ const na = attrs && typeof attrs === 'object' ? attrs : {}
2351
+ for (const [k, old] of [...tweakVars]) {
2352
+ if (Object.hasOwn(nv, k)) continue
2353
+ if (old.v) de.style.setProperty(k, old.v, old.p)
2354
+ else de.style.removeProperty(k)
2355
+ tweakVars.delete(k)
2356
+ }
2357
+ for (const [k, v] of Object.entries(nv)) {
2358
+ if (!/^--[\w-]{1,60}$/.test(k) || v == null || String(v).length > 80) continue
2359
+ if (!tweakVars.has(k)) tweakVars.set(k, { v: de.style.getPropertyValue(k), p: de.style.getPropertyPriority(k) })
2360
+ de.style.setProperty(k, String(v), 'important')
2361
+ }
2362
+ for (const [a, old] of [...tweakAttrs]) {
2363
+ if (Object.hasOwn(na, a)) continue
2364
+ if (old == null) de.removeAttribute(a)
2365
+ else de.setAttribute(a, old)
2366
+ tweakAttrs.delete(a)
2367
+ }
2368
+ for (const [a, v] of Object.entries(na)) {
2369
+ if (!/^data-[a-z0-9-]{1,40}$/.test(a) || v == null || String(v).length > 60) continue
2370
+ if (!tweakAttrs.has(a)) tweakAttrs.set(a, de.getAttribute(a))
2371
+ de.setAttribute(a, String(v))
2372
+ }
2373
+ }
2374
+
2343
2375
  addEventListener('message', (e) => {
2344
2376
  if (!parentWin || e.source !== parentWin || !parentOrigins.includes(e.origin)) return
2345
2377
  const d = e.data
@@ -2363,6 +2395,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2363
2395
  else if (d.type === 'styleMode') setStyleMode(!!d.on)
2364
2396
  else if (d.type === 'styleSel') onStyleSel(d.selector)
2365
2397
  else if (d.type === 'style') styleApply(d.css)
2398
+ else if (d.type === 'tweak') applyTweak(d.vars, d.attrs)
2366
2399
  })
2367
2400
 
2368
2401
  // 无头浏览器(check_design / look_design 在电脑上另开的,见 browser.js)直接调这几个:
@@ -2381,6 +2414,8 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2381
2414
  return { issues: r.issues.map(({ el, ...rest }) => rest), metrics: r.metrics }
2382
2415
  },
2383
2416
  dark: (on) => { const has = hasDark(); if (has) setDark(!!on); return has },
2417
+ /** 调节滑块的值套到页面上(和右侧栏拖的时候一样) */
2418
+ tweak: (vars, attrs) => { applyTweak(vars, attrs); return true },
2384
2419
  settle: () => settle().then(() => true),
2385
2420
  /** 跳到别的屏的地方(路径和按钮上的字),量完查一下目标在不在 */
2386
2421
  links: () => linksOf().map(({ path, text }) => ({ path, text })),
package/lib/serve.js CHANGED
@@ -58,6 +58,7 @@ export const MIME = {
58
58
  // 改动通知里不算的:版本库、依赖、系统垃圾
59
59
  const EXPORTS = /^(exports([\\/][^\\/]+\.(png|pdf|pptx))?|refs([\\/][^\\/]+\.(png|jpe?g|gif|webp))?)$/i // 导出的图片 / PDF / PPT、参考图(和第一次建的 exports / refs 目录):不算设计改了
60
60
  const IGNORED = /(^|[\\/])(\.git|node_modules|\.DS_Store|\.next|\.cache|\.turbo)([\\/]|$)/
61
+ const HANDOFF = /^handoff([\\/]|$)/i // 转成代码的交接包(说明和截图):不算设计改了,也别在截图时把页面刷掉
61
62
 
62
63
  /** 只认本机名 + 自己的端口(防 DNS 重绑定:外站把域名解析到 127.0.0.1 也进不来) */
63
64
  export function hostOk(host, port) {
@@ -271,7 +272,7 @@ export function createHub({ log = () => {}, onChange = () => {}, history = null,
271
272
  try {
272
273
  entry.watcher = fs.watch(entry.root, { recursive: true }, (_type, name) => {
273
274
  const rel = String(name || '')
274
- if (IGNORED.test(rel) || /(^|[\\/])\./.test(rel) || EXPORTS.test(rel)) return
275
+ if (IGNORED.test(rel) || /(^|[\\/])\./.test(rel) || EXPORTS.test(rel) || HANDOFF.test(rel)) return
275
276
  last = rel
276
277
  batch.add(rel.split(path.sep).join('/'))
277
278
  clearTimeout(timer)