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/client.js CHANGED
@@ -303,6 +303,49 @@ window.__ModuleLoader__.load({
303
303
  failed: '没放进去:{msg}',
304
304
  openFailed: '打不开:{msg}',
305
305
  headerTip: '设计工作台',
306
+ tweaks: '调节滑块',
307
+ twTip: '模型给这套设计做的几个维度:拖的时候所有屏一起变,还没写进文件。',
308
+ twEmpty: '这套设计还没有专属滑块。让模型挑几个最值得试的维度(留白、圆角、主色、排法……),做成滑块放在这里。',
309
+ twMake: '让模型做几个',
310
+ twFix: '让模型修好',
311
+ twMakeText: '〔调节滑块〕给这套设计做 3–5 个调节滑块:挑最值得试的维度(比如留白、圆角、主色、卡片排法),照 ui-design 技能「调节滑块」一节写进设计目录的 tweaks.json,把样式接上,做完用 check_design 查一遍。',
312
+ twFixText: '〔调节滑块〕tweaks.json 有问题:{errs}。照 ui-design 技能「调节滑块」一节改好。',
313
+ twAskHead: '〔调节〕我在右侧调节滑块里调成了下面这样,照这个改设计(改样式源文件,tweaks.json 里对应的 value 也改成新值):',
314
+ twAskTip: '让模型照这几个值改设计(可以顺手调整相关的地方)',
315
+ twSaveTip: 'CSS 变量写进 tokens.css 的默认值,属性写到每屏的 <html> 上(改前改后各存一版)',
316
+ twNoWrite: '开发服务器的页面不能直接写回,交给模型改',
317
+ twMoved: '设计已经换了,没写回;再拖一次',
318
+ twSaved: '已写回 {n} 项',
319
+ twMissed: '{names} 在样式里没找到默认值,没写回:交给模型改',
320
+ systems: '品牌设计系统',
321
+ sysOpen: '品牌设计系统…',
322
+ sysEmptyLine: '有自己的品牌?',
323
+ sysTip: '从你的网站或项目里提取的颜色、字体、圆角和组件。设为默认后,做新设计都照它来。',
324
+ sysEmpty: '还没有。在下面填你的网站地址或项目文件夹,让模型提一套。',
325
+ sysDefault: '默认',
326
+ sysSet: '设为默认',
327
+ sysUnset: '不再默认',
328
+ sysApply: '套用到当前设计',
329
+ sysPh: '网站地址或项目文件夹',
330
+ sysExtract: '提取',
331
+ sysExtractText: '〔提取设计系统〕从 {src} 提取我的品牌设计系统(调 extract_design_system),设为默认,以后做新设计都照它来。提完对照截图核对一遍,再打开样张给我看。',
332
+ sysApplyText: '〔套用设计系统〕把右侧这套设计换成设计系统「{name}」({dir}):复制它的 tokens.css、components.css 进设计目录,每屏都 link 上,按钮、输入框、卡片换成里面的类,颜色、字体、圆角都照它;版式和内容不变。',
333
+ sysSetOk: '之后做新设计都照「{name}」',
334
+ sysUnsetOk: '已取消默认',
335
+ handoff: '转成代码',
336
+ hoTip: '把设计做成真项目的代码:先整理交接说明和截图,再一屏一屏写,每屏写完和设计稿逐像素比,对上了再做下一屏。',
337
+ hoFw: '用什么做',
338
+ hoSame: '跟现有项目一样({label})',
339
+ hoHtml: '纯 HTML / CSS',
340
+ hoDest: '代码放在',
341
+ hoScope: '范围',
342
+ hoAll: '全部 {n} 屏',
343
+ hoOne: '只转这一屏',
344
+ hoScopeOne: '只做 {name}',
345
+ hoGo: '开始转成代码',
346
+ hoProxy: '现在预览的是开发服务器上的页面,它已经是代码了。要转的是用本地 HTML 做的设计。',
347
+ hoLoading: '正在看会话目录…',
348
+ hoText: '〔转成代码〕把右侧的设计做成真项目的代码。\n- 用:{fw}\n- 放在:{dest}\n- 范围:{scope}\n先调 handoff_design(framework: {key},dest: {dest}{only})拿交接说明,照 HANDOFF.md 一屏一屏做;每屏做完把开发服务器跑起来,用 compare_design 和设计稿比,差异降到 3% 以下再做下一屏。设计目录里的文件别改。',
306
349
  }
307
350
  const EN = {
308
351
  title: 'Design Studio',
@@ -568,6 +611,49 @@ window.__ModuleLoader__.load({
568
611
  failed: 'Could not add it: {msg}',
569
612
  openFailed: 'Could not open: {msg}',
570
613
  headerTip: 'Design Studio',
614
+ tweaks: 'Tweaks',
615
+ twTip: 'Knobs the model made for this design. Every screen follows as you drag; nothing is written yet.',
616
+ twEmpty: 'This design has no tweaks yet. Ask the model to pick the dimensions most worth trying (spacing, radius, brand color, layout…) and put them here as knobs.',
617
+ twMake: 'Ask the model',
618
+ twFix: 'Ask the model to fix it',
619
+ twMakeText: '[Tweaks] Make 3–5 tweaks for this design: pick the dimensions most worth trying (spacing, radius, brand color, card layout…), write them to tweaks.json in the design folder as the ui-design skill describes under "Tweaks", wire up the styles, then run check_design.',
620
+ twFixText: '[Tweaks] tweaks.json has problems: {errs}. Fix it as the ui-design skill describes under "Tweaks".',
621
+ twAskHead: '[Tweaks] I set the tweaks in the sidebar to these values. Change the design to match (edit the style sources and update the matching values in tweaks.json):',
622
+ twAskTip: 'Let the model apply these values (and adjust related details)',
623
+ twSaveTip: 'CSS variables go into the defaults in tokens.css, attributes onto each screen\'s <html> (a version is kept before and after)',
624
+ twNoWrite: 'Dev-server pages cannot be written back. Ask the model instead',
625
+ twMoved: 'The design changed, so nothing was written. Try again',
626
+ twSaved: 'Wrote back {n}',
627
+ twMissed: 'No default found in the styles for {names}, not written. Ask the model instead',
628
+ systems: 'Brand design systems',
629
+ sysOpen: 'Brand design systems…',
630
+ sysEmptyLine: 'Have your own brand?',
631
+ sysTip: 'Colors, fonts, radii and components pulled from your site or project. The default one is used for every new design.',
632
+ sysEmpty: 'None yet. Enter your website or project folder below and let the model extract one.',
633
+ sysDefault: 'Default',
634
+ sysSet: 'Make default',
635
+ sysUnset: 'Stop using',
636
+ sysApply: 'Apply to this design',
637
+ sysPh: 'Website or project folder',
638
+ sysExtract: 'Extract',
639
+ sysExtractText: '[Design system] Extract my brand design system from {src} (call extract_design_system) and make it the default for new designs. Check it against the screenshots, then open the specimen for me.',
640
+ sysApplyText: '[Design system] Switch the design in the sidebar to the design system "{name}" ({dir}): copy its tokens.css and components.css into the design folder, link them on every screen, use its button, input and card classes, and follow its colors, fonts and radii. Keep the layout and content.',
641
+ sysSetOk: 'New designs will follow "{name}"',
642
+ sysUnsetOk: 'No default design system now',
643
+ handoff: 'Build as code',
644
+ hoTip: 'Turn the design into a real project: a handoff note and screenshots first, then one screen at a time, each pixel-compared with the design before moving on.',
645
+ hoFw: 'Build with',
646
+ hoSame: 'Same as the project ({label})',
647
+ hoHtml: 'Plain HTML / CSS',
648
+ hoDest: 'Put the code in',
649
+ hoScope: 'Scope',
650
+ hoAll: 'All {n} screens',
651
+ hoOne: 'This screen only',
652
+ hoScopeOne: 'only {name}',
653
+ hoGo: 'Start building',
654
+ hoProxy: 'This preview is a dev-server page, which is already code. Building works on designs made as local HTML.',
655
+ hoLoading: 'Looking at the session folder…',
656
+ hoText: '[Build as code] Build the design in the sidebar as a real project.\n- With: {fw}\n- In: {dest}\n- Scope: {scope}\nCall handoff_design (framework: {key}, dest: {dest}{only}) for the handoff note and follow HANDOFF.md one screen at a time. After each screen, run the dev server and call compare_design until the difference is under 3%, then move on. Do not edit the design folder.',
571
657
  }
572
658
 
573
659
  function makeT(ctx) {
@@ -883,6 +969,39 @@ window.__ModuleLoader__.load({
883
969
  .ddp-act .ddp-btn{height:22px;min-width:22px;padding:0 4px;color:${ACCENT}}
884
970
  .ddp-act .ddp-btn svg{width:13px;height:13px}
885
971
  @container (max-width:500px){.ddp-btn.pick span,.ddp-kind>.ddp-btn span{display:none}}
972
+ .ddp-tw .row:first-child{border-top:0}
973
+ .ddp-sys .bd{display:flex;flex-direction:column;gap:8px;padding:2px 10px 10px}
974
+ .ddp-sysc{display:flex;flex-direction:column;gap:5px;padding:8px;border-radius:10px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.08))}
975
+ .ddp-sysc.def{box-shadow:inset 0 0 0 1.5px ${ACCENT}}
976
+ .ddp-sysc img{display:block;width:100%;aspect-ratio:16/10;object-fit:cover;object-position:top;border-radius:6px;background:var(--dsw-alias-bg-layer-1)}
977
+ .ddp-sysc .nm{display:flex;align-items:center;gap:6px;font-size:13px}
978
+ .ddp-sysc .nm b{flex:1;min-width:0;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
979
+ .ddp-sysc .nm span{flex:none;height:18px;padding:0 6px;border-radius:9px;background:${ACCENT};color:#fff;font-size:11px;line-height:18px}
980
+ .ddp-sysc .sw{display:flex;align-items:center;gap:3px;min-width:0}
981
+ .ddp-sysc .sw i{width:14px;height:14px;flex:none;border-radius:4px;box-shadow:inset 0 0 0 .5px rgba(127,127,127,.45)}
982
+ .ddp-sysc .sw em{margin-left:4px;font-size:11px;font-style:normal;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
983
+ .ddp-sysc small{font-size:11px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
984
+ .ddp-sysc .acts{display:flex;gap:4px}
985
+ .ddp-sysc .acts button{flex:1;height:26px;padding:0 6px;border:0;border-radius:7px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:12px/26px inherit;cursor:pointer;white-space:nowrap}
986
+ .ddp-sysc .acts button:hover{color:var(--dsw-alias-label-primary)}
987
+ .ddp-sys form{display:flex;gap:6px}
988
+ .ddp-sys form input,.ddp-ho input[type=text]{flex:1;min-width:0;height:28px;padding:0 9px;border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.3));border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:12px inherit;outline:none}
989
+ .ddp-sys form input:focus,.ddp-ho input[type=text]:focus{border-color:${ACCENT}}
990
+ .ddp-tokens.ddp-sys footer form button{width:auto;height:28px;flex:none;justify-content:center;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border-radius:999px;padding:0 12px}
991
+ .ddp-tokens.ddp-sys footer form button:disabled{opacity:.45;cursor:default}
992
+ .ddp-ho .bd{padding:0 14px 10px}
993
+ .ddp-ho .row{padding:9px 0 10px;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
994
+ .ddp-ho .row:first-child{border-top:0}
995
+ .ddp-ho label{display:block;margin-bottom:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}
996
+ .ddp-ho input[type=text]{width:100%;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
997
+ .ddp-ho .opt{display:flex;align-items:center;gap:8px;width:100%;min-height:28px;padding:4px 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:12px/18px inherit;text-align:left;cursor:pointer}
998
+ .ddp-ho .opt:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
999
+ .ddp-ho .opt i{width:12px;height:12px;flex:none;border-radius:50%;box-shadow:inset 0 0 0 1.5px var(--dsw-alias-label-tertiary)}
1000
+ .ddp-ho .opt.on{color:var(--dsw-alias-label-primary)}
1001
+ .ddp-ho .opt.on i{box-shadow:inset 0 0 0 4px ${ACCENT}}
1002
+ .ddp-ho footer .go{justify-content:center;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1)}
1003
+ .ddp-ho footer .go:hover{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);opacity:.88}
1004
+ .ddp-ho footer .go:disabled{opacity:.45;cursor:default}
886
1005
  .ddp-more{display:none}
887
1006
  .ddp-menu .o1,.ddp-menu .o2{display:none}
888
1007
  @container (max-width:700px){.ddp-bar>.o1{display:none}.ddp-bar>.ddp-more{display:block}.ddp-menu .o1{display:flex}}
@@ -940,6 +1059,9 @@ window.__ModuleLoader__.load({
940
1059
  stetho: () => svg([h('path', { key: 1, d: 'M6 3.5H5a1 1 0 0 0-1 1V9a5 5 0 0 0 10 0V4.5a1 1 0 0 0-1-1h-1' }), h('path', { key: 2, d: 'M9 14v1.5a4.5 4.5 0 0 0 9 0V13' }), h('circle', { key: 3, cx: 18, cy: 11, r: 2 })]),
941
1060
  pdf: () => svg([h('path', { key: 1, d: 'M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5z' }), h('path', { key: 2, d: 'M14 3.5v5h5' }), h('path', { key: 3, d: 'M8.5 13h7M8.5 16.5h4.5' })]),
942
1061
  deck: () => svg([h('rect', { key: 1, x: 3, y: 4, width: 18, height: 12.5, rx: 1.8 }), h('path', { key: 2, d: 'M12 16.5V20M8.5 20.5h7' }), h('path', { key: 3, d: 'M7 12.5l3-3 2.5 2 4-4' })]),
1062
+ dial: () => svg([h('circle', { key: 1, cx: 12, cy: 13.5, r: 6.5 }), h('path', { key: 2, d: 'M12 13.5l3.2-3.2' }), h('path', { key: 3, d: 'M12 3.5v2M4.6 7.6l1.4 1.1M19.4 7.6L18 8.7' })]),
1063
+ code: () => svg([h('path', { key: 1, d: 'M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5' }), h('path', { key: 2, d: 'M13.5 5l-3 14' })]),
1064
+ library: () => svg([h('rect', { key: 1, x: 3.5, y: 4, width: 5, height: 16, rx: 1.2 }), h('rect', { key: 2, x: 10, y: 4, width: 5, height: 16, rx: 1.2 }), h('path', { key: 3, d: 'M16.6 5.4l3.7 13.9' })]),
943
1065
  page: (p) => svg([h('rect', { key: 1, x: 3, y: 4, width: 18, height: 16, rx: 2.5 }), h('path', { key: 2, d: 'M3 9h18' }), h('rect', { key: 3, x: 7, y: 12.5, width: 5, height: 4, rx: 1, strokeDasharray: '2 1.6' })], p),
944
1066
  }
945
1067
  // 引导页上的大图标
@@ -2219,6 +2341,7 @@ window.__ModuleLoader__.load({
2219
2341
  L.readied.add(id)
2220
2342
  L.title = d.title || ''
2221
2343
  if (p.dark) postTo(id, { type: 'theme', dark: true })
2344
+ if (p.tweak) postTo(id, { type: 'tweak', vars: p.tweak.vars, attrs: p.tweak.attrs })
2222
2345
  if (p.onMeta) p.onMeta(d.meta || '', d.title || '', Array.isArray(d.tokens) ? d.tokens : [], !!d.dark, Array.isArray(d.links) ? d.links : [])
2223
2346
  if (p.onHref && d.href) p.onHref(d.href)
2224
2347
  if (isCur) {
@@ -2296,6 +2419,9 @@ window.__ModuleLoader__.load({
2296
2419
  const cssSig = JSON.stringify(props.styleCss || {})
2297
2420
  useEffect(() => { if (cur && props.styleEdit) postTo(cur, { type: 'style', css: props.styleCss || {} }) }, [cssSig])
2298
2421
  useEffect(() => { if (cur) postTo(cur, { type: 'demo', on: !!props.demo }) }, [!!props.demo])
2422
+ // 调节滑块:拖的时候改页面上的 CSS 变量 / <html> 属性(新载的一帧在 ready 时套上)
2423
+ const twSig = JSON.stringify(props.tweak || null)
2424
+ useEffect(() => { if (cur) postTo(cur, { type: 'tweak', vars: (props.tweak || {}).vars || {}, attrs: (props.tweak || {}).attrs || {} }) }, [twSig])
2299
2425
  useEffect(() => {
2300
2426
  // 刚退出改字(点按钮、Esc、模型开始干活):页面会把正在改的那一处交上来,这一小会儿还收
2301
2427
  if (editWas.current && !props.textEdit) editOffAt.current = Date.now()
@@ -2396,7 +2522,7 @@ window.__ModuleLoader__.load({
2396
2522
  }
2397
2523
 
2398
2524
  /** 设计规范面板:按组列出变量,点一个放进输入框 */
2399
- function TokensPanel({ t, tokens, onPick, onAsk, onRef, onStyles, onClose }) {
2525
+ function TokensPanel({ t, tokens, onPick, onAsk, onRef, onStyles, onSystems, onClose }) {
2400
2526
  const groups = {}
2401
2527
  for (const tok of tokens) (groups[tokenKind(tok)] = groups[tokenKind(tok)] || []).push(tok)
2402
2528
  const px = (v) => Math.max(0, parseFloat(v) * (/rem|em$/.test(v) ? 16 : 1)) || 0
@@ -2424,6 +2550,7 @@ window.__ModuleLoader__.load({
2424
2550
  : h('div', { className: 'none' }, t('tokensEmpty'), h('div', null, h('button', { className: 'ddp-go', onClick: onAsk }, t('tokensAsk'))))),
2425
2551
  onRef || onStyles ? h('footer', null,
2426
2552
  onStyles ? h('button', { onClick: onStyles }, h(I.sparkle), h('span', null, t('stylesOpen'))) : null,
2553
+ onSystems ? h('button', { onClick: onSystems }, h(I.library), h('span', null, t('sysOpen'))) : null,
2427
2554
  onRef ? h('button', { title: t('refPickTip'), onClick: onRef }, h(I.image), h('span', null, t('refPick'))) : null) : null)
2428
2555
  }
2429
2556
 
@@ -2530,6 +2657,160 @@ window.__ModuleLoader__.load({
2530
2657
  h('div', { className: 'bd' }, h(StyleCards, { t, onPick })))
2531
2658
  }
2532
2659
 
2660
+ // ── 调节滑块、品牌设计系统、转成代码 ─────────────────────────────────────
2661
+
2662
+ /** 滑块的值 → 写到页面上的样子(和服务端 tweaks.js 的 cssValue 一样) */
2663
+ const tweakCss = (k, v) => (k.kind === 'range' ? `${v}${k.unit || ''}` : String(v))
2664
+ const tweakShown = (k, v) => (k.kind === 'choice' ? ((k.options || []).find((o) => o.value === String(v)) || { label: v }).label : tweakCss(k, v))
2665
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
2666
+
2667
+ /** 拖过的滑块(id → 值)→ 发给页面的 { vars, attrs };没拖过的不发(页面用文件里的) */
2668
+ function tweakMsg(list, live) {
2669
+ const vars = {}
2670
+ const attrs = {}
2671
+ for (const k of list) {
2672
+ if (!hasOwn(live, k.id)) continue
2673
+ if (k.var) vars[k.var] = tweakCss(k, live[k.id])
2674
+ else if (k.attr) attrs[k.attr] = String(live[k.id])
2675
+ }
2676
+ return { vars, attrs }
2677
+ }
2678
+
2679
+ /** 拖过的滑块 → 交给模型的一段字 */
2680
+ function formatTweaks(t, list, live) {
2681
+ const rows = list.filter((k) => hasOwn(live, k.id)).map((k) => `- ${k.label}:${tweakShown(k, k.value)} → ${tweakShown(k, live[k.id])}(${k.var || k.attr})`)
2682
+ return t('twAskHead') + '\n' + rows.join('\n') + '\n'
2683
+ }
2684
+
2685
+ /** 调节滑块面板:模型给这套设计做的几个维度(tweaks.json),拖的时候所有屏一起变,定下来写回文件或交给模型 */
2686
+ function TweaksPanel({ t, tw, live, write, onSet, onReset, onAsk, onSave, onMake, onClose }) {
2687
+ const head = h('header', null, h(I.dial), h('b', null, t('tweaks')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close)))
2688
+ const box = (...kids) => h('div', { className: 'ddp-tokens ddp-style ddp-tw', onPointerDown: (e) => e.stopPropagation() }, head, ...kids)
2689
+ const list = (tw && tw.list) || []
2690
+ const errs = (tw && tw.errors) || []
2691
+ if (!list.length) {
2692
+ return box(h('div', { className: 'bd' }, h('div', { className: 'none' },
2693
+ errs.length ? errs.map((e, i) => h('div', { key: i }, e)) : t('twEmpty'),
2694
+ h('div', null, h('button', { className: 'ddp-go', onClick: () => onMake(errs) }, t(errs.length ? 'twFix' : 'twMake'))))))
2695
+ }
2696
+ const cur = (k) => (hasOwn(live, k.id) ? live[k.id] : k.value)
2697
+ const row = (k) => {
2698
+ const v = cur(k)
2699
+ const lab = h('label', null, h('span', { title: k.var || k.attr }, k.label), h('em', { className: hasOwn(live, k.id) ? 'v' : '' }, tweakShown(k, v)))
2700
+ const note = k.note ? h('div', { className: 'note' }, k.note) : null
2701
+ if (k.kind === 'range') {
2702
+ return h('div', { className: 'row', key: k.id }, lab,
2703
+ h('input', { type: 'range', min: k.min, max: k.max, step: k.step, value: v, 'aria-label': k.label, onChange: (e) => onSet(k, Number(e.target.value)) }), note)
2704
+ }
2705
+ if (k.kind === 'color') {
2706
+ return h('div', { className: 'row', key: k.id }, lab,
2707
+ h('div', { className: 'chips' }, h('input', { type: 'color', value: /^#[0-9a-f]{6}$/i.test(v) ? v : '#000000', 'aria-label': k.label, onChange: (e) => onSet(k, e.target.value) })), note)
2708
+ }
2709
+ return h('div', { className: 'row', key: k.id }, lab,
2710
+ h('div', { className: 'seg' }, ...(k.options || []).map((o) => h('button', { key: o.value, className: String(v) === o.value ? 'on' : '', onClick: () => onSet(k, o.value) }, o.label))), note)
2711
+ }
2712
+ const n = list.filter((k) => hasOwn(live, k.id)).length
2713
+ return box(
2714
+ h('p', { className: 'tip' }, t('twTip')),
2715
+ h('div', { className: 'bd' }, ...list.map(row), errs.length ? h('div', { className: 'note', key: 'err' }, errs.join(';')) : null),
2716
+ h('footer', { className: 'row' },
2717
+ h('button', { onClick: onReset, disabled: !n }, h('span', null, t('styleReset'))),
2718
+ h('button', { onClick: onAsk, disabled: !n, title: t('twAskTip') }, h('span', null, t('styleAsk'))),
2719
+ h('button', { className: 'go', onClick: onSave, disabled: !n || !write.ok, title: write.tip }, h('span', null, t('styleSave')))))
2720
+ }
2721
+
2722
+ async function getJson(path) {
2723
+ const r = await fetch(API + path, { credentials: 'same-origin' })
2724
+ const data = await r.json().catch(() => ({}))
2725
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`)
2726
+ return data
2727
+ }
2728
+
2729
+ /** 品牌设计系统库:从用户的网站 / 项目提取的几套,默认那套以后做新设计都照它来。rev 变了(模型干完一轮)重新列 */
2730
+ function SystemsPanel({ t, rev, hasDesign, onExtract, onApply, onSay, onClose }) {
2731
+ const [data, setData] = useState(null)
2732
+ const [err, setErr] = useState('')
2733
+ const [src, setSrc] = useState('')
2734
+ useEffect(() => {
2735
+ let alive = true
2736
+ getJson('systems').then((d) => { if (alive) { setData(d); setErr('') } }, (e) => { if (alive) setErr(e.message) })
2737
+ return () => { alive = false }
2738
+ }, [rev])
2739
+ const setDef = (x, on) => post('systems', { id: x.id, default: on }).then((d) => { setData(d); onSay(on ? t('sysSetOk', { name: x.name }) : t('sysUnsetOk')) }, (e) => setErr(e.message))
2740
+ const list = (data && data.systems) || []
2741
+ const go = (e) => {
2742
+ e.preventDefault()
2743
+ const v = src.trim()
2744
+ if (!v) return
2745
+ onExtract(v)
2746
+ setSrc('')
2747
+ }
2748
+ return h('div', { className: 'ddp-tokens ddp-sys', onPointerDown: (e) => e.stopPropagation() },
2749
+ h('header', null, h(I.library), h('b', null, t('systems')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
2750
+ h('p', { className: 'tip' }, t('sysTip')),
2751
+ h('div', { className: 'bd' },
2752
+ err ? h('div', { className: 'none' }, err) : null,
2753
+ data && !list.length ? h('div', { className: 'none' }, t('sysEmpty')) : null,
2754
+ ...list.map((x) => h('div', { key: x.id, className: 'ddp-sysc' + (x.default ? ' def' : '') },
2755
+ x.preview ? h('img', { src: `${API}systhumb?id=${encodeURIComponent(x.id)}&v=${x.createdAt || 0}`, alt: '', loading: 'lazy' }) : null,
2756
+ h('div', { className: 'nm' }, h('b', { title: x.dir }, x.name), x.default ? h('span', null, t('sysDefault')) : null),
2757
+ h('div', { className: 'sw' }, ...Object.entries(x.colors || {}).slice(0, 7).map(([k, v]) => h('i', { key: k, title: `${k} ${v}`, style: { background: v } })), x.font ? h('em', null, x.font) : null),
2758
+ x.sources && x.sources.length ? h('small', { title: x.sources.join('\n') }, x.sources[0]) : null,
2759
+ h('div', { className: 'acts' },
2760
+ h('button', { onClick: () => setDef(x, !x.default) }, x.default ? t('sysUnset') : t('sysSet')),
2761
+ hasDesign ? h('button', { onClick: () => onApply(x) }, t('sysApply')) : null)))),
2762
+ h('footer', null,
2763
+ h('form', { onSubmit: go },
2764
+ h('input', { value: src, placeholder: t('sysPh'), 'aria-label': t('sysPh'), onChange: (e) => setSrc(e.target.value) }),
2765
+ h('button', { type: 'submit', disabled: !src.trim() }, t('sysExtract')))))
2766
+ }
2767
+
2768
+ const FW_LIST = ['react', 'vue', 'next', 'html']
2769
+ const FW_LABEL = { react: 'React + Vite', vue: 'Vue 3 + Vite', next: 'Next.js' }
2770
+
2771
+ /** 转成代码:用什么做、放哪、转几屏;点了把一段话放进输入框,模型调 handoff_design 照交接包做,每屏做完 compare_design */
2772
+ function HandoffPanel({ t, sid, root, fileTarget, count, cur, onGo, onClose }) {
2773
+ const [info, setInfo] = useState(null)
2774
+ const [fw, setFw] = useState('')
2775
+ const [dest, setDest] = useState('')
2776
+ const [one, setOne] = useState(false)
2777
+ // 换了会话或设计目录就重新问:先清掉旧的(别把上一套设计的目标发出去)
2778
+ useEffect(() => {
2779
+ let alive = true
2780
+ setInfo(null)
2781
+ setDest('')
2782
+ if (!root) return () => { alive = false }
2783
+ getJson(`project?session=${encodeURIComponent(sid)}`).then((d) => {
2784
+ if (!alive) return
2785
+ setInfo(d)
2786
+ setFw(d.project && d.project.pkg && d.project.framework ? 'same' : 'react')
2787
+ setDest(d.root === root ? d.dest || '' : '')
2788
+ }, () => { if (alive) setInfo({}) })
2789
+ return () => { alive = false }
2790
+ }, [sid, root])
2791
+ const head = h('header', null, h(I.code), h('b', null, t('handoff')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close)))
2792
+ const box = (...kids) => h('div', { className: 'ddp-tokens ddp-style ddp-ho', onPointerDown: (e) => e.stopPropagation() }, head, ...kids)
2793
+ if (!fileTarget) return box(h('div', { className: 'bd' }, h('div', { className: 'none' }, t('hoProxy'))))
2794
+ if (!info) return box(h('p', { className: 'tip' }, t('hoLoading')))
2795
+ const p = info.project || {}
2796
+ const same = p.pkg && p.framework
2797
+ const opts = [...(same ? [['same', t('hoSame', { label: p.label })]] : []), ...FW_LIST.map((k) => [k, k === 'html' ? t('hoHtml') : FW_LABEL[k]])]
2798
+ const label = (opts.find(([k]) => k === fw) || ['', ''])[1]
2799
+ return box(
2800
+ h('p', { className: 'tip' }, t('hoTip')),
2801
+ h('div', { className: 'bd' },
2802
+ h('div', { className: 'row' }, h('label', null, t('hoFw')),
2803
+ ...opts.map(([k, l]) => h('button', { key: k, className: 'opt' + (fw === k ? ' on' : ''), onClick: () => setFw(k) }, h('i'), h('span', null, l)))),
2804
+ h('div', { className: 'row' }, h('label', null, t('hoDest')),
2805
+ h('input', { type: 'text', value: dest, spellCheck: false, 'aria-label': t('hoDest'), onChange: (e) => setDest(e.target.value) })),
2806
+ count > 1 && cur ? h('div', { className: 'row' }, h('label', null, t('hoScope')),
2807
+ h('div', { className: 'seg' },
2808
+ h('button', { className: one ? '' : 'on', onClick: () => setOne(false) }, t('hoAll', { n: count })),
2809
+ h('button', { className: one ? 'on' : '', onClick: () => setOne(true) }, t('hoOne')))) : null),
2810
+ h('footer', null,
2811
+ h('button', { className: 'go', disabled: !fw || !dest.trim(), onClick: () => onGo({ fw, label, dest: dest.trim(), only: one && count > 1 ? cur : '' }) }, h(I.code), h('span', null, t('hoGo')))))
2812
+ }
2813
+
2533
2814
  /** 菜单开着时:点外面、按 Esc 关掉 */
2534
2815
  function useDismiss(open, setOpen, ref) {
2535
2816
  useEffect(() => {
@@ -2873,6 +3154,8 @@ window.__ModuleLoader__.load({
2873
3154
  const [ckCount, setCkCount] = useState(0) // 最近一次体检(模型干完活会悄悄查一次)有几个问题:面板关着时标在按钮上
2874
3155
  const [reveal, setReveal] = useState(null) // 画布上要露出来的那一屏 { key, n }(体检里点了一条)
2875
3156
  const [demo, setDemo] = useState(null) // 演示:{ stack: [屏…], layers: [{ id, key, dir, shown }] }
3157
+ const [pane, setPane] = useState('') // 右边的面板:tweaks 调节滑块、systems 品牌设计系统、handoff 转成代码
3158
+ const [tw, setTw] = useState({}) // 拖过的滑块:id → 值(还没写进文件)
2876
3159
  const apis = useRef(new Map()) // 屏 → 截图用的接口
2877
3160
  const cmpRef = useRef(null)
2878
3161
  const [box, setBox] = useState({ w: 0, h: 0 })
@@ -2935,6 +3218,20 @@ window.__ModuleLoader__.load({
2935
3218
  // 远程看(手机走外网等):本地文件的预览由服务端打包传过来(见 Screen),目录里改了哪些文件也一起带过去
2936
3219
  const remote = fileTarget && sid ? { sid, at: st.changedAt || 0, files: st.changedFiles || [], changes: st.changes || [] } : null
2937
3220
 
3221
+ // 调节滑块:tweaks.json 改了(写回了、模型改了)就把和文件一样的值放掉;换了设计全放掉
3222
+ const tweaks = (fileTarget && st.tweaks) || null
3223
+ const tlist = (tweaks && tweaks.list) || []
3224
+ const tsig = JSON.stringify(tlist.map((k) => [k.id, k.value]))
3225
+ useEffect(() => {
3226
+ setTw((c) => {
3227
+ const out = {}
3228
+ for (const [id, v] of Object.entries(c)) { const k = tlist.find((x) => x.id === id); if (k && String(k.value) !== String(v)) out[id] = v }
3229
+ return Object.keys(out).length === Object.keys(c).length ? c : out
3230
+ })
3231
+ }, [tsig])
3232
+ useEffect(() => { setTw({}) }, [site])
3233
+ const tweak = Object.keys(tw).length ? tweakMsg(tlist, tw) : null
3234
+
2938
3235
  // 看旧版本:画布上换成那一版的几屏(按现在的顺序,那一版独有的接在后面)
2939
3236
  const vbase = ver ? `${origin}/__dsh_design__/v/${ver.n}/` : ''
2940
3237
  const shown = ver
@@ -3238,6 +3535,7 @@ window.__ModuleLoader__.load({
3238
3535
  styleEdit: styleEdit && !sc.v,
3239
3536
  styleSel: sty && sty.key === sc.key ? sty.selector : '',
3240
3537
  styleCss: sty && sty.key === sc.key ? sty.live : null,
3538
+ tweak: sc.v ? null : tweak,
3241
3539
  onStylePick: (p) => onStylePick(sc, p),
3242
3540
  onStyleCancel: () => setStyleEdit(false),
3243
3541
  dark,
@@ -3503,6 +3801,39 @@ window.__ModuleLoader__.load({
3503
3801
 
3504
3802
  const toggleDemo = () => (demo ? setDemo(null) : startDemo())
3505
3803
  const toggleHist = () => { setHist(!hist); setPanel(false); setStyles(false); setPhone(false); closeCheck(); setStyleEdit(false) }
3804
+ // 右边同一时间只开一个面板:别的开了,这几个关掉;开这几个时别的关掉
3805
+ const oldOpen = !!(panel || styles || hist || phone || ck || styleEdit)
3806
+ useEffect(() => { if (oldOpen) setPane('') }, [oldOpen])
3807
+ const openPane = (p) => {
3808
+ const next = pane === p ? '' : p
3809
+ if (next) { setPanel(false); setStyles(false); setHist(false); setPhone(false); closeCheck(); setStyleEdit(false) }
3810
+ setPane(next)
3811
+ }
3812
+ const setTweak = (k, v) => setTw((c) => {
3813
+ if (String(k.value) === String(v)) { const out = { ...c }; delete out[k.id]; return out }
3814
+ return { ...c, [k.id]: v }
3815
+ })
3816
+ const saveTweaks = async () => {
3817
+ try {
3818
+ // 带上拖的是哪套设计:请求路上会话换了设计,服务端就不写
3819
+ const r = await post('tweak', { session: sid, root: st && st.target ? st.target.root : '', values: tw })
3820
+ if (!r.ok) return say(r.reason === 'busy' ? t('styleBusyTip') : r.reason === 'moved' ? t('twMoved') : t('twNoWrite'))
3821
+ if (r.missed && r.missed.length) say(t('twMissed', { names: r.missed.map((x) => x.label).join(t('sep')) }))
3822
+ else showFlash(t('twSaved', { n: r.changed.length }))
3823
+ } catch (e) {
3824
+ say(t('failed', { msg: e.message }))
3825
+ }
3826
+ }
3827
+ const doHandoff = ({ fw, label, dest, only }) => {
3828
+ insert(t('hoText', {
3829
+ fw: label,
3830
+ key: fw,
3831
+ dest,
3832
+ scope: only ? t('hoScopeOne', { name: only }) : t('hoAll', { n: screens.length || 1 }),
3833
+ only: only ? `,screens: ["${only}"]` : '',
3834
+ }), 'insertedSend')
3835
+ setPane('')
3836
+ }
3506
3837
  const togglePhone = () => { setPhone(!phone); setPanel(false); setStyles(false); setHist(false); closeCheck(); setStyleEdit(false) }
3507
3838
  const reload = () => setReloadSeq((x) => x + 1)
3508
3839
 
@@ -3524,9 +3855,11 @@ window.__ModuleLoader__.load({
3524
3855
  h('button', { className: 'ddp-btn o2' + (demo ? ' sel' : ''), title: t('demo'), disabled: !url || !!ver, onClick: toggleDemo }, h(I.play)),
3525
3856
  h(KindMenu, { t, kind, open: menu, setOpen: setMenu, onPick: (k) => { setMenu(false); change({ kind: k }) } }),
3526
3857
  h('button', { className: 'ddp-btn' + (panel ? ' sel' : '') + (tokens.length && !panel ? ' has' : ''), title: t('tokens'), onClick: () => { setPanel(!panel); setStyles(false); setHist(false); setPhone(false); closeCheck(); setStyleEdit(false) } }, h(I.palette)),
3858
+ h('button', { className: 'ddp-btn o2' + (pane === 'tweaks' ? ' sel' : '') + (tlist.length && pane !== 'tweaks' ? ' has' : ''), title: t('tweaks'), disabled: !fileTarget || !!ver, onClick: () => openPane('tweaks') }, h(I.dial)),
3527
3859
  h('button', { className: 'ddp-btn o2' + (ck ? ' sel' : ''), title: ckCount && !ck ? t('checkCount', { n: ckCount }) : t('checkTip'), disabled: checkOff, onClick: () => (ck ? closeCheck() : runCheck()) },
3528
3860
  h(I.stetho), ckCount && !ck && !checkOff ? h('span', { className: 'bdg' }, ckCount > 99 ? '99+' : String(ckCount)) : null),
3529
3861
  h('button', { className: 'ddp-btn o2' + (hist || ver ? ' sel' : ''), title: t('history'), disabled: !fileTarget, onClick: toggleHist }, h(I.history)),
3862
+ h('button', { className: 'ddp-btn o2' + (pane === 'handoff' ? ' sel' : ''), title: t('handoff'), disabled: !url || !!ver, onClick: () => openPane('handoff') }, h(I.code)),
3530
3863
  h(ExportMenu, { t, kind, count: shown.length, open: exMenu, setOpen: setExMenu, onPick: (m) => doExport(m), disabled: !url }),
3531
3864
  anyDark || dark ? h('button', { className: 'ddp-btn o1' + (dark ? ' sel' : ''), title: t('dark'), onClick: () => setDark(!dark) }, h(I.moon)) : null,
3532
3865
  h('button', { className: 'ddp-btn o1' + (phone ? ' sel' : '') + (phoneOn && !phone ? ' has' : ''), title: t('phone'), disabled: !url, onClick: togglePhone }, h(I.scan)),
@@ -3536,6 +3869,9 @@ window.__ModuleLoader__.load({
3536
3869
  ['o2', I.play, t('demoShort'), !!demo, !url || !!ver, toggleDemo],
3537
3870
  ['o2', I.stetho, t('check'), !!ck, checkOff, () => (ck ? closeCheck() : runCheck())],
3538
3871
  ['o2', I.history, t('history'), hist || !!ver, !fileTarget, toggleHist],
3872
+ ['o2', I.dial, t('tweaks'), pane === 'tweaks', !fileTarget || !!ver, () => openPane('tweaks')],
3873
+ ['o2', I.code, t('handoff'), pane === 'handoff', !url || !!ver, () => openPane('handoff')],
3874
+ ['', I.library, t('systems'), pane === 'systems', false, () => openPane('systems')],
3539
3875
  anyDark || dark ? ['o1', I.moon, t('dark'), dark, false, () => setDark(!dark)] : null,
3540
3876
  ['o1', I.scan, t('phone'), phone, !url, togglePhone],
3541
3877
  ['o1', I.refresh, t('refresh'), false, false, reload],
@@ -3595,6 +3931,39 @@ window.__ModuleLoader__.load({
3595
3931
  })
3596
3932
  const root = (...kids) => h('div', rootProps, ...kids, dropLayer, fileInput)
3597
3933
 
3934
+ // 调节滑块 / 品牌设计系统 / 转成代码(右边的面板)
3935
+ const sidePane = () => {
3936
+ if (pane === 'tweaks') {
3937
+ return h(TweaksPanel, {
3938
+ t,
3939
+ tw: tweaks,
3940
+ live: tw,
3941
+ write: busy ? { ok: false, tip: t('styleBusyTip') } : { ok: true, tip: t('twSaveTip') },
3942
+ onSet: setTweak,
3943
+ onReset: () => setTw({}),
3944
+ onAsk: () => insert(formatTweaks(t, tlist, tw), 'insertedSend'),
3945
+ onSave: saveTweaks,
3946
+ onMake: (errs) => insert(errs && errs.length ? t('twFixText', { errs: errs.join(';') }) + '\n' : t('twMakeText') + '\n', 'insertedSend'),
3947
+ onClose: () => setPane(''),
3948
+ })
3949
+ }
3950
+ if (pane === 'systems') {
3951
+ return h(SystemsPanel, {
3952
+ t,
3953
+ rev: (st && st.turns) || 0,
3954
+ hasDesign: !!url,
3955
+ onExtract: (src) => insert(t('sysExtractText', { src }) + '\n', 'insertedSend'),
3956
+ onApply: (x) => insert(t('sysApplyText', { name: x.name, dir: x.dir }) + '\n', 'insertedSend'),
3957
+ onSay: say,
3958
+ onClose: () => setPane(''),
3959
+ })
3960
+ }
3961
+ if (pane === 'handoff') {
3962
+ return h(HandoffPanel, { t, sid, root: fileTarget ? st.target.root : '', fileTarget, count: screens.length, cur: (curScreen && curScreen.key) || focus, onGo: doHandoff, onClose: () => setPane('') })
3963
+ }
3964
+ return null
3965
+ }
3966
+
3598
3967
  if (!url) {
3599
3968
  if (st ? st.loading : !waited) return root(h('div', { className: 'ddp-stage', ref: stageRef }))
3600
3969
  if (busy) {
@@ -3618,7 +3987,9 @@ window.__ModuleLoader__.load({
3618
3987
  h('div', { className: 'ddp-stylepick' }, h('b', null, t('stylesHead')), h('p', null, t('stylesTip')), h(StyleCards, { t, onPick: (x) => insert(formatStyle(t, x, false)) })),
3619
3988
  h('p', null, t('emptyHint')),
3620
3989
  h(AddressForm, { t, sid, className: 'ddp-form', onDone: () => {} }),
3621
- h('p', { className: 'ddp-refline' }, t('refEmpty'), ' · ', h('button', { className: 'lk', onClick: pickRef }, t('refChoose')))),
3990
+ h('p', { className: 'ddp-refline' }, t('refEmpty'), ' · ', h('button', { className: 'lk', onClick: pickRef }, t('refChoose'))),
3991
+ h('p', { className: 'ddp-refline' }, t('sysEmptyLine'), ' · ', h('button', { className: 'lk', onClick: () => openPane('systems') }, t('sysOpen')))),
3992
+ pane === 'systems' ? sidePane() : null,
3622
3993
  toast ? h('div', { className: 'ddp-toast' }, h('span', null, toast.text)) : null)
3623
3994
  }
3624
3995
 
@@ -3644,6 +4015,7 @@ window.__ModuleLoader__.load({
3644
4015
  picking: false,
3645
4016
  demo: isTop,
3646
4017
  dark,
4018
+ tweak,
3647
4019
  zoom: z,
3648
4020
  reloadSeq,
3649
4021
  onNav: isTop ? demoGo : undefined,
@@ -3678,6 +4050,7 @@ window.__ModuleLoader__.load({
3678
4050
  picking,
3679
4051
  textEdit,
3680
4052
  dark,
4053
+ tweak,
3681
4054
  quietPick: true,
3682
4055
  zoom: z,
3683
4056
  reloadSeq,
@@ -3725,7 +4098,7 @@ window.__ModuleLoader__.load({
3725
4098
  sel: sel || focus,
3726
4099
  labelOf,
3727
4100
  reveal,
3728
- inset: ck || styleEdit || panel || styles ? 296 : 0, // 右边的面板 276 宽,离边 10
4101
+ inset: ck || styleEdit || panel || styles || pane ? 296 : 0, // 右边的面板 276 宽,离边 10
3729
4102
  render: (sc, z) => renderScreen(sc, z, false),
3730
4103
  flows,
3731
4104
  onSelect: setSel,
@@ -3735,7 +4108,7 @@ window.__ModuleLoader__.load({
3735
4108
  const fill = kind === 'web'
3736
4109
  const [dw, dh] = deviceBox(kind, size)
3737
4110
  // 右边开着面板(体检、调样式、设计规范、换风格):手机这类有外框的让到面板左边(侧栏够宽时),别被挡住
3738
- const aside = !fill && (ck || styleEdit || panel || styles) && box.w - 296 >= 240 ? 296 : 0
4111
+ const aside = !fill && (ck || styleEdit || panel || styles || pane) && box.w - 296 >= 240 ? 296 : 0
3739
4112
  const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - aside - 32) / dw, (box.h - 32 - (ver || hintTop ? 46 : 0)) / dh))
3740
4113
  const one = ver ? shown.find((x) => x.key === focus) || shown[0] || { key: focus, url, label: st.label } : { key: focus, url, label: st.label }
3741
4114
  body = h('div', { className: 'ddp-single', style: aside ? { right: aside } : undefined },
@@ -3756,7 +4129,7 @@ window.__ModuleLoader__.load({
3756
4129
  busy ? h('div', { className: 'ddp-glow' }) : null,
3757
4130
  busy ? h('div', { className: 'ddp-ring' }) : null,
3758
4131
  // 右边开着面板时提示条居中在面板左边那块,别被面板盖住一半
3759
- hintText ? h('div', { className: 'ddp-hint' + ((ck || styleEdit || panel || styles) && box.w - 296 >= 240 ? ' side' : '') }, hintText) : null,
4132
+ hintText ? h('div', { className: 'ddp-hint' + ((ck || styleEdit || panel || styles || pane) && box.w - 296 >= 240 ? ' side' : '') }, hintText) : null,
3760
4133
  ck ? h(CheckPanel, { t, ck, onFocus: focusIssue, onFix: fixIssues, onAgain: runCheck, onClose: closeCheck }) : null,
3761
4134
  styleEdit ? h(StylePanel, {
3762
4135
  t,
@@ -3770,7 +4143,8 @@ window.__ModuleLoader__.load({
3770
4143
  onClose: () => setStyleEdit(false),
3771
4144
  }) : null,
3772
4145
  panel ? h(TokensPanel, { t, tokens, onPick: (tok) => insert(formatToken(tok, stRef.current)), onAsk: () => insert(t('tokensAskText') + '\n', 'insertedSend'), onRef: pickRef,
3773
- onStyles: () => { setPanel(false); setStyles(true) }, onClose: () => setPanel(false) }) : null,
4146
+ onStyles: () => { setPanel(false); setStyles(true) }, onSystems: () => openPane('systems'), onClose: () => setPanel(false) }) : null,
4147
+ pane ? sidePane() : null,
3774
4148
  styles ? h(StylesPanel, { t, onPick: (x) => { setStyles(false); insert(formatStyle(t, x, true), 'insertedSend') }, onClose: () => setStyles(false) }) : null,
3775
4149
  phone ? h(PhonePanel, {
3776
4150
  t,
package/lib/core.js CHANGED
@@ -519,7 +519,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
519
519
 
520
520
  function persist(id, st) {
521
521
  if (!store) return
522
- if (st.target) saved[id] = { target: st.target, source: st.source, design: st.design, kind: st.kind, size: st.size, view: st.view, routes: st.routes, order: st.target.kind === 'file' ? st.screens : undefined, at: now() }
522
+ if (st.target) saved[id] = { target: st.target, source: st.source, design: st.design, kind: st.kind, size: st.size, view: st.view, routes: st.routes, order: st.target.kind === 'file' ? st.screens : undefined, cwd: cwds.get(id) || (saved[id] && saved[id].cwd) || undefined, at: now() }
523
523
  else delete saved[id]
524
524
  // 只留最近 200 个会话
525
525
  const ids = Object.keys(saved)
@@ -573,7 +573,8 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
573
573
  }
574
574
 
575
575
  function cwdOf(id) {
576
- return cwds.get(id) || os.homedir()
576
+ // DSH 重启后还没跑过工具时内存里没有,用缓存里记的(右侧栏「转成代码」要靠它找项目)
577
+ return cwds.get(id) || (saved[id] && saved[id].cwd) || os.homedir()
577
578
  }
578
579
 
579
580
  /**
@@ -759,7 +760,10 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
759
760
  break
760
761
  }
761
762
  case 'tool/result': {
762
- if (d.meta && typeof d.meta.cwd === 'string' && d.meta.cwd) cwds.set(sessionId, d.meta.cwd)
763
+ if (d.meta && typeof d.meta.cwd === 'string' && d.meta.cwd) {
764
+ cwds.set(sessionId, d.meta.cwd)
765
+ if (st.target && saved[sessionId] && saved[sessionId].cwd !== d.meta.cwd) persist(sessionId, st)
766
+ }
763
767
  st.writing = ''
764
768
  if (!live) break
765
769
  const pending = st.pendingHtml
@@ -838,10 +842,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
838
842
  const st = states.get(id)
839
843
  const t = st && st.target
840
844
  if (!t || t.kind !== 'file') return { ok: false, reason: 'not-file' }
841
- // 同一个目录别的会话的模型正在干活也不改(它可能正整份重写这个文件);经软链接打开的同一个目录也算
842
- const realOf = (p) => { try { return fs.realpathSync(p) } catch { return p } }
843
- const mine = realOf(t.root)
844
- for (const o of states.values()) if (o.turn && o.target && o.target.kind === 'file' && (o.target.root === t.root || realOf(o.target.root) === mine)) return { ok: false, reason: 'busy' }
845
+ if (busyRoot(t.root)) return { ok: false, reason: 'busy' }
845
846
  const name = String(key || path.basename(t.file))
846
847
  const file = path.join(t.root, name)
847
848
  if (path.dirname(file) !== t.root || !HTML_EXT.test(name) || !fs.existsSync(file)) return { ok: false, reason: 'no-screen' }
@@ -857,6 +858,31 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
857
858
  return { ok: true, file: name }
858
859
  }
859
860
 
861
+ /** 这个目录有没有会话的模型正在干活(它可能正整份重写文件,这时改了会被盖掉);经软链接打开的同一个目录也算 */
862
+ function busyRoot(root) {
863
+ const realOf = (p) => { try { return fs.realpathSync(p) } catch { return p } }
864
+ const mine = realOf(root)
865
+ for (const o of states.values()) if (o.turn && o.target && o.target.kind === 'file' && (o.target.root === root || realOf(o.target.root) === mine)) return true
866
+ return false
867
+ }
868
+
869
+ /**
870
+ * 改设计目录里的几个文件(调节滑块写回:tokens.css、每屏 <html> 上的属性、tweaks.json):fn(目录) → { ok, files, … };
871
+ * 改前改后各存一版。开发服务器的页面、模型正在干活时不改;给了 expectRoot 而现在看的不是它也不改
872
+ */
873
+ function editDir(id, fn, label, expectRoot) {
874
+ const st = states.get(id)
875
+ const t = st && st.target
876
+ if (!t || t.kind !== 'file') return { ok: false, reason: 'not-file' }
877
+ // 右侧栏发请求时看的是哪套设计:这期间换了设计就别写,免得写进另一套
878
+ if (expectRoot !== undefined && t.root !== expectRoot) return { ok: false, reason: 'moved' }
879
+ if (busyRoot(t.root)) return { ok: false, reason: 'busy' }
880
+ keepVersion(st, '')
881
+ const r = fn(t.root)
882
+ if (r && r.ok && r.files && r.files.length) keepVersion(st, typeof label === 'function' ? label(r) : label)
883
+ return r
884
+ }
885
+
860
886
  /** 版本列表(新的在前);开发服务器没有版本(项目自己有 git) */
861
887
  function versions(id) {
862
888
  const st = states.get(id)
@@ -881,7 +907,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
881
907
  const { target, url, label, source, rev, openRev, turn, writing, changedAt, kind, size, view, origin } = st
882
908
  const file = target.kind === 'file'
883
909
  return {
884
- target: file ? { kind: 'file', file: target.file } : { kind: 'proxy', port: target.port, path: target.path },
910
+ target: file ? { kind: 'file', file: target.file, root: target.root } : { kind: 'proxy', port: target.port, path: target.path },
885
911
  url,
886
912
  label,
887
913
  source,
@@ -908,5 +934,5 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
908
934
  }
909
935
  }
910
936
 
911
- return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, editStyle, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
937
+ return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, editStyle, editDir, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
912
938
  }