dsh-design-studio 0.4.0 → 0.5.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,8 +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: '在手机上看',
264
+ away: '这里看不到预览:开发服务器跑在电脑本机上,从手机或别的电脑打开 DSH 时连不过去(本地 HTML 文件的设计能看)。回到那台电脑上看;想在手机上看,就在电脑上点「在手机上看」扫码(同一个 Wi-Fi)。',
265
+ remoteFail: '这一屏没能传过来(连接断了,或图片太大)。点「刷新」再试。',
147
266
  phoneStarting: '正在打开…',
148
267
  phoneHow: '用手机相机扫一扫',
149
268
  phoneWifi: '手机和电脑要连同一个 Wi-Fi',
@@ -169,6 +288,7 @@ window.__ModuleLoader__.load({
169
288
  demoShort: '演示',
170
289
  editAddr: '换一个地址',
171
290
  working: '模型正在工作',
291
+ asking: '模型在等你回答(在对话里)',
172
292
  writing: '正在写 {file}',
173
293
  born: '已生成 {name}',
174
294
  updated: '已更新{file}',
@@ -244,12 +364,75 @@ window.__ModuleLoader__.load({
244
364
  gRadius: 'Radii',
245
365
  gShadow: 'Shadows',
246
366
  gOther: 'Other',
247
- exportT: 'Export image',
367
+ exportT: 'Export',
248
368
  exFrame: 'This screen · in frame',
249
369
  exRaw: 'This screen · plain',
250
370
  exLong: 'This screen · full page',
251
371
  exShow: 'Showcase of all screens',
252
- exWhere: 'Saved to exports/ in the design folder and copied to the clipboard',
372
+ exPdf: 'PDF · one page per screen',
373
+ exPptx: 'PowerPoint · one slide per screen (as images)',
374
+ exWhere: 'Saved to exports/ in the design folder; images are also copied to the clipboard',
375
+ exportedDoc: 'Exported {name} ({n} pages)',
376
+ check: 'Design check',
377
+ checkTip: 'Design check: measure overflowing or overlapping text, contrast, tap targets and type sizes',
378
+ checking: 'Checking…',
379
+ checkNone: 'No problems found',
380
+ checkSum: '{s} screens · {n} problems',
381
+ checkFix: 'Ask the model to fix',
382
+ checkAgain: 'Check again',
383
+ checkFail: 'Check failed: {msg}',
384
+ checkNote: 'Only measurable layout problems; whether it looks good is still up to you',
385
+ checkCount: 'Design check: {n} problems last time. Click to see them',
386
+ 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.',
387
+ 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:',
388
+ ckToolOk: 'Measured {s} screens ({labels}). No problems found.',
389
+ 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.',
390
+ ckToolErr: '{label}: could not measure ({msg})',
391
+ ck_hscroll: 'Wider than the screen',
392
+ ck_clipped: 'Text cut off',
393
+ ck_overflow: 'Text spills out of its box',
394
+ ck_overlap: 'Overlapping text',
395
+ ck_contrast: 'Low contrast',
396
+ ck_tap: 'Tap target too small',
397
+ ck_tiny: 'Text too small',
398
+ ck_img: 'Image failed to load',
399
+ ck_fonts: 'Too many type sizes',
400
+ ck_distort: 'Stretched image',
401
+ ck_long: 'Lines too long',
402
+ ck_wrap: 'Short text wraps',
403
+ ck_covered: 'Hidden under a fixed bar',
404
+ ck_tight: 'Too close to a fixed bar',
405
+ ck_jserr: 'Script error',
406
+ ck_missing: 'File failed to load',
407
+ cd_hscroll: 'sticks out {px}px',
408
+ cd_clipped: '{px}px cut off',
409
+ cd_overflow: 'sticks out {px}px',
410
+ cd_overlap: 'overlaps {other} "{otherText}"',
411
+ cd_contrast: '{fg} on {bg}, {ratio}:1 (needs ≥ {need}:1)',
412
+ cd_tap: '{w}×{h} (at least {min}×{min})',
413
+ cd_tiny: '{size}px (at least {min}px)',
414
+ cd_img: '{src}',
415
+ cd_fonts: '{n} type sizes on one screen: {sizes}',
416
+ cd_distort: '{nw}×{nh} shown at {w}×{h} (use object-fit: cover or fix the size)',
417
+ cd_long: 'about {chars} characters per line (keep it ≤ {max})',
418
+ cd_wrap: 'breaks onto {lines} lines (keep short labels and prices on one line: more width, white-space: nowrap, or shorter text)',
419
+ cd_covered: '{when}{px}px hidden under the {edgeName} {bar} (leave room for the bar)',
420
+ cd_tight: 'only {px}px from the {edgeName} {bar} (leave 16px or more)',
421
+ cd_jserr: '{msg}{at}',
422
+ cd_missing: '{what} {src} did not load (wrong path, or the file is missing)',
423
+ ckEdge_top: 'top bar',
424
+ ckEdge_bottom: 'bottom bar',
425
+ ckWhen_top: 'before scrolling, ',
426
+ ckWhen_bottom: 'even scrolled to the end, ',
427
+ ckWhat_css: 'stylesheet',
428
+ ckWhat_script: 'script',
429
+ ckWhat_font: 'font',
430
+ ckWhat_other: 'file',
431
+ ckSize: '{w}×{h}, page height {page}',
432
+ ckSizeBar: ', {edgeName} {bar} {gap}px from the content',
433
+ ckScreen: '{label} ({where})',
434
+ ckItem: '{i}. {type}: {where}{text} — {detail}',
435
+ ckQuote: ' "{text}"',
253
436
  exporting: 'Exporting…',
254
437
  exported: 'Exported {name}',
255
438
  exportedCopied: 'Exported {name} and copied it',
@@ -277,6 +460,9 @@ window.__ModuleLoader__.load({
277
460
  cmpNow: 'Now',
278
461
  dark: 'Dark mode',
279
462
  variants: 'Two more takes',
463
+ pickThis: 'Use this one: move the other takes to drafts/ and build the rest',
464
+ 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',
465
+ 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',
280
466
  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',
281
467
  exThis: 'Export this screen',
282
468
  demo: 'Play: tap buttons in the page to move between screens',
@@ -287,8 +473,61 @@ window.__ModuleLoader__.load({
287
473
  textEdit: 'Edit text: click any text in the page, Enter to save (Esc to stop)',
288
474
  textSaved: 'Saved to {file}',
289
475
  textAsk: 'Change the text "{before}" to "{after}" on the page (only this text, nothing else).\n{where}\n',
476
+ styleEdit: 'Style: click an element, then drag sliders for size, colors, radius and spacing (Esc to stop)',
477
+ styleAny: 'Click an element in any screen to style it · Esc to stop',
478
+ styleOne: 'Click an element in the page to style it · Esc to stop',
479
+ style: 'Style',
480
+ styleNone: 'Click an element in the page. Changes show in the preview first; save them to the file when you like them.',
481
+ styleFont: 'Font size',
482
+ styleWeight: 'Weight',
483
+ styleColor: 'Text',
484
+ styleBg: 'Background',
485
+ styleClear: 'transparent',
486
+ styleRadius: 'Radius',
487
+ stylePadY: 'Padding (top / bottom)',
488
+ stylePadX: 'Padding (left / right)',
489
+ styleGap: 'Gap',
490
+ styleW400: 'Regular',
491
+ styleW500: 'Medium',
492
+ styleW600: 'Semi',
493
+ styleW700: 'Bold',
494
+ styleVar: 'Token {name}',
495
+ styleGradient: 'It is a gradient now; picking a color makes it solid',
496
+ styleReset: 'Reset',
497
+ styleAsk: 'Ask the model',
498
+ styleSave: 'Save to file',
499
+ styleSaveTip: 'Writes into this element\'s style attribute in the source (a version is kept before and after)',
500
+ styleAskTip: 'Let the model move it into the stylesheet (using tokens where it can)',
501
+ styleNoWrite: 'Dev-server pages are not written here; ask the model',
502
+ styleBusyTip: 'The model is working; save when it stops',
503
+ styleSaved: 'Style saved to {file}',
504
+ styleMiss: 'Could not find this element in the source (maybe a script made it); asking the model instead',
505
+ styleN: '{n} changed',
506
+ 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',
507
+ ruleFrom: '- {props}: currently set by {sel} ({at})',
508
+ ruleMaybe: '- {props}: probably set by {sel} ({at}; it sits in @layer / @container and was not fully resolved, check before editing)',
509
+ ruleInline: '- {props}: currently in the element\'s inline style{over}',
510
+ ruleOver: ', overriding {sel} ({at})',
511
+ ruleAt: '{file} line {line}',
512
+ styleArrow: '{prop} {from} → {to}',
513
+ styleJoin: '; ',
514
+ lang: 'en',
515
+ stylesHead: 'Start from a style',
516
+ stylesTip: 'Pick one to put its colors, fonts and shapes in the chat box, then say what to make',
517
+ stylesOpen: 'Try another style…',
518
+ stylesTitle: 'Change the style',
519
+ stylesSwap: 'Pick one and the model restyles the current design (tokens.css first; layout and copy stay)',
520
+ styleText: '[Style] {name}: {desc}\nColors: {colors}\nFonts: {fonts}\nShape: {shape}\nNotes: {notes}\n{ask}',
521
+ 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}',
522
+ styleBgImage: '; on the background: {v}',
523
+ styleFonts: 'headings {head}; body {body}',
524
+ styleShape: 'radius {r}px; shadow {s}',
525
+ styleStart: 'Design in this style (put these into tokens.css first; no need to ask about the feel again):',
526
+ 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',
290
527
  demoNone: 'This screen has no links to other screens yet. Ask the model to connect screens with <a href="detail.html">',
291
528
  phone: 'View on phone',
529
+ 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).',
530
+ remoteFail: 'This screen couldn\'t be sent over (connection dropped, or the images are too large). Click Reload to try again.',
292
531
  phoneStarting: 'Starting…',
293
532
  phoneHow: 'Scan with your phone camera',
294
533
  phoneWifi: 'Phone and computer must be on the same Wi-Fi',
@@ -314,6 +553,7 @@ window.__ModuleLoader__.load({
314
553
  demoShort: 'Play',
315
554
  editAddr: 'Change address',
316
555
  working: 'The model is working',
556
+ asking: 'The model is waiting for your answer (in the chat)',
317
557
  writing: 'Writing {file}',
318
558
  born: 'Generated {name}',
319
559
  updated: 'Updated{file}',
@@ -385,6 +625,7 @@ window.__ModuleLoader__.load({
385
625
  .ddp-stage{position:relative;flex:1;min-height:0;overflow:hidden;background:var(--dsw-alias-bg-layer-2)}
386
626
  .ddp-stage.full{background:#fff}
387
627
  .ddp-scr{position:absolute;inset:0;overflow:hidden}
628
+ .ddp-away{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center;font-size:13px;line-height:1.6;color:var(--dsw-alias-text-secondary,#888);background:var(--dsw-alias-bg-layer-1)}
388
629
  .ddp-frame{position:absolute;left:0;top:0;width:100%;height:100%;border:0;background:#fff;opacity:0;z-index:0;pointer-events:none}
389
630
  .ddp-frame.old{opacity:1;z-index:1}
390
631
  .ddp-frame.cur{opacity:1;z-index:2;pointer-events:auto;transition:opacity .32s ease}
@@ -431,6 +672,7 @@ window.__ModuleLoader__.load({
431
672
  .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;
432
673
  background:rgba(18,18,24,.84);color:#ececf0;font-size:12px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
433
674
  box-shadow:0 6px 18px rgba(0,0,0,.22);backdrop-filter:blur(8px);pointer-events:none;animation:ddp-in .2s ease}
675
+ .ddp-hint.side{left:calc((100% - 296px) / 2);max-width:calc(100% - 320px)}
434
676
  @property --ddp-a{syntax:'<angle>';initial-value:0deg;inherits:false}
435
677
  .ddp-ring{position:absolute;inset:0;z-index:4;pointer-events:none;padding:2.5px;
436
678
  background:conic-gradient(from var(--ddp-a),transparent 0deg 140deg,${ACCENT} 240deg,#a78bfa 300deg,#5eead4 340deg,transparent 360deg);
@@ -448,6 +690,7 @@ window.__ModuleLoader__.load({
448
690
  .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}
449
691
  .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}
450
692
  .ddp-chip.ok svg{width:14px;height:14px;flex:none;color:#4ade80}
693
+ .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)}
451
694
  @keyframes ddp-rot{to{transform:rotate(360deg)}}
452
695
  @keyframes ddp-txt{from{background-position:100% 0}to{background-position:-160% 0}}
453
696
  @keyframes ddp-blink{50%{opacity:.25}}
@@ -525,6 +768,54 @@ window.__ModuleLoader__.load({
525
768
  .ddp-empty .ddp-refline{margin-top:0}
526
769
  .ddp-empty .lk{border:0;padding:0;background:transparent;color:${ACCENT};font:inherit;cursor:pointer}
527
770
  .ddp-empty .lk:hover{text-decoration:underline;text-underline-offset:2px}
771
+ .ddp-ck{align-items:flex-start}
772
+ .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}
773
+ .ddp-ck .no.hi{background:#e5484d}
774
+ .ddp-tok.ddp-ck .nm{font-family:inherit;font-size:12px;line-height:17px}
775
+ .ddp-tok.ddp-ck .vl{white-space:normal;overflow-wrap:anywhere}
776
+ .ddp-check .ok{display:flex;align-items:center;gap:8px;padding:14px 6px;color:var(--dsw-alias-label-secondary)}
777
+ .ddp-check .ok svg{width:16px;height:16px;flex:none;color:#30a46c}
778
+ .ddp-check footer.row,.ddp-style footer.row{display:flex;gap:6px}
779
+ .ddp-check footer.row button,.ddp-style footer.row button{flex:1;justify-content:center;padding:0 6px}
780
+ .ddp-check footer .go,.ddp-style footer .go{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1)}
781
+ .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}
782
+ .ddp-check footer button:disabled,.ddp-style footer button:disabled{opacity:.45;cursor:default}
783
+ .ddp-style .bd{padding:0 14px 10px}
784
+ .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}
785
+ .ddp-style .who code{font:11px/17px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary)}
786
+ .ddp-style .row{padding:9px 0 10px;border-top:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
787
+ .ddp-style label{display:flex;align-items:baseline;gap:8px;margin-bottom:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}
788
+ .ddp-style label span{flex:1}
789
+ .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}
790
+ .ddp-style label em.v{color:${ACCENT}}
791
+ .ddp-style input[type=range]{display:block;width:100%;margin:0;accent-color:${ACCENT};cursor:pointer}
792
+ .ddp-style .chips{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-top:7px}
793
+ .ddp-style .row>.chips:first-of-type{margin-top:0}
794
+ .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}
795
+ .ddp-style .chip:hover{color:var(--dsw-alias-label-primary)}
796
+ .ddp-style .chip.on{box-shadow:inset 0 0 0 1.5px ${ACCENT};color:var(--dsw-alias-label-primary)}
797
+ .ddp-style .chip.c{width:22px;padding:2px}
798
+ .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)}
799
+ .ddp-style input[type=color]{width:30px;height:22px;padding:0;border:0;border-radius:6px;background:none;cursor:pointer}
800
+ .ddp-style .seg{display:flex;gap:2px;padding:2px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1))}
801
+ .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}
802
+ .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)}
803
+ .ddp-style .note{margin-top:6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
804
+ .ddp-style .cnt{padding:0 14px 6px;font-size:11px;color:var(--dsw-alias-label-tertiary)}
805
+ .ddp-styles{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:10px 8px}
806
+ .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}
807
+ .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}
808
+ .ddp-sc:hover .pv{transform:translateY(-1px);box-shadow:0 0 0 1.5px ${ACCENT},0 6px 16px rgba(0,0,0,.12)}
809
+ .ddp-sc:hover{color:var(--dsw-alias-label-primary)}
810
+ .ddp-sc .pv b{position:absolute;left:8px;top:6px;font-size:17px;line-height:1}
811
+ .ddp-sc .pv .bt{position:absolute;right:8px;top:8px;width:20px;height:9px}
812
+ .ddp-sc .pv .cd{position:absolute;left:8px;right:8px;bottom:7px;height:20px}
813
+ .ddp-sc .pv .ln{position:absolute;left:6px;top:6px;width:46%;height:3px;border-radius:2px;opacity:.85}
814
+ .ddp-sc .pv .ln.s{top:12px;width:30%;opacity:.6}
815
+ .ddp-sc .nm{font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
816
+ .ddp-stylepick{width:100%;max-width:440px;margin:4px 0 6px;text-align:left}
817
+ .ddp-stylepick>b{display:block;margin-bottom:2px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
818
+ .ddp-stylepick>p{margin:0 0 10px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
528
819
  .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}
529
820
  .ddp-tok:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
530
821
  .ddp-tok .pv{width:24px;height:24px;flex:none;display:flex;align-items:center;justify-content:center;overflow:hidden}
@@ -539,6 +830,7 @@ window.__ModuleLoader__.load({
539
830
  .ddp-tok .vl{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:15px}
540
831
  .ddp-btn.has::after{content:"";position:absolute;right:4px;top:4px;width:5px;height:5px;border-radius:50%;background:${ACCENT}}
541
832
  .ddp-btn{position:relative}
833
+ .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}
542
834
  .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}
543
835
  .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}
544
836
  .ddp-ver:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
@@ -632,6 +924,7 @@ window.__ModuleLoader__.load({
632
924
  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' }),
633
925
  h('rect', { key: 3, x: 16.5, y: 8, width: 5, height: 10.5, rx: 1.2 })]),
634
926
  text: () => svg([h('path', { key: 1, d: 'M5 7V5h11v2M10.5 5v14M8 19h5' }), h('path', { key: 2, d: 'M17 12v8M15.5 12h3M15.5 20h3' })]),
927
+ 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 })]),
635
928
  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' })]),
636
929
  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 })]),
