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/client.js CHANGED
@@ -99,12 +99,75 @@ window.__ModuleLoader__.load({
99
99
  gRadius: '圆角',
100
100
  gShadow: '阴影',
101
101
  gOther: '其他',
102
- exportT: '导出图片',
102
+ exportT: '导出',
103
103
  exFrame: '这一屏 · 带外框',
104
104
  exRaw: '这一屏 · 纯画面',
105
105
  exLong: '这一屏 · 整页长图',
106
106
  exShow: '整套展示图',
107
- exWhere: '存到设计目录的 exports/,同时复制到剪贴板',
107
+ exPdf: 'PDF · 每屏一页',
108
+ exPptx: 'PPT · 每屏一页(整页图片)',
109
+ exWhere: '存到设计目录的 exports/;图片还会复制到剪贴板',
110
+ exportedDoc: '已导出 {name}({n} 页)',
111
+ check: '设计体检',
112
+ checkTip: '设计体检:量一量文字溢出、重叠、对比度、点击区、字号',
113
+ checking: '正在体检…',
114
+ checkNone: '没发现问题',
115
+ checkSum: '{s} 屏 · {n} 个问题',
116
+ checkFix: '交给模型修',
117
+ checkAgain: '再查一次',
118
+ checkFail: '没查成:{msg}',
119
+ checkNote: '只量得出排版上的硬伤,好不好看还得你看',
120
+ checkCount: '设计体检:上次量出 {n} 个问题,点开看',
121
+ checkAsk: '〔设计体检〕发现 {n} 个问题,请逐个修好:改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量。',
122
+ ckToolHead: '量了 {s} 屏,发现 {n} 个问题。逐个改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量:',
123
+ ckToolOk: '量了 {s} 屏({labels}),没发现问题。',
124
+ ckToolOff: '用户正在看旧版本、演示或多尺寸,右侧栏现在量不了;过一会儿再调,或照「自查」清单自己过一遍。',
125
+ ckToolErr: '{label}:没量成({msg})',
126
+ ck_hscroll: '超出屏幕',
127
+ ck_clipped: '文字被截断',
128
+ ck_overflow: '文字超出框',
129
+ ck_overlap: '文字叠在一起',
130
+ ck_contrast: '对比度不够',
131
+ ck_tap: '点击区太小',
132
+ ck_tiny: '字太小',
133
+ ck_img: '图片没加载出来',
134
+ ck_fonts: '字号太杂',
135
+ ck_distort: '图片被拉变形',
136
+ ck_long: '一行太长',
137
+ ck_wrap: '文字被挤断行',
138
+ ck_covered: '被固定栏压住',
139
+ ck_tight: '离固定栏太近',
140
+ ck_jserr: '页面脚本报错',
141
+ ck_missing: '文件没载出来',
142
+ cd_hscroll: '伸出屏幕 {px}px',
143
+ cd_clipped: '被切掉 {px}px',
144
+ cd_overflow: '伸出框 {px}px',
145
+ cd_overlap: '和 {other}「{otherText}」叠在一起',
146
+ cd_contrast: '{fg} 配 {bg},{ratio}:1(要 ≥ {need}:1)',
147
+ cd_tap: '{w}×{h}(至少 {min}×{min})',
148
+ cd_tiny: '{size}px(至少 {min}px)',
149
+ cd_img: '{src}',
150
+ cd_fonts: '一屏用了 {n} 种字号:{sizes}',
151
+ cd_distort: '原图 {nw}×{nh},显示成 {w}×{h}(用 object-fit: cover 或改尺寸)',
152
+ cd_long: '一行约 {chars} 字(最好 ≤ {max})',
153
+ cd_wrap: '折成了 {lines} 行(按钮、价格这种短字别断行:给它更多宽度、white-space: nowrap 或缩短文字)',
154
+ cd_covered: '{when}被{edgeName} {bar} 压住 {px}px(给内容留出栏的高度)',
155
+ cd_tight: '离{edgeName} {bar} 只有 {px}px(留 16px 以上)',
156
+ cd_jserr: '{msg}{at}',
157
+ cd_missing: '{what} {src} 载不出来(路径写错了,或者文件不在)',
158
+ ckEdge_top: '顶栏',
159
+ ckEdge_bottom: '底栏',
160
+ ckWhen_top: '没滚时',
161
+ ckWhen_bottom: '滚到底还',
162
+ ckWhat_css: '样式表',
163
+ ckWhat_script: '脚本',
164
+ ckWhat_font: '字体',
165
+ ckWhat_other: '文件',
166
+ ckSize: '{w}×{h},页面高 {page}',
167
+ ckSizeBar: ',{edgeName} {bar} 离内容 {gap}px',
168
+ ckScreen: '{label}({where})',
169
+ ckItem: '{i}. {type}:{where}{text} — {detail}',
170
+ ckQuote: '「{text}」',
108
171
  exporting: '正在导出…',
109
172
  exported: '已导出 {name}',
110
173
  exportedCopied: '已导出 {name},也复制到剪贴板了',
@@ -132,6 +195,9 @@ window.__ModuleLoader__.load({
132
195
  cmpNow: '现在',
133
196
  dark: '深色模式',
134
197
  variants: '再出两版',
198
+ pickThis: '就要这个:其他版本挪进 drafts/,接着做全套',
199
+ pickText: '〔选定方向〕就用「{name}」({where})。先把同组的 {others} 挪进 drafts/ 子目录(别删),再把它改名成 {base}.html;它的设计变量搬进 tokens.css(没有就新建)、改用 tokens.css。然后接着做全套。\n',
200
+ pickKeep: '〔选定方向〕留原版「{name}」({where})。同组的 {others} 挪进 drafts/ 子目录(别删),其他不动。\n',
135
201
  variantText: '给「{name}」再出两版不同方向的设计(布局或视觉风格明显不同),存成同目录下的 {b}-b.html 和 {b}-c.html,会紧挨着原版排在画布上。原版和 tokens.css 别动;新版要换配色就在自己文件里覆盖变量。\n{where}\n',
136
202
  exThis: '导出这一屏',
137
203
  demo: '演示:点页面里的按钮跳到别的屏',
@@ -142,9 +208,61 @@ window.__ModuleLoader__.load({
142
208
  textEdit: '改字:点页面里的文字直接改,回车保存(Esc 退出)',
143
209
  textSaved: '已改好,写回 {file}',
144
210
  textAsk: '把页面上的「{before}」改成「{after}」(只改这一处文字,别的不动)。\n{where}\n',
211
+ styleEdit: '调样式:点页面里的元素,拖滑块改字号、颜色、圆角、间距(Esc 退出)',
212
+ styleAny: '点任意一屏里的元素调样式 · Esc 退出',
213
+ styleOne: '点页面里的元素调样式 · Esc 退出',
214
+ style: '调样式',
215
+ styleNone: '在页面上点一个元素。改的时候页面上先看效果,满意了再写回文件。',
216
+ styleFont: '字号',
217
+ styleWeight: '字重',
218
+ styleColor: '文字',
219
+ styleBg: '背景',
220
+ styleClear: '透明',
221
+ styleRadius: '圆角',
222
+ stylePadY: '上下内边距',
223
+ stylePadX: '左右内边距',
224
+ styleGap: '子元素间距',
225
+ styleW400: '常规',
226
+ styleW500: '中等',
227
+ styleW600: '半粗',
228
+ styleW700: '粗',
229
+ styleVar: '设计变量 {name}',
230
+ styleGradient: '现在是渐变,选一个颜色会换成纯色',
231
+ styleReset: '还原',
232
+ styleAsk: '交给模型',
233
+ styleSave: '写回文件',
234
+ styleSaveTip: '写进这个元素在源码里的 style 属性(改前改后各存一版)',
235
+ styleAskTip: '让模型改进样式表(能用设计变量就用)',
236
+ styleNoWrite: '开发服务器的页面不在这里写回,交给模型改',
237
+ styleBusyTip: '模型在干活,等它停了再写回',
238
+ styleSaved: '样式已写回 {file}',
239
+ styleMiss: '源码里没找到这个元素(可能是脚本生成的),改成交给模型',
240
+ styleN: '改了 {n} 项',
241
+ styleAskText: '〔调样式〕{name}({sel}):{list}。{rules}把这些写进它的样式规则(能用设计变量就用设计变量),别写成行内 style。\n{where}\n',
242
+ ruleFrom: '- {props}:现在由 {sel} 决定({at})',
243
+ ruleMaybe: '- {props}:大概由 {sel} 决定({at};它在 @layer / @container 里,没细算,改之前看一眼)',
244
+ ruleInline: '- {props}:现在写在元素的行内 style 上{over}',
245
+ ruleOver: ',盖过了 {sel}({at})',
246
+ ruleAt: '{file} 第 {line} 行',
247
+ styleArrow: '{prop} {from} → {to}',
248
+ styleJoin: ';',
249
+ lang: 'zh',
250
+ stylesHead: '先挑一个风格开工',
251
+ stylesTip: '点一个,整套配色、字体、圆角放进输入框;再写要做什么',
252
+ stylesOpen: '换个风格…',
253
+ stylesTitle: '换个风格',
254
+ stylesSwap: '点一个,让模型把现在的设计换成这个风格(先改 tokens.css,布局和文案不动)',
255
+ styleText: '〔风格〕{name}——{desc}\n配色:{colors}\n字体:{fonts}\n形状:{shape}\n要点:{notes}\n{ask}',
256
+ styleColors: '底色 {bg},卡片 {surface},文字 {text},次要文字 {muted},分隔线 {line},主色 {primary}(上面的字 {on}),点缀 {accent}(只做图形、大字),链接和小字强调 {link}{bgImage}',
257
+ styleBgImage: ';底色上叠 {v}',
258
+ styleFonts: '标题 {head};正文 {body}',
259
+ styleShape: '圆角 {r}px;阴影 {s}',
260
+ styleStart: '照这个风格来做(先把这些定进 tokens.css;不用再问想要什么感觉):',
261
+ styleRestyle: '把现在的设计换成这个风格:先改 tokens.css(配色、字体、圆角、阴影),各屏跟着变;页面里写死的颜色也换成变量;布局和文案先不动。\n',
145
262
  demoNone: '这一屏还没有跳到别的屏的按钮;可以让模型用 <a href="detail.html"> 把几屏串起来',
146
263
  phone: '在手机上看',
147
- away: '这里看不到预览:预览跑在电脑本机上,从手机或别的电脑打开 DSH 时连不过去。回到那台电脑上看;想在手机上看,就在电脑上点「在手机上看」扫码(同一个 Wi-Fi)。',
264
+ away: '这里看不到预览:开发服务器跑在电脑本机上,从手机或别的电脑打开 DSH 时连不过去(本地 HTML 文件的设计能看)。回到那台电脑上看;想在手机上看,就在电脑上点「在手机上看」扫码(同一个 Wi-Fi)。',
265
+ remoteFail: '这一屏没能传过来(连接断了,或图片太大)。点「刷新」再试。',
148
266
  phoneStarting: '正在打开…',
149
267
  phoneHow: '用手机相机扫一扫',
150
268
  phoneWifi: '手机和电脑要连同一个 Wi-Fi',
@@ -170,6 +288,7 @@ window.__ModuleLoader__.load({
170
288
  demoShort: '演示',
171
289
  editAddr: '换一个地址',
172
290
  working: '模型正在工作',
291
+ asking: '模型在等你回答(在对话里)',
173
292
  writing: '正在写 {file}',
174
293
  born: '已生成 {name}',
175
294
  updated: '已更新{file}',
@@ -184,6 +303,49 @@ window.__ModuleLoader__.load({
184
303
  failed: '没放进去:{msg}',
185
304
  openFailed: '打不开:{msg}',
186
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% 以下再做下一屏。设计目录里的文件别改。',
187
349
  }
188
350
  const EN = {
189
351
  title: 'Design Studio',
@@ -245,12 +407,75 @@ window.__ModuleLoader__.load({
245
407
  gRadius: 'Radii',
246
408
  gShadow: 'Shadows',
247
409
  gOther: 'Other',
248
- exportT: 'Export image',
410
+ exportT: 'Export',
249
411
  exFrame: 'This screen · in frame',
250
412
  exRaw: 'This screen · plain',
251
413
  exLong: 'This screen · full page',
252
414
  exShow: 'Showcase of all screens',
253
- exWhere: 'Saved to exports/ in the design folder and copied to the clipboard',
415
+ exPdf: 'PDF · one page per screen',
416
+ exPptx: 'PowerPoint · one slide per screen (as images)',
417
+ exWhere: 'Saved to exports/ in the design folder; images are also copied to the clipboard',
418
+ exportedDoc: 'Exported {name} ({n} pages)',
419
+ check: 'Design check',
420
+ checkTip: 'Design check: measure overflowing or overlapping text, contrast, tap targets and type sizes',
421
+ checking: 'Checking…',
422
+ checkNone: 'No problems found',
423
+ checkSum: '{s} screens · {n} problems',
424
+ checkFix: 'Ask the model to fix',
425
+ checkAgain: 'Check again',
426
+ checkFail: 'Check failed: {msg}',
427
+ checkNote: 'Only measurable layout problems; whether it looks good is still up to you',
428
+ checkCount: 'Design check: {n} problems last time. Click to see them',
429
+ checkAsk: '[Design check] Found {n} problems. Please fix each one in the source files, touching only what is listed; for colors and type sizes, change the design tokens where possible.',
430
+ ckToolHead: 'Measured {s} screens and found {n} problems. Fix each one in the source files, touching only what is listed; for colors and type sizes, change the design tokens where possible:',
431
+ ckToolOk: 'Measured {s} screens ({labels}). No problems found.',
432
+ ckToolOff: 'The user is looking at an old version, the demo or the multi-size view, so the sidebar can\'t measure right now. Try again in a moment, or go through the self-check list yourself.',
433
+ ckToolErr: '{label}: could not measure ({msg})',
434
+ ck_hscroll: 'Wider than the screen',
435
+ ck_clipped: 'Text cut off',
436
+ ck_overflow: 'Text spills out of its box',
437
+ ck_overlap: 'Overlapping text',
438
+ ck_contrast: 'Low contrast',
439
+ ck_tap: 'Tap target too small',
440
+ ck_tiny: 'Text too small',
441
+ ck_img: 'Image failed to load',
442
+ ck_fonts: 'Too many type sizes',
443
+ ck_distort: 'Stretched image',
444
+ ck_long: 'Lines too long',
445
+ ck_wrap: 'Short text wraps',
446
+ ck_covered: 'Hidden under a fixed bar',
447
+ ck_tight: 'Too close to a fixed bar',
448
+ ck_jserr: 'Script error',
449
+ ck_missing: 'File failed to load',
450
+ cd_hscroll: 'sticks out {px}px',
451
+ cd_clipped: '{px}px cut off',
452
+ cd_overflow: 'sticks out {px}px',
453
+ cd_overlap: 'overlaps {other} "{otherText}"',
454
+ cd_contrast: '{fg} on {bg}, {ratio}:1 (needs ≥ {need}:1)',
455
+ cd_tap: '{w}×{h} (at least {min}×{min})',
456
+ cd_tiny: '{size}px (at least {min}px)',
457
+ cd_img: '{src}',
458
+ cd_fonts: '{n} type sizes on one screen: {sizes}',
459
+ cd_distort: '{nw}×{nh} shown at {w}×{h} (use object-fit: cover or fix the size)',
460
+ cd_long: 'about {chars} characters per line (keep it ≤ {max})',
461
+ cd_wrap: 'breaks onto {lines} lines (keep short labels and prices on one line: more width, white-space: nowrap, or shorter text)',
462
+ cd_covered: '{when}{px}px hidden under the {edgeName} {bar} (leave room for the bar)',
463
+ cd_tight: 'only {px}px from the {edgeName} {bar} (leave 16px or more)',
464
+ cd_jserr: '{msg}{at}',
465
+ cd_missing: '{what} {src} did not load (wrong path, or the file is missing)',
466
+ ckEdge_top: 'top bar',
467
+ ckEdge_bottom: 'bottom bar',
468
+ ckWhen_top: 'before scrolling, ',
469
+ ckWhen_bottom: 'even scrolled to the end, ',
470
+ ckWhat_css: 'stylesheet',
471
+ ckWhat_script: 'script',
472
+ ckWhat_font: 'font',
473
+ ckWhat_other: 'file',
474
+ ckSize: '{w}×{h}, page height {page}',
475
+ ckSizeBar: ', {edgeName} {bar} {gap}px from the content',
476
+ ckScreen: '{label} ({where})',
477
+ ckItem: '{i}. {type}: {where}{text} — {detail}',
478
+ ckQuote: ' "{text}"',
254
479
  exporting: 'Exporting…',
255
480
  exported: 'Exported {name}',
256
481
  exportedCopied: 'Exported {name} and copied it',
@@ -278,6 +503,9 @@ window.__ModuleLoader__.load({
278
503
  cmpNow: 'Now',
279
504
  dark: 'Dark mode',
280
505
  variants: 'Two more takes',
506
+ pickThis: 'Use this one: move the other takes to drafts/ and build the rest',
507
+ pickText: '[Direction picked] Use "{name}" ({where}). First move the other takes in its group ({others}) into a drafts/ subfolder (don\'t delete them), then rename it to {base}.html; move its design tokens into tokens.css (create it if missing) and switch it to tokens.css. Then build the rest.\n',
508
+ pickKeep: '[Direction picked] Keep the original "{name}" ({where}). Move the other takes in its group ({others}) into a drafts/ subfolder (don\'t delete them) and leave everything else as is.\n',
281
509
  variantText: 'Make two alternative designs of "{name}" in clearly different directions (layout or visual style). Save them next to it as {b}-b.html and {b}-c.html; they will sit right after the original on the canvas. Leave the original and tokens.css unchanged; override variables inside the new files if they need other colors.\n{where}\n',
282
510
  exThis: 'Export this screen',
283
511
  demo: 'Play: tap buttons in the page to move between screens',
@@ -288,9 +516,61 @@ window.__ModuleLoader__.load({
288
516
  textEdit: 'Edit text: click any text in the page, Enter to save (Esc to stop)',
289
517
  textSaved: 'Saved to {file}',
290
518
  textAsk: 'Change the text "{before}" to "{after}" on the page (only this text, nothing else).\n{where}\n',
519
+ styleEdit: 'Style: click an element, then drag sliders for size, colors, radius and spacing (Esc to stop)',
520
+ styleAny: 'Click an element in any screen to style it · Esc to stop',
521
+ styleOne: 'Click an element in the page to style it · Esc to stop',
522
+ style: 'Style',
523
+ styleNone: 'Click an element in the page. Changes show in the preview first; save them to the file when you like them.',
524
+ styleFont: 'Font size',
525
+ styleWeight: 'Weight',
526
+ styleColor: 'Text',
527
+ styleBg: 'Background',
528
+ styleClear: 'transparent',
529
+ styleRadius: 'Radius',
530
+ stylePadY: 'Padding (top / bottom)',
531
+ stylePadX: 'Padding (left / right)',
532
+ styleGap: 'Gap',
533
+ styleW400: 'Regular',
534
+ styleW500: 'Medium',
535
+ styleW600: 'Semi',
536
+ styleW700: 'Bold',
537
+ styleVar: 'Token {name}',
538
+ styleGradient: 'It is a gradient now; picking a color makes it solid',
539
+ styleReset: 'Reset',
540
+ styleAsk: 'Ask the model',
541
+ styleSave: 'Save to file',
542
+ styleSaveTip: 'Writes into this element\'s style attribute in the source (a version is kept before and after)',
543
+ styleAskTip: 'Let the model move it into the stylesheet (using tokens where it can)',
544
+ styleNoWrite: 'Dev-server pages are not written here; ask the model',
545
+ styleBusyTip: 'The model is working; save when it stops',
546
+ styleSaved: 'Style saved to {file}',
547
+ styleMiss: 'Could not find this element in the source (maybe a script made it); asking the model instead',
548
+ styleN: '{n} changed',
549
+ styleAskText: '[Style] {name} ({sel}): {list}. {rules}Put it in its stylesheet rule, using design tokens where you can, not an inline style.\n{where}\n',
550
+ ruleFrom: '- {props}: currently set by {sel} ({at})',
551
+ ruleMaybe: '- {props}: probably set by {sel} ({at}; it sits in @layer / @container and was not fully resolved, check before editing)',
552
+ ruleInline: '- {props}: currently in the element\'s inline style{over}',
553
+ ruleOver: ', overriding {sel} ({at})',
554
+ ruleAt: '{file} line {line}',
555
+ styleArrow: '{prop} {from} → {to}',
556
+ styleJoin: '; ',
557
+ lang: 'en',
558
+ stylesHead: 'Start from a style',
559
+ stylesTip: 'Pick one to put its colors, fonts and shapes in the chat box, then say what to make',
560
+ stylesOpen: 'Try another style…',
561
+ stylesTitle: 'Change the style',
562
+ stylesSwap: 'Pick one and the model restyles the current design (tokens.css first; layout and copy stay)',
563
+ styleText: '[Style] {name}: {desc}\nColors: {colors}\nFonts: {fonts}\nShape: {shape}\nNotes: {notes}\n{ask}',
564
+ styleColors: 'background {bg}, cards {surface}, text {text}, muted text {muted}, lines {line}, primary {primary} (text on it {on}), accent {accent} (graphics and large text only), links and small emphasis {link}{bgImage}',
565
+ styleBgImage: '; on the background: {v}',
566
+ styleFonts: 'headings {head}; body {body}',
567
+ styleShape: 'radius {r}px; shadow {s}',
568
+ styleStart: 'Design in this style (put these into tokens.css first; no need to ask about the feel again):',
569
+ styleRestyle: 'Restyle the current design to this: update tokens.css first (colors, fonts, radius, shadows) so every screen follows; replace hard-coded colors in the pages with tokens; keep layout and copy for now.\n',
291
570
  demoNone: 'This screen has no links to other screens yet. Ask the model to connect screens with <a href="detail.html">',
292
571
  phone: 'View on phone',
293
- away: 'The preview runs on the computer itself, so it can\'t load when DSH is opened from a phone or another computer. View it on that computer, or click "View on phone" there and scan the code (same Wi-Fi).',
572
+ away: 'The dev server runs on the computer itself, so it can\'t load when DSH is opened from a phone or another computer (designs made of local HTML files do show). View it on that computer, or click "View on phone" there and scan the code (same Wi-Fi).',
573
+ remoteFail: 'This screen couldn\'t be sent over (connection dropped, or the images are too large). Click Reload to try again.',
294
574
  phoneStarting: 'Starting…',
295
575
  phoneHow: 'Scan with your phone camera',
296
576
  phoneWifi: 'Phone and computer must be on the same Wi-Fi',
@@ -316,6 +596,7 @@ window.__ModuleLoader__.load({
316
596
  demoShort: 'Play',
317
597
  editAddr: 'Change address',
318
598
  working: 'The model is working',
599
+ asking: 'The model is waiting for your answer (in the chat)',
319
600
  writing: 'Writing {file}',
320
601
  born: 'Generated {name}',
321
602
  updated: 'Updated{file}',
@@ -330,6 +611,49 @@ window.__ModuleLoader__.load({
330
611
  failed: 'Could not add it: {msg}',
331
612
  openFailed: 'Could not open: {msg}',
332
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.',
333
657
  }
334
658
 
335
659
  function makeT(ctx) {
@@ -434,6 +758,7 @@ window.__ModuleLoader__.load({
434
758
  .ddp-hint{position:absolute;left:50%;top:12px;transform:translateX(-50%);z-index:7;max-width:calc(100% - 24px);padding:6px 13px;border-radius:999px;
435
759
  background:rgba(18,18,24,.84);color:#ececf0;font-size:12px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
436
760
  box-shadow:0 6px 18px rgba(0,0,0,.22);backdrop-filter:blur(8px);pointer-events:none;animation:ddp-in .2s ease}
761
+ .ddp-hint.side{left:calc((100% - 296px) / 2);max-width:calc(100% - 320px)}
437
762
  @property --ddp-a{syntax:'<angle>';initial-value:0deg;inherits:false}
438
763
  .ddp-ring{position:absolute;inset:0;z-index:4;pointer-events:none;padding:2.5px;
439
764
  background:conic-gradient(from var(--ddp-a),transparent 0deg 140deg,${ACCENT} 240deg,#a78bfa 300deg,#5eead4 340deg,transparent 360deg);
@@ -451,6 +776,7 @@ window.__ModuleLoader__.load({
451
776
  .ddp-chip .sp{width:12px;height:12px;flex:none;border-radius:50%;border:1.6px solid rgba(255,255,255,.22);border-top-color:#9db0ff;animation:ddp-rot .75s linear infinite}
452
777
  .ddp-chip .tx{background:linear-gradient(90deg,#f1f1f4 0 38%,#a9b8ff 50%,#f1f1f4 62% 100%);background-size:260% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:ddp-txt 2.2s linear infinite}
453
778
  .ddp-chip.ok svg{width:14px;height:14px;flex:none;color:#4ade80}
779
+ .ddp-chip .q{width:8px;height:8px;flex:none;border-radius:50%;background:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.25)}
454
780
  @keyframes ddp-rot{to{transform:rotate(360deg)}}
455
781
  @keyframes ddp-txt{from{background-position:100% 0}to{background-position:-160% 0}}
456
782
  @keyframes ddp-blink{50%{opacity:.25}}
@@ -528,6 +854,54 @@ window.__ModuleLoader__.load({
528
854
  .ddp-empty .ddp-refline{margin-top:0}
529
855
  .ddp-empty .lk{border:0;padding:0;background:transparent;color:${ACCENT};font:inherit;cursor:pointer}
530
856
  .ddp-empty .lk:hover{text-decoration:underline;text-underline-offset:2px}
857
+ .ddp-ck{align-items:flex-start}
858
+ .ddp-ck .no{width:18px;height:18px;flex:none;margin-top:1px;border-radius:9px;background:#f59e0b;color:#fff;font:700 10px/18px system-ui,sans-serif;text-align:center}
859
+ .ddp-ck .no.hi{background:#e5484d}
860
+ .ddp-tok.ddp-ck .nm{font-family:inherit;font-size:12px;line-height:17px}
861
+ .ddp-tok.ddp-ck .vl{white-space:normal;overflow-wrap:anywhere}
862
+ .ddp-check .ok{display:flex;align-items:center;gap:8px;padding:14px 6px;color:var(--dsw-alias-label-secondary)}
863
+ .ddp-check .ok svg{width:16px;height:16px;flex:none;color:#30a46c}
864
+ .ddp-check footer.row,.ddp-style footer.row{display:flex;gap:6px}
865
+ .ddp-check footer.row button,.ddp-style footer.row button{flex:1;justify-content:center;padding:0 6px}
866
+ .ddp-check footer .go,.ddp-style footer .go{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1)}
867
+ .ddp-check footer .go:hover,.ddp-style footer .go:hover{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);opacity:.88}
868
+ .ddp-check footer button:disabled,.ddp-style footer button:disabled{opacity:.45;cursor:default}
869
+ .ddp-style .bd{padding:0 14px 10px}
870
+ .ddp-style .who{margin:0;padding:0 14px 6px;font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
871
+ .ddp-style .who code{font:11px/17px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary)}
872
+ .ddp-style .row{padding:9px 0 10px;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
873
+ .ddp-style label{display:flex;align-items:baseline;gap:8px;margin-bottom:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}
874
+ .ddp-style label span{flex:1}
875
+ .ddp-style label em{font:11px/16px ui-monospace,SFMono-Regular,Menlo,monospace;font-style:normal;color:var(--dsw-alias-label-tertiary);max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
876
+ .ddp-style label em.v{color:${ACCENT}}
877
+ .ddp-style input[type=range]{display:block;width:100%;margin:0;accent-color:${ACCENT};cursor:pointer}
878
+ .ddp-style .chips{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-top:7px}
879
+ .ddp-style .row>.chips:first-of-type{margin-top:0}
880
+ .ddp-style .chip{height:22px;max-width:100%;padding:0 7px;border:0;border-radius:6px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));color:var(--dsw-alias-label-secondary);font:11px/22px inherit;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
881
+ .ddp-style .chip:hover{color:var(--dsw-alias-label-primary)}
882
+ .ddp-style .chip.on{box-shadow:inset 0 0 0 1.5px ${ACCENT};color:var(--dsw-alias-label-primary)}
883
+ .ddp-style .chip.c{width:22px;padding:2px}
884
+ .ddp-style .chip.c i{display:block;width:100%;height:100%;border-radius:4px;box-shadow:inset 0 0 0 .5px rgba(127,127,127,.45)}
885
+ .ddp-style input[type=color]{width:30px;height:22px;padding:0;border:0;border-radius:6px;background:none;cursor:pointer}
886
+ .ddp-style .seg{display:flex;gap:2px;padding:2px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1))}
887
+ .ddp-style .seg button{flex:1;height:24px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:12px/24px inherit;cursor:pointer}
888
+ .ddp-style .seg button.on{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.14)}
889
+ .ddp-style .note{margin-top:6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
890
+ .ddp-style .cnt{padding:0 14px 6px;font-size:11px;color:var(--dsw-alias-label-tertiary)}
891
+ .ddp-styles{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:10px 8px}
892
+ .ddp-sc{display:flex;flex-direction:column;gap:5px;min-width:0;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;text-align:left;cursor:pointer}
893
+ .ddp-sc .pv{position:relative;display:block;height:58px;border-radius:10px;overflow:hidden;box-shadow:0 0 0 .5px rgba(127,127,127,.35);transition:transform .15s ${EASE},box-shadow .15s}
894
+ .ddp-sc:hover .pv{transform:translateY(-1px);box-shadow:0 0 0 1.5px ${ACCENT},0 6px 16px rgba(0,0,0,.12)}
895
+ .ddp-sc:hover{color:var(--dsw-alias-label-primary)}
896
+ .ddp-sc .pv b{position:absolute;left:8px;top:6px;font-size:17px;line-height:1}
897
+ .ddp-sc .pv .bt{position:absolute;right:8px;top:8px;width:20px;height:9px}
898
+ .ddp-sc .pv .cd{position:absolute;left:8px;right:8px;bottom:7px;height:20px}
899
+ .ddp-sc .pv .ln{position:absolute;left:6px;top:6px;width:46%;height:3px;border-radius:2px;opacity:.85}
900
+ .ddp-sc .pv .ln.s{top:12px;width:30%;opacity:.6}
901
+ .ddp-sc .nm{font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
902
+ .ddp-stylepick{width:100%;max-width:440px;margin:4px 0 6px;text-align:left}
903
+ .ddp-stylepick>b{display:block;margin-bottom:2px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
904
+ .ddp-stylepick>p{margin:0 0 10px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
531
905
  .ddp-tok{display:flex;align-items:center;gap:9px;width:100%;min-height:32px;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}
532
906
  .ddp-tok:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
533
907
  .ddp-tok .pv{width:24px;height:24px;flex:none;display:flex;align-items:center;justify-content:center;overflow:hidden}
@@ -542,6 +916,7 @@ window.__ModuleLoader__.load({
542
916
  .ddp-tok .vl{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:15px}
543
917
  .ddp-btn.has::after{content:"";position:absolute;right:4px;top:4px;width:5px;height:5px;border-radius:50%;background:${ACCENT}}
544
918
  .ddp-btn{position:relative}
919
+ .ddp-btn .bdg{position:absolute;top:0;right:0;min-width:14px;height:14px;padding:0 3px;box-sizing:border-box;border-radius:7px;background:#f59e0b;color:#fff;font:700 9px/14px system-ui,sans-serif;text-align:center;pointer-events:none}
545
920
  .ddp-menu .ft{margin:4px 4px 2px;padding:6px 6px 2px;border-top:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:15px;max-width:220px;white-space:normal}
546
921
  .ddp-ver{display:flex;flex-direction:column;align-items:stretch;gap:2px;width:100%;padding:8px 10px;margin-bottom:2px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}
547
922
  .ddp-ver:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
@@ -594,6 +969,39 @@ window.__ModuleLoader__.load({
594
969
  .ddp-act .ddp-btn{height:22px;min-width:22px;padding:0 4px;color:${ACCENT}}
595
970
  .ddp-act .ddp-btn svg{width:13px;height:13px}
596
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}
597
1005
  .ddp-more{display:none}
598
1006
  .ddp-menu .o1,.ddp-menu .o2{display:none}
599
1007
  @container (max-width:700px){.ddp-bar>.o1{display:none}.ddp-bar>.ddp-more{display:block}.ddp-menu .o1{display:flex}}
@@ -635,6 +1043,7 @@ window.__ModuleLoader__.load({
635
1043
  sizes: () => svg([h('rect', { key: 1, x: 2.5, y: 5, width: 12, height: 9.5, rx: 1.4 }), h('path', { key: 2, d: 'M6.5 18h4' }),
636
1044
  h('rect', { key: 3, x: 16.5, y: 8, width: 5, height: 10.5, rx: 1.2 })]),
637
1045
  text: () => svg([h('path', { key: 1, d: 'M5 7V5h11v2M10.5 5v14M8 19h5' }), h('path', { key: 2, d: 'M17 12v8M15.5 12h3M15.5 20h3' })]),
1046
+ sliders: () => svg([h('path', { key: 1, d: 'M4 7h9M17 7h3M4 17h3M11 17h9' }), h('circle', { key: 2, cx: 15, cy: 7, r: 2 }), h('circle', { key: 3, cx: 9, cy: 17, r: 2 })]),
638
1047
  image: () => svg([h('rect', { key: 1, x: 3.5, y: 4.5, width: 17, height: 15, rx: 2.5 }), h('circle', { key: 2, cx: 9, cy: 10, r: 1.6 }), h('path', { key: 3, d: 'M4 17l5-4.5 4 3.5 3-2.5 4.5 4', strokeLinejoin: 'round' })]),
639
1048
  more: () => svg([h('circle', { key: 1, cx: 5.5, cy: 12, r: 1.3 }), h('circle', { key: 2, cx: 12, cy: 12, r: 1.3 }), h('circle', { key: 3, cx: 18.5, cy: 12, r: 1.3 })]),
640
1049
  play: () => svg([h('path', { key: 1, d: 'M8 5.5v13l10.5-6.5z', strokeLinejoin: 'round' })]),
@@ -647,6 +1056,12 @@ window.__ModuleLoader__.load({
647
1056
  refresh: () => svg([h('path', { key: 1, d: 'M20 11a8 8 0 1 0-2.3 5.7' }), h('path', { key: 2, d: 'M20 5v6h-6' })]),
648
1057
  external: () => svg([h('path', { key: 1, d: 'M14 4h6v6' }), h('path', { key: 2, d: 'M20 4l-9 9' }), h('path', { key: 3, d: 'M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5' })]),
649
1058
  check: () => svg([h('path', { key: 1, d: 'M5 12.5l4.5 4.5L19 7.5', strokeWidth: 2.4 })]),
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 })]),
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' })]),
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' })]),
650
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),
651
1066
  }
652
1067
  // 引导页上的大图标
@@ -719,6 +1134,19 @@ window.__ModuleLoader__.load({
719
1134
  // ── 选中内容 → 输入框里的一段字 ──────────────────────────────────────────
720
1135
 
721
1136
  /** 页面地址 → 用户认得的位置:本地文件给路径,开发服务器给 localhost 地址 */
1137
+ /**
1138
+ * 这一屏里现在显示的是哪页(在预览里点链接跳过页,就是跳到的那页)→ { key, url }。
1139
+ * 同一个预览服务器上的 .html 才算、旧版本不算;不然还是这一屏。写回文件按它找文件,别写到原来那页去。
1140
+ */
1141
+ function pageOf(sc, href) {
1142
+ try {
1143
+ const u = new URL(href)
1144
+ const p = decodeURIComponent(u.pathname).replace(/^\/+/, '')
1145
+ if (u.origin === new URL(sc.url).origin && /\.html?$/i.test(p) && !p.startsWith('__dsh_design__/')) return { key: p, url: u.origin + u.pathname }
1146
+ } catch { /* 地址不对 */ }
1147
+ return { key: sc.key, url: sc.url }
1148
+ }
1149
+
722
1150
  function whereOf(href, st) {
723
1151
  try {
724
1152
  const u = new URL(href)
@@ -803,6 +1231,231 @@ window.__ModuleLoader__.load({
803
1231
  return `〔设计变量〕${tok.name}: ${tok.value}\n${whereOf(tok.href, st)}\n`
804
1232
  }
805
1233
 
1234
+ // ── 风格卡:开局挑一个风格(或给已有的设计换风格),整套配色、字体、圆角、阴影、要点放进输入框 ──────
1235
+ // 配色都量过对比度:正文和次要文字对底色 ≥ 4.5:1,主色按钮上的字 ≥ 4.5:1
1236
+
1237
+ const SANS = '"PingFang SC", "HarmonyOS Sans SC", "Microsoft YaHei", system-ui, sans-serif'
1238
+ const SERIF = '"Songti SC", "Noto Serif SC", "Source Han Serif SC", serif'
1239
+ const ROUND = '"SF Pro Rounded", "PingFang SC", "HarmonyOS Sans SC", system-ui, sans-serif'
1240
+ const STYLES = [
1241
+ { id: 'minimal', zh: '极简白', en: 'Minimal', dzh: '大留白、细线、黑白加一点蓝', den: 'Lots of space, hairlines, black and white with a touch of blue',
1242
+ c: { bg: '#FAFAF9', surface: '#FFFFFF', text: '#18181B', muted: '#6B6B73', line: '#E4E4E7', primary: '#18181B', accent: '#2563EB', on: '#FFFFFF', link: '#2563EB' },
1243
+ head: SANS, w: 600, radius: 10, shadow: '0 1px 2px rgba(24,24,27,.06)',
1244
+ nzh: '靠字号和留白分层级,不靠色块;分隔用 1px 细线;蓝色只给链接和选中态;大标题字距收 -0.02em',
1245
+ nen: 'Hierarchy from type size and whitespace, not color blocks; 1px hairlines; blue only for links and selection; tighten big headings to -0.02em' },
1246
+ { id: 'warm', zh: '温暖手作', en: 'Warm craft', dzh: '纸感底色、圆润、手绘感', den: 'Paper tones, soft and rounded, hand-made feel',
1247
+ c: { bg: '#F6EFE6', surface: '#FFF9F2', text: '#2B1D14', muted: '#6E5A4B', line: '#E7D9C8', primary: '#A9542A', accent: '#5E7A4A', on: '#FFFFFF', link: '#52703F' },
1248
+ head: SERIF, w: 600, radius: 16, shadow: '0 8px 24px rgba(120,72,30,.12)',
1249
+ nzh: '标题用衬线字,正文无衬线;插画用手绘线条和色块;阴影带暖色;中性色都偏暖,不用纯灰',
1250
+ nen: 'Serif headings, sans body; hand-drawn line illustrations and color blocks; warm-tinted shadows; warm neutrals, no pure gray' },
1251
+ { id: 'editorial', zh: '杂志编辑', en: 'Editorial', dzh: '大标题、黑线栅格、红色点题', den: 'Big headlines, black-rule grid, a red accent',
1252
+ c: { bg: '#F4F1EA', surface: '#FFFFFF', text: '#111111', muted: '#555555', line: '#111111', primary: '#C8231B', accent: '#111111', on: '#FFFFFF', link: '#C8231B' },
1253
+ head: SERIF, w: 700, radius: 2, shadow: 'none',
1254
+ nzh: '标题很大、粗细对比强烈;用粗细两种黑线分栏;版式不对称、有留白;红色只在一两处;几乎没有圆角和阴影',
1255
+ nen: 'Very large headlines with strong weight contrast; thick and thin black rules for columns; asymmetric layout; red in one or two places only; almost no radius or shadow' },
1256
+ { id: 'dark', zh: '深色高级', en: 'Dark luxe', dzh: '近黑底、香槟金点缀、大图', den: 'Near-black, champagne gold accents, big imagery',
1257
+ c: { bg: '#0E0E10', surface: '#18181B', text: '#F2EFE9', muted: '#A19C93', line: '#2C2C30', primary: '#C9A86A', accent: '#E8E2D6', on: '#17140E', link: '#C9A86A' },
1258
+ head: SERIF, w: 500, radius: 12, shadow: '0 12px 40px rgba(0,0,0,.45)',
1259
+ nzh: '金色只点在关键按钮和细线上;层级靠表面色深浅,不靠阴影;标题细一点、字距放开;留白要多',
1260
+ nen: 'Gold only on key buttons and fine lines; layers from surface shades, not shadows; lighter, tracked-out headings; generous space' },
1261
+ { id: 'playful', zh: '活泼年轻', en: 'Playful', dzh: '粗描边、硬阴影、贴纸感', den: 'Thick outlines, hard shadows, sticker feel',
1262
+ c: { bg: '#FFF7E8', surface: '#FFFFFF', text: '#1D1B3A', muted: '#5D5A82', line: '#1D1B3A', primary: '#FF6A3D', accent: '#1F9E92', on: '#1D1B3A', link: '#137167' },
1263
+ head: ROUND, w: 800, radius: 20, shadow: '4px 4px 0 #1D1B3A',
1264
+ nzh: '卡片和按钮 2px 深色描边 + 不模糊的硬阴影;主色按钮上用深色字;元素可以略微倾斜、像贴纸;颜色最多三种',
1265
+ nen: '2px dark outlines and hard, unblurred shadows on cards and buttons; dark text on the primary button; slightly tilted sticker-like bits; three colors max' },
1266
+ { id: 'fresh', zh: '清新自然', en: 'Fresh', dzh: '浅绿底、圆角卡片、轻插画', den: 'Pale green, rounded cards, light illustration',
1267
+ c: { bg: '#F3F7F2', surface: '#FFFFFF', text: '#1F2A22', muted: '#56665A', line: '#DCE6DA', primary: '#2F7D4F', accent: '#C9962A', on: '#FFFFFF', link: '#2F7D4F' },
1268
+ head: SANS, w: 600, radius: 14, shadow: '0 6px 18px rgba(47,125,79,.10)',
1269
+ nzh: '大面积浅色、卡片白底;插画用叶子、阳光这类自然元素;黄色只做小点缀;整体轻、透气',
1270
+ nen: 'Large light areas with white cards; nature motifs (leaves, sunlight) in illustrations; yellow only as a small accent; light and airy' },
1271
+ { id: 'tech', zh: '专业科技', en: 'Pro / tech', dzh: '冷灰蓝、信息密、数字清楚', den: 'Cool gray-blue, dense info, crisp numbers',
1272
+ c: { bg: '#F5F7FB', surface: '#FFFFFF', text: '#0F172A', muted: '#55657D', line: '#E2E8F0', primary: '#2457F5', accent: '#0F8F8E', on: '#FFFFFF', link: '#2457F5' },
1273
+ head: SANS, w: 600, radius: 8, shadow: '0 1px 3px rgba(15,23,42,.08)',
1274
+ nzh: '信息密度高但对齐严格;数字用等宽(font-variant-numeric: tabular-nums);表格行高 44–48;状态用小色点和标签;主色只给主操作',
1275
+ nen: 'Dense but strictly aligned; tabular numbers (font-variant-numeric: tabular-nums); 44–48px table rows; status as small dots and tags; primary color only for the main action' },
1276
+ { id: 'glass', zh: '玻璃质感', en: 'Glass', dzh: '柔光底、浮层毛玻璃', den: 'Soft glow background, frosted overlays',
1277
+ c: { bg: '#EAF0F6', surface: '#F7F9FC', text: '#10151C', muted: '#4F5B69', line: '#D5DEE8', primary: '#CC4520', accent: '#2B6CB0', on: '#FFFFFF', link: '#2B6CB0' },
1278
+ bgImage: 'radial-gradient(circle at 20% 15%, #FFD9C7 0, transparent 45%), radial-gradient(circle at 85% 70%, #C9E2FF 0, transparent 50%)',
1279
+ head: SANS, w: 600, radius: 18, shadow: '0 10px 30px rgba(16,21,28,.10)',
1280
+ nzh: '底色加两三团柔和的光斑(别用紫蓝渐变);只有浮层、卡片、标签栏用毛玻璃(半透明白 + backdrop-filter: blur(20px) + 1px 白色描边),正文区是实色;毛玻璃上的字照样要够深',
1281
+ nen: 'Two or three soft light blobs on the background (not a purple-blue gradient); frosted glass only on overlays, cards and tab bars (translucent white + backdrop-filter: blur(20px) + 1px white border), solid elsewhere; text on glass stays dark enough' },
1282
+ { id: 'soft', zh: '柔和立体', en: 'Soft UI', dzh: '同色底、凸起凹陷的柔光阴影', den: 'Same-color surfaces with raised and pressed soft shadows',
1283
+ c: { bg: '#E9EDF2', surface: '#E9EDF2', text: '#2D3440', muted: '#556072', line: '#D3D9E1', primary: '#3E5BD8', accent: '#22A06B', on: '#FFFFFF', link: '#3E5BD8' },
1284
+ head: ROUND, w: 700, radius: 18, shadow: '6px 6px 14px #C8CED6, -6px -6px 14px #FFFFFF',
1285
+ nzh: '卡片和底同色,靠一亮一暗两道阴影凸起,按下用内阴影;容易看不清,所以主按钮一定用主色实底、文字够深;别整屏都是凸起',
1286
+ nen: 'Cards share the background color and rise with a light and a dark shadow; pressed states use inset shadows; it gets low-contrast fast, so the main button is solid primary with strong text; don\'t make everything raised' },
1287
+ ]
1288
+
1289
+ /** 选了一个风格 → 输入框里的一段字(restyle:给已经打开的设计换风格) */
1290
+ function formatStyle(t, pre, restyle) {
1291
+ const en = t('lang') === 'en'
1292
+ const c = pre.c
1293
+ const fonts = t('styleFonts', { head: `${pre.head} · ${pre.w}`, body: SANS })
1294
+ return t('styleText', {
1295
+ name: en ? pre.en : pre.zh,
1296
+ desc: en ? pre.den : pre.dzh,
1297
+ colors: t('styleColors', { ...c, bgImage: pre.bgImage ? t('styleBgImage', { v: pre.bgImage }) : '' }),
1298
+ fonts,
1299
+ shape: t('styleShape', { r: pre.radius, s: pre.shadow }),
1300
+ notes: en ? pre.nen : pre.nzh,
1301
+ ask: t(restyle ? 'styleRestyle' : 'styleStart'),
1302
+ })
1303
+ }
1304
+
1305
+ // ── 调样式 ───────────────────────────────────────────────────────────────
1306
+
1307
+ const VAR_ONLY = /^var\(\s*(--[\w-]+)\s*\)$/
1308
+ /** 面板上能调的几项:属性 → [文案, 哪一行](上下 / 左右内边距各管两条) */
1309
+ const STYLE_LABEL = { 'font-size': 'styleFont', 'font-weight': 'styleWeight', color: 'styleColor', background: 'styleBg', 'border-radius': 'styleRadius',
1310
+ 'padding-top': 'stylePadY', 'padding-bottom': 'stylePadY', 'padding-left': 'stylePadX', 'padding-right': 'stylePadX', gap: 'styleGap' }
1311
+
1312
+ /** 变量名 → 它现在的值(页面 :root 上的) */
1313
+ const tokenVal = (name, tokens) => { const tok = (tokens || []).find((x) => x.name === name); return tok ? String(tok.value).trim() : '' }
1314
+
1315
+ /** 值 → 像素数(var(--x) 先换成变量的值;rem / em 按 16);认不出返回 null */
1316
+ function pxOf(v, tokens) {
1317
+ let s = String(v || '').trim()
1318
+ const m = VAR_ONLY.exec(s)
1319
+ if (m) s = tokenVal(m[1], tokens)
1320
+ const n = parseFloat(s)
1321
+ if (!isFinite(n)) return null
1322
+ return /r?em$/.test(s) ? n * 16 : n
1323
+ }
1324
+
1325
+ /** 透明的颜色(没设背景时 getComputedStyle 给 rgba(0, 0, 0, 0)) */
1326
+ const clearColor = (v) => /^(transparent|rgba\(.*,\s*0\)|hsla\(.*,\s*0%?\))$/i.test(String(v || '').trim())
1327
+
1328
+ /** 值 → '#rrggbb'(颜色框只认这个);透明的给白色,不然显示成黑的 */
1329
+ const colorHex = (v, tokens) => {
1330
+ const m = VAR_ONLY.exec(String(v || '').trim())
1331
+ const c = m ? tokenVal(m[1], tokens) : v
1332
+ return '#' + (clearColor(c) ? 'ffffff' : hexOf(c))
1333
+ }
1334
+
1335
+ /** 给人看的值:颜色换成 #RRGGBB,透明的说透明,别的原样 */
1336
+ const showVal = (prop, v, t) => {
1337
+ if (!/^(color|background)$/.test(prop)) return String(v)
1338
+ if (clearColor(v)) return t ? t('styleClear') : 'transparent'
1339
+ return /^(rgb|hsl)/i.test(String(v)) ? '#' + hexOf(v).toUpperCase() : String(v)
1340
+ }
1341
+
1342
+ /** 改了的几项 → 「字号 15px → 17px」这种;上下 / 左右内边距只说一次 */
1343
+ function styleChanges(t, sty) {
1344
+ const out = []
1345
+ const seen = new Set()
1346
+ for (const [k, v] of Object.entries((sty && sty.live) || {})) {
1347
+ const key = STYLE_LABEL[k]
1348
+ if (!key || seen.has(key)) continue
1349
+ seen.add(key)
1350
+ out.push(t('styleArrow', { prop: t(key), from: showVal(k, sty.base[k], t), to: showVal(k, v, t) }))
1351
+ }
1352
+ return out
1353
+ }
1354
+
1355
+ const ruleKey = (r) => `${r.href}|${r.sel}|${r.n}`
1356
+ /** 规则在哪:服务端找到了行号就是「app.css 第 42 行」,没找到就是文件名 */
1357
+ function ruleAt(t, r, located) {
1358
+ const hit = located && located.get(ruleKey(r))
1359
+ if (hit && hit.file) return hit.line ? t('ruleAt', { file: hit.file, line: hit.line }) : hit.file
1360
+ try { return decodeURIComponent(new URL(r.href).pathname.split('/').pop()) || r.href } catch { return String(r.href || '') }
1361
+ }
1362
+
1363
+ /** 改了的这几项现在各由哪条规则决定(同一条规则的合成一行),模型照着去改那条,不用再 grep */
1364
+ function ruleLines(t, sty, located) {
1365
+ const groups = new Map()
1366
+ for (const k of Object.keys((sty && sty.live) || {})) {
1367
+ const r = sty.rules && sty.rules[k]
1368
+ const key = STYLE_LABEL[k]
1369
+ if (!r || !key) continue
1370
+ const g = r.inline ? `inline|${r.sel ? ruleKey(r) : ''}` : ruleKey(r)
1371
+ if (!groups.has(g)) groups.set(g, { r, props: [] })
1372
+ const label = t(key)
1373
+ if (!groups.get(g).props.includes(label)) groups.get(g).props.push(label)
1374
+ }
1375
+ const out = []
1376
+ for (const { r, props } of groups.values()) {
1377
+ const p = props.join('、')
1378
+ if (r.inline) out.push(t('ruleInline', { props: p, over: r.sel ? t('ruleOver', { sel: r.sel, at: ruleAt(t, r, located) }) : '' }))
1379
+ else out.push(t(r.maybe ? 'ruleMaybe' : 'ruleFrom', { props: p, sel: r.sel, at: ruleAt(t, r, located) }))
1380
+ }
1381
+ return out
1382
+ }
1383
+
1384
+ /** 调好的样式交给模型:哪一屏、哪个元素、改了什么、现在由哪条规则决定(located:服务端找到的行号),让它改进样式表 */
1385
+ function formatStyleAsk(t, sty, st, located) {
1386
+ const rules = ruleLines(t, sty, located)
1387
+ return t('styleAskText', { name: sty.name, sel: sty.selector, list: styleChanges(t, sty).join(t('styleJoin')), rules: rules.length ? `\n${rules.join('\n')}\n` : '', where: whereOf(sty.url, st) })
1388
+ }
1389
+
1390
+ // ── 设计体检 ─────────────────────────────────────────────────────────────
1391
+
1392
+ /** 一条问题的具体数字(「#C8C8C8 配 #FFFFFF,1.7:1」这种) */
1393
+ function checkDetail(t, it) {
1394
+ const edge = it.edge === 'top' || it.edge === 'bottom' ? it.edge : ''
1395
+ return t('cd_' + it.type, {
1396
+ ...it,
1397
+ n: it.sizes ? it.sizes.length : '',
1398
+ sizes: it.sizes ? it.sizes.join(' / ') : '',
1399
+ otherText: String(it.otherText || '').slice(0, 20),
1400
+ edgeName: edge ? t('ckEdge_' + edge) : '',
1401
+ when: edge ? t('ckWhen_' + edge) : '',
1402
+ what: it.type === 'missing' ? t('ckWhat_' + (['css', 'script', 'font'].includes(it.what) ? it.what : 'other')) : '',
1403
+ at: it.type === 'jserr' && it.src ? ` (${it.src}${it.line ? ':' + it.line : ''})` : '',
1404
+ })
1405
+ }
1406
+
1407
+ /** 量的时候这一屏多大、页面多高、固定栏离内容多远(让模型知道量的是什么) */
1408
+ function sizeLine(t, m) {
1409
+ if (!m || !m.w) return ''
1410
+ let out = t('ckSize', { w: m.w, h: m.h, page: m.page || m.h })
1411
+ for (const b of m.bars || []) if (b.gap != null) out += t('ckSizeBar', { edgeName: t('ckEdge_' + b.edge), bar: b.name, gap: b.gap })
1412
+ return out
1413
+ }
1414
+
1415
+ /** 体检结果 → 输入框里的一段字:按屏分组,带文件位置、选择器和数字,模型照着改 */
1416
+ function formatCheck(t, list, st, head = 'checkAsk') {
1417
+ const n = list.reduce((m, x) => m + x.issues.length, 0)
1418
+ const lines = [t(head, { n, s: list.length })]
1419
+ for (const sc of list) {
1420
+ if (sc.error && head !== 'checkAsk') lines.push('', t('ckToolErr', { label: sc.label, msg: sc.error }))
1421
+ if (!sc.issues.length) continue
1422
+ const sz = head === 'checkAsk' ? '' : sizeLine(t, sc.metrics)
1423
+ lines.push('', t('ckScreen', { label: sc.label, where: whereOf(sc.url, st) }) + (sz ? ` · ${sz}` : ''))
1424
+ sc.issues.forEach((it, i) => lines.push(t('ckItem', {
1425
+ i: i + 1,
1426
+ type: t('ck_' + it.type),
1427
+ where: it.sel || '',
1428
+ text: it.text ? t('ckQuote', { text: it.text }) : '',
1429
+ detail: checkDetail(t, it),
1430
+ })))
1431
+ }
1432
+ return lines.join('\n') + '\n'
1433
+ }
1434
+
1435
+ /**
1436
+ * 一屏是不是「同一屏的几个版本」之一:home-a / home-b / home-c,或 login / login-b / login-c(和 core.js 的 placeScreen 一个规则)。
1437
+ * 是就给出 { stem, letter, others }(others:同组的其他文件名),不是给 null
1438
+ */
1439
+ function takesOf(keys, key) {
1440
+ const VAR = /^(.+)-([a-z])\.html?$/i
1441
+ const part = (x) => {
1442
+ const v = VAR.exec(x)
1443
+ return v ? { stem: v[1].toLowerCase(), letter: v[2].toLowerCase() } : { stem: x.toLowerCase().replace(/\.html?$/i, ''), letter: '' }
1444
+ }
1445
+ if (!/\.html?$/i.test(key)) return null
1446
+ const me = part(key)
1447
+ const others = keys.filter((x) => x !== key && /\.html?$/i.test(x) && part(x).stem === me.stem && (me.letter || part(x).letter))
1448
+ if (!others.length || (!me.letter && !others.some((x) => part(x).letter))) return null
1449
+ return { stem: VAR.exec(key) ? VAR.exec(key)[1] : key.replace(/\.html?$/i, ''), letter: me.letter, others }
1450
+ }
1451
+
1452
+ /** 体检结果 → 交给 check_design 工具的一段字(模型自己调的,不带〔设计体检〕开头) */
1453
+ function formatCheckTool(t, list, st) {
1454
+ const n = list.reduce((m, x) => m + x.issues.length, 0)
1455
+ if (n || list.some((x) => x.error)) return formatCheck(t, list, st, 'ckToolHead')
1456
+ return t('ckToolOk', { s: list.length, labels: list.map((x) => { const sz = sizeLine(t, x.metrics); return sz ? `${x.label} ${sz}` : x.label }).join(' / ') })
1457
+ }
1458
+
806
1459
  // ── 导出图片:设备外框、整套展示图 ───────────────────────────────────────
807
1460
 
808
1461
  const SHOW_COLS = { app: 4, tablet: 3, web: 2, poster: 3, slides: 2, email: 3 }
@@ -1140,6 +1793,32 @@ window.__ModuleLoader__.load({
1140
1793
  return canvasBlob(c)
1141
1794
  }
1142
1795
 
1796
+ /** CSS 颜色 → 'RRGGBB'(PPT 留边的底色);认不出就白色 */
1797
+ function hexOf(color) {
1798
+ try {
1799
+ const c = document.createElement('canvas')
1800
+ c.width = c.height = 1
1801
+ const ctx = c.getContext('2d')
1802
+ ctx.fillStyle = '#ffffff'
1803
+ ctx.fillStyle = color || '#ffffff'
1804
+ ctx.fillRect(0, 0, 1, 1)
1805
+ return [...ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)].map((x) => x.toString(16).padStart(2, '0')).join('')
1806
+ } catch { return 'ffffff' }
1807
+ }
1808
+
1809
+ /** 截图 → JPEG(base64)。PDF / PPT 里一页一张,JPEG 小得多;透明的地方垫白 */
1810
+ async function jpegB64(img) {
1811
+ const c = document.createElement('canvas')
1812
+ c.width = img.width
1813
+ c.height = img.height
1814
+ const ctx = c.getContext('2d')
1815
+ ctx.fillStyle = '#ffffff'
1816
+ ctx.fillRect(0, 0, c.width, c.height)
1817
+ ctx.drawImage(img, 0, 0)
1818
+ const blob = await new Promise((resolve, reject) => c.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.9))
1819
+ return blobB64(blob)
1820
+ }
1821
+
1143
1822
  function blobB64(blob) {
1144
1823
  return new Promise((resolve, reject) => {
1145
1824
  const r = new FileReader()
@@ -1476,15 +2155,32 @@ window.__ModuleLoader__.load({
1476
2155
  function away(src) {
1477
2156
  try { return !!src && LOCAL_HOST.test(new URL(src).hostname) && /^https?:$/.test(location.protocol) && !LOCAL_HOST.test(location.hostname) } catch { return false }
1478
2157
  }
2158
+ // 远程看(本地文件的预览):服务端把这一屏连同样式、图片打成一份 HTML,放进沙盒 iframe 的 srcdoc
2159
+ const remOf = (p) => (p.remote && away(p.src) ? p.remote : null)
2160
+ const OLD_VER = /^__dsh_design__\/v\/(\d+)\/(.*)$/
2161
+ /** 这一屏(预览服务器上的地址)打包后的取法 */
2162
+ function bundleOf(href, sid) {
2163
+ let p
2164
+ try { p = new URL(href).pathname } catch { return '' }
2165
+ try { p = decodeURIComponent(p) } catch { /* 原样 */ }
2166
+ p = p.replace(/^\/+/, '')
2167
+ let v = ''
2168
+ const m = OLD_VER.exec(p)
2169
+ if (m) { v = m[1]; p = m[2] }
2170
+ if (!p || p.endsWith('/')) p += 'index.html'
2171
+ return `${API}bundle?${new URLSearchParams({ session: sid, key: p, v, parent: location.origin })}`
2172
+ }
1479
2173
 
1480
2174
  function Screen(props) {
1481
2175
  const src = props.src
1482
2176
  const origin = src ? new URL(src).origin : ''
1483
2177
  const [frames, setFrames] = useState([])
1484
2178
  const [cur, setCur] = useState(0)
2179
+ const [failed, setFailed] = useState(false) // 远程看:打包没取回来
2180
+ const rem = remOf(props)
1485
2181
  const wins = useRef(new Map())
1486
2182
  const life = useRef(null)
1487
- if (!life.current) life.current = { next: 1, loading: 0, again: false, file: '', seeds: new Map(), seedOf: new Map(), readied: new Set(), snapTimer: null }
2183
+ if (!life.current) life.current = { next: 1, loading: 0, again: false, file: '', seeds: new Map(), seedOf: new Map(), readied: new Set(), snapTimer: null, gen: 0, at: '', deps: null }
1488
2184
  const curRef = useRef(0)
1489
2185
  const P = useRef(props)
1490
2186
  P.current = props
@@ -1503,6 +2199,16 @@ window.__ModuleLoader__.load({
1503
2199
  postTo(curRef.current, { type: 'capture', id, ...opts })
1504
2200
  setTimeout(() => { if (caps.current.has(id)) { caps.current.delete(id); reject(new Error('timeout')) } }, 30000)
1505
2201
  }),
2202
+ // 设计体检:页面里量完回 { issues },问题同时标在页面上(quiet:只要结果,不标)
2203
+ check: (kind, quiet) => new Promise((resolve, reject) => {
2204
+ if (!curRef.current) { reject(new Error('not loaded')); return }
2205
+ const id = `${Date.now()}-${++seq}`
2206
+ caps.current.set(id, { resolve, reject })
2207
+ postTo(curRef.current, { type: 'check', id, kind, quiet: !!quiet })
2208
+ setTimeout(() => { if (caps.current.has(id)) { caps.current.delete(id); reject(new Error('timeout')) } }, 15000)
2209
+ }),
2210
+ checkFocus: (i) => { if (curRef.current) postTo(curRef.current, { type: 'checkFocus', i }) },
2211
+ checkClear: () => { if (curRef.current) postTo(curRef.current, { type: 'checkClear' }) },
1506
2212
  }
1507
2213
  }
1508
2214
  // 登记到右侧栏:导出图片时按屏找到它
@@ -1516,7 +2222,10 @@ window.__ModuleLoader__.load({
1516
2222
 
1517
2223
  const postTo = (id, msg) => {
1518
2224
  const f = wins.current.get(id)
1519
- if (f && f.contentWindow && origin) f.contentWindow.postMessage({ dshDesign: 1, ...msg }, origin)
2225
+ if (!f || !f.contentWindow) return
2226
+ // 远程看:沙盒里的页面是不透明的源,只能发给 *(发的都是界面指令,没有要保密的)
2227
+ if (f.hasAttribute('srcdoc')) f.contentWindow.postMessage({ dshDesign: 1, ...msg }, '*')
2228
+ else if (origin) f.contentWindow.postMessage({ dshDesign: 1, ...msg }, origin)
1520
2229
  }
1521
2230
 
1522
2231
  // 在后台载一帧新的;正在载就记一笔,等它换上来再载
@@ -1525,13 +2234,44 @@ window.__ModuleLoader__.load({
1525
2234
  if (L.loading) { L.again = true; return }
1526
2235
  const id = L.next++
1527
2236
  L.loading = id
2237
+ if (seed) L.seedOf.set(id, seed)
2238
+ const r = remOf(P.current)
2239
+ if (r) {
2240
+ const gen = L.gen
2241
+ fetch(bundleOf(L.at || P.current.src, r.sid), { credentials: 'same-origin', cache: 'no-store' })
2242
+ .then((res) => res.json().then((d) => {
2243
+ if (!res.ok || !d || typeof d.html !== 'string') throw new Error((d && d.error) || `HTTP ${res.status}`)
2244
+ return d
2245
+ }))
2246
+ .then((d) => {
2247
+ if (L.gen !== gen || L.loading !== id) return
2248
+ L.deps = Array.isArray(d.deps) ? d.deps : null
2249
+ L.fails = 0
2250
+ setFailed(false)
2251
+ setFrames((fs) => [...fs, { id, doc: d.html }])
2252
+ })
2253
+ .catch(() => {
2254
+ if (L.gen !== gen || L.loading !== id) return
2255
+ L.loading = 0
2256
+ if (!curRef.current) setFailed(true)
2257
+ // 没取回来:那次改动还没显示出来,过一会儿再试(最多三回),别等下一次改动
2258
+ L.fails = (L.fails || 0) + 1
2259
+ if (L.again || L.fails <= 3) { L.again = false; setTimeout(() => { if (L.gen === gen) load(null) }, 1500 * Math.min(L.fails, 3)) }
2260
+ })
2261
+ return
2262
+ }
1528
2263
  let url = P.current.src
1529
2264
  try { if (seed && seed.href && new URL(seed.href).origin === origin) url = seed.href } catch { /* 用原地址 */ }
1530
- if (seed) L.seedOf.set(id, seed)
1531
2265
  setFrames((fs) => [...fs, { id, src: url }])
1532
2266
  }
1533
2267
 
1534
2268
  const pickMsg = () => ({ type: 'pick', hint: false }) // 提示放在右侧栏(页面顶上会被手机的灵动岛挡住)
2269
+ // 调样式:开模式、选中右侧栏记着的那个元素、把改到一半的样式再套上(新载的一帧要从头告诉它)
2270
+ const styleTo = (id, p) => {
2271
+ postTo(id, { type: 'styleMode', on: true })
2272
+ if (p.styleSel) postTo(id, { type: 'styleSel', selector: p.styleSel })
2273
+ if (p.styleCss) postTo(id, { type: 'style', css: p.styleCss })
2274
+ }
1535
2275
 
1536
2276
  // 一帧好了:淡入换上来
1537
2277
  const swap = (id, changes) => {
@@ -1548,6 +2288,7 @@ window.__ModuleLoader__.load({
1548
2288
  if (p.picking) postTo(id, pickMsg())
1549
2289
  if (p.demo) postTo(id, { type: 'demo', on: true })
1550
2290
  if (p.textEdit) postTo(id, { type: 'edit', on: true, hint: false })
2291
+ if (p.styleEdit) styleTo(id, p)
1551
2292
  if (first && p.onShown) p.onShown()
1552
2293
  if (first) { if (build && p.onBuilt) p.onBuilt(L.title || '') } else if (p.onNote) p.onNote({ changes, file: L.file, build })
1553
2294
  L.file = ''
@@ -1565,16 +2306,21 @@ window.__ModuleLoader__.load({
1565
2306
  L.again = false
1566
2307
  L.seeds.clear()
1567
2308
  L.seedOf.clear()
2309
+ L.gen++
2310
+ L.at = ''
2311
+ L.deps = null
2312
+ L.fails = 0
1568
2313
  curRef.current = 0
1569
2314
  setCur(0)
1570
2315
  setFrames([])
2316
+ setFailed(false)
1571
2317
  if (src) setTimeout(() => load(null), 0)
1572
- }, [src])
2318
+ }, [src, !!rem])
1573
2319
 
1574
2320
  // 页面发来的消息(几屏共用一个源,按 iframe 认是不是自己的)
1575
2321
  useEffect(() => {
1576
2322
  const on = (e) => {
1577
- if (!origin || e.origin !== origin) return
2323
+ if (rem ? e.origin !== 'null' : !origin || e.origin !== origin) return
1578
2324
  let id = 0
1579
2325
  for (const [k, f] of wins.current) if (f && f.contentWindow === e.source) { id = k; break }
1580
2326
  const d = e.data
@@ -1595,6 +2341,7 @@ window.__ModuleLoader__.load({
1595
2341
  L.readied.add(id)
1596
2342
  L.title = d.title || ''
1597
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 })
1598
2345
  if (p.onMeta) p.onMeta(d.meta || '', d.title || '', Array.isArray(d.tokens) ? d.tokens : [], !!d.dark, Array.isArray(d.links) ? d.links : [])
1599
2346
  if (p.onHref && d.href) p.onHref(d.href)
1600
2347
  if (isCur) {
@@ -1603,6 +2350,7 @@ window.__ModuleLoader__.load({
1603
2350
  if (p.picking) postTo(id, pickMsg())
1604
2351
  if (p.demo) postTo(id, { type: 'demo', on: true })
1605
2352
  if (p.textEdit) postTo(id, { type: 'edit', on: true, hint: false })
2353
+ if (p.styleEdit) styleTo(id, p)
1606
2354
  if (d.changes && d.changes.count && p.onNote) p.onNote({ changes: d.changes, file: '' })
1607
2355
  } else if (id > curRef.current) swap(id, d.changes)
1608
2356
  break
@@ -1622,14 +2370,20 @@ window.__ModuleLoader__.load({
1622
2370
  if (isCur && p.onNote) p.onNote({ changes: d.changes, file: '' })
1623
2371
  break
1624
2372
  case 'cancelled':
1625
- if (isCur && p.onCancelled) p.onCancelled()
2373
+ if (!isCur) break
2374
+ if (d.style) { if (p.onStyleCancel) p.onStyleCancel() } else if (p.onCancelled) p.onCancelled()
2375
+ break
2376
+ case 'stylePick':
2377
+ if (isCur && p.styleEdit && p.onStylePick && d.payload && d.payload.loc) p.onStylePick(d.payload)
1626
2378
  break
1627
2379
  // 点选、改字只在用户开着对应模式时收(页面自己的脚本也能发这种消息,不能随时往输入框里塞字、往文件里写)
1628
2380
  case 'picked':
1629
2381
  if (isCur && p.picking && p.onPicked) p.onPicked(d.payload)
1630
2382
  break
1631
2383
  case 'nav':
1632
- if (isCur && p.onNav && typeof d.path === 'string') p.onNav(d.path)
2384
+ if (!isCur || typeof d.path !== 'string') break
2385
+ if (p.onNav) p.onNav(d.path)
2386
+ else if (remOf(p)) { L.at = origin + d.path; load(null) } // 远程看:页面里点了跳页,这一框换成那一页(和本机预览时 iframe 自己跳过去一样)
1633
2387
  break
1634
2388
  case 'edited':
1635
2389
  if (isCur && (p.textEdit || Date.now() - editOffAt.current < EDIT_GRACE_MS) && p.onEdited && typeof d.before === 'string' && typeof d.after === 'string') p.onEdited(d)
@@ -1637,6 +2391,7 @@ window.__ModuleLoader__.load({
1637
2391
  case 'editDone':
1638
2392
  if (isCur && p.onEditDone) p.onEditDone()
1639
2393
  break
2394
+ case 'checked':
1640
2395
  case 'captured': {
1641
2396
  const c = caps.current.get(d.id)
1642
2397
  if (c) {
@@ -1651,7 +2406,7 @@ window.__ModuleLoader__.load({
1651
2406
  }
1652
2407
  window.addEventListener('message', on)
1653
2408
  return () => window.removeEventListener('message', on)
1654
- }, [origin, src])
2409
+ }, [origin, src, !!rem])
1655
2410
 
1656
2411
  // 模型在不在干活、画布缩放比例、选取开关:告诉页面
1657
2412
  useEffect(() => { if (cur) postTo(cur, { type: 'busy', on: !!props.busy }) }, [!!props.busy, cur])
@@ -1659,7 +2414,14 @@ window.__ModuleLoader__.load({
1659
2414
  useEffect(() => { if (cur) postTo(cur, { type: 'zoom', z: zq / 100 }) }, [zq])
1660
2415
  useEffect(() => { if (cur) postTo(cur, props.picking ? pickMsg() : { type: 'cancel' }) }, [!!props.picking])
1661
2416
  useEffect(() => { if (cur) postTo(cur, { type: 'theme', dark: !!props.dark }) }, [!!props.dark])
2417
+ useEffect(() => { if (cur) { if (props.styleEdit) styleTo(cur, props); else postTo(cur, { type: 'styleMode', on: false }) } }, [!!props.styleEdit])
2418
+ useEffect(() => { if (cur && props.styleEdit) postTo(cur, { type: 'styleSel', selector: props.styleSel || '' }) }, [props.styleSel || ''])
2419
+ const cssSig = JSON.stringify(props.styleCss || {})
2420
+ useEffect(() => { if (cur && props.styleEdit) postTo(cur, { type: 'style', css: props.styleCss || {} }) }, [cssSig])
1662
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])
1663
2425
  useEffect(() => {
1664
2426
  // 刚退出改字(点按钮、Esc、模型开始干活):页面会把正在改的那一处交上来,这一小会儿还收
1665
2427
  if (editWas.current && !props.textEdit) editOffAt.current = Date.now()
@@ -1668,17 +2430,46 @@ window.__ModuleLoader__.load({
1668
2430
  }, [!!props.textEdit])
1669
2431
 
1670
2432
  // 刷新:先问当前这帧要滚动位置和指纹,问不到就直接载
1671
- const seq = useRef(props.reloadSeq)
1672
- useEffect(() => {
1673
- if (seq.current === props.reloadSeq) return
1674
- seq.current = props.reloadSeq
2433
+ const refresh = () => {
1675
2434
  const L = life.current
1676
2435
  if (!curRef.current) { load(null); return }
1677
2436
  clearTimeout(L.snapTimer)
1678
2437
  L.snapTimer = setTimeout(() => load(null), 400)
1679
2438
  postTo(curRef.current, { type: 'snapshot' })
2439
+ }
2440
+ const seq = useRef(props.reloadSeq)
2441
+ useEffect(() => {
2442
+ if (seq.current === props.reloadSeq) return
2443
+ seq.current = props.reloadSeq
2444
+ refresh()
1680
2445
  }, [props.reloadSeq])
1681
2446
 
2447
+ // 远程看:页面连不上预览服务器、收不到它的改动通知,跟着右侧栏轮询到的「目录里改了哪些文件」重新打包
2448
+ // (改的全是这一屏没用到的就不动;旧版本不会变)。两次轮询之间可能来了好几批:按序号取上次看到之后所有批的并集,
2449
+ // 记录断了档(中间的批已经挤掉了)就不知道改了啥,整屏重打包
2450
+ const changes = (rem && rem.changes) || []
2451
+ const chgN = changes.length ? changes[changes.length - 1].n : 0
2452
+ const chg = rem ? `${rem.at || 0}|${chgN}` : ''
2453
+ const chgWas = useRef(chg)
2454
+ const seenN = useRef(chgN)
2455
+ useEffect(() => {
2456
+ if (chgWas.current === chg) return
2457
+ chgWas.current = chg
2458
+ const from = seenN.current
2459
+ seenN.current = chgN
2460
+ if (!rem || !rem.at) return
2461
+ const L = life.current
2462
+ let path = ''
2463
+ try { path = new URL(L.at || src).pathname.replace(/^\/+/, '') } catch { /* 地址不对 */ }
2464
+ if (OLD_VER.test(path)) return
2465
+ const fresh = changes.filter((c) => c.n > from)
2466
+ const unknown = !changes.length || changes[0].n > from + 1 || fresh.some((c) => !(c.files || []).length)
2467
+ const files = [...new Set(fresh.flatMap((c) => c.files || []))]
2468
+ if (!unknown && L.deps && !files.some((f) => L.deps.includes(f))) return
2469
+ L.file = files[files.length - 1] || L.file
2470
+ refresh()
2471
+ }, [chg])
2472
+
1682
2473
  // 页面没装上脚本(极少数,比如不是 HTML):载完等一会儿也换上来
1683
2474
  const onFrameLoad = (id) => {
1684
2475
  setTimeout(() => {
@@ -1687,15 +2478,17 @@ window.__ModuleLoader__.load({
1687
2478
  }, 1600)
1688
2479
  }
1689
2480
 
1690
- // 预览服务器只听电脑本机(127.0.0.1)。从手机(dsh-mobile)或别的电脑打开 DSH 网页时,这个地址指的是它们自己,
1691
- // 框里会一直空白:说清楚
1692
- if (away(src)) return h('div', { className: 'ddp-scr' + (props.dark ? ' dk' : '') }, h('div', { className: 'ddp-away', style: { fontSize: 13 / Math.max(0.25, props.zoom || 1) } }, props.t ? props.t('away') : ZH.away)) // 画布缩小了,字别跟着变小
2481
+ // 预览服务器只听电脑本机(127.0.0.1)。从手机(dsh-mobile)或别的电脑打开 DSH 网页时,这个地址指的是它们自己:
2482
+ // 本地文件的预览打包传过来(上面);开发服务器的没法打包,框里会一直空白,说清楚
2483
+ const tell = (k) => h('div', { className: 'ddp-scr' + (props.dark ? ' dk' : '') }, h('div', { className: 'ddp-away', style: { fontSize: 13 / Math.max(0.25, props.zoom || 1) } }, props.t ? props.t(k) : ZH[k])) // 画布缩小了,字别跟着变小
2484
+ if (away(src) && !rem) return tell('away')
2485
+ if (rem && failed && !cur) return tell('remoteFail')
1693
2486
  return h('div', { className: 'ddp-scr' + (props.dark ? ' dk' : '') },
1694
2487
  ...frames.map((f) => h('iframe', {
1695
2488
  key: f.id,
1696
2489
  ref: (el) => { if (el) wins.current.set(f.id, el); else wins.current.delete(f.id) },
1697
2490
  className: 'ddp-frame' + (f.id === cur ? ' cur' : f.id < cur ? ' old' : ''),
1698
- src: f.src,
2491
+ ...(typeof f.doc === 'string' ? { srcDoc: f.doc, sandbox: 'allow-scripts allow-forms', referrerPolicy: 'no-referrer' } : { src: f.src }),
1699
2492
  title: props.label || '',
1700
2493
  tabIndex: f.id === cur ? 0 : -1,
1701
2494
  'aria-hidden': f.id === cur ? undefined : true,
@@ -1729,7 +2522,7 @@ window.__ModuleLoader__.load({
1729
2522
  }
1730
2523
 
1731
2524
  /** 设计规范面板:按组列出变量,点一个放进输入框 */
1732
- function TokensPanel({ t, tokens, onPick, onAsk, onRef, onClose }) {
2525
+ function TokensPanel({ t, tokens, onPick, onAsk, onRef, onStyles, onSystems, onClose }) {
1733
2526
  const groups = {}
1734
2527
  for (const tok of tokens) (groups[tokenKind(tok)] = groups[tokenKind(tok)] || []).push(tok)
1735
2528
  const px = (v) => Math.max(0, parseFloat(v) * (/rem|em$/.test(v) ? 16 : 1)) || 0
@@ -1755,7 +2548,267 @@ window.__ModuleLoader__.load({
1755
2548
  h('span', { className: g === 'shadow' ? 'pv lt' : 'pv' }, preview(g, tok.value)),
1756
2549
  h('span', { className: 'tx' }, h('span', { className: 'nm' }, tok.name), h('span', { className: 'vl' }, tok.value))))))
1757
2550
  : h('div', { className: 'none' }, t('tokensEmpty'), h('div', null, h('button', { className: 'ddp-go', onClick: onAsk }, t('tokensAsk'))))),
1758
- onRef ? h('footer', null, h('button', { title: t('refPickTip'), onClick: onRef }, h(I.image), h('span', null, t('refPick')))) : null)
2551
+ onRef || onStyles ? h('footer', null,
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,
2554
+ onRef ? h('button', { title: t('refPickTip'), onClick: onRef }, h(I.image), h('span', null, t('refPick'))) : null) : null)
2555
+ }
2556
+
2557
+ /** 设计体检的结果:按屏列出问题(和页面上的编号一样),点一条在页面上滚过去、那个框闪一下 */
2558
+ function CheckPanel({ t, ck, onFocus, onFix, onAgain, onClose }) {
2559
+ const list = ck.list || []
2560
+ const n = list.reduce((m, x) => m + x.issues.length, 0)
2561
+ const multi = list.length > 1
2562
+ return h('div', { className: 'ddp-tokens ddp-check', onPointerDown: (e) => e.stopPropagation() },
2563
+ h('header', null, h(I.stetho), h('b', null, t('check')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
2564
+ h('p', { className: 'tip' }, ck.running ? t('checking') : n ? t('checkSum', { s: list.filter((x) => x.issues.length).length, n }) : t('checkNote')),
2565
+ h('div', { className: 'bd' },
2566
+ ...list.filter((sc) => sc.issues.length || sc.error).map((sc) => h('div', { key: sc.key },
2567
+ multi ? h('h4', null, sc.label) : null,
2568
+ sc.error ? h('div', { className: 'none' }, t('checkFail', { msg: sc.error })) : null,
2569
+ ...sc.issues.map((it, i) => h('button', { key: i, className: 'ddp-tok ddp-ck', title: it.sel || '', onClick: () => onFocus(sc.key, i) },
2570
+ h('span', { className: 'no' + (it.sev > 1 ? ' hi' : '') }, String(i + 1)),
2571
+ h('span', { className: 'tx' },
2572
+ h('span', { className: 'nm' }, t('ck_' + it.type) + (it.text ? ` · ${it.text}` : '')),
2573
+ h('span', { className: 'vl' }, checkDetail(t, it))))))),
2574
+ !ck.running && !n ? h('div', { className: 'ok' }, h(I.check), h('span', null, t('checkNone'))) : null),
2575
+ n ? h('p', { className: 'tip' }, t('checkNote')) : null,
2576
+ h('footer', { className: 'row' },
2577
+ h('button', { onClick: onAgain, disabled: ck.running }, h(I.refresh), h('span', null, t('checkAgain'))),
2578
+ h('button', { className: 'go', onClick: onFix, disabled: ck.running || !n }, h(I.sparkle), h('span', null, t('checkFix')))))
2579
+ }
2580
+
2581
+ /**
2582
+ * 调样式面板:选中的元素的字号、字重、颜色、背景、圆角、内边距、间距。
2583
+ * 拖的时候页面上先看效果(还没写进文件);设计变量列成小块,点一个就用 var(--x)。
2584
+ * write:{ ok, tip } 能不能写回文件(开发服务器、模型在干活时不能)
2585
+ */
2586
+ function StylePanel({ t, sty, tokens, write, onSet, onReset, onAsk, onSave, onClose }) {
2587
+ const head = h('header', null, h(I.sliders), h('b', null, t('style')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close)))
2588
+ const box = (...kids) => h('div', { className: 'ddp-tokens ddp-style', onPointerDown: (e) => e.stopPropagation() }, head, ...kids)
2589
+ if (!sty) return box(h('p', { className: 'tip' }, t('styleNone')))
2590
+ const groups = {}
2591
+ for (const tok of tokens || []) (groups[tokenKind(tok)] = groups[tokenKind(tok)] || []).push(tok)
2592
+ const cur = (k) => (k in sty.live ? sty.live[k] : sty.base[k])
2593
+ // 现在用的是哪个变量:改过的看改成了什么,没改的看样式表里怎么写的
2594
+ const varOf = (k) => { const m = VAR_ONLY.exec(String(cur(k) || '').trim()); return m ? m[1] : k in sty.live ? '' : (sty.vars || {})[k] || '' }
2595
+ const set = (props, v) => onSet(Object.fromEntries(props.map((p) => [p, v])))
2596
+ const chips = (kind, props) => (groups[kind] || []).slice(0, 10).map((tok) => h('button', {
2597
+ key: tok.name,
2598
+ className: 'chip' + (kind === 'color' ? ' c' : '') + (varOf(props[0]) === tok.name ? ' on' : ''),
2599
+ title: `${tok.name}: ${tok.value}`,
2600
+ onClick: () => set(props, `var(${tok.name})`),
2601
+ }, kind === 'color' ? h('i', { style: { background: tok.value } }) : tok.name.replace(/^--/, '')))
2602
+ const shown = (k, text) => (varOf(k) ? h('em', { className: 'v', title: t('styleVar', { name: varOf(k) }) }, varOf(k)) : h('em', null, text))
2603
+ const slider = (key, props, max, kind) => {
2604
+ const v = Math.round(pxOf(cur(props[0]), tokens) ?? 0)
2605
+ const c = kind ? chips(kind, props) : []
2606
+ return h('div', { className: 'row', key },
2607
+ h('label', null, h('span', null, t(key)), shown(props[0], `${v}px`)),
2608
+ h('input', { type: 'range', min: 0, max: Math.max(max, v), step: 1, value: v, 'aria-label': t(key), onChange: (e) => set(props, `${e.target.value}px`) }),
2609
+ c.length ? h('div', { className: 'chips' }, ...c) : null)
2610
+ }
2611
+ const color = (key, prop, note) => h('div', { className: 'row', key },
2612
+ h('label', null, h('span', null, t(key)), shown(prop, showVal(prop, cur(prop), t))),
2613
+ h('div', { className: 'chips' },
2614
+ h('input', { type: 'color', value: colorHex(cur(prop), tokens), 'aria-label': t(key), onChange: (e) => set([prop], e.target.value) }),
2615
+ ...chips('color', [prop])),
2616
+ note ? h('div', { className: 'note' }, note) : null)
2617
+ const w = Math.min(700, Math.max(400, Math.round(parseInt(cur('font-weight'), 10) / 100) * 100 || 400))
2618
+ const n = Object.keys(sty.live).length
2619
+ return box(
2620
+ h('p', { className: 'who', title: sty.selector }, h('code', null, sty.name), sty.text ? ` · ${sty.text}` : ''),
2621
+ h('div', { className: 'bd' },
2622
+ slider('styleFont', ['font-size'], 72, 'size'),
2623
+ h('div', { className: 'row', key: 'w' },
2624
+ h('label', null, h('span', null, t('styleWeight')), h('em', null, String(cur('font-weight')))),
2625
+ h('div', { className: 'seg' }, ...[400, 500, 600, 700].map((x) => h('button', { key: x, className: x === w ? 'on' : '', style: { fontWeight: x }, onClick: () => set(['font-weight'], String(x)) }, t('styleW' + x))))),
2626
+ color('styleColor', 'color'),
2627
+ color('styleBg', 'background', sty.gradient && !('background' in sty.live) ? t('styleGradient') : ''),
2628
+ slider('styleRadius', ['border-radius'], 40, 'radius'),
2629
+ slider('stylePadY', ['padding-top', 'padding-bottom'], 48, 'space'),
2630
+ slider('stylePadX', ['padding-left', 'padding-right'], 48, 'space'),
2631
+ sty.layout ? slider('styleGap', ['gap'], 48, 'space') : null),
2632
+ n ? h('div', { className: 'cnt' }, t('styleN', { n: styleChanges(t, sty).length })) : null,
2633
+ h('footer', { className: 'row' },
2634
+ h('button', { onClick: onReset, disabled: !n }, h('span', null, t('styleReset'))),
2635
+ h('button', { onClick: onAsk, disabled: !n, title: t('styleAskTip') }, h('span', null, t('styleAsk'))),
2636
+ h('button', { className: 'go', onClick: onSave, disabled: !n || !write.ok, title: write.tip }, h('span', null, t('styleSave')))))
2637
+ }
2638
+
2639
+ /** 一排风格卡:每张是个小样(底色、标题字、卡片、主色按钮),点了交给 onPick */
2640
+ function StyleCards({ t, onPick }) {
2641
+ const en = t('lang') === 'en'
2642
+ return h('div', { className: 'ddp-styles' }, ...STYLES.map((x) => h('button', { key: x.id, className: 'ddp-sc', title: en ? x.den : x.dzh, onClick: () => onPick(x) },
2643
+ h('span', { className: 'pv', style: { background: x.c.bg, backgroundImage: x.bgImage || 'none', color: x.c.text } },
2644
+ h('b', { style: { fontFamily: x.head, fontWeight: x.w } }, en ? 'Aa' : '字'),
2645
+ h('i', { className: 'bt', style: { background: x.c.primary, borderRadius: Math.min(x.radius, 6), boxShadow: x.id === 'playful' ? `1.5px 1.5px 0 ${x.c.line}` : 'none', outline: x.id === 'playful' ? `1px solid ${x.c.line}` : 'none' } }),
2646
+ h('i', { className: 'cd', style: { background: x.id === 'glass' ? 'rgba(255,255,255,.6)' : x.c.surface, borderRadius: Math.min(x.radius, 8) / 2 + 2,
2647
+ boxShadow: x.id === 'soft' ? '2px 2px 5px #C8CED6, -2px -2px 5px #FFFFFF' : x.id === 'playful' ? `2px 2px 0 ${x.c.line}` : x.id === 'editorial' ? `inset 0 1px 0 ${x.c.line}` : `0 0 0 .5px ${x.c.line}` } },
2648
+ h('i', { className: 'ln', style: { background: x.c.text } }), h('i', { className: 'ln s', style: { background: x.c.muted } }))),
2649
+ h('span', { className: 'nm' }, en ? x.en : x.zh))))
2650
+ }
2651
+
2652
+ /** 换风格面板(设计已经打开时):点一张卡让模型整套换成那个风格 */
2653
+ function StylesPanel({ t, onPick, onClose }) {
2654
+ return h('div', { className: 'ddp-tokens', onPointerDown: (e) => e.stopPropagation() },
2655
+ h('header', null, h(I.palette), h('b', null, t('stylesTitle')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
2656
+ h('p', { className: 'tip' }, t('stylesSwap')),
2657
+ h('div', { className: 'bd' }, h(StyleCards, { t, onPick })))
2658
+ }
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')))))
1759
2812
  }
1760
2813
 
1761
2814
  /** 菜单开着时:点外面、按 Esc 关掉 */
@@ -1782,6 +2835,7 @@ window.__ModuleLoader__.load({
1782
2835
  items.push(['raw', I.image, t('exRaw')])
1783
2836
  if (kind !== 'poster' && kind !== 'slides') items.push(['long', I.long, t('exLong')]) // 海报、幻灯片画幅是定的,没有「整页」
1784
2837
  if (count > 1) items.push(['show', I.canvas, t('exShow')])
2838
+ items.push(['pdf', I.pdf, t('exPdf')], ['pptx', I.deck, t('exPptx')])
1785
2839
  return h('div', { className: 'ddp-kind', ref },
1786
2840
  h('button', { className: 'ddp-btn' + (open ? ' sel' : ''), title: t('exportT'), disabled, onClick: () => setOpen(!open) }, h(I.download)),
1787
2841
  open ? h('div', { className: 'ddp-menu', role: 'menu' },
@@ -1912,6 +2966,29 @@ window.__ModuleLoader__.load({
1912
2966
  }
1913
2967
  }, [layoutKey])
1914
2968
 
2969
+ // 让某一屏露出来(体检里点了一条):整屏不在看得见的地方就把镜头挪过去;右边 inset 像素被面板挡着
2970
+ const rv = props.reveal
2971
+ useEffect(() => {
2972
+ const c = camRef.current
2973
+ if (!rv || !L || !c) return
2974
+ const i = screens.findIndex((x) => x.key === rv.key)
2975
+ if (i < 0) return
2976
+ const [cw, ch] = boxes ? boxes[i] : [dw, dh]
2977
+ const vw = Math.max(120, box.w - (props.inset || 0))
2978
+ const x0 = c.x + L.pos[i].x * c.z
2979
+ const y0 = c.y + L.pos[i].y * c.z
2980
+ const w = cw * c.z
2981
+ const hh = ch * c.z
2982
+ let x = c.x
2983
+ let y = c.y
2984
+ if (x0 < 8 || x0 + w > vw - 8) x += (w > vw - 16 ? 8 : (vw - w) / 2) - x0
2985
+ if (y0 < LAB || y0 + hh > box.h - 8) y += (hh > box.h - LAB - 8 ? LAB : (box.h - hh) / 2) - y0
2986
+ if (x === c.x && y === c.y) return
2987
+ auto.current = false
2988
+ setAnim(true)
2989
+ setCam({ ...c, x, y })
2990
+ }, [rv && rv.n])
2991
+
1915
2992
  const zoomAt = (factor, px, py, smooth) => {
1916
2993
  const c = camRef.current
1917
2994
  if (!c) return
@@ -2049,6 +3126,8 @@ window.__ModuleLoader__.load({
2049
3126
  useEffect(() => { const tm = setTimeout(() => setWaited(true), 2500); return () => clearTimeout(tm) }, [])
2050
3127
  const [picking, setPicking] = useState(false)
2051
3128
  const [textEdit, setTextEdit] = useState(false) // 改字:点页面里的文字直接改
3129
+ const [styleEdit, setStyleEdit] = useState(false) // 调样式:点元素、拖滑块,写回它的 style 属性
3130
+ const [sty, setSty] = useState(null) // 调样式选中的元素:{ key, url, name, selector, text, loc, base, vars, gradient, layout, live }
2052
3131
  const [editing, setEditing] = useState(false)
2053
3132
  const [menu, setMenu] = useState(false)
2054
3133
  const [toast, setToast] = useState('')
@@ -2057,6 +3136,7 @@ window.__ModuleLoader__.load({
2057
3136
  const [info, setInfo] = useState({}) // 目录|屏 → { meta, title }(页面报上来的)
2058
3137
  const [sel, setSel] = useState('')
2059
3138
  const [panel, setPanel] = useState(false)
3139
+ const [styles, setStyles] = useState(false) // 换风格面板
2060
3140
  const [dropping, setDropping] = useState(false) // 正拖着图片进来
2061
3141
  const refInput = useRef(null)
2062
3142
  const [exMenu, setExMenu] = useState(false)
@@ -2070,7 +3150,12 @@ window.__ModuleLoader__.load({
2070
3150
  const [dark, setDark] = useState(false)
2071
3151
  const [phone, setPhone] = useState(false) // 手机上看的面板
2072
3152
  const [multi, setMulti] = useState(false) // 多尺寸并排
3153
+ const [ck, setCk] = useState(null) // 设计体检:null = 面板关着;{ running, list: [{ key, label, url, issues, error }] }
3154
+ const [ckCount, setCkCount] = useState(0) // 最近一次体检(模型干完活会悄悄查一次)有几个问题:面板关着时标在按钮上
3155
+ const [reveal, setReveal] = useState(null) // 画布上要露出来的那一屏 { key, n }(体检里点了一条)
2073
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 → 值(还没写进文件)
2074
3159
  const apis = useRef(new Map()) // 屏 → 截图用的接口
2075
3160
  const cmpRef = useRef(null)
2076
3161
  const [box, setBox] = useState({ w: 0, h: 0 })
@@ -2130,6 +3215,22 @@ window.__ModuleLoader__.load({
2130
3215
  const labelOf = (sc) => (infoOf(ik(sc)) || {}).title || sc.label
2131
3216
  const anyDark = Object.keys(info).some((k) => k.startsWith(site + '|') && info[k].dark)
2132
3217
  const fileTarget = !!(st && st.target && st.target.kind === 'file')
3218
+ // 远程看(手机走外网等):本地文件的预览由服务端打包传过来(见 Screen),目录里改了哪些文件也一起带过去
3219
+ const remote = fileTarget && sid ? { sid, at: st.changedAt || 0, files: st.changedFiles || [], changes: st.changes || [] } : null
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
2133
3234
 
2134
3235
  // 看旧版本:画布上换成那一版的几屏(按现在的顺序,那一版独有的接在后面)
2135
3236
  const vbase = ver ? `${origin}/__dsh_design__/v/${ver.n}/` : ''
@@ -2218,7 +3319,7 @@ window.__ModuleLoader__.load({
2218
3319
  return () => ro.disconnect()
2219
3320
  }, [!!url])
2220
3321
 
2221
- useEffect(() => { setPicking(false); setTextEdit(false); setSel(''); setVer(null); setCmp(false) }, [site])
3322
+ useEffect(() => { setPicking(false); setTextEdit(false); setStyleEdit(false); setSel(''); setVer(null); setCmp(false) }, [site])
2222
3323
 
2223
3324
  // 版本列表:面板开着时拉一次,存了新版本(vrev 变了)再拉
2224
3325
  const vrev = st ? st.vrev : 0
@@ -2256,6 +3357,7 @@ window.__ModuleLoader__.load({
2256
3357
  const togglePick = () => {
2257
3358
  if (picking) { setPicking(false); return }
2258
3359
  setTextEdit(false)
3360
+ setStyleEdit(false)
2259
3361
  setPicking(true)
2260
3362
  if (!canvas) {
2261
3363
  // 单页:把焦点给页面,Esc 在页面里也管用
@@ -2267,20 +3369,76 @@ window.__ModuleLoader__.load({
2267
3369
  const toggleText = () => {
2268
3370
  if (textEdit) { setTextEdit(false); return }
2269
3371
  setPicking(false)
3372
+ setStyleEdit(false)
2270
3373
  setTextEdit(true)
2271
3374
  }
2272
3375
 
2273
- const askText = (sc, d) => insert(t('textAsk', { before: d.before, after: d.after, where: whereOf(sc.url, stRef.current) }), 'insertedSend')
3376
+ const askText = (sc, d) => insert(t('textAsk', { before: d.before, after: d.after, where: whereOf(pageOf(sc, d.href).url, stRef.current) }), 'insertedSend')
2274
3377
 
2275
3378
  const onEdited = (sc, d) => {
2276
3379
  if (!fileTarget) return askText(sc, d)
2277
- post('edit', { session: sid, key: sc.key, before: d.before, after: d.after, nth: d.nth })
3380
+ post('edit', { session: sid, key: pageOf(sc, d.href).key, before: d.before, after: d.after, nth: d.nth })
2278
3381
  .then((r) => (r && r.ok ? say(t('textSaved', { file: r.file })) : askText(sc, d)), () => askText(sc, d))
2279
3382
  }
2280
3383
 
2281
3384
  // 模型在干活时不能改字(它可能正整份重写文件,改了会被盖掉)
2282
3385
  useEffect(() => { if (ver || demo || busy) setTextEdit(false) }, [!!ver, !!demo, busy])
2283
3386
 
3387
+ // ── 调样式:点一个元素,面板里拖滑块先在页面上看效果;写回文件 = 并进它在源码里的 style 属性 ──
3388
+ const toggleStyle = () => {
3389
+ if (styleEdit) { setStyleEdit(false); return }
3390
+ setPicking(false)
3391
+ setTextEdit(false)
3392
+ setPanel(false)
3393
+ setStyles(false)
3394
+ setHist(false)
3395
+ setPhone(false)
3396
+ closeCheck()
3397
+ setStyleEdit(true)
3398
+ }
3399
+ useEffect(() => { if (!styleEdit) setSty(null) }, [styleEdit])
3400
+ const onStylePick = (sc, p) => {
3401
+ if (canvas) setSel(sc.key)
3402
+ const pg = pageOf(sc, p.href)
3403
+ setSty({ key: pg.key, url: pg.url, name: p.name, selector: p.selector, text: p.text, loc: p.loc, base: p.css || {}, vars: p.vars || {}, rules: p.rules && typeof p.rules === 'object' ? p.rules : {}, gradient: !!p.gradient, layout: !!p.layout, live: {} })
3404
+ }
3405
+ const setStyleProp = (patch) => setSty((c) => (c ? { ...c, live: { ...c.live, ...patch } } : c))
3406
+ // 交给模型:先问服务端那几条规则在源码第几行(本地文件才有;问不到就只写文件名)
3407
+ const askStyle = async () => {
3408
+ const s0 = sty
3409
+ if (!s0) return
3410
+ let located = null
3411
+ const items = [...new Map(Object.keys(s0.live || {}).map((k) => s0.rules && s0.rules[k]).filter((r) => r && r.sel && r.href).map((r) => [`${r.href}|${r.sel}|${r.n}`, r])).values()]
3412
+ if (fileTarget && items.length) {
3413
+ try {
3414
+ const r = await post('locate', { session: sid, items: items.map((x) => ({ href: x.href, sel: x.sel, n: x.n })) })
3415
+ located = new Map(items.map((x, i) => [`${x.href}|${x.sel}|${x.n}`, r && Array.isArray(r.items) ? r.items[i] : null]))
3416
+ } catch { /* 找不到行号就只写文件名 */ }
3417
+ }
3418
+ insert(formatStyleAsk(t, s0, stRef.current, located), 'insertedSend')
3419
+ }
3420
+ const saveStyle = () => {
3421
+ const s0 = sty
3422
+ if (!s0 || !Object.keys(s0.live).length) return
3423
+ if (!fileTarget) return askStyle()
3424
+ const saved = s0.live
3425
+ post('style', { session: sid, key: s0.key, loc: s0.loc, css: saved, name: s0.name }).then((r) => {
3426
+ if (r && r.ok) {
3427
+ say(t('styleSaved', { file: r.file }))
3428
+ // 写进去的算「原样」了;写的时候又拖过的留着
3429
+ setSty((c) => (c && c.key === s0.key && c.selector === s0.selector
3430
+ ? { ...c, base: { ...c.base, ...saved }, live: Object.fromEntries(Object.entries(c.live).filter(([k, v]) => saved[k] !== v)) } : c))
3431
+ } else if (r && r.reason === 'busy') say(t('styleBusyTip'))
3432
+ else { say(t('styleMiss')); askStyle() }
3433
+ }, () => askStyle())
3434
+ }
3435
+ useEffect(() => {
3436
+ if (!styleEdit) return undefined
3437
+ const onKey = (e) => { if (e.key === 'Escape') setStyleEdit(false) }
3438
+ window.addEventListener('keydown', onKey)
3439
+ return () => window.removeEventListener('keydown', onKey)
3440
+ }, [styleEdit])
3441
+
2284
3442
  useEffect(() => {
2285
3443
  if (!dropping) return undefined
2286
3444
  const off = () => setDropping(false)
@@ -2367,13 +3525,19 @@ window.__ModuleLoader__.load({
2367
3525
  }
2368
3526
 
2369
3527
  const renderScreen = (sc, z, fill) => h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(sc) },
2370
- h(Screen, { t,
3528
+ h(Screen, { t, remote,
2371
3529
  src: sc.url,
2372
3530
  label: labelOf(sc),
2373
3531
  busy: busy && !sc.v,
2374
3532
  born: !sc.v && isBorn(sc.key),
2375
3533
  picking: picking && !sc.v,
2376
3534
  textEdit: textEdit && !sc.v,
3535
+ styleEdit: styleEdit && !sc.v,
3536
+ styleSel: sty && sty.key === sc.key ? sty.selector : '',
3537
+ styleCss: sty && sty.key === sc.key ? sty.live : null,
3538
+ tweak: sc.v ? null : tweak,
3539
+ onStylePick: (p) => onStylePick(sc, p),
3540
+ onStyleCancel: () => setStyleEdit(false),
2377
3541
  dark,
2378
3542
  apis: apis.current,
2379
3543
  apiKey: ik(sc),
@@ -2391,16 +3555,23 @@ window.__ModuleLoader__.load({
2391
3555
  }))
2392
3556
 
2393
3557
  // ── 导出图片 ──
2394
- const shotOf = async (sc, full) => {
3558
+ // 截一屏:图片和它的 CSS 尺寸(PDF / PPT 按这个定页面大小)
3559
+ // 那一屏的接口(截图、体检);刚载入的等它就绪
3560
+ const apiOf = async (sc) => {
2395
3561
  let api = apis.current.get(ik(sc))
2396
3562
  for (let i = 0; i < 40 && !(api && api.ready()); i++) {
2397
3563
  await new Promise((r) => setTimeout(r, 150))
2398
3564
  api = apis.current.get(ik(sc))
2399
3565
  }
2400
3566
  if (!api) throw new Error('not loaded')
3567
+ return api
3568
+ }
3569
+ const captureOf = async (sc, full) => {
3570
+ const api = await apiOf(sc)
2401
3571
  const r = await api.capture({ full: !!full, scale: 2 })
2402
- return loadImg(r.png)
3572
+ return { img: await loadImg(r.png), w: r.w, h: r.h, links: Array.isArray(r.links) ? r.links : [] }
2403
3573
  }
3574
+ const shotOf = async (sc, full) => (await captureOf(sc, full)).img
2404
3575
 
2405
3576
  const saveImage = async (name, blob) => {
2406
3577
  const png = await blobB64(blob)
@@ -2416,6 +3587,31 @@ window.__ModuleLoader__.load({
2416
3587
  try {
2417
3588
  const prefix = ver ? `v${ver.n}-` : ''
2418
3589
  const suffix = dark ? '-dark' : ''
3590
+ if (mode === 'pdf' || mode === 'pptx') {
3591
+ // 每屏一页。PDF 里网页、邮件放整页长图(页面多高就多高);PPT 一张幻灯片一个画面
3592
+ const long = mode === 'pdf' && (kind === 'web' || kind === 'email')
3593
+ const pages = []
3594
+ const keys = []
3595
+ for (const sc of shown) {
3596
+ try {
3597
+ const r = await captureOf(sc, long)
3598
+ pages.push({ jpg: await jpegB64(r.img), w: r.w, h: r.h, title: labelOf(sc), links: r.links })
3599
+ keys.push(sc.key)
3600
+ } catch (e) { console.warn('[dsh-design-studio] 截图失败', sc.key, e) }
3601
+ }
3602
+ if (!pages.length) throw new Error('no screens')
3603
+ // 原型里的跳转:路径换成第几页,PDF / PPT 里点了能跳过去
3604
+ for (const p of pages) {
3605
+ p.links = p.links.map((l) => ({ x: l.x, y: l.y, w: l.w, h: l.h, to: keys.indexOf(keyOfPath(l.path, fileTarget)) })).filter((l) => l.to >= 0)
3606
+ }
3607
+ const sp = splitTitles(pages.map((x) => x.title))
3608
+ const folder = st && st.target && st.target.kind === 'file' ? String(st.target.file).split(/[\\/]/).slice(-2, -1)[0] : ''
3609
+ const title = sp.brand || folder || st.label || ''
3610
+ const r = await post('doc', { session: sid, format: mode, name: prefix + (title || 'design') + suffix, title, bg: hexOf(pickColors(tokens).bg), pages })
3611
+ const file = String(r.path || '').split(/[\\/]/).pop()
3612
+ say(t('exportedDoc', { name: file, n: pages.length }), { label: t('reveal'), run: () => post('reveal', { path: r.path }).catch(() => {}) })
3613
+ return
3614
+ }
2419
3615
  if (mode === 'show') {
2420
3616
  const shots = []
2421
3617
  for (const sc of shown) {
@@ -2471,7 +3667,17 @@ window.__ModuleLoader__.load({
2471
3667
  insert(t('variantText', { name: labelOf(sc), b, where: whereOf(sc.url, stRef.current) }), 'insertedSend')
2472
3668
  }
2473
3669
 
3670
+ // 同一屏的几个版本(开局的三个方向 home-a / b / c,或「再出两版」的 login / login-b / login-c):挑一个
3671
+ const pickDirection = (sc) => {
3672
+ const g = takesOf(shown.map((x) => x.key), sc.key)
3673
+ if (!g) return
3674
+ const others = g.others.join('、')
3675
+ const where = whereOf(sc.url, stRef.current)
3676
+ insert(g.letter ? t('pickText', { name: labelOf(sc), where, others, base: g.stem }) : t('pickKeep', { name: labelOf(sc), where, others }), 'insertedSend')
3677
+ }
3678
+
2474
3679
  const actions = (sc) => [
3680
+ !sc.v && takesOf(shown.map((x) => x.key), sc.key) ? h('button', { key: 'p', className: 'ddp-btn', title: t('pickThis'), onClick: () => pickDirection(sc) }, h(I.check)) : null,
2475
3681
  h('button', { key: 'x', className: 'ddp-btn', title: t('exThis'), onClick: () => doExport(FRAMED[kind] ? 'frame' : 'raw', sc.key) }, h(I.download)),
2476
3682
  sc.v ? null : h('button', { key: 'v', className: 'ddp-btn', title: t('variants'), onClick: () => askVariants(sc) }, h(I.sparkle)),
2477
3683
  h('button', { key: 'o', className: 'ddp-btn', title: t('openScreen'), onClick: () => change({ view: 'single', focus: sc.key }) }, h(I.single)),
@@ -2507,9 +3713,128 @@ window.__ModuleLoader__.load({
2507
3713
  const hintTop = (picking || textEdit) && !canvas && !inMulti && !ver && !demo && kind !== 'web'
2508
3714
  const flows = canvas && !inMulti && !picking && !textEdit ? flowsOf(shown, (sc) => (infoOf(ik(sc)) || {}).links, fileTarget) : []
2509
3715
 
3716
+ // ── 设计体检:画布上量所有屏,单页量这一屏;问题标在页面上,列在面板里 ──
3717
+ const closeCheck = () => {
3718
+ if (ck) for (const sc of ck.list || []) { const a = apis.current.get(ik(sc)); if (a) a.checkClear() }
3719
+ setCk(null)
3720
+ }
3721
+ const checkList = () => (canvas ? shown : [shown.find((x) => x.key === focus) || { key: focus, url, label: st.label }])
3722
+ // 几屏一起量(各量各的 iframe),一屏卡住不拖累别的
3723
+ const measure = (list, quiet) => Promise.all(list.map(async (sc) => {
3724
+ const row = { key: sc.key, v: sc.v, label: labelOf(sc), url: sc.url, issues: [] }
3725
+ try {
3726
+ const r = await (await apiOf(sc)).check(kind, quiet)
3727
+ row.issues = Array.isArray(r.issues) ? r.issues : []
3728
+ if (r.metrics && typeof r.metrics === 'object') row.metrics = r.metrics
3729
+ } catch (e) { row.error = e.message }
3730
+ return row
3731
+ }))
3732
+ const total = (list) => list.reduce((m, x) => m + x.issues.length, 0)
3733
+ const runCheck = async () => {
3734
+ setPanel(false); setHist(false); setPhone(false)
3735
+ setCk({ running: true, list: [] })
3736
+ const out = await measure(checkList(), false)
3737
+ setCk({ running: false, list: out })
3738
+ setCkCount(total(out))
3739
+ // 有问题的第一屏被面板挡着、在画布外面:挪过来
3740
+ const first = out.find((x) => x.issues.length)
3741
+ if (canvas && first) setReveal((r) => ({ key: first.key, n: ((r && r.n) || 0) + 1 }))
3742
+ return out
3743
+ }
3744
+ const focusIssue = (key, i) => {
3745
+ const sc = (ck && ck.list.find((x) => x.key === key)) || null
3746
+ if (!sc) return
3747
+ if (canvas) { setSel(key); setReveal((r) => ({ key, n: ((r && r.n) || 0) + 1 })) }
3748
+ const a = apis.current.get(ik(sc))
3749
+ if (a) a.checkFocus(i)
3750
+ }
3751
+ // 打开一套设计、模型又干完一轮(turns 变了):面板开着就再查一次(改没改好一眼看到);关着就悄悄量一下,问题数标在按钮上。
3752
+ // 模型干着活时不量;等一会儿再量:刚写完的屏还在刷新、播生成动画
3753
+ const autoCk = useRef(null)
3754
+ const ckKey = url && !busy ? `${site}|${(st && st.turns) || 0}` : ''
3755
+ const seenCk = useRef('')
3756
+ useEffect(() => {
3757
+ if (!ckKey || ckKey === seenCk.current) return undefined
3758
+ seenCk.current = ckKey
3759
+ const timer = setTimeout(async () => {
3760
+ const a = autoCk.current
3761
+ if (a.off) return
3762
+ if (a.ck && !a.ck.running) return a.run()
3763
+ if (a.ck) return
3764
+ const out = await a.measure(a.list(), true)
3765
+ if (!autoCk.current.ck) setCkCount(total(out))
3766
+ }, 2500)
3767
+ return () => clearTimeout(timer)
3768
+ }, [ckKey])
3769
+ useEffect(() => { setCkCount(0) }, [site])
3770
+ // 模型调了 check_design(轮询里出现新的 checkReq):量完把清单交回去;面板开着就顺手刷新面板,关着就只更新按钮上的数。
3771
+ // 稍等一下再量:模型刚写完的屏还在刷新
3772
+ const ckReq = (st && st.checkReq) || ''
3773
+ const doneReq = useRef('')
3774
+ useEffect(() => {
3775
+ if (!ckReq || ckReq === doneReq.current) return undefined
3776
+ doneReq.current = ckReq
3777
+ const timer = setTimeout(async () => {
3778
+ const a = autoCk.current
3779
+ let body
3780
+ if (a.off) body = { error: t('ckToolOff') }
3781
+ else {
3782
+ const out = a.ck && !a.ck.running ? await a.run() : await a.measure(a.list(), true)
3783
+ if (!autoCk.current.ck) setCkCount(total(out))
3784
+ // measured:真量到的屏数。一屏都没量到(这台设备上的预览不回话)时服务端先不收,等别的设备上的右侧栏
3785
+ body = { text: formatCheckTool(t, out, stRef.current), measured: out.filter((x) => !x.error).length }
3786
+ }
3787
+ post('check', { session: sid, id: ckReq, ...body }).catch(() => {})
3788
+ }, 1500)
3789
+ return () => clearTimeout(timer)
3790
+ }, [ckReq])
3791
+ const fixIssues = () => {
3792
+ if (!ck) return
3793
+ insert(formatCheck(t, ck.list, stRef.current), 'insertedSend')
3794
+ closeCheck()
3795
+ }
3796
+ const checkOff = !url || !!ver || !!demo || inMulti
3797
+ const styleOff = !url || !!ver || !!demo || inMulti || busy
3798
+ useEffect(() => { if (styleOff) setStyleEdit(false) }, [styleOff])
3799
+ autoCk.current = { ck, off: checkOff, run: runCheck, list: checkList, measure }
3800
+ useEffect(() => { if (checkOff && ck) closeCheck() }, [checkOff])
3801
+
2510
3802
  const toggleDemo = () => (demo ? setDemo(null) : startDemo())
2511
- const toggleHist = () => { setHist(!hist); setPanel(false); setPhone(false) }
2512
- const togglePhone = () => { setPhone(!phone); setPanel(false); setHist(false) }
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
+ }
3837
+ const togglePhone = () => { setPhone(!phone); setPanel(false); setStyles(false); setHist(false); closeCheck(); setStyleEdit(false) }
2513
3838
  const reload = () => setReloadSeq((x) => x + 1)
2514
3839
 
2515
3840
  const bar = h('div', { className: 'ddp-bar' },
@@ -2521,6 +3846,7 @@ window.__ModuleLoader__.load({
2521
3846
  h('button', { className: 'ddp-btn pick' + (picking ? ' on' : ''), title: t('pickTip'), disabled: !url || !!ver || !!demo, onClick: togglePick },
2522
3847
  h(I.pick), h('span', null, picking ? t('picking') : t('pick'))),
2523
3848
  h('button', { className: 'ddp-btn' + (textEdit ? ' on' : ''), title: t('textEdit'), disabled: !url || !!ver || !!demo || busy, onClick: toggleText }, h(I.text)),
3849
+ h('button', { className: 'ddp-btn' + (styleEdit ? ' on' : ''), title: t('styleEdit'), disabled: styleOff, onClick: toggleStyle }, h(I.sliders)),
2524
3850
  h('span', { className: 'ddp-sep' }),
2525
3851
  h('div', { className: 'ddp-seg' },
2526
3852
  h('button', { className: 'ddp-btn' + (canvas && !inMulti ? ' sel' : ''), title: t('canvas'), onClick: () => { setMulti(false); change({ view: 'canvas' }) } }, h(I.canvas)),
@@ -2528,24 +3854,35 @@ window.__ModuleLoader__.load({
2528
3854
  h('button', { className: 'ddp-btn' + (inMulti ? ' sel' : ''), title: sizeSet ? t('sizes') : t('sizesNo'), disabled: !sizeSet || !!ver, onClick: () => setMulti(!inMulti) }, h(I.sizes))),
2529
3855
  h('button', { className: 'ddp-btn o2' + (demo ? ' sel' : ''), title: t('demo'), disabled: !url || !!ver, onClick: toggleDemo }, h(I.play)),
2530
3856
  h(KindMenu, { t, kind, open: menu, setOpen: setMenu, onPick: (k) => { setMenu(false); change({ kind: k }) } }),
2531
- h('button', { className: 'ddp-btn' + (panel ? ' sel' : '') + (tokens.length && !panel ? ' has' : ''), title: t('tokens'), onClick: () => { setPanel(!panel); setHist(false); setPhone(false) } }, h(I.palette)),
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)),
3859
+ h('button', { className: 'ddp-btn o2' + (ck ? ' sel' : ''), title: ckCount && !ck ? t('checkCount', { n: ckCount }) : t('checkTip'), disabled: checkOff, onClick: () => (ck ? closeCheck() : runCheck()) },
3860
+ h(I.stetho), ckCount && !ck && !checkOff ? h('span', { className: 'bdg' }, ckCount > 99 ? '99+' : String(ckCount)) : null),
2532
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)),
2533
3863
  h(ExportMenu, { t, kind, count: shown.length, open: exMenu, setOpen: setExMenu, onPick: (m) => doExport(m), disabled: !url }),
2534
3864
  anyDark || dark ? h('button', { className: 'ddp-btn o1' + (dark ? ' sel' : ''), title: t('dark'), onClick: () => setDark(!dark) }, h(I.moon)) : null,
2535
3865
  h('button', { className: 'ddp-btn o1' + (phone ? ' sel' : '') + (phoneOn && !phone ? ' has' : ''), title: t('phone'), disabled: !url, onClick: togglePhone }, h(I.scan)),
2536
3866
  h('button', { className: 'ddp-btn o1', title: t('refresh'), onClick: reload }, h(I.refresh)),
2537
3867
  h('a', { className: 'ddp-btn o1', title: t('external'), href: url, target: '_blank', rel: 'noopener noreferrer' }, h(I.external)),
2538
- h(MoreMenu, { t, open: more, setOpen: setMore, has: phoneOn && !phone, items: [
3868
+ h(MoreMenu, { t, open: more, setOpen: setMore, has: (phoneOn && !phone) || (!!ckCount && !ck && !checkOff), items: [
2539
3869
  ['o2', I.play, t('demoShort'), !!demo, !url || !!ver, toggleDemo],
3870
+ ['o2', I.stetho, t('check'), !!ck, checkOff, () => (ck ? closeCheck() : runCheck())],
2540
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')],
2541
3875
  anyDark || dark ? ['o1', I.moon, t('dark'), dark, false, () => setDark(!dark)] : null,
2542
3876
  ['o1', I.scan, t('phone'), phone, !url, togglePhone],
2543
3877
  ['o1', I.refresh, t('refresh'), false, false, reload],
2544
3878
  ['o1', I.external, t('external'), false, !url, url],
2545
3879
  ] }))
2546
3880
 
2547
- const busyChip = (cls) => h('div', { className: 'ddp-chip' + cls, key: 'busy' }, h('i', { className: 'sp' }),
2548
- h('span', { className: 'tx' }, st && st.writing ? t('writing', { file: st.writing }) : t('working')))
3881
+ // 模型用提问卡问你、在等回答:不转圈,换成一个橙点
3882
+ const busyChip = (cls) => st && st.asking
3883
+ ? h('div', { className: 'ddp-chip' + cls, key: 'busy' }, h('i', { className: 'q' }), h('span', null, t('asking')))
3884
+ : h('div', { className: 'ddp-chip' + cls, key: 'busy' }, h('i', { className: 'sp' }),
3885
+ h('span', { className: 'tx' }, st && st.writing ? t('writing', { file: st.writing }) : t('working')))
2549
3886
 
2550
3887
  // ── 参考图:拖进来 / 粘贴 / 从设计规范面板选;存进设计目录的 refs/,尺寸和主要颜色放进输入框 ──
2551
3888
  const addRef = async (file) => {
@@ -2594,6 +3931,39 @@ window.__ModuleLoader__.load({
2594
3931
  })
2595
3932
  const root = (...kids) => h('div', rootProps, ...kids, dropLayer, fileInput)
2596
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
+
2597
3967
  if (!url) {
2598
3968
  if (st ? st.loading : !waited) return root(h('div', { className: 'ddp-stage', ref: stageRef }))
2599
3969
  if (busy) {
@@ -2614,9 +3984,12 @@ window.__ModuleLoader__.load({
2614
3984
  onClick: () => post('open', { session: sid, target: st.hint }).then((s) => { if (poll) poll.set(s) }, (e) => say(t('openFailed', { msg: e.message }))),
2615
3985
  }, t('hintOpen', { name: st.hint.split(/[\\/]/).pop() })) : null,
2616
3986
  h('ol', { className: 'ddp-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3'))),
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)) })),
2617
3988
  h('p', null, t('emptyHint')),
2618
3989
  h(AddressForm, { t, sid, className: 'ddp-form', onDone: () => {} }),
2619
- 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,
2620
3993
  toast ? h('div', { className: 'ddp-toast' }, h('span', null, toast.text)) : null)
2621
3994
  }
2622
3995
 
@@ -2634,7 +4007,7 @@ window.__ModuleLoader__.load({
2634
4007
  h('div', { className: 'ddp-single' },
2635
4008
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
2636
4009
  h(Device, { kind, w: size[0], h: size[1], fill, label },
2637
- h(Screen, { t,
4010
+ h(Screen, { t, remote,
2638
4011
  src: urlOfKey(l.key),
2639
4012
  label,
2640
4013
  busy: false,
@@ -2642,6 +4015,7 @@ window.__ModuleLoader__.load({
2642
4015
  picking: false,
2643
4016
  demo: isTop,
2644
4017
  dark,
4018
+ tweak,
2645
4019
  zoom: z,
2646
4020
  reloadSeq,
2647
4021
  onNav: isTop ? demoGo : undefined,
@@ -2668,7 +4042,7 @@ window.__ModuleLoader__.load({
2668
4042
  sel: '',
2669
4043
  labelOf: (sc) => sc.label,
2670
4044
  render: (sc, z, i) => h(Device, { kind: items[i].kind, w: items[i].size[0], h: items[i].size[1], label: labelOf(base) },
2671
- h(Screen, { t,
4045
+ h(Screen, { t, remote,
2672
4046
  src: base.url,
2673
4047
  label: labelOf(base),
2674
4048
  busy,
@@ -2676,6 +4050,7 @@ window.__ModuleLoader__.load({
2676
4050
  picking,
2677
4051
  textEdit,
2678
4052
  dark,
4053
+ tweak,
2679
4054
  quietPick: true,
2680
4055
  zoom: z,
2681
4056
  reloadSeq,
@@ -2697,7 +4072,7 @@ window.__ModuleLoader__.load({
2697
4072
  const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver ? 46 : 0)) / dh))
2698
4073
  // 两边的小标签不跟着画面缩小(缩了看不清);手机、平板躲开状态栏
2699
4074
  const tagAt = (side) => ({ top: kind === 'app' || kind === 'tablet' ? 60 : 10, transform: `scale(${1 / z})`, transformOrigin: 'top ' + side })
2700
- const screenOf = (sc) => h(Screen, { t, src: sc.url, label: labelOf(sc), busy: false, born: false, picking: false, dark, zoom: z, reloadSeq,
4075
+ const screenOf = (sc) => h(Screen, { t, remote, src: sc.url, label: labelOf(sc), busy: false, born: false, picking: false, dark, zoom: z, reloadSeq,
2701
4076
  apis: apis.current, apiKey: 'cmp|' + ik(sc), onMeta: (m, title, toks, dk) => remember(ik(sc), m, title, toks, dk) })
2702
4077
  body = h('div', { className: 'ddp-single' },
2703
4078
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
@@ -2719,9 +4094,11 @@ window.__ModuleLoader__.load({
2719
4094
  kind,
2720
4095
  size,
2721
4096
  ghost,
2722
- picking: picking || textEdit,
4097
+ picking: picking || textEdit || styleEdit,
2723
4098
  sel: sel || focus,
2724
4099
  labelOf,
4100
+ reveal,
4101
+ inset: ck || styleEdit || panel || styles || pane ? 296 : 0, // 右边的面板 276 宽,离边 10
2725
4102
  render: (sc, z) => renderScreen(sc, z, false),
2726
4103
  flows,
2727
4104
  onSelect: setSel,
@@ -2730,16 +4107,18 @@ window.__ModuleLoader__.load({
2730
4107
  } else {
2731
4108
  const fill = kind === 'web'
2732
4109
  const [dw, dh] = deviceBox(kind, size)
2733
- const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver || hintTop ? 46 : 0)) / dh))
4110
+ // 右边开着面板(体检、调样式、设计规范、换风格):手机这类有外框的让到面板左边(侧栏够宽时),别被挡住
4111
+ const aside = !fill && (ck || styleEdit || panel || styles || pane) && box.w - 296 >= 240 ? 296 : 0
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))
2734
4113
  const one = ver ? shown.find((x) => x.key === focus) || shown[0] || { key: focus, url, label: st.label } : { key: focus, url, label: st.label }
2735
- body = h('div', { className: 'ddp-single' },
4114
+ body = h('div', { className: 'ddp-single', style: aside ? { right: aside } : undefined },
2736
4115
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
2737
4116
  renderScreen(one, z, fill)))
2738
4117
  }
2739
4118
 
2740
4119
  let chip = null
2741
4120
  const side = canvas || inMulti ? ' left' : ''
2742
- const hintText = picking ? t(canvas || inMulti ? 'pickAny' : 'pickOne') : textEdit ? t(canvas || inMulti ? 'textAny' : 'textOne') : ''
4121
+ const hintText = picking ? t(canvas || inMulti ? 'pickAny' : 'pickOne') : textEdit ? t(canvas || inMulti ? 'textAny' : 'textOne') : styleEdit && !sty ? t(canvas ? 'styleAny' : 'styleOne') : ''
2743
4122
  if (flash) chip = h('div', { className: 'ddp-chip ok' + side, key: 'ok' }, h(I.check), h('span', null, flash))
2744
4123
  else if (busy) chip = busyChip(side)
2745
4124
 
@@ -2749,8 +4128,24 @@ window.__ModuleLoader__.load({
2749
4128
  h('div', { className: 'ddp-body' + (ver || demo || hintTop ? ' vb' : '') }, body),
2750
4129
  busy ? h('div', { className: 'ddp-glow' }) : null,
2751
4130
  busy ? h('div', { className: 'ddp-ring' }) : null,
2752
- hintText ? h('div', { className: 'ddp-hint' }, hintText) : null,
2753
- panel ? h(TokensPanel, { t, tokens, onPick: (tok) => insert(formatToken(tok, stRef.current)), onAsk: () => insert(t('tokensAskText') + '\n', 'insertedSend'), onRef: pickRef, onClose: () => setPanel(false) }) : null,
4131
+ // 右边开着面板时提示条居中在面板左边那块,别被面板盖住一半
4132
+ hintText ? h('div', { className: 'ddp-hint' + ((ck || styleEdit || panel || styles || pane) && box.w - 296 >= 240 ? ' side' : '') }, hintText) : null,
4133
+ ck ? h(CheckPanel, { t, ck, onFocus: focusIssue, onFix: fixIssues, onAgain: runCheck, onClose: closeCheck }) : null,
4134
+ styleEdit ? h(StylePanel, {
4135
+ t,
4136
+ sty,
4137
+ tokens: (sty && (infoOf(sty.key) || {}).tokens) || tokens,
4138
+ write: !fileTarget ? { ok: false, tip: t('styleNoWrite') } : busy ? { ok: false, tip: t('styleBusyTip') } : { ok: true, tip: t('styleSaveTip') },
4139
+ onSet: setStyleProp,
4140
+ onReset: () => setSty((c) => (c ? { ...c, live: {} } : c)),
4141
+ onAsk: askStyle,
4142
+ onSave: saveStyle,
4143
+ onClose: () => setStyleEdit(false),
4144
+ }) : null,
4145
+ panel ? h(TokensPanel, { t, tokens, onPick: (tok) => insert(formatToken(tok, stRef.current)), onAsk: () => insert(t('tokensAskText') + '\n', 'insertedSend'), onRef: pickRef,
4146
+ onStyles: () => { setPanel(false); setStyles(true) }, onSystems: () => openPane('systems'), onClose: () => setPanel(false) }) : null,
4147
+ pane ? sidePane() : null,
4148
+ styles ? h(StylesPanel, { t, onPick: (x) => { setStyles(false); insert(formatStyle(t, x, true), 'insertedSend') }, onClose: () => setStyles(false) }) : null,
2754
4149
  phone ? h(PhonePanel, {
2755
4150
  t,
2756
4151
  sid,
@@ -2849,7 +4244,7 @@ window.__ModuleLoader__.load({
2849
4244
  // 注意:这里是客户端**服务名**(短的),不是包名
2850
4245
  exports.inject = INJECT
2851
4246
  // 测试用
2852
- exports._test = { poller, frameOf, paletteOf, formatRef, layoutRow, keyOfPath, flowsOf, flowLines, formatPick, whereOf, parseMeta, sizeOf, layoutGrid, fitCam, tokenKind, formatToken, splitTitles, pickColors, hhmm }
4247
+ exports._test = { STYLES, formatStyle, pageOf, pxOf, styleChanges, formatStyleAsk, takesOf, formatCheckTool, poller, frameOf, paletteOf, formatRef, layoutRow, keyOfPath, flowsOf, flowLines, formatPick, whereOf, parseMeta, sizeOf, layoutGrid, fitCam, tokenKind, formatToken, splitTitles, pickColors, hhmm }
2853
4248
  return module.exports
2854
4249
  },
2855
4250
  })