637
930
  play: () => svg([h('path', { key: 1, d: 'M8 5.5v13l10.5-6.5z', strokeLinejoin: 'round' })]),
@@ -644,6 +937,9 @@ window.__ModuleLoader__.load({
644
937
  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' })]),
645
938
  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' })]),
646
939
  check: () => svg([h('path', { key: 1, d: 'M5 12.5l4.5 4.5L19 7.5', strokeWidth: 2.4 })]),
940
+ stetho: () => svg([h('path', { key: 1, d: 'M6 3.5H5a1 1 0 0 0-1 1V9a5 5 0 0 0 10 0V4.5a1 1 0 0 0-1-1h-1' }), h('path', { key: 2, d: 'M9 14v1.5a4.5 4.5 0 0 0 9 0V13' }), h('circle', { key: 3, cx: 18, cy: 11, r: 2 })]),
941
+ pdf: () => svg([h('path', { key: 1, d: 'M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5z' }), h('path', { key: 2, d: 'M14 3.5v5h5' }), h('path', { key: 3, d: 'M8.5 13h7M8.5 16.5h4.5' })]),
942
+ 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' })]),
647
943
  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),
648
944
  }
649
945
  // 引导页上的大图标
@@ -716,6 +1012,19 @@ window.__ModuleLoader__.load({
716
1012
  // ── 选中内容 → 输入框里的一段字 ──────────────────────────────────────────
717
1013
 
718
1014
  /** 页面地址 → 用户认得的位置:本地文件给路径,开发服务器给 localhost 地址 */
1015
+ /**
1016
+ * 这一屏里现在显示的是哪页(在预览里点链接跳过页,就是跳到的那页)→ { key, url }。
1017
+ * 同一个预览服务器上的 .html 才算、旧版本不算;不然还是这一屏。写回文件按它找文件,别写到原来那页去。
1018
+ */
1019
+ function pageOf(sc, href) {
1020
+ try {
1021
+ const u = new URL(href)
1022
+ const p = decodeURIComponent(u.pathname).replace(/^\/+/, '')
1023
+ if (u.origin === new URL(sc.url).origin && /\.html?$/i.test(p) && !p.startsWith('__dsh_design__/')) return { key: p, url: u.origin + u.pathname }
1024
+ } catch { /* 地址不对 */ }
1025
+ return { key: sc.key, url: sc.url }
1026
+ }
1027
+
719
1028
  function whereOf(href, st) {
720
1029
  try {
721
1030
  const u = new URL(href)
@@ -800,6 +1109,231 @@ window.__ModuleLoader__.load({
800
1109
  return `〔设计变量〕${tok.name}: ${tok.value}\n${whereOf(tok.href, st)}\n`
801
1110
  }
802
1111
 
1112
+ // ── 风格卡:开局挑一个风格(或给已有的设计换风格),整套配色、字体、圆角、阴影、要点放进输入框 ──────
1113
+ // 配色都量过对比度:正文和次要文字对底色 ≥ 4.5:1,主色按钮上的字 ≥ 4.5:1
1114
+
1115
+ const SANS = '"PingFang SC", "HarmonyOS Sans SC", "Microsoft YaHei", system-ui, sans-serif'
1116
+ const SERIF = '"Songti SC", "Noto Serif SC", "Source Han Serif SC", serif'
1117
+ const ROUND = '"SF Pro Rounded", "PingFang SC", "HarmonyOS Sans SC", system-ui, sans-serif'
1118
+ const STYLES = [
1119
+ { id: 'minimal', zh: '极简白', en: 'Minimal', dzh: '大留白、细线、黑白加一点蓝', den: 'Lots of space, hairlines, black and white with a touch of blue',
1120
+ c: { bg: '#FAFAF9', surface: '#FFFFFF', text: '#18181B', muted: '#6B6B73', line: '#E4E4E7', primary: '#18181B', accent: '#2563EB', on: '#FFFFFF', link: '#2563EB' },
1121
+ head: SANS, w: 600, radius: 10, shadow: '0 1px 2px rgba(24,24,27,.06)',
1122
+ nzh: '靠字号和留白分层级,不靠色块;分隔用 1px 细线;蓝色只给链接和选中态;大标题字距收 -0.02em',
1123
+ nen: 'Hierarchy from type size and whitespace, not color blocks; 1px hairlines; blue only for links and selection; tighten big headings to -0.02em' },
1124
+ { id: 'warm', zh: '温暖手作', en: 'Warm craft', dzh: '纸感底色、圆润、手绘感', den: 'Paper tones, soft and rounded, hand-made feel',
1125
+ c: { bg: '#F6EFE6', surface: '#FFF9F2', text: '#2B1D14', muted: '#6E5A4B', line: '#E7D9C8', primary: '#A9542A', accent: '#5E7A4A', on: '#FFFFFF', link: '#52703F' },
1126
+ head: SERIF, w: 600, radius: 16, shadow: '0 8px 24px rgba(120,72,30,.12)',
1127
+ nzh: '标题用衬线字,正文无衬线;插画用手绘线条和色块;阴影带暖色;中性色都偏暖,不用纯灰',
1128
+ nen: 'Serif headings, sans body; hand-drawn line illustrations and color blocks; warm-tinted shadows; warm neutrals, no pure gray' },
1129
+ { id: 'editorial', zh: '杂志编辑', en: 'Editorial', dzh: '大标题、黑线栅格、红色点题', den: 'Big headlines, black-rule grid, a red accent',
1130
+ c: { bg: '#F4F1EA', surface: '#FFFFFF', text: '#111111', muted: '#555555', line: '#111111', primary: '#C8231B', accent: '#111111', on: '#FFFFFF', link: '#C8231B' },
1131
+ head: SERIF, w: 700, radius: 2, shadow: 'none',
1132
+ nzh: '标题很大、粗细对比强烈;用粗细两种黑线分栏;版式不对称、有留白;红色只在一两处;几乎没有圆角和阴影',
1133
+ 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' },
1134
+ { id: 'dark', zh: '深色高级', en: 'Dark luxe', dzh: '近黑底、香槟金点缀、大图', den: 'Near-black, champagne gold accents, big imagery',
1135
+ c: { bg: '#0E0E10', surface: '#18181B', text: '#F2EFE9', muted: '#A19C93', line: '#2C2C30', primary: '#C9A86A', accent: '#E8E2D6', on: '#17140E', link: '#C9A86A' },
1136
+ head: SERIF, w: 500, radius: 12, shadow: '0 12px 40px rgba(0,0,0,.45)',
1137
+ nzh: '金色只点在关键按钮和细线上;层级靠表面色深浅,不靠阴影;标题细一点、字距放开;留白要多',
1138
+ nen: 'Gold only on key buttons and fine lines; layers from surface shades, not shadows; lighter, tracked-out headings; generous space' },
1139
+ { id: 'playful', zh: '活泼年轻', en: 'Playful', dzh: '粗描边、硬阴影、贴纸感', den: 'Thick outlines, hard shadows, sticker feel',
1140
+ c: { bg: '#FFF7E8', surface: '#FFFFFF', text: '#1D1B3A', muted: '#5D5A82', line: '#1D1B3A', primary: '#FF6A3D', accent: '#1F9E92', on: '#1D1B3A', link: '#137167' },
1141
+ head: ROUND, w: 800, radius: 20, shadow: '4px 4px 0 #1D1B3A',
1142
+ nzh: '卡片和按钮 2px 深色描边 + 不模糊的硬阴影;主色按钮上用深色字;元素可以略微倾斜、像贴纸;颜色最多三种',
1143
+ 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' },
1144
+ { id: 'fresh', zh: '清新自然', en: 'Fresh', dzh: '浅绿底、圆角卡片、轻插画', den: 'Pale green, rounded cards, light illustration',
1145
+ c: { bg: '#F3F7F2', surface: '#FFFFFF', text: '#1F2A22', muted: '#56665A', line: '#DCE6DA', primary: '#2F7D4F', accent: '#C9962A', on: '#FFFFFF', link: '#2F7D4F' },
1146
+ head: SANS, w: 600, radius: 14, shadow: '0 6px 18px rgba(47,125,79,.10)',
1147
+ nzh: '大面积浅色、卡片白底;插画用叶子、阳光这类自然元素;黄色只做小点缀;整体轻、透气',
1148
+ nen: 'Large light areas with white cards; nature motifs (leaves, sunlight) in illustrations; yellow only as a small accent; light and airy' },
1149
+ { id: 'tech', zh: '专业科技', en: 'Pro / tech', dzh: '冷灰蓝、信息密、数字清楚', den: 'Cool gray-blue, dense info, crisp numbers',
1150
+ c: { bg: '#F5F7FB', surface: '#FFFFFF', text: '#0F172A', muted: '#55657D', line: '#E2E8F0', primary: '#2457F5', accent: '#0F8F8E', on: '#FFFFFF', link: '#2457F5' },
1151
+ head: SANS, w: 600, radius: 8, shadow: '0 1px 3px rgba(15,23,42,.08)',
1152
+ nzh: '信息密度高但对齐严格;数字用等宽(font-variant-numeric: tabular-nums);表格行高 44–48;状态用小色点和标签;主色只给主操作',
1153
+ 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' },
1154
+ { id: 'glass', zh: '玻璃质感', en: 'Glass', dzh: '柔光底、浮层毛玻璃', den: 'Soft glow background, frosted overlays',
1155
+ c: { bg: '#EAF0F6', surface: '#F7F9FC', text: '#10151C', muted: '#4F5B69', line: '#D5DEE8', primary: '#CC4520', accent: '#2B6CB0', on: '#FFFFFF', link: '#2B6CB0' },
1156
+ bgImage: 'radial-gradient(circle at 20% 15%, #FFD9C7 0, transparent 45%), radial-gradient(circle at 85% 70%, #C9E2FF 0, transparent 50%)',
1157
+ head: SANS, w: 600, radius: 18, shadow: '0 10px 30px rgba(16,21,28,.10)',
1158
+ nzh: '底色加两三团柔和的光斑(别用紫蓝渐变);只有浮层、卡片、标签栏用毛玻璃(半透明白 + backdrop-filter: blur(20px) + 1px 白色描边),正文区是实色;毛玻璃上的字照样要够深',
1159
+ 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' },
1160
+ { id: 'soft', zh: '柔和立体', en: 'Soft UI', dzh: '同色底、凸起凹陷的柔光阴影', den: 'Same-color surfaces with raised and pressed soft shadows',
1161
+ c: { bg: '#E9EDF2', surface: '#E9EDF2', text: '#2D3440', muted: '#556072', line: '#D3D9E1', primary: '#3E5BD8', accent: '#22A06B', on: '#FFFFFF', link: '#3E5BD8' },
1162
+ head: ROUND, w: 700, radius: 18, shadow: '6px 6px 14px #C8CED6, -6px -6px 14px #FFFFFF',
1163
+ nzh: '卡片和底同色,靠一亮一暗两道阴影凸起,按下用内阴影;容易看不清,所以主按钮一定用主色实底、文字够深;别整屏都是凸起',
1164
+ 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' },
1165
+ ]
1166
+
1167
+ /** 选了一个风格 → 输入框里的一段字(restyle:给已经打开的设计换风格) */
1168
+ function formatStyle(t, pre, restyle) {
1169
+ const en = t('lang') === 'en'
1170
+ const c = pre.c
1171
+ const fonts = t('styleFonts', { head: `${pre.head} · ${pre.w}`, body: SANS })
1172
+ return t('styleText', {
1173
+ name: en ? pre.en : pre.zh,
1174
+ desc: en ? pre.den : pre.dzh,
1175
+ colors: t('styleColors', { ...c, bgImage: pre.bgImage ? t('styleBgImage', { v: pre.bgImage }) : '' }),
1176
+ fonts,
1177
+ shape: t('styleShape', { r: pre.radius, s: pre.shadow }),
1178
+ notes: en ? pre.nen : pre.nzh,
1179
+ ask: t(restyle ? 'styleRestyle' : 'styleStart'),
1180
+ })
1181
+ }
1182
+
1183
+ // ── 调样式 ───────────────────────────────────────────────────────────────
1184
+
1185
+ const VAR_ONLY = /^var\(\s*(--[\w-]+)\s*\)$/
1186
+ /** 面板上能调的几项:属性 → [文案, 哪一行](上下 / 左右内边距各管两条) */
1187
+ const STYLE_LABEL = { 'font-size': 'styleFont', 'font-weight': 'styleWeight', color: 'styleColor', background: 'styleBg', 'border-radius': 'styleRadius',
1188
+ 'padding-top': 'stylePadY', 'padding-bottom': 'stylePadY', 'padding-left': 'stylePadX', 'padding-right': 'stylePadX', gap: 'styleGap' }
1189
+
1190
+ /** 变量名 → 它现在的值(页面 :root 上的) */
1191
+ const tokenVal = (name, tokens) => { const tok = (tokens || []).find((x) => x.name === name); return tok ? String(tok.value).trim() : '' }
1192
+
1193
+ /** 值 → 像素数(var(--x) 先换成变量的值;rem / em 按 16);认不出返回 null */
1194
+ function pxOf(v, tokens) {
1195
+ let s = String(v || '').trim()
1196
+ const m = VAR_ONLY.exec(s)
1197
+ if (m) s = tokenVal(m[1], tokens)
1198
+ const n = parseFloat(s)
1199
+ if (!isFinite(n)) return null
1200
+ return /r?em$/.test(s) ? n * 16 : n
1201
+ }
1202
+
1203
+ /** 透明的颜色(没设背景时 getComputedStyle 给 rgba(0, 0, 0, 0)) */
1204
+ const clearColor = (v) => /^(transparent|rgba\(.*,\s*0\)|hsla\(.*,\s*0%?\))$/i.test(String(v || '').trim())
1205
+
1206
+ /** 值 → '#rrggbb'(颜色框只认这个);透明的给白色,不然显示成黑的 */
1207
+ const colorHex = (v, tokens) => {
1208
+ const m = VAR_ONLY.exec(String(v || '').trim())
1209
+ const c = m ? tokenVal(m[1], tokens) : v
1210
+ return '#' + (clearColor(c) ? 'ffffff' : hexOf(c))
1211
+ }
1212
+
1213
+ /** 给人看的值:颜色换成 #RRGGBB,透明的说透明,别的原样 */
1214
+ const showVal = (prop, v, t) => {
1215
+ if (!/^(color|background)$/.test(prop)) return String(v)
1216
+ if (clearColor(v)) return t ? t('styleClear') : 'transparent'
1217
+ return /^(rgb|hsl)/i.test(String(v)) ? '#' + hexOf(v).toUpperCase() : String(v)
1218
+ }
1219
+
1220
+ /** 改了的几项 → 「字号 15px → 17px」这种;上下 / 左右内边距只说一次 */
1221
+ function styleChanges(t, sty) {
1222
+ const out = []
1223
+ const seen = new Set()
1224
+ for (const [k, v] of Object.entries((sty && sty.live) || {})) {
1225
+ const key = STYLE_LABEL[k]
1226
+ if (!key || seen.has(key)) continue
1227
+ seen.add(key)
1228
+ out.push(t('styleArrow', { prop: t(key), from: showVal(k, sty.base[k], t), to: showVal(k, v, t) }))
1229
+ }
1230
+ return out
1231
+ }
1232
+
1233
+ const ruleKey = (r) => `${r.href}|${r.sel}|${r.n}`
1234
+ /** 规则在哪:服务端找到了行号就是「app.css 第 42 行」,没找到就是文件名 */
1235
+ function ruleAt(t, r, located) {
1236
+ const hit = located && located.get(ruleKey(r))
1237
+ if (hit && hit.file) return hit.line ? t('ruleAt', { file: hit.file, line: hit.line }) : hit.file
1238
+ try { return decodeURIComponent(new URL(r.href).pathname.split('/').pop()) || r.href } catch { return String(r.href || '') }
1239
+ }
1240
+
1241
+ /** 改了的这几项现在各由哪条规则决定(同一条规则的合成一行),模型照着去改那条,不用再 grep */
1242
+ function ruleLines(t, sty, located) {
1243
+ const groups = new Map()
1244
+ for (const k of Object.keys((sty && sty.live) || {})) {
1245
+ const r = sty.rules && sty.rules[k]
1246
+ const key = STYLE_LABEL[k]
1247
+ if (!r || !key) continue
1248
+ const g = r.inline ? `inline|${r.sel ? ruleKey(r) : ''}` : ruleKey(r)
1249
+ if (!groups.has(g)) groups.set(g, { r, props: [] })
1250
+ const label = t(key)
1251
+ if (!groups.get(g).props.includes(label)) groups.get(g).props.push(label)
1252
+ }
1253
+ const out = []
1254
+ for (const { r, props } of groups.values()) {
1255
+ const p = props.join('、')
1256
+ if (r.inline) out.push(t('ruleInline', { props: p, over: r.sel ? t('ruleOver', { sel: r.sel, at: ruleAt(t, r, located) }) : '' }))
1257
+ else out.push(t(r.maybe ? 'ruleMaybe' : 'ruleFrom', { props: p, sel: r.sel, at: ruleAt(t, r, located) }))
1258
+ }
1259
+ return out
1260
+ }
1261
+
1262
+ /** 调好的样式交给模型:哪一屏、哪个元素、改了什么、现在由哪条规则决定(located:服务端找到的行号),让它改进样式表 */
1263
+ function formatStyleAsk(t, sty, st, located) {
1264
+ const rules = ruleLines(t, sty, located)
1265
+ 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) })
1266
+ }
1267
+
1268
+ // ── 设计体检 ─────────────────────────────────────────────────────────────
1269
+
1270
+ /** 一条问题的具体数字(「#C8C8C8 配 #FFFFFF,1.7:1」这种) */
1271
+ function checkDetail(t, it) {
1272
+ const edge = it.edge === 'top' || it.edge === 'bottom' ? it.edge : ''
1273
+ return t('cd_' + it.type, {
1274
+ ...it,
1275
+ n: it.sizes ? it.sizes.length : '',
1276
+ sizes: it.sizes ? it.sizes.join(' / ') : '',
1277
+ otherText: String(it.otherText || '').slice(0, 20),
1278
+ edgeName: edge ? t('ckEdge_' + edge) : '',
1279
+ when: edge ? t('ckWhen_' + edge) : '',
1280
+ what: it.type === 'missing' ? t('ckWhat_' + (['css', 'script', 'font'].includes(it.what) ? it.what : 'other')) : '',
1281
+ at: it.type === 'jserr' && it.src ? ` (${it.src}${it.line ? ':' + it.line : ''})` : '',
1282
+ })
1283
+ }
1284
+
1285
+ /** 量的时候这一屏多大、页面多高、固定栏离内容多远(让模型知道量的是什么) */
1286
+ function sizeLine(t, m) {
1287
+ if (!m || !m.w) return ''
1288
+ let out = t('ckSize', { w: m.w, h: m.h, page: m.page || m.h })
1289
+ for (const b of m.bars || []) if (b.gap != null) out += t('ckSizeBar', { edgeName: t('ckEdge_' + b.edge), bar: b.name, gap: b.gap })
1290
+ return out
1291
+ }
1292
+
1293
+ /** 体检结果 → 输入框里的一段字:按屏分组,带文件位置、选择器和数字,模型照着改 */
1294
+ function formatCheck(t, list, st, head = 'checkAsk') {
1295
+ const n = list.reduce((m, x) => m + x.issues.length, 0)
1296
+ const lines = [t(head, { n, s: list.length })]
1297
+ for (const sc of list) {
1298
+ if (sc.error && head !== 'checkAsk') lines.push('', t('ckToolErr', { label: sc.label, msg: sc.error }))
1299
+ if (!sc.issues.length) continue
1300
+ const sz = head === 'checkAsk' ? '' : sizeLine(t, sc.metrics)
1301
+ lines.push('', t('ckScreen', { label: sc.label, where: whereOf(sc.url, st) }) + (sz ? ` · ${sz}` : ''))
1302
+ sc.issues.forEach((it, i) => lines.push(t('ckItem', {
1303
+ i: i + 1,
1304
+ type: t('ck_' + it.type),
1305
+ where: it.sel || '',
1306
+ text: it.text ? t('ckQuote', { text: it.text }) : '',
1307
+ detail: checkDetail(t, it),
1308
+ })))
1309
+ }
1310
+ return lines.join('\n') + '\n'
1311
+ }
1312
+
1313
+ /**
1314
+ * 一屏是不是「同一屏的几个版本」之一:home-a / home-b / home-c,或 login / login-b / login-c(和 core.js 的 placeScreen 一个规则)。
1315
+ * 是就给出 { stem, letter, others }(others:同组的其他文件名),不是给 null
1316
+ */
1317
+ function takesOf(keys, key) {
1318
+ const VAR = /^(.+)-([a-z])\.html?$/i
1319
+ const part = (x) => {
1320
+ const v = VAR.exec(x)
1321
+ return v ? { stem: v[1].toLowerCase(), letter: v[2].toLowerCase() } : { stem: x.toLowerCase().replace(/\.html?$/i, ''), letter: '' }
1322
+ }
1323
+ if (!/\.html?$/i.test(key)) return null
1324
+ const me = part(key)
1325
+ const others = keys.filter((x) => x !== key && /\.html?$/i.test(x) && part(x).stem === me.stem && (me.letter || part(x).letter))
1326
+ if (!others.length || (!me.letter && !others.some((x) => part(x).letter))) return null
1327
+ return { stem: VAR.exec(key) ? VAR.exec(key)[1] : key.replace(/\.html?$/i, ''), letter: me.letter, others }
1328
+ }
1329
+
1330
+ /** 体检结果 → 交给 check_design 工具的一段字(模型自己调的,不带〔设计体检〕开头) */
1331
+ function formatCheckTool(t, list, st) {
1332
+ const n = list.reduce((m, x) => m + x.issues.length, 0)
1333
+ if (n || list.some((x) => x.error)) return formatCheck(t, list, st, 'ckToolHead')
1334
+ return t('ckToolOk', { s: list.length, labels: list.map((x) => { const sz = sizeLine(t, x.metrics); return sz ? `${x.label} ${sz}` : x.label }).join(' / ') })
1335
+ }
1336
+
803
1337
  // ── 导出图片:设备外框、整套展示图 ───────────────────────────────────────
804
1338
 
805
1339
  const SHOW_COLS = { app: 4, tablet: 3, web: 2, poster: 3, slides: 2, email: 3 }
@@ -1137,6 +1671,32 @@ window.__ModuleLoader__.load({
1137
1671
  return canvasBlob(c)
1138
1672
  }
1139
1673
 
1674
+ /** CSS 颜色 → 'RRGGBB'(PPT 留边的底色);认不出就白色 */
1675
+ function hexOf(color) {
1676
+ try {
1677
+ const c = document.createElement('canvas')
1678
+ c.width = c.height = 1
1679
+ const ctx = c.getContext('2d')
1680
+ ctx.fillStyle = '#ffffff'
1681
+ ctx.fillStyle = color || '#ffffff'
1682
+ ctx.fillRect(0, 0, 1, 1)
1683
+ return [...ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)].map((x) => x.toString(16).padStart(2, '0')).join('')
1684
+ } catch { return 'ffffff' }
1685
+ }
1686
+
1687
+ /** 截图 → JPEG(base64)。PDF / PPT 里一页一张,JPEG 小得多;透明的地方垫白 */
1688
+ async function jpegB64(img) {
1689
+ const c = document.createElement('canvas')
1690
+ c.width = img.width
1691
+ c.height = img.height
1692
+ const ctx = c.getContext('2d')
1693
+ ctx.fillStyle = '#ffffff'
1694
+ ctx.fillRect(0, 0, c.width, c.height)
1695
+ ctx.drawImage(img, 0, 0)
1696
+ const blob = await new Promise((resolve, reject) => c.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.9))
1697
+ return blobB64(blob)
1698
+ }
1699
+
1140
1700
  function blobB64(blob) {
1141
1701
  return new Promise((resolve, reject) => {
1142
1702
  const r = new FileReader()
@@ -1468,14 +2028,37 @@ window.__ModuleLoader__.load({
1468
2028
  * 一屏:几帧 iframe 叠在一起。cur 是正在显示的;比它新的在后台载,载好了淡入换上来,旧的随后拆掉(不白屏)。
1469
2029
  * 和页面里 picker.js 的握手:hello → init(上一帧的滚动位置和指纹)→ ready → 换上来 → reveal(闪改动 / 播生成动画)。
1470
2030
  */
2031
+ const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]|::1)$/i
2032
+ /** 预览在电脑本机,而 DSH 网页是从别的设备打开的 */
2033
+ function away(src) {
2034
+ try { return !!src && LOCAL_HOST.test(new URL(src).hostname) && /^https?:$/.test(location.protocol) && !LOCAL_HOST.test(location.hostname) } catch { return false }
2035
+ }
2036
+ // 远程看(本地文件的预览):服务端把这一屏连同样式、图片打成一份 HTML,放进沙盒 iframe 的 srcdoc
2037
+ const remOf = (p) => (p.remote && away(p.src) ? p.remote : null)
2038
+ const OLD_VER = /^__dsh_design__\/v\/(\d+)\/(.*)$/
2039
+ /** 这一屏(预览服务器上的地址)打包后的取法 */
2040
+ function bundleOf(href, sid) {
2041
+ let p
2042
+ try { p = new URL(href).pathname } catch { return '' }
2043
+ try { p = decodeURIComponent(p) } catch { /* 原样 */ }
2044
+ p = p.replace(/^\/+/, '')
2045
+ let v = ''
2046
+ const m = OLD_VER.exec(p)
2047
+ if (m) { v = m[1]; p = m[2] }
2048
+ if (!p || p.endsWith('/')) p += 'index.html'
2049
+ return `${API}bundle?${new URLSearchParams({ session: sid, key: p, v, parent: location.origin })}`
2050
+ }
2051
+
1471
2052
  function Screen(props) {
1472
2053
  const src = props.src
1473
2054
  const origin = src ? new URL(src).origin : ''
1474
2055
  const [frames, setFrames] = useState([])
1475
2056
  const [cur, setCur] = useState(0)
2057
+ const [failed, setFailed] = useState(false) // 远程看:打包没取回来
2058
+ const rem = remOf(props)
1476
2059
  const wins = useRef(new Map())
1477
2060
  const life = useRef(null)
1478
- if (!life.current) life.current = { next: 1, loading: 0, again: false, file: '', seeds: new Map(), seedOf: new Map(), readied: new Set(), snapTimer: null }
2061
+ 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 }
1479
2062
  const curRef = useRef(0)
1480
2063
  const P = useRef(props)
1481
2064
  P.current = props
@@ -1494,6 +2077,16 @@ window.__ModuleLoader__.load({
1494
2077
  postTo(curRef.current, { type: 'capture', id, ...opts })
1495
2078
  setTimeout(() => { if (caps.current.has(id)) { caps.current.delete(id); reject(new Error('timeout')) } }, 30000)
1496
2079
  }),
2080
+ // 设计体检:页面里量完回 { issues },问题同时标在页面上(quiet:只要结果,不标)
2081
+ check: (kind, quiet) => new Promise((resolve, reject) => {
2082
+ if (!curRef.current) { reject(new Error('not loaded')); return }
2083
+ const id = `${Date.now()}-${++seq}`
2084
+ caps.current.set(id, { resolve, reject })
2085
+ postTo(curRef.current, { type: 'check', id, kind, quiet: !!quiet })
2086
+ setTimeout(() => { if (caps.current.has(id)) { caps.current.delete(id); reject(new Error('timeout')) } }, 15000)
2087
+ }),
2088
+ checkFocus: (i) => { if (curRef.current) postTo(curRef.current, { type: 'checkFocus', i }) },
2089
+ checkClear: () => { if (curRef.current) postTo(curRef.current, { type: 'checkClear' }) },
1497
2090
  }
1498
2091
  }
1499
2092
  // 登记到右侧栏:导出图片时按屏找到它
@@ -1507,7 +2100,10 @@ window.__ModuleLoader__.load({
1507
2100
 
1508
2101
  const postTo = (id, msg) => {
1509
2102
  const f = wins.current.get(id)
1510
- if (f && f.contentWindow && origin) f.contentWindow.postMessage({ dshDesign: 1, ...msg }, origin)
2103
+ if (!f || !f.contentWindow) return
2104
+ // 远程看:沙盒里的页面是不透明的源,只能发给 *(发的都是界面指令,没有要保密的)
2105
+ if (f.hasAttribute('srcdoc')) f.contentWindow.postMessage({ dshDesign: 1, ...msg }, '*')
2106
+ else if (origin) f.contentWindow.postMessage({ dshDesign: 1, ...msg }, origin)
1511
2107
  }
1512
2108
 
1513
2109
  // 在后台载一帧新的;正在载就记一笔,等它换上来再载
@@ -1516,13 +2112,44 @@ window.__ModuleLoader__.load({
1516
2112
  if (L.loading) { L.again = true; return }
1517
2113
  const id = L.next++
1518
2114
  L.loading = id
2115
+ if (seed) L.seedOf.set(id, seed)
2116
+ const r = remOf(P.current)
2117
+ if (r) {
2118
+ const gen = L.gen
2119
+ fetch(bundleOf(L.at || P.current.src, r.sid), { credentials: 'same-origin', cache: 'no-store' })
2120
+ .then((res) => res.json().then((d) => {
2121
+ if (!res.ok || !d || typeof d.html !== 'string') throw new Error((d && d.error) || `HTTP ${res.status}`)
2122
+ return d
2123
+ }))
2124
+ .then((d) => {
2125
+ if (L.gen !== gen || L.loading !== id) return
2126
+ L.deps = Array.isArray(d.deps) ? d.deps : null
2127
+ L.fails = 0
2128
+ setFailed(false)
2129
+ setFrames((fs) => [...fs, { id, doc: d.html }])
2130
+ })
2131
+ .catch(() => {
2132
+ if (L.gen !== gen || L.loading !== id) return
2133
+ L.loading = 0
2134
+ if (!curRef.current) setFailed(true)
2135
+ // 没取回来:那次改动还没显示出来,过一会儿再试(最多三回),别等下一次改动
2136
+ L.fails = (L.fails || 0) + 1
2137
+ if (L.again || L.fails <= 3) { L.again = false; setTimeout(() => { if (L.gen === gen) load(null) }, 1500 * Math.min(L.fails, 3)) }
2138
+ })
2139
+ return
2140
+ }
1519
2141
  let url = P.current.src
1520
2142
  try { if (seed && seed.href && new URL(seed.href).origin === origin) url = seed.href } catch { /* 用原地址 */ }
1521
- if (seed) L.seedOf.set(id, seed)
1522
2143
  setFrames((fs) => [...fs, { id, src: url }])
1523
2144
  }
1524
2145
 
1525
2146
  const pickMsg = () => ({ type: 'pick', hint: false }) // 提示放在右侧栏(页面顶上会被手机的灵动岛挡住)
2147
+ // 调样式:开模式、选中右侧栏记着的那个元素、把改到一半的样式再套上(新载的一帧要从头告诉它)
2148
+ const styleTo = (id, p) => {
2149
+ postTo(id, { type: 'styleMode', on: true })
2150
+ if (p.styleSel) postTo(id, { type: 'styleSel', selector: p.styleSel })
2151
+ if (p.styleCss) postTo(id, { type: 'style', css: p.styleCss })
2152
+ }
1526
2153
 
1527
2154
  // 一帧好了:淡入换上来
1528
2155
  const swap = (id, changes) => {
@@ -1539,6 +2166,7 @@ window.__ModuleLoader__.load({
1539
2166
  if (p.picking) postTo(id, pickMsg())
1540
2167
  if (p.demo) postTo(id, { type: 'demo', on: true })
1541
2168
  if (p.textEdit) postTo(id, { type: 'edit', on: true, hint: false })
2169
+ if (p.styleEdit) styleTo(id, p)
1542
2170
  if (first && p.onShown) p.onShown()
1543
2171
  if (first) { if (build && p.onBuilt) p.onBuilt(L.title || '') } else if (p.onNote) p.onNote({ changes, file: L.file, build })
1544
2172
  L.file = ''
@@ -1556,16 +2184,21 @@ window.__ModuleLoader__.load({
1556
2184
  L.again = false
1557
2185
  L.seeds.clear()
1558
2186
  L.seedOf.clear()
2187
+ L.gen++
2188
+ L.at = ''
2189
+ L.deps = null
2190
+ L.fails = 0
1559
2191
  curRef.current = 0
1560
2192
  setCur(0)
1561
2193
  setFrames([])
2194
+ setFailed(false)
1562
2195
  if (src) setTimeout(() => load(null), 0)
1563
- }, [src])
2196
+ }, [src, !!rem])
1564
2197
 
1565
2198
  // 页面发来的消息(几屏共用一个源,按 iframe 认是不是自己的)
1566
2199
  useEffect(() => {
1567
2200
  const on = (e) => {
1568
- if (!origin || e.origin !== origin) return
2201
+ if (rem ? e.origin !== 'null' : !origin || e.origin !== origin) return
1569
2202
  let id = 0
1570
2203
  for (const [k, f] of wins.current) if (f && f.contentWindow === e.source) { id = k; break }
1571
2204
  const d = e.data
@@ -1594,6 +2227,7 @@ window.__ModuleLoader__.load({
1594
2227
  if (p.picking) postTo(id, pickMsg())
1595
2228
  if (p.demo) postTo(id, { type: 'demo', on: true })
1596
2229
  if (p.textEdit) postTo(id, { type: 'edit', on: true, hint: false })
2230
+ if (p.styleEdit) styleTo(id, p)
1597
2231
  if (d.changes && d.changes.count && p.onNote) p.onNote({ changes: d.changes, file: '' })
1598
2232
  } else if (id > curRef.current) swap(id, d.changes)
1599
2233
  break
@@ -1613,14 +2247,20 @@ window.__ModuleLoader__.load({
1613
2247
  if (isCur && p.onNote) p.onNote({ changes: d.changes, file: '' })
1614
2248
  break
1615
2249
  case 'cancelled':
1616
- if (isCur && p.onCancelled) p.onCancelled()
2250
+ if (!isCur) break
2251
+ if (d.style) { if (p.onStyleCancel) p.onStyleCancel() } else if (p.onCancelled) p.onCancelled()
2252
+ break
2253
+ case 'stylePick':
2254
+ if (isCur && p.styleEdit && p.onStylePick && d.payload && d.payload.loc) p.onStylePick(d.payload)
1617
2255
  break
1618
2256
  // 点选、改字只在用户开着对应模式时收(页面自己的脚本也能发这种消息,不能随时往输入框里塞字、往文件里写)
1619
2257
  case 'picked':
1620
2258
  if (isCur && p.picking && p.onPicked) p.onPicked(d.payload)
1621
2259
  break
1622
2260
  case 'nav':
1623
- if (isCur && p.onNav && typeof d.path === 'string') p.onNav(d.path)
2261
+ if (!isCur || typeof d.path !== 'string') break
2262
+ if (p.onNav) p.onNav(d.path)
2263
+ else if (remOf(p)) { L.at = origin + d.path; load(null) } // 远程看:页面里点了跳页,这一框换成那一页(和本机预览时 iframe 自己跳过去一样)
1624
2264
  break
1625
2265
  case 'edited':
1626
2266
  if (isCur && (p.textEdit || Date.now() - editOffAt.current < EDIT_GRACE_MS) && p.onEdited && typeof d.before === 'string' && typeof d.after === 'string') p.onEdited(d)
@@ -1628,6 +2268,7 @@ window.__ModuleLoader__.load({
1628
2268
  case 'editDone':
1629
2269
  if (isCur && p.onEditDone) p.onEditDone()
1630
2270
  break
2271
+ case 'checked':
1631
2272
  case 'captured': {
1632
2273
  const c = caps.current.get(d.id)
1633
2274
  if (c) {
@@ -1642,7 +2283,7 @@ window.__ModuleLoader__.load({
1642
2283
  }
1643
2284
  window.addEventListener('message', on)
1644
2285
  return () => window.removeEventListener('message', on)
1645
- }, [origin, src])
2286
+ }, [origin, src, !!rem])
1646
2287
 
1647
2288
  // 模型在不在干活、画布缩放比例、选取开关:告诉页面
1648
2289
  useEffect(() => { if (cur) postTo(cur, { type: 'busy', on: !!props.busy }) }, [!!props.busy, cur])
@@ -1650,6 +2291,10 @@ window.__ModuleLoader__.load({
1650
2291
  useEffect(() => { if (cur) postTo(cur, { type: 'zoom', z: zq / 100 }) }, [zq])
1651
2292
  useEffect(() => { if (cur) postTo(cur, props.picking ? pickMsg() : { type: 'cancel' }) }, [!!props.picking])
1652
2293
  useEffect(() => { if (cur) postTo(cur, { type: 'theme', dark: !!props.dark }) }, [!!props.dark])
2294
+ useEffect(() => { if (cur) { if (props.styleEdit) styleTo(cur, props); else postTo(cur, { type: 'styleMode', on: false }) } }, [!!props.styleEdit])
2295
+ useEffect(() => { if (cur && props.styleEdit) postTo(cur, { type: 'styleSel', selector: props.styleSel || '' }) }, [props.styleSel || ''])
2296
+ const cssSig = JSON.stringify(props.styleCss || {})
2297
+ useEffect(() => { if (cur && props.styleEdit) postTo(cur, { type: 'style', css: props.styleCss || {} }) }, [cssSig])
1653
2298
  useEffect(() => { if (cur) postTo(cur, { type: 'demo', on: !!props.demo }) }, [!!props.demo])
1654
2299
  useEffect(() => {
1655
2300
  // 刚退出改字(点按钮、Esc、模型开始干活):页面会把正在改的那一处交上来,这一小会儿还收
@@ -1659,17 +2304,46 @@ window.__ModuleLoader__.load({
1659
2304
  }, [!!props.textEdit])
1660
2305
 
1661
2306
  // 刷新:先问当前这帧要滚动位置和指纹,问不到就直接载
1662
- const seq = useRef(props.reloadSeq)
1663
- useEffect(() => {
1664
- if (seq.current === props.reloadSeq) return
1665
- seq.current = props.reloadSeq
2307
+ const refresh = () => {
1666
2308
  const L = life.current
1667
2309
  if (!curRef.current) { load(null); return }
1668
2310
  clearTimeout(L.snapTimer)
1669
2311
  L.snapTimer = setTimeout(() => load(null), 400)
1670
2312
  postTo(curRef.current, { type: 'snapshot' })
2313
+ }
2314
+ const seq = useRef(props.reloadSeq)
2315
+ useEffect(() => {
2316
+ if (seq.current === props.reloadSeq) return
2317
+ seq.current = props.reloadSeq
2318
+ refresh()
1671
2319
  }, [props.reloadSeq])
1672
2320
 
2321
+ // 远程看:页面连不上预览服务器、收不到它的改动通知,跟着右侧栏轮询到的「目录里改了哪些文件」重新打包
2322
+ // (改的全是这一屏没用到的就不动;旧版本不会变)。两次轮询之间可能来了好几批:按序号取上次看到之后所有批的并集,
2323
+ // 记录断了档(中间的批已经挤掉了)就不知道改了啥,整屏重打包
2324
+ const changes = (rem && rem.changes) || []
2325
+ const chgN = changes.length ? changes[changes.length - 1].n : 0
2326
+ const chg = rem ? `${rem.at || 0}|${chgN}` : ''
2327
+ const chgWas = useRef(chg)
2328
+ const seenN = useRef(chgN)
2329
+ useEffect(() => {
2330
+ if (chgWas.current === chg) return
2331
+ chgWas.current = chg
2332
+ const from = seenN.current
2333
+ seenN.current = chgN
2334
+ if (!rem || !rem.at) return
2335
+ const L = life.current
2336
+ let path = ''
2337
+ try { path = new URL(L.at || src).pathname.replace(/^\/+/, '') } catch { /* 地址不对 */ }
2338
+ if (OLD_VER.test(path)) return
2339
+ const fresh = changes.filter((c) => c.n > from)
2340
+ const unknown = !changes.length || changes[0].n > from + 1 || fresh.some((c) => !(c.files || []).length)
2341
+ const files = [...new Set(fresh.flatMap((c) => c.files || []))]
2342
+ if (!unknown && L.deps && !files.some((f) => L.deps.includes(f))) return
2343
+ L.file = files[files.length - 1] || L.file
2344
+ refresh()
2345
+ }, [chg])
2346
+
1673
2347
  // 页面没装上脚本(极少数,比如不是 HTML):载完等一会儿也换上来
1674
2348
  const onFrameLoad = (id) => {
1675
2349
  setTimeout(() => {
@@ -1678,12 +2352,17 @@ window.__ModuleLoader__.load({
1678
2352
  }, 1600)
1679
2353
  }
1680
2354
 
2355
+ // 预览服务器只听电脑本机(127.0.0.1)。从手机(dsh-mobile)或别的电脑打开 DSH 网页时,这个地址指的是它们自己:
2356
+ // 本地文件的预览打包传过来(上面);开发服务器的没法打包,框里会一直空白,说清楚
2357
+ 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])) // 画布缩小了,字别跟着变小
2358
+ if (away(src) && !rem) return tell('away')
2359
+ if (rem && failed && !cur) return tell('remoteFail')
1681
2360
  return h('div', { className: 'ddp-scr' + (props.dark ? ' dk' : '') },
1682
2361
  ...frames.map((f) => h('iframe', {
1683
2362
  key: f.id,
1684
2363
  ref: (el) => { if (el) wins.current.set(f.id, el); else wins.current.delete(f.id) },
1685
2364
  className: 'ddp-frame' + (f.id === cur ? ' cur' : f.id < cur ? ' old' : ''),
1686
- src: f.src,
2365
+ ...(typeof f.doc === 'string' ? { srcDoc: f.doc, sandbox: 'allow-scripts allow-forms', referrerPolicy: 'no-referrer' } : { src: f.src }),
1687
2366
  title: props.label || '',
1688
2367
  tabIndex: f.id === cur ? 0 : -1,
1689
2368
  'aria-hidden': f.id === cur ? undefined : true,
@@ -1717,7 +2396,7 @@ window.__ModuleLoader__.load({
1717
2396
  }
1718
2397
 
1719
2398
  /** 设计规范面板:按组列出变量,点一个放进输入框 */
1720
- function TokensPanel({ t, tokens, onPick, onAsk, onRef, onClose }) {
2399
+ function TokensPanel({ t, tokens, onPick, onAsk, onRef, onStyles, onClose }) {
1721
2400
  const groups = {}
1722
2401
  for (const tok of tokens) (groups[tokenKind(tok)] = groups[tokenKind(tok)] || []).push(tok)
1723
2402
  const px = (v) => Math.max(0, parseFloat(v) * (/rem|em$/.test(v) ? 16 : 1)) || 0
@@ -1743,7 +2422,112 @@ window.__ModuleLoader__.load({
1743
2422
  h('span', { className: g === 'shadow' ? 'pv lt' : 'pv' }, preview(g, tok.value)),
1744
2423
  h('span', { className: 'tx' }, h('span', { className: 'nm' }, tok.name), h('span', { className: 'vl' }, tok.value))))))
1745
2424
  : h('div', { className: 'none' }, t('tokensEmpty'), h('div', null, h('button', { className: 'ddp-go', onClick: onAsk }, t('tokensAsk'))))),
1746
- onRef ? h('footer', null, h('button', { title: t('refPickTip'), onClick: onRef }, h(I.image), h('span', null, t('refPick')))) : null)
2425
+ onRef || onStyles ? h('footer', null,
2426
+ onStyles ? h('button', { onClick: onStyles }, h(I.sparkle), h('span', null, t('stylesOpen'))) : null,
2427
+ onRef ? h('button', { title: t('refPickTip'), onClick: onRef }, h(I.image), h('span', null, t('refPick'))) : null) : null)
2428
+ }
2429
+
2430
+ /** 设计体检的结果:按屏列出问题(和页面上的编号一样),点一条在页面上滚过去、那个框闪一下 */
2431
+ function CheckPanel({ t, ck, onFocus, onFix, onAgain, onClose }) {
2432
+ const list = ck.list || []
2433
+ const n = list.reduce((m, x) => m + x.issues.length, 0)
2434
+ const multi = list.length > 1
2435
+ return h('div', { className: 'ddp-tokens ddp-check', onPointerDown: (e) => e.stopPropagation() },
2436
+ h('header', null, h(I.stetho), h('b', null, t('check')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
2437
+ h('p', { className: 'tip' }, ck.running ? t('checking') : n ? t('checkSum', { s: list.filter((x) => x.issues.length).length, n }) : t('checkNote')),
2438
+ h('div', { className: 'bd' },
2439
+ ...list.filter((sc) => sc.issues.length || sc.error).map((sc) => h('div', { key: sc.key },
2440
+ multi ? h('h4', null, sc.label) : null,
2441
+ sc.error ? h('div', { className: 'none' }, t('checkFail', { msg: sc.error })) : null,
2442
+ ...sc.issues.map((it, i) => h('button', { key: i, className: 'ddp-tok ddp-ck', title: it.sel || '', onClick: () => onFocus(sc.key, i) },
2443
+ h('span', { className: 'no' + (it.sev > 1 ? ' hi' : '') }, String(i + 1)),
2444
+ h('span', { className: 'tx' },
2445
+ h('span', { className: 'nm' }, t('ck_' + it.type) + (it.text ? ` · ${it.text}` : '')),
2446
+ h('span', { className: 'vl' }, checkDetail(t, it))))))),
2447
+ !ck.running && !n ? h('div', { className: 'ok' }, h(I.check), h('span', null, t('checkNone'))) : null),
2448
+ n ? h('p', { className: 'tip' }, t('checkNote')) : null,
2449
+ h('footer', { className: 'row' },
2450
+ h('button', { onClick: onAgain, disabled: ck.running }, h(I.refresh), h('span', null, t('checkAgain'))),
2451
+ h('button', { className: 'go', onClick: onFix, disabled: ck.running || !n }, h(I.sparkle), h('span', null, t('checkFix')))))
2452
+ }
2453
+
2454
+ /**
2455
+ * 调样式面板:选中的元素的字号、字重、颜色、背景、圆角、内边距、间距。
2456
+ * 拖的时候页面上先看效果(还没写进文件);设计变量列成小块,点一个就用 var(--x)。
2457
+ * write:{ ok, tip } 能不能写回文件(开发服务器、模型在干活时不能)
2458
+ */
2459
+ function StylePanel({ t, sty, tokens, write, onSet, onReset, onAsk, onSave, onClose }) {
2460
+ const head = h('header', null, h(I.sliders), h('b', null, t('style')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close)))
2461
+ const box = (...kids) => h('div', { className: 'ddp-tokens ddp-style', onPointerDown: (e) => e.stopPropagation() }, head, ...kids)
2462
+ if (!sty) return box(h('p', { className: 'tip' }, t('styleNone')))
2463
+ const groups = {}
2464
+ for (const tok of tokens || []) (groups[tokenKind(tok)] = groups[tokenKind(tok)] || []).push(tok)
2465
+ const cur = (k) => (k in sty.live ? sty.live[k] : sty.base[k])
2466
+ // 现在用的是哪个变量:改过的看改成了什么,没改的看样式表里怎么写的
2467
+ const varOf = (k) => { const m = VAR_ONLY.exec(String(cur(k) || '').trim()); return m ? m[1] : k in sty.live ? '' : (sty.vars || {})[k] || '' }
2468
+ const set = (props, v) => onSet(Object.fromEntries(props.map((p) => [p, v])))
2469
+ const chips = (kind, props) => (groups[kind] || []).slice(0, 10).map((tok) => h('button', {
2470
+ key: tok.name,
2471
+ className: 'chip' + (kind === 'color' ? ' c' : '') + (varOf(props[0]) === tok.name ? ' on' : ''),
2472
+ title: `${tok.name}: ${tok.value}`,
2473
+ onClick: () => set(props, `var(${tok.name})`),
2474
+ }, kind === 'color' ? h('i', { style: { background: tok.value } }) : tok.name.replace(/^--/, '')))
2475
+ const shown = (k, text) => (varOf(k) ? h('em', { className: 'v', title: t('styleVar', { name: varOf(k) }) }, varOf(k)) : h('em', null, text))
2476
+ const slider = (key, props, max, kind) => {
2477
+ const v = Math.round(pxOf(cur(props[0]), tokens) ?? 0)
2478
+ const c = kind ? chips(kind, props) : []
2479
+ return h('div', { className: 'row', key },
2480
+ h('label', null, h('span', null, t(key)), shown(props[0], `${v}px`)),
2481
+ 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`) }),
2482
+ c.length ? h('div', { className: 'chips' }, ...c) : null)
2483
+ }
2484
+ const color = (key, prop, note) => h('div', { className: 'row', key },
2485
+ h('label', null, h('span', null, t(key)), shown(prop, showVal(prop, cur(prop), t))),
2486
+ h('div', { className: 'chips' },
2487
+ h('input', { type: 'color', value: colorHex(cur(prop), tokens), 'aria-label': t(key), onChange: (e) => set([prop], e.target.value) }),
2488
+ ...chips('color', [prop])),
2489
+ note ? h('div', { className: 'note' }, note) : null)
2490
+ const w = Math.min(700, Math.max(400, Math.round(parseInt(cur('font-weight'), 10) / 100) * 100 || 400))
2491
+ const n = Object.keys(sty.live).length
2492
+ return box(
2493
+ h('p', { className: 'who', title: sty.selector }, h('code', null, sty.name), sty.text ? ` · ${sty.text}` : ''),
2494
+ h('div', { className: 'bd' },
2495
+ slider('styleFont', ['font-size'], 72, 'size'),
2496
+ h('div', { className: 'row', key: 'w' },
2497
+ h('label', null, h('span', null, t('styleWeight')), h('em', null, String(cur('font-weight')))),
2498
+ 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))))),
2499
+ color('styleColor', 'color'),
2500
+ color('styleBg', 'background', sty.gradient && !('background' in sty.live) ? t('styleGradient') : ''),
2501
+ slider('styleRadius', ['border-radius'], 40, 'radius'),
2502
+ slider('stylePadY', ['padding-top', 'padding-bottom'], 48, 'space'),
2503
+ slider('stylePadX', ['padding-left', 'padding-right'], 48, 'space'),
2504
+ sty.layout ? slider('styleGap', ['gap'], 48, 'space') : null),
2505
+ n ? h('div', { className: 'cnt' }, t('styleN', { n: styleChanges(t, sty).length })) : null,
2506
+ h('footer', { className: 'row' },
2507
+ h('button', { onClick: onReset, disabled: !n }, h('span', null, t('styleReset'))),
2508
+ h('button', { onClick: onAsk, disabled: !n, title: t('styleAskTip') }, h('span', null, t('styleAsk'))),
2509
+ h('button', { className: 'go', onClick: onSave, disabled: !n || !write.ok, title: write.tip }, h('span', null, t('styleSave')))))
2510
+ }
2511
+
2512
+ /** 一排风格卡:每张是个小样(底色、标题字、卡片、主色按钮),点了交给 onPick */
2513
+ function StyleCards({ t, onPick }) {
2514
+ const en = t('lang') === 'en'
2515
+ 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) },
2516
+ h('span', { className: 'pv', style: { background: x.c.bg, backgroundImage: x.bgImage || 'none', color: x.c.text } },
2517
+ h('b', { style: { fontFamily: x.head, fontWeight: x.w } }, en ? 'Aa' : '字'),
2518
+ 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' } }),
2519
+ 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,
2520
+ 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}` } },
2521
+ h('i', { className: 'ln', style: { background: x.c.text } }), h('i', { className: 'ln s', style: { background: x.c.muted } }))),
2522
+ h('span', { className: 'nm' }, en ? x.en : x.zh))))
2523
+ }
2524
+
2525
+ /** 换风格面板(设计已经打开时):点一张卡让模型整套换成那个风格 */
2526
+ function StylesPanel({ t, onPick, onClose }) {
2527
+ return h('div', { className: 'ddp-tokens', onPointerDown: (e) => e.stopPropagation() },
2528
+ h('header', null, h(I.palette), h('b', null, t('stylesTitle')), h('button', { className: 'ddp-btn', title: '×', onClick: onClose }, h(I.close))),
2529
+ h('p', { className: 'tip' }, t('stylesSwap')),
2530
+ h('div', { className: 'bd' }, h(StyleCards, { t, onPick })))
1747
2531
  }
1748
2532
 
1749
2533
  /** 菜单开着时:点外面、按 Esc 关掉 */
@@ -1770,6 +2554,7 @@ window.__ModuleLoader__.load({
1770
2554
  items.push(['raw', I.image, t('exRaw')])
1771
2555
  if (kind !== 'poster' && kind !== 'slides') items.push(['long', I.long, t('exLong')]) // 海报、幻灯片画幅是定的,没有「整页」
1772
2556
  if (count > 1) items.push(['show', I.canvas, t('exShow')])
2557
+ items.push(['pdf', I.pdf, t('exPdf')], ['pptx', I.deck, t('exPptx')])
1773
2558
  return h('div', { className: 'ddp-kind', ref },
1774
2559
  h('button', { className: 'ddp-btn' + (open ? ' sel' : ''), title: t('exportT'), disabled, onClick: () => setOpen(!open) }, h(I.download)),
1775
2560
  open ? h('div', { className: 'ddp-menu', role: 'menu' },
@@ -1900,6 +2685,29 @@ window.__ModuleLoader__.load({
1900
2685
  }
1901
2686
  }, [layoutKey])
1902
2687
 
2688
+ // 让某一屏露出来(体检里点了一条):整屏不在看得见的地方就把镜头挪过去;右边 inset 像素被面板挡着
2689
+ const rv = props.reveal
2690
+ useEffect(() => {
2691
+ const c = camRef.current
2692
+ if (!rv || !L || !c) return
2693
+ const i = screens.findIndex((x) => x.key === rv.key)
2694
+ if (i < 0) return
2695
+ const [cw, ch] = boxes ? boxes[i] : [dw, dh]
2696
+ const vw = Math.max(120, box.w - (props.inset || 0))
2697
+ const x0 = c.x + L.pos[i].x * c.z
2698
+ const y0 = c.y + L.pos[i].y * c.z
2699
+ const w = cw * c.z
2700
+ const hh = ch * c.z
2701
+ let x = c.x
2702
+ let y = c.y
2703
+ if (x0 < 8 || x0 + w > vw - 8) x += (w > vw - 16 ? 8 : (vw - w) / 2) - x0
2704
+ if (y0 < LAB || y0 + hh > box.h - 8) y += (hh > box.h - LAB - 8 ? LAB : (box.h - hh) / 2) - y0
2705
+ if (x === c.x && y === c.y) return
2706
+ auto.current = false
2707
+ setAnim(true)
2708
+ setCam({ ...c, x, y })
2709
+ }, [rv && rv.n])
2710
+
1903
2711
  const zoomAt = (factor, px, py, smooth) => {
1904
2712
  const c = camRef.current
1905
2713
  if (!c) return
@@ -2037,6 +2845,8 @@ window.__ModuleLoader__.load({
2037
2845
  useEffect(() => { const tm = setTimeout(() => setWaited(true), 2500); return () => clearTimeout(tm) }, [])
2038
2846
  const [picking, setPicking] = useState(false)
2039
2847
  const [textEdit, setTextEdit] = useState(false) // 改字:点页面里的文字直接改
2848
+ const [styleEdit, setStyleEdit] = useState(false) // 调样式:点元素、拖滑块,写回它的 style 属性
2849
+ const [sty, setSty] = useState(null) // 调样式选中的元素:{ key, url, name, selector, text, loc, base, vars, gradient, layout, live }
2040
2850
  const [editing, setEditing] = useState(false)
2041
2851
  const [menu, setMenu] = useState(false)
2042
2852
  const [toast, setToast] = useState('')
@@ -2045,6 +2855,7 @@ window.__ModuleLoader__.load({
2045
2855
  const [info, setInfo] = useState({}) // 目录|屏 → { meta, title }(页面报上来的)
2046
2856
  const [sel, setSel] = useState('')
2047
2857
  const [panel, setPanel] = useState(false)
2858
+ const [styles, setStyles] = useState(false) // 换风格面板
2048
2859
  const [dropping, setDropping] = useState(false) // 正拖着图片进来
2049
2860
  const refInput = useRef(null)
2050
2861
  const [exMenu, setExMenu] = useState(false)
@@ -2058,6 +2869,9 @@ window.__ModuleLoader__.load({
2058
2869
  const [dark, setDark] = useState(false)
2059
2870
  const [phone, setPhone] = useState(false) // 手机上看的面板
2060
2871
  const [multi, setMulti] = useState(false) // 多尺寸并排
2872
+ const [ck, setCk] = useState(null) // 设计体检:null = 面板关着;{ running, list: [{ key, label, url, issues, error }] }
2873
+ const [ckCount, setCkCount] = useState(0) // 最近一次体检(模型干完活会悄悄查一次)有几个问题:面板关着时标在按钮上
2874
+ const [reveal, setReveal] = useState(null) // 画布上要露出来的那一屏 { key, n }(体检里点了一条)
2061
2875
  const [demo, setDemo] = useState(null) // 演示:{ stack: [屏…], layers: [{ id, key, dir, shown }] }
2062
2876
  const apis = useRef(new Map()) // 屏 → 截图用的接口
2063
2877
  const cmpRef = useRef(null)
@@ -2118,6 +2932,8 @@ window.__ModuleLoader__.load({
2118
2932
  const labelOf = (sc) => (infoOf(ik(sc)) || {}).title || sc.label
2119
2933
  const anyDark = Object.keys(info).some((k) => k.startsWith(site + '|') && info[k].dark)
2120
2934
  const fileTarget = !!(st && st.target && st.target.kind === 'file')
2935
+ // 远程看(手机走外网等):本地文件的预览由服务端打包传过来(见 Screen),目录里改了哪些文件也一起带过去
2936
+ const remote = fileTarget && sid ? { sid, at: st.changedAt || 0, files: st.changedFiles || [], changes: st.changes || [] } : null
2121
2937
 
2122
2938
  // 看旧版本:画布上换成那一版的几屏(按现在的顺序,那一版独有的接在后面)
2123
2939
  const vbase = ver ? `${origin}/__dsh_design__/v/${ver.n}/` : ''
@@ -2206,7 +3022,7 @@ window.__ModuleLoader__.load({
2206
3022
  return () => ro.disconnect()
2207
3023
  }, [!!url])
2208
3024
 
2209
- useEffect(() => { setPicking(false); setTextEdit(false); setSel(''); setVer(null); setCmp(false) }, [site])
3025
+ useEffect(() => { setPicking(false); setTextEdit(false); setStyleEdit(false); setSel(''); setVer(null); setCmp(false) }, [site])
2210
3026
 
2211
3027
  // 版本列表:面板开着时拉一次,存了新版本(vrev 变了)再拉
2212
3028
  const vrev = st ? st.vrev : 0
@@ -2244,6 +3060,7 @@ window.__ModuleLoader__.load({
2244
3060
  const togglePick = () => {
2245
3061
  if (picking) { setPicking(false); return }
2246
3062
  setTextEdit(false)
3063
+ setStyleEdit(false)
2247
3064
  setPicking(true)
2248
3065
  if (!canvas) {
2249
3066
  // 单页:把焦点给页面,Esc 在页面里也管用
@@ -2255,20 +3072,76 @@ window.__ModuleLoader__.load({
2255
3072
  const toggleText = () => {
2256
3073
  if (textEdit) { setTextEdit(false); return }
2257
3074
  setPicking(false)
3075
+ setStyleEdit(false)
2258
3076
  setTextEdit(true)
2259
3077
  }
2260
3078
 
2261
- const askText = (sc, d) => insert(t('textAsk', { before: d.before, after: d.after, where: whereOf(sc.url, stRef.current) }), 'insertedSend')
3079
+ const askText = (sc, d) => insert(t('textAsk', { before: d.before, after: d.after, where: whereOf(pageOf(sc, d.href).url, stRef.current) }), 'insertedSend')
2262
3080
 
2263
3081
  const onEdited = (sc, d) => {
2264
3082
  if (!fileTarget) return askText(sc, d)
2265
- post('edit', { session: sid, key: sc.key, before: d.before, after: d.after, nth: d.nth })
3083
+ post('edit', { session: sid, key: pageOf(sc, d.href).key, before: d.before, after: d.after, nth: d.nth })
2266
3084
  .then((r) => (r && r.ok ? say(t('textSaved', { file: r.file })) : askText(sc, d)), () => askText(sc, d))
2267
3085
  }
2268
3086
 
2269
3087
  // 模型在干活时不能改字(它可能正整份重写文件,改了会被盖掉)
2270
3088
  useEffect(() => { if (ver || demo || busy) setTextEdit(false) }, [!!ver, !!demo, busy])
2271
3089
 
3090
+ // ── 调样式:点一个元素,面板里拖滑块先在页面上看效果;写回文件 = 并进它在源码里的 style 属性 ──
3091
+ const toggleStyle = () => {
3092
+ if (styleEdit) { setStyleEdit(false); return }
3093
+ setPicking(false)
3094
+ setTextEdit(false)
3095
+ setPanel(false)
3096
+ setStyles(false)
3097
+ setHist(false)
3098
+ setPhone(false)
3099
+ closeCheck()
3100
+ setStyleEdit(true)
3101
+ }
3102
+ useEffect(() => { if (!styleEdit) setSty(null) }, [styleEdit])
3103
+ const onStylePick = (sc, p) => {
3104
+ if (canvas) setSel(sc.key)
3105
+ const pg = pageOf(sc, p.href)
3106
+ 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: {} })
3107
+ }
3108
+ const setStyleProp = (patch) => setSty((c) => (c ? { ...c, live: { ...c.live, ...patch } } : c))
3109
+ // 交给模型:先问服务端那几条规则在源码第几行(本地文件才有;问不到就只写文件名)
3110
+ const askStyle = async () => {
3111
+ const s0 = sty
3112
+ if (!s0) return
3113
+ let located = null
3114
+ 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()]
3115
+ if (fileTarget && items.length) {
3116
+ try {
3117
+ const r = await post('locate', { session: sid, items: items.map((x) => ({ href: x.href, sel: x.sel, n: x.n })) })
3118
+ located = new Map(items.map((x, i) => [`${x.href}|${x.sel}|${x.n}`, r && Array.isArray(r.items) ? r.items[i] : null]))
3119
+ } catch { /* 找不到行号就只写文件名 */ }
3120
+ }
3121
+ insert(formatStyleAsk(t, s0, stRef.current, located), 'insertedSend')
3122
+ }
3123
+ const saveStyle = () => {
3124
+ const s0 = sty
3125
+ if (!s0 || !Object.keys(s0.live).length) return
3126
+ if (!fileTarget) return askStyle()
3127
+ const saved = s0.live
3128
+ post('style', { session: sid, key: s0.key, loc: s0.loc, css: saved, name: s0.name }).then((r) => {
3129
+ if (r && r.ok) {
3130
+ say(t('styleSaved', { file: r.file }))
3131
+ // 写进去的算「原样」了;写的时候又拖过的留着
3132
+ setSty((c) => (c && c.key === s0.key && c.selector === s0.selector
3133
+ ? { ...c, base: { ...c.base, ...saved }, live: Object.fromEntries(Object.entries(c.live).filter(([k, v]) => saved[k] !== v)) } : c))
3134
+ } else if (r && r.reason === 'busy') say(t('styleBusyTip'))
3135
+ else { say(t('styleMiss')); askStyle() }
3136
+ }, () => askStyle())
3137
+ }
3138
+ useEffect(() => {
3139
+ if (!styleEdit) return undefined
3140
+ const onKey = (e) => { if (e.key === 'Escape') setStyleEdit(false) }
3141
+ window.addEventListener('keydown', onKey)
3142
+ return () => window.removeEventListener('keydown', onKey)
3143
+ }, [styleEdit])
3144
+
2272
3145
  useEffect(() => {
2273
3146
  if (!dropping) return undefined
2274
3147
  const off = () => setDropping(false)
@@ -2355,13 +3228,18 @@ window.__ModuleLoader__.load({
2355
3228
  }
2356
3229
 
2357
3230
  const renderScreen = (sc, z, fill) => h(Device, { kind, w: size[0], h: size[1], fill, label: labelOf(sc) },
2358
- h(Screen, {
3231
+ h(Screen, { t, remote,
2359
3232
  src: sc.url,
2360
3233
  label: labelOf(sc),
2361
3234
  busy: busy && !sc.v,
2362
3235
  born: !sc.v && isBorn(sc.key),
2363
3236
  picking: picking && !sc.v,
2364
3237
  textEdit: textEdit && !sc.v,
3238
+ styleEdit: styleEdit && !sc.v,
3239
+ styleSel: sty && sty.key === sc.key ? sty.selector : '',
3240
+ styleCss: sty && sty.key === sc.key ? sty.live : null,
3241
+ onStylePick: (p) => onStylePick(sc, p),
3242
+ onStyleCancel: () => setStyleEdit(false),
2365
3243
  dark,
2366
3244
  apis: apis.current,
2367
3245
  apiKey: ik(sc),
@@ -2379,16 +3257,23 @@ window.__ModuleLoader__.load({
2379
3257
  }))
2380
3258
 
2381
3259
  // ── 导出图片 ──
2382
- const shotOf = async (sc, full) => {
3260
+ // 截一屏:图片和它的 CSS 尺寸(PDF / PPT 按这个定页面大小)
3261
+ // 那一屏的接口(截图、体检);刚载入的等它就绪
3262
+ const apiOf = async (sc) => {
2383
3263
  let api = apis.current.get(ik(sc))
2384
3264
  for (let i = 0; i < 40 && !(api && api.ready()); i++) {
2385
3265
  await new Promise((r) => setTimeout(r, 150))
2386
3266
  api = apis.current.get(ik(sc))
2387
3267
  }
2388
3268
  if (!api) throw new Error('not loaded')
3269
+ return api
3270
+ }
3271
+ const captureOf = async (sc, full) => {
3272
+ const api = await apiOf(sc)
2389
3273
  const r = await api.capture({ full: !!full, scale: 2 })
2390
- return loadImg(r.png)
3274
+ return { img: await loadImg(r.png), w: r.w, h: r.h, links: Array.isArray(r.links) ? r.links : [] }
2391
3275
  }
3276
+ const shotOf = async (sc, full) => (await captureOf(sc, full)).img
2392
3277
 
2393
3278
  const saveImage = async (name, blob) => {
2394
3279
  const png = await blobB64(blob)
@@ -2404,6 +3289,31 @@ window.__ModuleLoader__.load({
2404
3289
  try {
2405
3290
  const prefix = ver ? `v${ver.n}-` : ''
2406
3291
  const suffix = dark ? '-dark' : ''
3292
+ if (mode === 'pdf' || mode === 'pptx') {
3293
+ // 每屏一页。PDF 里网页、邮件放整页长图(页面多高就多高);PPT 一张幻灯片一个画面
3294
+ const long = mode === 'pdf' && (kind === 'web' || kind === 'email')
3295
+ const pages = []
3296
+ const keys = []
3297
+ for (const sc of shown) {
3298
+ try {
3299
+ const r = await captureOf(sc, long)
3300
+ pages.push({ jpg: await jpegB64(r.img), w: r.w, h: r.h, title: labelOf(sc), links: r.links })
3301
+ keys.push(sc.key)
3302
+ } catch (e) { console.warn('[dsh-design-studio] 截图失败', sc.key, e) }
3303
+ }
3304
+ if (!pages.length) throw new Error('no screens')
3305
+ // 原型里的跳转:路径换成第几页,PDF / PPT 里点了能跳过去
3306
+ for (const p of pages) {
3307
+ 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)
3308
+ }
3309
+ const sp = splitTitles(pages.map((x) => x.title))
3310
+ const folder = st && st.target && st.target.kind === 'file' ? String(st.target.file).split(/[\\/]/).slice(-2, -1)[0] : ''
3311
+ const title = sp.brand || folder || st.label || ''
3312
+ const r = await post('doc', { session: sid, format: mode, name: prefix + (title || 'design') + suffix, title, bg: hexOf(pickColors(tokens).bg), pages })
3313
+ const file = String(r.path || '').split(/[\\/]/).pop()
3314
+ say(t('exportedDoc', { name: file, n: pages.length }), { label: t('reveal'), run: () => post('reveal', { path: r.path }).catch(() => {}) })
3315
+ return
3316
+ }
2407
3317
  if (mode === 'show') {
2408
3318
  const shots = []
2409
3319
  for (const sc of shown) {
@@ -2459,7 +3369,17 @@ window.__ModuleLoader__.load({
2459
3369
  insert(t('variantText', { name: labelOf(sc), b, where: whereOf(sc.url, stRef.current) }), 'insertedSend')
2460
3370
  }
2461
3371
 
3372
+ // 同一屏的几个版本(开局的三个方向 home-a / b / c,或「再出两版」的 login / login-b / login-c):挑一个
3373
+ const pickDirection = (sc) => {
3374
+ const g = takesOf(shown.map((x) => x.key), sc.key)
3375
+ if (!g) return
3376
+ const others = g.others.join('、')
3377
+ const where = whereOf(sc.url, stRef.current)
3378
+ insert(g.letter ? t('pickText', { name: labelOf(sc), where, others, base: g.stem }) : t('pickKeep', { name: labelOf(sc), where, others }), 'insertedSend')
3379
+ }
3380
+
2462
3381
  const actions = (sc) => [
3382
+ !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,
2463
3383
  h('button', { key: 'x', className: 'ddp-btn', title: t('exThis'), onClick: () => doExport(FRAMED[kind] ? 'frame' : 'raw', sc.key) }, h(I.download)),
2464
3384
  sc.v ? null : h('button', { key: 'v', className: 'ddp-btn', title: t('variants'), onClick: () => askVariants(sc) }, h(I.sparkle)),
2465
3385
  h('button', { key: 'o', className: 'ddp-btn', title: t('openScreen'), onClick: () => change({ view: 'single', focus: sc.key }) }, h(I.single)),
@@ -2495,9 +3415,95 @@ window.__ModuleLoader__.load({
2495
3415
  const hintTop = (picking || textEdit) && !canvas && !inMulti && !ver && !demo && kind !== 'web'
2496
3416
  const flows = canvas && !inMulti && !picking && !textEdit ? flowsOf(shown, (sc) => (infoOf(ik(sc)) || {}).links, fileTarget) : []
2497
3417
 
3418
+ // ── 设计体检:画布上量所有屏,单页量这一屏;问题标在页面上,列在面板里 ──
3419
+ const closeCheck = () => {
3420
+ if (ck) for (const sc of ck.list || []) { const a = apis.current.get(ik(sc)); if (a) a.checkClear() }
3421
+ setCk(null)
3422
+ }
3423
+ const checkList = () => (canvas ? shown : [shown.find((x) => x.key === focus) || { key: focus, url, label: st.label }])
3424
+ // 几屏一起量(各量各的 iframe),一屏卡住不拖累别的
3425
+ const measure = (list, quiet) => Promise.all(list.map(async (sc) => {
3426
+ const row = { key: sc.key, v: sc.v, label: labelOf(sc), url: sc.url, issues: [] }
3427
+ try {
3428
+ const r = await (await apiOf(sc)).check(kind, quiet)
3429
+ row.issues = Array.isArray(r.issues) ? r.issues : []
3430
+ if (r.metrics && typeof r.metrics === 'object') row.metrics = r.metrics
3431
+ } catch (e) { row.error = e.message }
3432
+ return row
3433
+ }))
3434
+ const total = (list) => list.reduce((m, x) => m + x.issues.length, 0)
3435
+ const runCheck = async () => {
3436
+ setPanel(false); setHist(false); setPhone(false)
3437
+ setCk({ running: true, list: [] })
3438
+ const out = await measure(checkList(), false)
3439
+ setCk({ running: false, list: out })
3440
+ setCkCount(total(out))
3441
+ // 有问题的第一屏被面板挡着、在画布外面:挪过来
3442
+ const first = out.find((x) => x.issues.length)
3443
+ if (canvas && first) setReveal((r) => ({ key: first.key, n: ((r && r.n) || 0) + 1 }))
3444
+ return out
3445
+ }
3446
+ const focusIssue = (key, i) => {
3447
+ const sc = (ck && ck.list.find((x) => x.key === key)) || null
3448
+ if (!sc) return
3449
+ if (canvas) { setSel(key); setReveal((r) => ({ key, n: ((r && r.n) || 0) + 1 })) }
3450
+ const a = apis.current.get(ik(sc))
3451
+ if (a) a.checkFocus(i)
3452
+ }
3453
+ // 打开一套设计、模型又干完一轮(turns 变了):面板开着就再查一次(改没改好一眼看到);关着就悄悄量一下,问题数标在按钮上。
3454
+ // 模型干着活时不量;等一会儿再量:刚写完的屏还在刷新、播生成动画
3455
+ const autoCk = useRef(null)
3456
+ const ckKey = url && !busy ? `${site}|${(st && st.turns) || 0}` : ''
3457
+ const seenCk = useRef('')
3458
+ useEffect(() => {
3459
+ if (!ckKey || ckKey === seenCk.current) return undefined
3460
+ seenCk.current = ckKey
3461
+ const timer = setTimeout(async () => {
3462
+ const a = autoCk.current
3463
+ if (a.off) return
3464
+ if (a.ck && !a.ck.running) return a.run()
3465
+ if (a.ck) return
3466
+ const out = await a.measure(a.list(), true)
3467
+ if (!autoCk.current.ck) setCkCount(total(out))
3468
+ }, 2500)
3469
+ return () => clearTimeout(timer)
3470
+ }, [ckKey])
3471
+ useEffect(() => { setCkCount(0) }, [site])
3472
+ // 模型调了 check_design(轮询里出现新的 checkReq):量完把清单交回去;面板开着就顺手刷新面板,关着就只更新按钮上的数。
3473
+ // 稍等一下再量:模型刚写完的屏还在刷新
3474
+ const ckReq = (st && st.checkReq) || ''
3475
+ const doneReq = useRef('')
3476
+ useEffect(() => {
3477
+ if (!ckReq || ckReq === doneReq.current) return undefined
3478
+ doneReq.current = ckReq
3479
+ const timer = setTimeout(async () => {
3480
+ const a = autoCk.current
3481
+ let body
3482
+ if (a.off) body = { error: t('ckToolOff') }
3483
+ else {
3484
+ const out = a.ck && !a.ck.running ? await a.run() : await a.measure(a.list(), true)
3485
+ if (!autoCk.current.ck) setCkCount(total(out))
3486
+ // measured:真量到的屏数。一屏都没量到(这台设备上的预览不回话)时服务端先不收,等别的设备上的右侧栏
3487
+ body = { text: formatCheckTool(t, out, stRef.current), measured: out.filter((x) => !x.error).length }
3488
+ }
3489
+ post('check', { session: sid, id: ckReq, ...body }).catch(() => {})
3490
+ }, 1500)
3491
+ return () => clearTimeout(timer)
3492
+ }, [ckReq])
3493
+ const fixIssues = () => {
3494
+ if (!ck) return
3495
+ insert(formatCheck(t, ck.list, stRef.current), 'insertedSend')
3496
+ closeCheck()
3497
+ }
3498
+ const checkOff = !url || !!ver || !!demo || inMulti
3499
+ const styleOff = !url || !!ver || !!demo || inMulti || busy
3500
+ useEffect(() => { if (styleOff) setStyleEdit(false) }, [styleOff])
3501
+ autoCk.current = { ck, off: checkOff, run: runCheck, list: checkList, measure }
3502
+ useEffect(() => { if (checkOff && ck) closeCheck() }, [checkOff])
3503
+
2498
3504
  const toggleDemo = () => (demo ? setDemo(null) : startDemo())
2499
- const toggleHist = () => { setHist(!hist); setPanel(false); setPhone(false) }
2500
- const togglePhone = () => { setPhone(!phone); setPanel(false); setHist(false) }
3505
+ const toggleHist = () => { setHist(!hist); setPanel(false); setStyles(false); setPhone(false); closeCheck(); setStyleEdit(false) }
3506
+ const togglePhone = () => { setPhone(!phone); setPanel(false); setStyles(false); setHist(false); closeCheck(); setStyleEdit(false) }
2501
3507
  const reload = () => setReloadSeq((x) => x + 1)
2502
3508
 
2503
3509
  const bar = h('div', { className: 'ddp-bar' },
@@ -2509,6 +3515,7 @@ window.__ModuleLoader__.load({
2509
3515
  h('button', { className: 'ddp-btn pick' + (picking ? ' on' : ''), title: t('pickTip'), disabled: !url || !!ver || !!demo, onClick: togglePick },
2510
3516
  h(I.pick), h('span', null, picking ? t('picking') : t('pick'))),
2511
3517
  h('button', { className: 'ddp-btn' + (textEdit ? ' on' : ''), title: t('textEdit'), disabled: !url || !!ver || !!demo || busy, onClick: toggleText }, h(I.text)),
3518
+ h('button', { className: 'ddp-btn' + (styleEdit ? ' on' : ''), title: t('styleEdit'), disabled: styleOff, onClick: toggleStyle }, h(I.sliders)),
2512
3519
  h('span', { className: 'ddp-sep' }),
2513
3520
  h('div', { className: 'ddp-seg' },
2514
3521
  h('button', { className: 'ddp-btn' + (canvas && !inMulti ? ' sel' : ''), title: t('canvas'), onClick: () => { setMulti(false); change({ view: 'canvas' }) } }, h(I.canvas)),
@@ -2516,15 +3523,18 @@ window.__ModuleLoader__.load({
2516
3523
  h('button', { className: 'ddp-btn' + (inMulti ? ' sel' : ''), title: sizeSet ? t('sizes') : t('sizesNo'), disabled: !sizeSet || !!ver, onClick: () => setMulti(!inMulti) }, h(I.sizes))),
2517
3524
  h('button', { className: 'ddp-btn o2' + (demo ? ' sel' : ''), title: t('demo'), disabled: !url || !!ver, onClick: toggleDemo }, h(I.play)),
2518
3525
  h(KindMenu, { t, kind, open: menu, setOpen: setMenu, onPick: (k) => { setMenu(false); change({ kind: k }) } }),
2519
- h('button', { className: 'ddp-btn' + (panel ? ' sel' : '') + (tokens.length && !panel ? ' has' : ''), title: t('tokens'), onClick: () => { setPanel(!panel); setHist(false); setPhone(false) } }, h(I.palette)),
3526
+ 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)),
3527
+ h('button', { className: 'ddp-btn o2' + (ck ? ' sel' : ''), title: ckCount && !ck ? t('checkCount', { n: ckCount }) : t('checkTip'), disabled: checkOff, onClick: () => (ck ? closeCheck() : runCheck()) },
3528
+ h(I.stetho), ckCount && !ck && !checkOff ? h('span', { className: 'bdg' }, ckCount > 99 ? '99+' : String(ckCount)) : null),
2520
3529
  h('button', { className: 'ddp-btn o2' + (hist || ver ? ' sel' : ''), title: t('history'), disabled: !fileTarget, onClick: toggleHist }, h(I.history)),
2521
3530
  h(ExportMenu, { t, kind, count: shown.length, open: exMenu, setOpen: setExMenu, onPick: (m) => doExport(m), disabled: !url }),
2522
3531
  anyDark || dark ? h('button', { className: 'ddp-btn o1' + (dark ? ' sel' : ''), title: t('dark'), onClick: () => setDark(!dark) }, h(I.moon)) : null,
2523
3532
  h('button', { className: 'ddp-btn o1' + (phone ? ' sel' : '') + (phoneOn && !phone ? ' has' : ''), title: t('phone'), disabled: !url, onClick: togglePhone }, h(I.scan)),
2524
3533
  h('button', { className: 'ddp-btn o1', title: t('refresh'), onClick: reload }, h(I.refresh)),
2525
3534
  h('a', { className: 'ddp-btn o1', title: t('external'), href: url, target: '_blank', rel: 'noopener noreferrer' }, h(I.external)),
2526
- h(MoreMenu, { t, open: more, setOpen: setMore, has: phoneOn && !phone, items: [
3535
+ h(MoreMenu, { t, open: more, setOpen: setMore, has: (phoneOn && !phone) || (!!ckCount && !ck && !checkOff), items: [
2527
3536
  ['o2', I.play, t('demoShort'), !!demo, !url || !!ver, toggleDemo],
3537
+ ['o2', I.stetho, t('check'), !!ck, checkOff, () => (ck ? closeCheck() : runCheck())],
2528
3538
  ['o2', I.history, t('history'), hist || !!ver, !fileTarget, toggleHist],
2529
3539
  anyDark || dark ? ['o1', I.moon, t('dark'), dark, false, () => setDark(!dark)] : null,
2530
3540
  ['o1', I.scan, t('phone'), phone, !url, togglePhone],
@@ -2532,8 +3542,11 @@ window.__ModuleLoader__.load({
2532
3542
  ['o1', I.external, t('external'), false, !url, url],
2533
3543
  ] }))
2534
3544
 
2535
- const busyChip = (cls) => h('div', { className: 'ddp-chip' + cls, key: 'busy' }, h('i', { className: 'sp' }),
2536
- h('span', { className: 'tx' }, st && st.writing ? t('writing', { file: st.writing }) : t('working')))
3545
+ // 模型用提问卡问你、在等回答:不转圈,换成一个橙点
3546
+ const busyChip = (cls) => st && st.asking
3547
+ ? h('div', { className: 'ddp-chip' + cls, key: 'busy' }, h('i', { className: 'q' }), h('span', null, t('asking')))
3548
+ : h('div', { className: 'ddp-chip' + cls, key: 'busy' }, h('i', { className: 'sp' }),
3549
+ h('span', { className: 'tx' }, st && st.writing ? t('writing', { file: st.writing }) : t('working')))
2537
3550
 
2538
3551
  // ── 参考图:拖进来 / 粘贴 / 从设计规范面板选;存进设计目录的 refs/,尺寸和主要颜色放进输入框 ──
2539
3552
  const addRef = async (file) => {
@@ -2602,6 +3615,7 @@ window.__ModuleLoader__.load({
2602
3615
  onClick: () => post('open', { session: sid, target: st.hint }).then((s) => { if (poll) poll.set(s) }, (e) => say(t('openFailed', { msg: e.message }))),
2603
3616
  }, t('hintOpen', { name: st.hint.split(/[\\/]/).pop() })) : null,
2604
3617
  h('ol', { className: 'ddp-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3'))),
3618
+ 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)) })),
2605
3619
  h('p', null, t('emptyHint')),
2606
3620
  h(AddressForm, { t, sid, className: 'ddp-form', onDone: () => {} }),
2607
3621
  h('p', { className: 'ddp-refline' }, t('refEmpty'), ' · ', h('button', { className: 'lk', onClick: pickRef }, t('refChoose')))),
@@ -2622,7 +3636,7 @@ window.__ModuleLoader__.load({
2622
3636
  h('div', { className: 'ddp-single' },
2623
3637
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
2624
3638
  h(Device, { kind, w: size[0], h: size[1], fill, label },
2625
- h(Screen, {
3639
+ h(Screen, { t, remote,
2626
3640
  src: urlOfKey(l.key),
2627
3641
  label,
2628
3642
  busy: false,
@@ -2656,7 +3670,7 @@ window.__ModuleLoader__.load({
2656
3670
  sel: '',
2657
3671
  labelOf: (sc) => sc.label,
2658
3672
  render: (sc, z, i) => h(Device, { kind: items[i].kind, w: items[i].size[0], h: items[i].size[1], label: labelOf(base) },
2659
- h(Screen, {
3673
+ h(Screen, { t, remote,
2660
3674
  src: base.url,
2661
3675
  label: labelOf(base),
2662
3676
  busy,
@@ -2685,7 +3699,7 @@ window.__ModuleLoader__.load({
2685
3699
  const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver ? 46 : 0)) / dh))
2686
3700
  // 两边的小标签不跟着画面缩小(缩了看不清);手机、平板躲开状态栏
2687
3701
  const tagAt = (side) => ({ top: kind === 'app' || kind === 'tablet' ? 60 : 10, transform: `scale(${1 / z})`, transformOrigin: 'top ' + side })
2688
- const screenOf = (sc) => h(Screen, { src: sc.url, label: labelOf(sc), busy: false, born: false, picking: false, dark, zoom: z, reloadSeq,
3702
+ const screenOf = (sc) => h(Screen, { t, remote, src: sc.url, label: labelOf(sc), busy: false, born: false, picking: false, dark, zoom: z, reloadSeq,
2689
3703
  apis: apis.current, apiKey: 'cmp|' + ik(sc), onMeta: (m, title, toks, dk) => remember(ik(sc), m, title, toks, dk) })
2690
3704
  body = h('div', { className: 'ddp-single' },
2691
3705
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
@@ -2707,9 +3721,11 @@ window.__ModuleLoader__.load({
2707
3721
  kind,
2708
3722
  size,
2709
3723
  ghost,
2710
- picking: picking || textEdit,
3724
+ picking: picking || textEdit || styleEdit,
2711
3725
  sel: sel || focus,
2712
3726
  labelOf,
3727
+ reveal,
3728
+ inset: ck || styleEdit || panel || styles ? 296 : 0, // 右边的面板 276 宽,离边 10
2713
3729
  render: (sc, z) => renderScreen(sc, z, false),
2714
3730
  flows,
2715
3731
  onSelect: setSel,
@@ -2718,16 +3734,18 @@ window.__ModuleLoader__.load({
2718
3734
  } else {
2719
3735
  const fill = kind === 'web'
2720
3736
  const [dw, dh] = deviceBox(kind, size)
2721
- const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - 32) / dw, (box.h - 32 - (ver || hintTop ? 46 : 0)) / dh))
3737
+ // 右边开着面板(体检、调样式、设计规范、换风格):手机这类有外框的让到面板左边(侧栏够宽时),别被挡住
3738
+ const aside = !fill && (ck || styleEdit || panel || styles) && box.w - 296 >= 240 ? 296 : 0
3739
+ const z = fill ? 1 : Math.max(0.05, Math.min(1, (box.w - aside - 32) / dw, (box.h - 32 - (ver || hintTop ? 46 : 0)) / dh))
2722
3740
  const one = ver ? shown.find((x) => x.key === focus) || shown[0] || { key: focus, url, label: st.label } : { key: focus, url, label: st.label }
2723
- body = h('div', { className: 'ddp-single' },
3741
+ body = h('div', { className: 'ddp-single', style: aside ? { right: aside } : undefined },
2724
3742
  h('div', { className: 'ddp-fit' + (fill ? ' fill' : ''), style: fill ? undefined : { width: dw, height: dh, transform: `translate(-50%, -50%) scale(${z})` } },
2725
3743
  renderScreen(one, z, fill)))
2726
3744
  }
2727
3745
 
2728
3746
  let chip = null
2729
3747
  const side = canvas || inMulti ? ' left' : ''
2730
- const hintText = picking ? t(canvas || inMulti ? 'pickAny' : 'pickOne') : textEdit ? t(canvas || inMulti ? 'textAny' : 'textOne') : ''
3748
+ const hintText = picking ? t(canvas || inMulti ? 'pickAny' : 'pickOne') : textEdit ? t(canvas || inMulti ? 'textAny' : 'textOne') : styleEdit && !sty ? t(canvas ? 'styleAny' : 'styleOne') : ''
2731
3749
  if (flash) chip = h('div', { className: 'ddp-chip ok' + side, key: 'ok' }, h(I.check), h('span', null, flash))
2732
3750
  else if (busy) chip = busyChip(side)
2733
3751
 
@@ -2737,8 +3755,23 @@ window.__ModuleLoader__.load({
2737
3755
  h('div', { className: 'ddp-body' + (ver || demo || hintTop ? ' vb' : '') }, body),
2738
3756
  busy ? h('div', { className: 'ddp-glow' }) : null,
2739
3757
  busy ? h('div', { className: 'ddp-ring' }) : null,
2740
- hintText ? h('div', { className: 'ddp-hint' }, hintText) : null,
2741
- panel ? h(TokensPanel, { t, tokens, onPick: (tok) => insert(formatToken(tok, stRef.current)), onAsk: () => insert(t('tokensAskText') + '\n', 'insertedSend'), onRef: pickRef, onClose: () => setPanel(false) }) : null,
3758
+ // 右边开着面板时提示条居中在面板左边那块,别被面板盖住一半
3759
+ hintText ? h('div', { className: 'ddp-hint' + ((ck || styleEdit || panel || styles) && box.w - 296 >= 240 ? ' side' : '') }, hintText) : null,
3760
+ ck ? h(CheckPanel, { t, ck, onFocus: focusIssue, onFix: fixIssues, onAgain: runCheck, onClose: closeCheck }) : null,
3761
+ styleEdit ? h(StylePanel, {
3762
+ t,
3763
+ sty,
3764
+ tokens: (sty && (infoOf(sty.key) || {}).tokens) || tokens,
3765
+ write: !fileTarget ? { ok: false, tip: t('styleNoWrite') } : busy ? { ok: false, tip: t('styleBusyTip') } : { ok: true, tip: t('styleSaveTip') },
3766
+ onSet: setStyleProp,
3767
+ onReset: () => setSty((c) => (c ? { ...c, live: {} } : c)),
3768
+ onAsk: askStyle,
3769
+ onSave: saveStyle,
3770
+ onClose: () => setStyleEdit(false),
3771
+ }) : null,
3772
+ panel ? h(TokensPanel, { t, tokens, onPick: (tok) => insert(formatToken(tok, stRef.current)), onAsk: () => insert(t('tokensAskText') + '\n', 'insertedSend'), onRef: pickRef,
3773
+ onStyles: () => { setPanel(false); setStyles(true) }, onClose: () => setPanel(false) }) : null,
3774
+ styles ? h(StylesPanel, { t, onPick: (x) => { setStyles(false); insert(formatStyle(t, x, true), 'insertedSend') }, onClose: () => setStyles(false) }) : null,
2742
3775
  phone ? h(PhonePanel, {
2743
3776
  t,
2744
3777
  sid,
@@ -2837,7 +3870,7 @@ window.__ModuleLoader__.load({
2837
3870
  // 注意:这里是客户端**服务名**(短的),不是包名
2838
3871
  exports.inject = INJECT
2839
3872
  // 测试用
2840
- exports._test = { poller, frameOf, paletteOf, formatRef, layoutRow, keyOfPath, flowsOf, flowLines, formatPick, whereOf, parseMeta, sizeOf, layoutGrid, fitCam, tokenKind, formatToken, splitTitles, pickColors, hhmm }
3873
+ 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 }
2841
3874
  return module.exports
2842
3875
  },
2843
3876
  })