dsh-mosaic 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +151 -0
  4. package/SKILL.md +153 -0
  5. package/THIRD_PARTY_NOTICES.md +79 -0
  6. package/assets/icon-180.png +0 -0
  7. package/assets/icon.svg +13 -0
  8. package/assets/screenshots/preview.jpg +0 -0
  9. package/cordis.patch.yml +10 -0
  10. package/docs/catalog-entry.example.yml +9 -0
  11. package/docs/publishing.md +29 -0
  12. package/lib/assets/licenses/braintree-sanitize-url.LICENSE +21 -0
  13. package/lib/assets/licenses/chevrotain.LICENSE +202 -0
  14. package/lib/assets/licenses/cytoscape-cose-bilkent.LICENSE +21 -0
  15. package/lib/assets/licenses/cytoscape-fcose.LICENSE +19 -0
  16. package/lib/assets/licenses/cytoscape.LICENSE +19 -0
  17. package/lib/assets/licenses/d3-sankey.LICENSE +27 -0
  18. package/lib/assets/licenses/d3.LICENSE +13 -0
  19. package/lib/assets/licenses/dagre-d3-es.LICENSE +23 -0
  20. package/lib/assets/licenses/dayjs.LICENSE +21 -0
  21. package/lib/assets/licenses/dompurify.LICENSE +202 -0
  22. package/lib/assets/licenses/elkjs.LICENSE +263 -0
  23. package/lib/assets/licenses/es-toolkit.LICENSE +21 -0
  24. package/lib/assets/licenses/iconify-utils.LICENSE +21 -0
  25. package/lib/assets/licenses/katex.LICENSE +21 -0
  26. package/lib/assets/licenses/khroma.LICENSE +21 -0
  27. package/lib/assets/licenses/marked.LICENSE +44 -0
  28. package/lib/assets/licenses/mermaid-js-parser.LICENSE +21 -0
  29. package/lib/assets/licenses/mermaid.LICENSE +21 -0
  30. package/lib/assets/licenses/roughjs.LICENSE +21 -0
  31. package/lib/assets/licenses/stylis.LICENSE +21 -0
  32. package/lib/assets/licenses/ts-dedent.LICENSE +21 -0
  33. package/lib/assets/licenses/types-d3.LICENSE +21 -0
  34. package/lib/assets/licenses/upsetjs-venn.js.LICENSE +22 -0
  35. package/lib/assets/licenses/uuid.LICENSE +9 -0
  36. package/lib/assets/mermaid.min.js +4330 -0
  37. package/lib/client.js +3191 -0
  38. package/lib/index.js +365 -0
  39. package/lib/log.js +212 -0
  40. package/lib/prompt-store.js +354 -0
  41. package/lib/render-tool.js +162 -0
  42. package/lib/validate-tool.js +160 -0
  43. package/lib/validate.js +1070 -0
  44. package/locale/en.json +6 -0
  45. package/locale/zh.json +6 -0
  46. package/package.json +66 -0
  47. package/screenshots.json +3 -0
package/lib/client.js ADDED
@@ -0,0 +1,3191 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-mosaic',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const ReactDOMClient = require('react-dom/client')
6
+ const ReactDOM = require('react-dom')
7
+ const h = React.createElement
8
+ const {
9
+ useState, useEffect, useMemo, useRef, useCallback, createContext, useContext, Fragment,
10
+ } = React
11
+
12
+ const PLUGIN_ID = 'dsh-mosaic'
13
+ const STYLE_CSS_ID = 'dsh-mosaic/client.css'
14
+ const STORE_KEY = 'mosaic.state.v1'
15
+ const MAX_STORED = 100
16
+ // Client diagnostics are forwarded to the Host log; failures are what matter here,
17
+ // so the reporter is best-effort, capped, and never throws.
18
+ const LOG_URL = '/dsh-mosaic/log'
19
+ const LOG_REPORTS_PER_WINDOW = 20
20
+ const LOG_WINDOW_MS = 60000
21
+ let logWindow = []
22
+
23
+ function reportLog(level, event, detail) {
24
+ try {
25
+ const now = Date.now()
26
+ logWindow = logWindow.filter((at) => now - at < LOG_WINDOW_MS)
27
+ if (logWindow.length >= LOG_REPORTS_PER_WINDOW) return
28
+ logWindow.push(now)
29
+ fetch(LOG_URL, {
30
+ method: 'POST',
31
+ headers: { 'content-type': 'application/json' },
32
+ body: JSON.stringify({ level, event, detail: detail ?? {} }),
33
+ }).catch(() => {})
34
+ } catch {
35
+ // Diagnostics must never affect rendering.
36
+ }
37
+ }
38
+ const MAX_NODES = 80
39
+ const MAX_DEPTH = 6
40
+
41
+ // Visible copy belongs to the Client locale service: both shipped locales are
42
+ // registered as one namespace and every component reads it through `t`.
43
+ const NS = 'mosaic'
44
+ const dictionaries = {
45
+ zh: {
46
+ copy: '复制',
47
+ copied: '已复制',
48
+ copyMarkdown: '复制 Markdown',
49
+ copyCsv: '复制 CSV',
50
+ total: '合计',
51
+ grandTotal: '总计',
52
+ added: '(新增)',
53
+ openPdf: '打开 PDF 原文件',
54
+ selectPlaceholder: '请选择…',
55
+ answered: '已作答',
56
+ answerCorrect: '回答正确',
57
+ answerWrong: '回答不正确',
58
+ tryAgain: '再试一次',
59
+ code: '代码',
60
+ preview: '预览',
61
+ reload: '重载',
62
+ fullscreen: '全屏',
63
+ close: '关闭',
64
+ drawio: '下载 draw.io 文件',
65
+ mermaidFallback: 'Mermaid 渲染失败,已使用内置布局:{message}',
66
+ parseFailed: 'dsh-view 解析失败:{detail}(已保留代码块)',
67
+ actionNotice: '用户刚刚在界面中触发了动作 "{action}",请根据组件数据更新回答,并用 dsh-view 输出更新后的界面。组件数据:',
68
+ settingsTitle: 'Mosaic',
69
+ promptDesc: '插件会向模型贡献一段说明可视化能力的 Prompt。默认使用内置内容;你可以改成自己的写法,也可以随时恢复默认。',
70
+ promptLabel: 'Prompt 内容',
71
+ promptHint: '只影响能力说明,不规定回答内容;保存后从下一轮模型请求开始生效。',
72
+ promptSave: '保存',
73
+ promptSaving: '保存中…',
74
+ promptReset: '恢复默认',
75
+ promptSaved: '已保存。',
76
+ promptResetDone: '已恢复插件内置内容。',
77
+ promptDirty: '有未保存的修改',
78
+ promptChars: '{count} / {limit} 字符',
79
+ promptSourceUser: '已自定义',
80
+ promptSourceConfig: '来自配置文件',
81
+ promptSourceBuiltin: '内置默认',
82
+ promptLoadFailed: '读取失败:{reason}',
83
+ promptSaveFailed: '保存失败:{reason}',
84
+ promptMemoryOnly: '未能写入 Host 数据目录,本次仅在进程内生效。',
85
+ promptReadCorrupt: '保存的内容无法解析,已按内置内容运行;原文件已保留,可手工修复。',
86
+ promptReadUnreadable: '保存的内容读取失败(权限或路径问题),已按内置内容运行。',
87
+ copyJson: '复制 JSON',
88
+ },
89
+ en: {
90
+ copy: 'Copy',
91
+ copied: 'Copied',
92
+ copyMarkdown: 'Copy Markdown',
93
+ copyCsv: 'Copy CSV',
94
+ total: 'Total',
95
+ grandTotal: 'Total',
96
+ added: '(added)',
97
+ openPdf: 'Open the original PDF',
98
+ selectPlaceholder: 'Select…',
99
+ answered: 'Answered',
100
+ answerCorrect: 'Correct',
101
+ answerWrong: 'Not correct',
102
+ tryAgain: 'Try again',
103
+ code: 'Code',
104
+ preview: 'Preview',
105
+ reload: 'Reload',
106
+ fullscreen: 'Fullscreen',
107
+ close: 'Close',
108
+ drawio: 'Download a draw.io file',
109
+ mermaidFallback: 'Mermaid rendering failed; the built-in layout was used: {message}',
110
+ parseFailed: 'dsh-view could not be parsed: {detail} (the code block is kept)',
111
+ actionNotice: 'The user just triggered the action "{action}" in the interface above. Update the answer from its data and reply with an updated dsh-view fence. Data:',
112
+ settingsTitle: 'Mosaic',
113
+ promptDesc: "This plugin contributes a prompt that explains its visualization to the model. The built-in text is used by default; you can replace it with your own wording and restore the default later.",
114
+ promptLabel: 'Prompt',
115
+ promptHint: 'This only shapes capability guidance, never the answer; a saved change applies from the next model request.',
116
+ promptSave: 'Save',
117
+ promptSaving: 'Saving…',
118
+ promptReset: 'Restore default',
119
+ promptSaved: 'Saved.',
120
+ promptResetDone: 'Restored the built-in prompt.',
121
+ promptDirty: 'Unsaved changes',
122
+ promptChars: '{count} / {limit} characters',
123
+ promptSourceUser: 'Customized',
124
+ promptSourceConfig: 'From the config file',
125
+ promptSourceBuiltin: 'Built-in default',
126
+ promptLoadFailed: 'Could not read the prompt: {reason}',
127
+ promptSaveFailed: 'Could not save: {reason}',
128
+ promptMemoryOnly: 'Could not write the Host data directory, so this change only lives in this process.',
129
+ promptReadCorrupt: 'The saved override is not valid JSON, so the built-in text is used. The file was kept for manual repair.',
130
+ promptReadUnreadable: 'The saved override could not be read (permissions or path), so the built-in text is used.',
131
+ copyJson: 'Copy JSON',
132
+ },
133
+ }
134
+
135
+ // Safe before the dictionary registers; keys never disappear.
136
+ let translate = (key, vars) => {
137
+ let text = dictionaries.zh[key] ?? key
138
+ if (vars) for (const name of Object.keys(vars)) text = text.replace(`{${name}}`, String(vars[name]))
139
+ return text
140
+ }
141
+
142
+ function clearLegacyStorage() {
143
+ try {
144
+ localStorage.removeItem('dsh-mosaic.theme.v1')
145
+ localStorage.removeItem('mosaic.theme.v1')
146
+ localStorage.removeItem('dsh-mosaic.prompt.v1')
147
+ } catch {}
148
+ }
149
+
150
+ /* ------------------------------------------------------------------ */
151
+ /* styles */
152
+ /* ------------------------------------------------------------------ */
153
+
154
+ const CSS = `
155
+ .mosaic-fence{margin:16px 0;min-width:0}
156
+ .mosaic-root,.mosaic-overlay{
157
+ --m-bg:var(--dsw-alias-bg-layer-1);
158
+ --m-bg2:var(--dsw-alias-bg-layer-2);
159
+ --m-text:var(--dsw-alias-label-primary);
160
+ --m-muted:var(--dsw-alias-label-secondary);
161
+ --m-border:var(--dsw-alias-border-l1);
162
+ --m-accent:var(--dsw-alias-brand-primary);
163
+ --m-success:var(--dsw-alias-state-success-primary);
164
+ --m-warn:var(--dsw-alias-state-warn-primary);
165
+ --m-danger:var(--dsw-alias-state-error-primary);
166
+ --m-series-1:#4f6bed;
167
+ --m-series-2:#1f9d8b;
168
+ --m-series-3:#d99a2b;
169
+ --m-series-4:#d35d5d;
170
+ --m-series-5:#8a63d2;
171
+ --m-radius:12px;
172
+ --m-card-bg:var(--m-bg);
173
+ --m-card-border:var(--m-border);
174
+ --m-card-shadow:0 1px 2px rgba(15,23,42,.04);
175
+ --m-card-padding:16px 18px;
176
+ color:var(--m-text);
177
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
178
+ font-size:14px;
179
+ line-height:1.55;
180
+ min-width:0;
181
+ }
182
+ .mosaic-root *,.mosaic-root *::before,.mosaic-root *::after{box-sizing:border-box}
183
+ .mosaic-root a{color:var(--m-accent)}
184
+ .mosaic-banner{font-size:12px;font-weight:600;color:var(--m-muted);letter-spacing:.02em;margin:0 0 8px}
185
+ .mosaic-head{display:flex;align-items:flex-start;gap:8px;margin:0 0 8px;position:relative}
186
+ .mosaic-head .mosaic-banner{flex:1;min-width:0;margin:0}
187
+ .mosaic-settings{flex-direction:column;gap:14px;width:100%;display:flex}
188
+ .mosaic-settings-desc{color:var(--dsw-alias-label-secondary);margin:0;font-size:14px;line-height:22px}
189
+ .mosaic-prompt-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
190
+ .mosaic-prompt-label{color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px}
191
+ .mosaic-prompt-meta{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
192
+ .mosaic-prompt-field{box-sizing:border-box;width:100%;min-height:240px;resize:vertical;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.7;tab-size:2}
193
+ .mosaic-prompt-field:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
194
+ .mosaic-prompt-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
195
+ .mosaic-prompt-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;flex:1;min-width:220px}
196
+ .mosaic-prompt-reset,.mosaic-prompt-save{border-radius:var(--dsw-radius-md,8px);height:32px;padding:0 12px;font:inherit;font-size:13px;line-height:18px;cursor:pointer;flex:none}
197
+ .mosaic-prompt-reset{border:.5px solid var(--dsw-alias-border-l3);background:transparent;color:var(--dsw-alias-label-primary)}
198
+ .mosaic-prompt-reset:hover:enabled{background:var(--dsw-alias-interactive-bg-hover)}
199
+ .mosaic-prompt-save{border:0;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base)}
200
+ .mosaic-prompt-save:hover:enabled{opacity:.88}
201
+ .mosaic-prompt-reset:disabled,.mosaic-prompt-save:disabled{opacity:.45;cursor:not-allowed}
202
+ .mosaic-prompt-reset:focus-visible,.mosaic-prompt-save:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
203
+ .mosaic-prompt-warn{color:var(--dsw-alias-state-warn-primary);margin:0;font-size:12px;line-height:18px}
204
+ .mosaic-prompt-notice{margin:0;font-size:12px;line-height:18px}
205
+ .mosaic-prompt-notice.is-ok{color:var(--dsw-alias-state-success-primary)}
206
+ .mosaic-prompt-notice.is-error{color:var(--dsw-alias-state-error-primary)}
207
+ .mosaic-json-copy{box-sizing:border-box;border:0;border-radius:var(--dsw-radius-sm,6px);cursor:pointer;height:28px;color:var(--dsw-alias-label-secondary);background:transparent;flex:none;margin-left:auto;padding:0 8px;font-family:inherit;font-size:12px;line-height:18px;display:inline-flex;align-items:center}
208
+ .mosaic-json-copy:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
209
+ .mosaic-json-copy:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
210
+ .mosaic-col{display:flex;flex-direction:column;min-width:0}
211
+ .mosaic-row{display:flex;gap:12px;min-width:0;align-items:stretch}
212
+ .mosaic-row-wrap{flex-wrap:wrap}
213
+ .mosaic-grid{display:grid;gap:12px;min-width:0}
214
+ .mosaic-grid-item{min-width:0}
215
+ .mosaic-card{background:var(--m-card-bg);border:1px solid var(--m-card-border);border-radius:var(--m-radius);padding:var(--m-card-padding);min-width:0;box-shadow:var(--m-card-shadow)}
216
+ .mosaic-card-flat{box-shadow:none;background:var(--m-bg2)}
217
+ .mosaic-card-info{background:color-mix(in srgb,var(--m-accent) 8%,var(--m-bg));border-color:color-mix(in srgb,var(--m-accent) 26%,var(--m-border))}
218
+ .mosaic-card-success{background:color-mix(in srgb,var(--m-success) 8%,var(--m-bg));border-color:color-mix(in srgb,var(--m-success) 26%,var(--m-border))}
219
+ .mosaic-card-warning{background:color-mix(in srgb,var(--m-warn) 9%,var(--m-bg));border-color:color-mix(in srgb,var(--m-warn) 28%,var(--m-border))}
220
+ .mosaic-card-danger{background:color-mix(in srgb,var(--m-danger) 8%,var(--m-bg));border-color:color-mix(in srgb,var(--m-danger) 26%,var(--m-border))}
221
+ .mosaic-eyebrow{font-size:10.5px;font-weight:650;letter-spacing:.08em;text-transform:uppercase;color:var(--m-muted);margin:0 0 3px}
222
+ .mosaic-card-title{font-size:15px;font-weight:650;line-height:1.3;margin:0 0 10px}
223
+ .mosaic-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0}
224
+ .mosaic-display{font-size:30px;line-height:1.18;font-weight:650;letter-spacing:-.022em}
225
+ .mosaic-h1{font-size:24px;line-height:1.2;font-weight:650;letter-spacing:-.015em}
226
+ .mosaic-h2{font-size:19px;line-height:1.25;font-weight:650;letter-spacing:-.01em}
227
+ .mosaic-h3{font-size:16px;line-height:1.3;font-weight:650}
228
+ .mosaic-muted{color:var(--m-muted)}
229
+ .mosaic-caption{font-size:12px;color:var(--m-muted)}
230
+ .mosaic-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
231
+ .mosaic-tone-danger{color:var(--m-danger)}
232
+ .mosaic-tone-success{color:var(--m-success)}
233
+ .mosaic-tone-warning{color:var(--m-warn)}
234
+ .mosaic-tone-info{color:var(--m-accent)}
235
+ .mosaic-tone-muted{color:var(--m-muted)}
236
+ .mosaic-center{text-align:center}
237
+ .mosaic-right{text-align:right}
238
+ .mosaic-divider{display:flex;align-items:center;gap:10px;color:var(--m-muted);font-size:11px;margin:4px 0}
239
+ .mosaic-divider::before,.mosaic-divider::after{content:"";flex:1;height:1px;background:var(--m-border)}
240
+ .mosaic-spacer{height:8px}
241
+ .mosaic-stat{display:flex;flex-direction:column;gap:4px;min-width:0}
242
+ .mosaic-stat-label{font-size:12px;color:var(--m-muted);line-height:1.35}
243
+ .mosaic-stat-value{font-size:26px;line-height:1.15;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
244
+ .mosaic-stat-foot{display:flex;align-items:center;gap:8px;min-width:0}
245
+ .mosaic-delta{font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
246
+ .mosaic-delta-pos{color:var(--m-success)}
247
+ .mosaic-delta-neg{color:var(--m-danger)}
248
+ .mosaic-delta-flat{color:var(--m-muted)}
249
+ .mosaic-spark{width:84px;height:22px;flex:none;margin-left:4px}
250
+ .mosaic-badge{display:inline-flex;align-items:center;align-self:flex-start;gap:4px;max-width:100%;padding:2px 7px;border-radius:999px;font-size:11.5px;line-height:1.5;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
251
+ .mosaic-badge-success{background:color-mix(in srgb,var(--m-success) 14%,transparent);color:var(--m-success)}
252
+ .mosaic-badge-warn{background:color-mix(in srgb,var(--m-warn) 16%,transparent);color:var(--m-warn)}
253
+ .mosaic-badge-danger{background:color-mix(in srgb,var(--m-danger) 14%,transparent);color:var(--m-danger)}
254
+ .mosaic-badge-accent{background:color-mix(in srgb,var(--m-accent) 14%,transparent);color:var(--m-accent)}
255
+ .mosaic-progress{display:flex;flex-direction:column;gap:6px;min-width:0}
256
+ .mosaic-progress-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;font-size:12px;color:var(--m-muted)}
257
+ .mosaic-progress-head b{color:var(--m-text);font-size:13px;font-variant-numeric:tabular-nums}
258
+ .mosaic-track{position:relative;height:7px;border-radius:999px;background:var(--m-bg2);overflow:hidden}
259
+ .mosaic-fill{position:absolute;inset:0 auto 0 0;border-radius:inherit;background:var(--m-series-1)}
260
+ .mosaic-target{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--m-text);opacity:.55}
261
+ .mosaic-ring{display:flex;align-items:center;gap:12px}
262
+ .mosaic-ring svg{width:64px;height:64px;flex:none}
263
+ .mosaic-table-wrap{overflow-x:auto;border:1px solid var(--m-card-border);border-radius:var(--m-radius);background:var(--m-card-bg);box-shadow:var(--m-card-shadow)}
264
+ .mosaic-table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:0}
265
+ .mosaic-table th,.mosaic-table td{padding:9px 12px;text-align:left;vertical-align:top;border-bottom:1px solid var(--m-border);overflow-wrap:anywhere}
266
+ .mosaic-table tr:last-child td{border-bottom:0}
267
+ .mosaic-table tbody tr:hover td{background:color-mix(in srgb,var(--m-accent) 3%,var(--m-bg))}
268
+ .mosaic-table th{position:sticky;top:0;background:var(--m-bg2);z-index:1;font-size:11.5px;font-weight:650;color:var(--m-muted);letter-spacing:.02em;white-space:nowrap}
269
+ /* From four columns on, auto layout lets the longest cell decide the width and the
270
+ card starts scrolling sideways; equal shares keep every column readable instead. */
271
+ .mosaic-table-dense{table-layout:fixed}
272
+ .mosaic-table-dense th{white-space:normal}
273
+ .mosaic-table-dense td.num{white-space:normal}
274
+ .mosaic-table th button{all:unset;cursor:pointer;display:inline-flex;gap:4px;align-items:center;color:inherit;font:inherit}
275
+ .mosaic-table th button:focus-visible{outline:2px solid var(--m-accent);outline-offset:2px;border-radius:4px}
276
+ .mosaic-table td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
277
+ .mosaic-table td.delta-pos{color:var(--m-success)}
278
+ .mosaic-table td.delta-neg{color:var(--m-danger)}
279
+ .mosaic-table tr.total td{font-weight:650;background:var(--m-bg2)}
280
+ .mosaic-table-tools{display:flex;justify-content:flex-end;gap:6px;margin:0 0 6px}
281
+ .mosaic-table-tools button,.mosaic-copy{all:unset;cursor:pointer;font-size:11px;color:var(--m-muted);border:1px solid var(--m-border);border-radius:6px;padding:2px 7px}
282
+ .mosaic-table-tools button:hover,.mosaic-copy:hover{color:var(--m-text);border-color:var(--m-muted)}
283
+ .mosaic-cell-bar{display:block;height:5px;border-radius:999px;background:var(--m-bg2);overflow:hidden;min-width:56px}
284
+ .mosaic-cell-bar span{display:block;height:100%;border-radius:inherit;background:var(--m-series-1)}
285
+ .mosaic-list{display:flex;flex-direction:column;gap:7px;margin:0;padding:0;list-style:none}
286
+ .mosaic-list-item{display:flex;gap:8px;align-items:flex-start;min-width:0}
287
+ .mosaic-list-dot{width:5px;height:5px;border-radius:50%;background:var(--m-accent);margin-top:8px;flex:none}
288
+ .mosaic-list-title{font-weight:550}
289
+ .mosaic-list-desc{color:var(--m-muted);font-size:12.5px}
290
+ .mosaic-kv{display:grid;grid-template-columns:minmax(90px,auto) 1fr;gap:6px 16px;font-size:13px}
291
+ .mosaic-kv-key{color:var(--m-muted)}
292
+ .mosaic-kv-value{min-width:0;overflow-wrap:anywhere}
293
+ .mosaic-steps{display:flex;flex-direction:column;gap:10px;margin:0;padding:0;list-style:none}
294
+ .mosaic-step{display:flex;gap:10px;min-width:0}
295
+ .mosaic-step-index{width:22px;height:22px;border-radius:50%;border:1px solid var(--m-border);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:650;flex:none;color:var(--m-muted)}
296
+ .mosaic-step-active .mosaic-step-index{background:var(--m-accent);border-color:var(--m-accent);color:#fff}
297
+ .mosaic-step-title{font-weight:600;line-height:1.35}
298
+ .mosaic-step-desc{color:var(--m-muted);font-size:12.5px;margin-top:2px}
299
+ .mosaic-callout{position:relative;border:1px solid color-mix(in srgb,var(--m-accent) 22%,var(--m-border));background:color-mix(in srgb,var(--m-accent) 5%,var(--m-bg));border-radius:calc(var(--m-radius) - 2px);padding:11px 13px}
300
+ .mosaic-callout-success{border-color:color-mix(in srgb,var(--m-success) 24%,var(--m-border));background:color-mix(in srgb,var(--m-success) 6%,var(--m-bg))}
301
+ .mosaic-callout-warning{border-color:color-mix(in srgb,var(--m-warn) 24%,var(--m-border));background:color-mix(in srgb,var(--m-warn) 7%,var(--m-bg))}
302
+ .mosaic-callout-error{border-color:color-mix(in srgb,var(--m-danger) 24%,var(--m-border));background:color-mix(in srgb,var(--m-danger) 6%,var(--m-bg))}
303
+ .mosaic-callout-title{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--m-accent);margin-bottom:4px}
304
+ .mosaic-callout-title::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
305
+ .mosaic-callout-success .mosaic-callout-title{color:var(--m-success)}
306
+ .mosaic-callout-warning .mosaic-callout-title{color:var(--m-warn)}
307
+ .mosaic-callout-error .mosaic-callout-title{color:var(--m-danger)}
308
+ .mosaic-code{position:relative;background:var(--m-bg2);border:1px solid var(--m-border);border-radius:10px;padding:10px 12px;overflow:auto;font-size:12px;line-height:1.55}
309
+ .mosaic-code pre{margin:0;white-space:pre;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
310
+ .mosaic-code .mosaic-copy{position:absolute;top:6px;right:6px;background:var(--m-bg)}
311
+ .mosaic-inline-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em;background:var(--m-bg2);border:1px solid var(--m-border);border-radius:4px;padding:0 3px}
312
+ .mosaic-diff{display:flex;flex-direction:column;gap:8px}
313
+ .mosaic-diff-file{font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--m-muted)}
314
+ .mosaic-diff-pair{display:grid;grid-template-columns:1fr 1fr;gap:8px}
315
+ .mosaic-diff-side{border:1px solid var(--m-border);border-radius:8px;padding:0;overflow:auto;font-size:12px;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
316
+ .mosaic-diff-line{display:grid;grid-template-columns:14px 30px minmax(0,1fr);gap:4px;padding:1px 8px;min-height:18px}
317
+ .mosaic-diff-gutter{color:var(--m-muted);text-align:center;user-select:none}
318
+ .mosaic-diff-num{color:var(--m-muted);text-align:right;user-select:none;font-variant-numeric:tabular-nums}
319
+ .mosaic-diff-text{white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}
320
+ .mosaic-diff-old{background:color-mix(in srgb,var(--m-danger) 7%,var(--m-bg))}
321
+ .mosaic-diff-new{background:color-mix(in srgb,var(--m-success) 7%,var(--m-bg))}
322
+ .mosaic-chart{width:100%;height:auto;display:block;overflow:visible}
323
+ .mosaic-chart text{font-size:10px;fill:var(--m-muted)}
324
+ .mosaic-chart .mosaic-chart-title{font-size:12px;font-weight:600;fill:var(--m-text)}
325
+ .mosaic-chart .mosaic-axis{stroke:var(--m-border);stroke-width:1}
326
+ .mosaic-chart .mosaic-axis-label{font-size:9px;fill:var(--m-muted);font-variant-numeric:tabular-nums}
327
+ .mosaic-chart .mosaic-grid-line{stroke:var(--m-border);stroke-width:1;opacity:.45}
328
+ .mosaic-chart .mosaic-line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
329
+ .mosaic-chart .mosaic-dot{stroke:var(--m-bg);stroke-width:1.5}
330
+ .mosaic-chart .mosaic-bar{shape-rendering:geometricPrecision}
331
+ .mosaic-chart .mosaic-donut-label{font-size:11px;fill:var(--m-text)}
332
+ .mosaic-diagram-wrap{max-width:100%;max-height:560px;overflow:auto;padding-bottom:2px}
333
+ .mosaic-diagram{display:block;width:auto;max-width:none;height:auto;margin:0 auto;overflow:visible}
334
+ .mosaic-mermaid{max-width:100%;max-height:560px;overflow:auto;padding:2px 0}
335
+ .mosaic-mermaid svg{display:block;max-width:100%;height:auto;margin:0 auto}
336
+ .mosaic-mermaid .labelBkg{background:var(--m-bg)!important;color:var(--m-text)!important;padding:0 3px;border-radius:3px}
337
+ .mosaic-mermaid .edgeLabel{color:var(--m-text)!important}
338
+ .mosaic-diagram-head{display:flex;align-items:center;gap:8px;margin:0 0 6px}
339
+ .mosaic-diagram-export{margin-left:auto;border:1px solid var(--m-border);background:var(--m-bg);color:var(--m-muted);font:11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;padding:2px 7px;border-radius:5px;cursor:pointer}
340
+ .mosaic-diagram-export:hover{color:var(--m-text);border-color:var(--m-accent)}
341
+ .mosaic-diagram .diagram-node{stroke-width:1.2}
342
+ .mosaic-diagram .diagram-node-label{font-size:12px;font-weight:600;fill:var(--m-text)}
343
+ .mosaic-diagram .diagram-node-sub{font-size:10px;fill:var(--m-muted)}
344
+ .mosaic-diagram .diagram-edge{fill:none;stroke:var(--m-muted);stroke-width:1.4}
345
+ .mosaic-diagram .diagram-edge-accent{stroke:var(--m-accent);stroke-width:1.8}
346
+ .mosaic-diagram .diagram-edge-dashed{stroke-dasharray:5 4}
347
+ .mosaic-diagram .diagram-edge-label{font-size:10px;fill:var(--m-muted);paint-order:stroke;stroke:var(--m-bg);stroke-width:3;stroke-linejoin:round}
348
+ .mosaic-diagram .diagram-lifeline{stroke:var(--m-border);stroke-width:1;stroke-dasharray:3 4}
349
+ .mosaic-diagram .diagram-participant{fill:var(--m-bg2);stroke:var(--m-border);stroke-width:1}
350
+ .mosaic-diagram .diagram-participant-label{font-size:11px;font-weight:600;fill:var(--m-text)}
351
+ .mosaic-diagram .diagram-message{stroke:var(--m-text);stroke-width:1.2;fill:none}
352
+ .mosaic-diagram .diagram-message-return{stroke:var(--m-muted);stroke-dasharray:4 3}
353
+ .mosaic-diagram .diagram-message-async{stroke:var(--m-accent);stroke-dasharray:2 3}
354
+ .mosaic-diagram .diagram-msg-label{font-size:10.5px;fill:var(--m-text);paint-order:stroke;stroke:var(--m-bg);stroke-width:3;stroke-linejoin:round}
355
+ .mosaic-timeline{list-style:none;margin:0;padding:0 0 0 14px;position:relative;display:flex;flex-direction:column;gap:14px}
356
+ .mosaic-timeline::before{content:"";position:absolute;left:4px;top:4px;bottom:4px;width:1px;background:var(--m-border)}
357
+ .mosaic-timeline-item{position:relative;padding-left:12px;min-width:0}
358
+ .mosaic-timeline-dot{position:absolute;left:-13px;top:5px;width:7px;height:7px;border-radius:50%;background:var(--m-accent);box-shadow:0 0 0 3px var(--m-bg)}
359
+ .mosaic-timeline-time{font-size:10.5px;color:var(--m-muted);margin-bottom:2px}
360
+ .mosaic-timeline-title{font-weight:600;line-height:1.35}
361
+ .mosaic-timeline-desc{font-size:12.5px;color:var(--m-muted);margin-top:2px}
362
+ .mosaic-layers{display:flex;flex-direction:column;gap:7px}
363
+ .mosaic-layer{border:1px solid var(--m-border);border-radius:9px;background:var(--m-bg);padding:8px 10px}
364
+ .mosaic-layer-title{font-size:12px;font-weight:650;color:var(--m-text);margin-bottom:3px}
365
+ .mosaic-layer-items{margin:0;padding-left:16px;color:var(--m-muted);font-size:12.5px;line-height:1.5}
366
+ .mosaic-caption-line{font-size:12px;color:var(--m-muted);margin-top:5px}
367
+ .mosaic-media{display:flex;flex-direction:column;gap:5px;min-width:0}
368
+ .mosaic-image{display:block;max-width:100%;max-height:520px;border-radius:10px;border:1px solid var(--m-border);cursor:zoom-in;object-fit:contain;background:var(--m-bg2)}
369
+ .mosaic-video{display:block;width:100%;border-radius:10px;background:#000;border:1px solid var(--m-border)}
370
+ .mosaic-audio{display:block;width:100%}
371
+ .mosaic-pdf{display:block;width:100%;height:480px;border:1px solid var(--m-border);border-radius:10px;background:var(--m-bg)}
372
+ .mosaic-link{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:1px solid var(--m-border);border-radius:10px;text-decoration:none;color:var(--m-text);background:var(--m-bg)}
373
+ .mosaic-link:hover{border-color:var(--m-accent)}
374
+ .mosaic-link-icon{width:28px;height:28px;border-radius:7px;background:var(--m-bg2);display:flex;align-items:center;justify-content:center;color:var(--m-accent);flex:none;font-size:14px}
375
+ .mosaic-link-label{font-weight:600;overflow-wrap:anywhere}
376
+ .mosaic-link-desc{font-size:12px;color:var(--m-muted);margin-top:2px;overflow-wrap:anywhere}
377
+ .mosaic-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:6px 13px;border:1px solid var(--m-border);border-radius:8px;background:var(--m-bg);color:var(--m-text);font:inherit;font-size:13px;font-weight:550;cursor:pointer}
378
+ .mosaic-button:hover{border-color:var(--m-muted)}
379
+ .mosaic-button:focus-visible{outline:2px solid var(--m-accent);outline-offset:2px}
380
+ .mosaic-button:disabled,.mosaic-button[aria-disabled="true"]{opacity:.5;cursor:not-allowed}
381
+ .mosaic-button-primary{background:var(--m-accent);border-color:var(--m-accent);color:#fff}
382
+ .mosaic-button-primary:hover{filter:brightness(.95);border-color:var(--m-accent)}
383
+ .mosaic-button-danger{background:var(--m-danger);border-color:var(--m-danger);color:#fff}
384
+ .mosaic-button-success{background:var(--m-success);border-color:var(--m-success);color:#fff}
385
+ .mosaic-button-ghost{background:transparent}
386
+ .mosaic-button-full{width:100%}
387
+ .mosaic-form{display:flex;flex-direction:column;gap:5px;min-width:0}
388
+ .mosaic-field-label{font-size:12px;color:var(--m-muted)}
389
+ .mosaic-input,.mosaic-select{width:100%;min-height:34px;padding:6px 9px;border:1px solid var(--m-border);border-radius:8px;background:var(--m-bg);color:var(--m-text);font:inherit;font-size:13px}
390
+ .mosaic-input:focus,.mosaic-select:focus{outline:2px solid var(--m-accent);outline-offset:1px;border-color:var(--m-accent)}
391
+ .mosaic-slider-row{display:flex;align-items:center;gap:10px}
392
+ .mosaic-slider{flex:1;min-width:0;accent-color:var(--m-accent)}
393
+ .mosaic-slider-value{font-size:12px;color:var(--m-muted);font-variant-numeric:tabular-nums;min-width:34px;text-align:right}
394
+ .mosaic-tabs{display:flex;flex-direction:column;gap:10px;min-width:0}
395
+ .mosaic-tabbar{display:flex;gap:2px;border-bottom:1px solid var(--m-border);overflow-x:auto}
396
+ .mosaic-tab{border:0;background:transparent;color:var(--m-muted);font:inherit;font-size:12.5px;font-weight:600;padding:7px 10px;cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}
397
+ .mosaic-tab:hover{color:var(--m-text)}
398
+ .mosaic-tab-active{color:var(--m-text);border-bottom-color:var(--m-accent)}
399
+ .mosaic-tab:focus-visible{outline:2px solid var(--m-accent);outline-offset:-2px}
400
+ .mosaic-tab-body{min-width:0}
401
+ .mosaic-quiz{display:flex;flex-direction:column;gap:10px;min-width:0}
402
+ .mosaic-quiz-question{font-size:15px;font-weight:650;line-height:1.4}
403
+ .mosaic-quiz-options{display:flex;flex-direction:column;gap:6px}
404
+ .mosaic-quiz-option{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border:1px solid var(--m-border);border-radius:8px;background:var(--m-bg);color:var(--m-text);font:inherit;font-size:13px;text-align:left;cursor:pointer}
405
+ .mosaic-quiz-option:hover{border-color:var(--m-muted)}
406
+ .mosaic-quiz-option:disabled{cursor:default}
407
+ .mosaic-quiz-correct{border-color:var(--m-success);background:color-mix(in srgb,var(--m-success) 8%,var(--m-bg))}
408
+ .mosaic-quiz-wrong{border-color:var(--m-danger);background:color-mix(in srgb,var(--m-danger) 8%,var(--m-bg))}
409
+ .mosaic-quiz-mark{font-weight:700;flex:none}
410
+ .mosaic-quiz-feedback{font-size:12px;color:var(--m-muted);margin-top:2px}
411
+ .mosaic-quiz-result{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;border-radius:8px;background:var(--m-bg2);font-weight:600}
412
+ .mosaic-quiz-result.ok{color:var(--m-success)}
413
+ .mosaic-quiz-result.no{color:var(--m-danger)}
414
+ .mosaic-widget{border:1px solid var(--m-border);border-radius:12px;overflow:hidden;background:var(--m-bg);min-width:0}
415
+ .mosaic-widget-head{display:flex;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid var(--m-border);background:var(--m-bg2);min-width:0}
416
+ .mosaic-widget-title{font-size:12px;font-weight:650;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
417
+ .mosaic-widget-actions{margin-left:auto;display:flex;gap:4px;flex:none}
418
+ .mosaic-widget-action{border:0;background:transparent;color:var(--m-muted);font:inherit;font-size:11px;cursor:pointer;padding:2px 5px;border-radius:5px}
419
+ .mosaic-widget-action:hover{color:var(--m-text);background:var(--m-border)}
420
+ .mosaic-widget-iframe{display:block;width:100%;border:0;background:transparent}
421
+ .mosaic-widget-code{margin:0;padding:10px 12px;max-height:380px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre;background:var(--m-bg2)}
422
+ .mosaic-widget-caption{font-size:12px;color:var(--m-muted);margin-top:5px}
423
+ .mosaic-tool-fallback{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--m-border);border-radius:8px;color:var(--m-muted);font-size:12px}
424
+ .mosaic-tool-fallback b{color:var(--m-text);font-weight:600}
425
+ .mosaic-overlay-host{position:fixed;inset:0;z-index:2147483000}
426
+ .mosaic-overlay{position:fixed;inset:0;outline:none;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:24px;z-index:2147483000}
427
+ .mosaic-overlay-body{position:relative;width:min(1120px,96vw);max-height:92vh;overflow:auto;background:var(--m-bg);color:var(--m-text);border:1px solid var(--m-border);border-radius:14px;padding:16px;box-shadow:0 24px 70px rgba(0,0,0,.35)}
428
+ .mosaic-overlay-close{position:sticky;top:0;float:right;z-index:2;border:1px solid var(--m-border);background:var(--m-bg);color:var(--m-text);border-radius:8px;width:30px;height:30px;font-size:18px;line-height:1;cursor:pointer}
429
+ .mosaic-overlay-close:hover{background:var(--m-bg2)}
430
+ .mosaic-overlay-image{display:block;max-width:100%;max-height:76vh;margin:0 auto;border-radius:10px;object-fit:contain}
431
+ .mosaic-diagnostic{margin:0 0 6px;padding:6px 10px;border:1px solid color-mix(in srgb,var(--m-danger) 40%,transparent);border-radius:7px;background:color-mix(in srgb,var(--m-danger) 10%,transparent);color:var(--m-danger);font-size:12px;line-height:1.5;white-space:pre-wrap}
432
+ .mosaic-error{margin:8px 0;padding:8px 10px;border:1px solid color-mix(in srgb,var(--m-danger) 40%,transparent);border-radius:8px;background:color-mix(in srgb,var(--m-danger) 8%,var(--m-bg));color:var(--m-danger);font-size:12px}
433
+ .mosaic-skeleton{display:flex;flex-direction:column;gap:8px;padding:10px 0}
434
+ .mosaic-skeleton span{display:block;height:10px;border-radius:5px;background:var(--m-bg2);animation:mosaic-pulse 1.2s ease-in-out infinite}
435
+ .mosaic-skeleton span:nth-child(2){width:70%}
436
+ .mosaic-skeleton span:nth-child(3){width:45%}
437
+ @keyframes mosaic-pulse{0%,100%{opacity:.5}50%{opacity:1}}
438
+ @media (max-width:640px){
439
+ .mosaic-row{flex-wrap:wrap}
440
+ .mosaic-diff-pair{grid-template-columns:1fr}
441
+ .mosaic-kv{grid-template-columns:1fr}
442
+ .mosaic-table th,.mosaic-table td{padding:7px 8px}
443
+ }
444
+ @media (prefers-reduced-motion:reduce){
445
+ .mosaic-skeleton span{animation:none}
446
+ }
447
+ `
448
+
449
+ function ensureStyles() {
450
+ if (typeof document === 'undefined') return () => {}
451
+ // Tagged with this package's identity so the client module system's owned-style
452
+ // cleanup (`removeOwnedStyles`) can reclaim it when the entry is replaced or pruned,
453
+ // and so this generation always owns the disposer it returns.
454
+ for (const stale of document.querySelectorAll(`style[data-plugin-css=${JSON.stringify(STYLE_CSS_ID)}]`)) stale.remove()
455
+ const style = document.createElement('style')
456
+ style.dataset.plugin = PLUGIN_ID
457
+ style.dataset.pluginCss = STYLE_CSS_ID
458
+ style.textContent = CSS
459
+ document.head.appendChild(style)
460
+ return () => style.remove()
461
+ }
462
+
463
+ /* ------------------------------------------------------------------ */
464
+ /* tiny utilities */
465
+ /* ------------------------------------------------------------------ */
466
+
467
+ function fingerprint(text) {
468
+ let hash = 5381
469
+ for (let index = 0; index < text.length; index += 1) {
470
+ hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0
471
+ }
472
+ return hash.toString(36)
473
+ }
474
+
475
+ function safeUrl(value, media = false) {
476
+ if (typeof value !== 'string') return undefined
477
+ const url = value.trim()
478
+ if (url === '' || url.length > 2048) return undefined
479
+ if (/^https?:\/\//i.test(url)) return url
480
+ if (!media && /^mailto:[^@\s]+@[^@\s]+$/i.test(url)) return url
481
+ if (url.startsWith('/') && !url.startsWith('//')) return url
482
+ if (url.startsWith('./') || url.startsWith('../')) return url
483
+ return undefined
484
+ }
485
+
486
+ function clamp(value, min, max) {
487
+ return Math.min(max, Math.max(min, value))
488
+ }
489
+
490
+ function asString(value, cap = 2000) {
491
+ return typeof value === 'string' ? value.slice(0, cap) : undefined
492
+ }
493
+
494
+ function asNumber(value) {
495
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
496
+ }
497
+
498
+ function readStore() {
499
+ try {
500
+ const raw = localStorage.getItem(STORE_KEY)
501
+ if (!raw) return { order: [], blocks: {} }
502
+ const parsed = JSON.parse(raw)
503
+ if (!parsed || !Array.isArray(parsed.order) || !parsed.blocks || typeof parsed.blocks !== 'object') {
504
+ return { order: [], blocks: {} }
505
+ }
506
+ return { order: parsed.order.filter((key) => typeof key === 'string'), blocks: parsed.blocks }
507
+ } catch {
508
+ return { order: [], blocks: {} }
509
+ }
510
+ }
511
+
512
+ function writeStore(store) {
513
+ try {
514
+ localStorage.setItem(STORE_KEY, JSON.stringify(store))
515
+ } catch {
516
+ // Quota/privacy failures are non-fatal.
517
+ }
518
+ }
519
+
520
+ function loadState(key) {
521
+ if (!key) return null
522
+ return readStore().blocks[key] ?? null
523
+ }
524
+
525
+ function saveState(key, value) {
526
+ if (!key) return
527
+ const store = readStore()
528
+ const order = store.order.filter((entry) => entry !== key)
529
+ order.unshift(key)
530
+ const blocks = { ...store.blocks, [key]: value }
531
+ while (order.length > MAX_STORED) {
532
+ const evicted = order.pop()
533
+ if (evicted) delete blocks[evicted]
534
+ }
535
+ writeStore({ order, blocks })
536
+ }
537
+
538
+ function clearState(key) {
539
+ if (!key) return
540
+ const store = readStore()
541
+ if (!(key in store.blocks)) return
542
+ const blocks = { ...store.blocks }
543
+ delete blocks[key]
544
+ writeStore({ order: store.order.filter((entry) => entry !== key), blocks })
545
+ }
546
+
547
+ const PROMPT_URL = '/dsh-mosaic/prompt'
548
+ const PROMPT_LIMIT = 128 * 1024
549
+
550
+ /**
551
+ * The model prompt, editable in place. The Host owns the value and persists it, so
552
+ * the same text is used by every browser and survives a restart; this page only
553
+ * reads it, posts a change, and shows which source is active.
554
+ */
555
+ function MosaicSettings({ t }) {
556
+ const alive = useRef(true)
557
+ const [state, setState] = useState({ loading: true, text: '', saved: '', info: null, notice: null, saving: false })
558
+ useEffect(() => () => { alive.current = false }, [])
559
+ const reason = (error) => (error instanceof Error ? error.message : String(error))
560
+
561
+ useEffect(() => {
562
+ let current = true
563
+ fetch(PROMPT_URL, { headers: { accept: 'application/json' } })
564
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
565
+ .then((data) => {
566
+ if (!current) return
567
+ const text = typeof data.prompt === 'string' ? data.prompt : ''
568
+ setState({ loading: false, text, saved: text, info: data, notice: null, saving: false })
569
+ })
570
+ .catch((error) => {
571
+ if (!current) return
572
+ reportLog('warn', 'prompt.load-failed', { reason: reason(error).slice(0, 200) })
573
+ setState((previous) => ({ ...previous, loading: false, notice: { kind: 'error', text: t('promptLoadFailed', { reason: reason(error) }) } }))
574
+ })
575
+ return () => { current = false }
576
+ }, [t])
577
+
578
+ const submit = useCallback(async (body) => {
579
+ setState((previous) => ({ ...previous, saving: true, notice: null }))
580
+ try {
581
+ const response = await fetch(PROMPT_URL, {
582
+ method: 'POST',
583
+ headers: { 'content-type': 'application/json' },
584
+ body: JSON.stringify(body),
585
+ })
586
+ const data = await response.json().catch(() => ({}))
587
+ if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
588
+ if (!alive.current) return
589
+ const text = typeof data.prompt === 'string' ? data.prompt : ''
590
+ setState({
591
+ loading: false,
592
+ text,
593
+ saved: text,
594
+ info: data,
595
+ saving: false,
596
+ notice: { kind: 'ok', text: body.reset === true ? t('promptResetDone') : t('promptSaved') },
597
+ })
598
+ } catch (error) {
599
+ if (!alive.current) return
600
+ reportLog('warn', 'prompt.save-failed', { reason: reason(error).slice(0, 200) })
601
+ setState((previous) => ({ ...previous, saving: false, notice: { kind: 'error', text: t('promptSaveFailed', { reason: reason(error) }) } }))
602
+ }
603
+ }, [t])
604
+
605
+ const info = state.info
606
+ const dirty = !state.loading && state.text !== state.saved
607
+ const sourceLabel = info?.source === 'user'
608
+ ? t('promptSourceUser')
609
+ : info?.source === 'config' ? t('promptSourceConfig') : t('promptSourceBuiltin')
610
+ const meta = [sourceLabel, dirty ? t('promptDirty') : '', state.loading ? '' : t('promptChars', { count: state.text.length, limit: PROMPT_LIMIT })]
611
+ .filter(Boolean)
612
+ .join(' · ')
613
+
614
+ return h('div', { className: 'mosaic-root mosaic-settings', 'data-mosaic-root': '' },
615
+ h('p', { className: 'mosaic-settings-desc' }, t('promptDesc')),
616
+ h('div', { className: 'mosaic-prompt-head' },
617
+ h('span', { className: 'mosaic-prompt-label' }, t('promptLabel')),
618
+ h('span', { className: 'mosaic-prompt-meta' }, meta)),
619
+ h('textarea', {
620
+ className: 'mosaic-prompt-field',
621
+ value: state.text,
622
+ rows: 14,
623
+ spellCheck: false,
624
+ 'aria-label': t('promptLabel'),
625
+ placeholder: state.loading ? t('promptSaving') : '',
626
+ onChange: (event) => setState((previous) => ({ ...previous, text: event.target.value, notice: null })),
627
+ }),
628
+ info && info.source === 'user' && info.persisted === false
629
+ ? h('p', { className: 'mosaic-prompt-warn' }, t('promptMemoryOnly'))
630
+ : null,
631
+ info?.readError === 'corrupt'
632
+ ? h('p', { className: 'mosaic-prompt-warn' }, t('promptReadCorrupt'))
633
+ : null,
634
+ info?.readError === 'unreadable'
635
+ ? h('p', { className: 'mosaic-prompt-warn' }, t('promptReadUnreadable'))
636
+ : null,
637
+ h('div', { className: 'mosaic-prompt-actions' },
638
+ h('span', { className: 'mosaic-prompt-hint' }, t('promptHint')),
639
+ h('button', {
640
+ type: 'button',
641
+ className: 'mosaic-prompt-reset',
642
+ disabled: state.loading || state.saving || info?.source !== 'user',
643
+ onClick: () => submit({ reset: true }),
644
+ }, t('promptReset')),
645
+ h('button', {
646
+ type: 'button',
647
+ className: 'mosaic-prompt-save',
648
+ disabled: !dirty || state.saving || state.text.trim() === '',
649
+ onClick: () => submit({ prompt: state.text }),
650
+ }, state.saving ? t('promptSaving') : t('promptSave'))),
651
+ state.notice
652
+ ? h('p', {
653
+ className: `mosaic-prompt-notice ${state.notice.kind === 'error' ? 'is-error' : 'is-ok'}`,
654
+ role: 'status',
655
+ }, state.notice.text)
656
+ : null)
657
+ }
658
+
659
+ function currentSessionId(ctx) {
660
+ try {
661
+ const list = ctx.sessions?.list?.getSnapshot?.()
662
+ if (!list || !Array.isArray(list.ids)) return undefined
663
+ return list.ids.find((id) => {
664
+ const snapshot = ctx.sessions?.retainInfo?.(id)?.getSnapshot?.()
665
+ return (snapshot?.retainedBy?.mainView ?? 0) > 0
666
+ })
667
+ } catch {
668
+ return undefined
669
+ }
670
+ }
671
+
672
+ // The context stays in the apply closure; components only receive this callback.
673
+ function makeSender(ctx) {
674
+ return function sendAction(sessionId, action, payload) {
675
+ if (!sessionId) return
676
+ try {
677
+ const scoped = ctx.sessions?.scope?.(sessionId)
678
+ const conversation = scoped?.get?.('conversation')
679
+ if (!conversation?.send) return
680
+ const text = `[mosaic-action] ${translate('actionNotice', { action })} ${JSON.stringify(payload ?? {})}`
681
+ Promise.resolve(conversation.send(text)).catch(() => {})
682
+ } catch {
683
+ // A gone session or busy agent drops the action; the UI stays live.
684
+ }
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Resolve the host theme tokens for the sandboxed widget document. The plugin
690
+ * owns no theme state and guesses no light/dark mode: the iframe declares
691
+ * `color-scheme: light dark`, and a token that is absent simply keeps the
692
+ * neutral value the widget document already falls back to.
693
+ */
694
+ function themeSnapshot(host) {
695
+ if (typeof window === 'undefined') return {}
696
+ const target = host instanceof Element ? host : (document.body ?? document.documentElement)
697
+ const style = getComputedStyle(target)
698
+ const tokens = {
699
+ bg: '--dsw-alias-bg-layer-1',
700
+ text: '--dsw-alias-label-primary',
701
+ muted: '--dsw-alias-label-secondary',
702
+ border: '--dsw-alias-border-l1',
703
+ accent: '--dsw-alias-brand-primary',
704
+ success: '--dsw-alias-state-success-primary',
705
+ warn: '--dsw-alias-state-warn-primary',
706
+ danger: '--dsw-alias-state-error-primary',
707
+ card: '--dsw-alias-bg-layer-2',
708
+ }
709
+ const snapshot = {}
710
+ for (const name of Object.keys(tokens)) {
711
+ const value = style.getPropertyValue(tokens[name]).trim()
712
+ if (value !== '') snapshot[name] = value
713
+ }
714
+ return snapshot
715
+ }
716
+
717
+ /* ------------------------------------------------------------------ */
718
+ /* partial JSON parser */
719
+ /* ------------------------------------------------------------------ */
720
+
721
+ function tryPartial(text) {
722
+ const stack = []
723
+ const candidates = []
724
+ let inString = false
725
+ let escaped = false
726
+ const push = (end, suffix) => {
727
+ if (candidates.length >= 24) candidates.shift()
728
+ candidates.push({ end, suffix })
729
+ }
730
+ for (let index = 0; index < text.length; index += 1) {
731
+ const character = text[index]
732
+ if (inString) {
733
+ if (escaped) escaped = false
734
+ else if (character === '\\') escaped = true
735
+ else if (character === '"') inString = false
736
+ continue
737
+ }
738
+ if (character === '"') {
739
+ inString = true
740
+ continue
741
+ }
742
+ if (character === '{' || character === '[') {
743
+ stack.push(character)
744
+ continue
745
+ }
746
+ if (character === '}' || character === ']') {
747
+ const expected = character === '}' ? '{' : '['
748
+ if (stack.pop() !== expected) break
749
+ if (character === '}' && stack.length === 2 && stack[0] === '{' && stack[1] === '[') {
750
+ push(index + 1, ']}')
751
+ }
752
+ if (stack.length === 0) push(index + 1, '')
753
+ }
754
+ }
755
+ candidates.sort((a, b) => b.end - a.end)
756
+ const seen = new Set()
757
+ for (const candidate of candidates) {
758
+ const key = `${candidate.end}:${candidate.suffix}`
759
+ if (seen.has(key)) continue
760
+ seen.add(key)
761
+ try {
762
+ const value = JSON.parse(text.slice(0, candidate.end) + candidate.suffix)
763
+ if (value && typeof value === 'object') return value
764
+ } catch {
765
+ // Try the next candidate.
766
+ }
767
+ }
768
+ return null
769
+ }
770
+
771
+ function parseView(raw) {
772
+ const text = String(raw ?? '').trim()
773
+ if (!text) return null
774
+ let value
775
+ try {
776
+ value = JSON.parse(text)
777
+ } catch {
778
+ value = tryPartial(text)
779
+ }
780
+ if (typeof value === 'string') {
781
+ try {
782
+ value = JSON.parse(value)
783
+ } catch {
784
+ return null
785
+ }
786
+ }
787
+ if (Array.isArray(value)) value = { items: value }
788
+ if (!value || typeof value !== 'object') return null
789
+ if (!Array.isArray(value.items)) {
790
+ if (typeof value.type === 'string') value = { items: [value] }
791
+ else return null
792
+ }
793
+ const items = value.items.slice(0, MAX_NODES).map((node) => coerceNode(node, 0)).filter(Boolean)
794
+ if (items.length === 0) return null
795
+ return {
796
+ title: typeof value.title === 'string' ? value.title.slice(0, 2000) : undefined,
797
+ gap: typeof value.gap === 'number' && Number.isFinite(value.gap) ? clamp(value.gap, 0, 48) : undefined,
798
+ items,
799
+ }
800
+ }
801
+
802
+ function coerceItems(value, depth) {
803
+ if (!Array.isArray(value)) return []
804
+ return value.slice(0, MAX_NODES).map((node) => coerceNode(node, depth + 1)).filter(Boolean)
805
+ }
806
+
807
+ function coerceNode(raw, depth) {
808
+ if (depth > MAX_DEPTH || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null
809
+ const type = typeof raw.type === 'string' ? raw.type : undefined
810
+ if (!type) return null
811
+ const out = { ...raw, type }
812
+ const text = (value, cap = 2000) => asString(value, cap)
813
+ switch (type) {
814
+ case 'text':
815
+ out.content = text(raw.content ?? raw.text)
816
+ if (!out.content) return null
817
+ break
818
+ case 'callout': {
819
+ out.content = text(raw.content ?? raw.text ?? raw.body ?? raw.desc)
820
+ if (!out.content) return null
821
+ if (raw.tone === 'danger') out.tone = 'error'
822
+ if (raw.tone === 'warn') out.tone = 'warning'
823
+ break
824
+ }
825
+ case 'table': {
826
+ let columns = Array.isArray(raw.columns) ? raw.columns.slice(0, 8).map((column) => text(column, 256) ?? '') : undefined
827
+ let rows = Array.isArray(raw.rows) ? raw.rows : Array.isArray(raw.data) ? raw.data : []
828
+ if (rows.length > 0 && rows[0] && typeof rows[0] === 'object' && !Array.isArray(rows[0])) {
829
+ const keys = columns && columns.length ? columns : Object.keys(rows[0])
830
+ rows = rows.map((row) => keys.map((key) => row?.[key]))
831
+ if (!columns || columns.length === 0) columns = keys
832
+ }
833
+ if ((!columns || columns.length === 0) && rows.length > 0 && Array.isArray(rows[0])) {
834
+ columns = rows[0].map((cell) => String(cell ?? ''))
835
+ rows = rows.slice(1)
836
+ }
837
+ if (!columns || columns.length === 0) return null
838
+ out.columns = columns
839
+ out.rows = rows.slice(0, 40).map((row) => Array.isArray(row) ? row.slice(0, 8).map((cell) => typeof cell === 'number' ? cell : String(cell ?? '')) : [])
840
+ break
841
+ }
842
+ case 'list':
843
+ out.items = Array.isArray(raw.items) ? raw.items.slice(0, 30).map((item) => {
844
+ if (typeof item === 'string') return item.slice(0, 2000)
845
+ if (item && typeof item.type === 'string') return coerceNode(item, depth + 1)
846
+ if (item && typeof item === 'object') {
847
+ const title = text(item.title ?? item.label ?? item.name)
848
+ if (!title) return null
849
+ return { title, desc: text(item.desc ?? item.description ?? item.content ?? item.text) }
850
+ }
851
+ return null
852
+ }).filter(Boolean) : []
853
+ if (out.items.length === 0) return null
854
+ break
855
+ case 'chart': {
856
+ out.kind = ['bars', 'line', 'donut'].includes(raw.kind) ? raw.kind : 'bars'
857
+ out.data = Array.isArray(raw.data) ? raw.data.slice(0, 40).map((datum) => ({
858
+ label: text(datum?.label, 128) ?? '',
859
+ value: asNumber(datum?.value) ?? 0,
860
+ })).filter((datum) => datum.label !== '') : []
861
+ out.series = Array.isArray(raw.series) ? raw.series.slice(0, 8).map((series) => ({
862
+ label: text(series?.label, 128) ?? '',
863
+ data: Array.isArray(series?.data) ? series.data.slice(0, 40).map((datum) => ({
864
+ label: text(datum?.label, 128) ?? '',
865
+ value: asNumber(datum?.value) ?? 0,
866
+ })).filter((datum) => datum.label !== '') : [],
867
+ })).filter((series) => series.label !== '') : []
868
+ if (out.data.length === 0 && out.series.length === 0) return null
869
+ if (out.kind === 'donut') out.series = []
870
+ break
871
+ }
872
+ case 'progress':
873
+ out.value = clamp(asNumber(raw.value) ?? 0, 0, 100)
874
+ break
875
+ case 'stat':
876
+ if (!text(raw.label ?? raw.title) || !text(raw.value, 128)) return null
877
+ out.label = text(raw.label ?? raw.title)
878
+ out.value = text(raw.value, 128)
879
+ out.spark = Array.isArray(raw.spark) ? raw.spark.filter((n) => typeof n === 'number' && Number.isFinite(n)).slice(0, 20) : undefined
880
+ break
881
+ case 'badge':
882
+ out.label = text(raw.label ?? raw.text)
883
+ if (!out.label) return null
884
+ break
885
+ case 'keyvalue':
886
+ out.pairs = Array.isArray(raw.pairs) ? raw.pairs.slice(0, 20).map((pair) => ({
887
+ key: text(pair?.key ?? pair?.label, 256) ?? '',
888
+ value: text(pair?.value, 2000) ?? '',
889
+ })).filter((pair) => pair.key !== '') : []
890
+ if (out.pairs.length === 0) return null
891
+ break
892
+ case 'steps':
893
+ out.steps = Array.isArray(raw.steps) ? raw.steps.slice(0, 12).map((step) => ({
894
+ title: text(step?.title, 256) ?? '',
895
+ desc: text(step?.desc, 2000),
896
+ })).filter((step) => step.title !== '') : []
897
+ if (out.steps.length === 0) return null
898
+ out.current = Number.isFinite(raw.current) ? clamp(Math.trunc(raw.current), 0, out.steps.length) : undefined
899
+ break
900
+ case 'code':
901
+ out.code = text(raw.code, 12000)
902
+ if (!out.code) return null
903
+ break
904
+ case 'diff':
905
+ out.diffs = Array.isArray(raw.diffs) ? raw.diffs.slice(0, 8).map((diff) => ({
906
+ path: text(diff?.path, 512) ?? '',
907
+ newText: text(diff?.newText, 12000) ?? '',
908
+ oldText: typeof diff?.oldText === 'string' ? diff.oldText.slice(0, 12000) : null,
909
+ })).filter((diff) => diff.path !== '') : []
910
+ if (out.diffs.length === 0) return null
911
+ break
912
+ case 'image':
913
+ case 'video':
914
+ case 'audio':
915
+ case 'pdf': {
916
+ const src = safeUrl(raw.src ?? raw.url, true)
917
+ if (!src) return null
918
+ out.src = src
919
+ if (type === 'video' && raw.poster !== undefined) {
920
+ const poster = safeUrl(raw.poster, true)
921
+ if (poster) out.poster = poster
922
+ else delete out.poster
923
+ }
924
+ break
925
+ }
926
+ case 'link': {
927
+ const href = safeUrl(raw.href ?? raw.url)
928
+ const label = text(raw.label ?? raw.text)
929
+ if (!href || !label) return null
930
+ out.href = href
931
+ out.label = label
932
+ break
933
+ }
934
+ case 'button':
935
+ out.label = text(raw.label ?? raw.text)
936
+ if (!out.label) return null
937
+ if (raw.action !== undefined && typeof raw.action !== 'string') delete out.action
938
+ if (raw.local !== undefined && !['reset', 'copy', 'open', 'fullscreen'].includes(raw.local)) delete out.local
939
+ break
940
+ case 'input':
941
+ case 'select':
942
+ case 'slider':
943
+ out.id = text(raw.id, 128)
944
+ break
945
+ case 'tabs':
946
+ out.tabs = Array.isArray(raw.tabs) ? raw.tabs.slice(0, 8).map((tab) => ({
947
+ label: text(tab?.label, 128) ?? '',
948
+ items: coerceItems(tab?.items ?? [], depth + 1),
949
+ })).filter((tab) => tab.label !== '') : []
950
+ if (out.tabs.length === 0) return null
951
+ break
952
+ case 'quiz':
953
+ out.question = text(raw.question)
954
+ out.options = Array.isArray(raw.options) ? raw.options.slice(0, 6).map((option) => {
955
+ if (typeof option === 'string') return { label: option.slice(0, 512) }
956
+ return {
957
+ label: text(option?.label, 512) ?? '',
958
+ correct: option?.correct === true,
959
+ feedback: text(option?.feedback),
960
+ }
961
+ }).filter((option) => option.label !== '') : []
962
+ if (!out.question || out.options.length < 2) return null
963
+ break
964
+ case 'widget':
965
+ out.code = text(raw.code, 20000)
966
+ if (!out.code) return null
967
+ out.height = clamp(asNumber(raw.height) ?? 320, 80, 900)
968
+ break
969
+ case 'diagram': {
970
+ const inferred = raw.kind === undefined
971
+ ? (Array.isArray(raw.nodes) || Array.isArray(raw.edges) ? 'flowchart'
972
+ : Array.isArray(raw.participants) || Array.isArray(raw.messages) ? 'sequence'
973
+ : Array.isArray(raw.layers) ? 'layers'
974
+ : Array.isArray(raw.items) ? 'timeline' : undefined)
975
+ : undefined
976
+ const kind = ['flowchart', 'sequence', 'timeline', 'layers'].includes(raw.kind) ? raw.kind : inferred
977
+ if (!kind) return null
978
+ out.kind = kind
979
+ out.title = text(raw.title, 256)
980
+ if (kind === 'flowchart') {
981
+ const ids = new Set()
982
+ out.nodes = (Array.isArray(raw.nodes) ? raw.nodes : []).slice(0, 12).map((node) => {
983
+ const id = text(node?.id, 64)
984
+ const label = text(node?.label, 128)
985
+ if (!id || !label || ids.has(id)) return null
986
+ ids.add(id)
987
+ return {
988
+ id,
989
+ label,
990
+ sub: text(node?.sub, 128),
991
+ tone: ['accent', 'success', 'warning', 'danger', 'muted'].includes(node?.tone) ? node.tone : undefined,
992
+ shape: ['box', 'round', 'diamond'].includes(node?.shape) ? node.shape : undefined,
993
+ }
994
+ }).filter(Boolean)
995
+ if (out.nodes.length === 0) return null
996
+ out.edges = (Array.isArray(raw.edges) ? raw.edges : []).slice(0, 20).map((edge) => {
997
+ const from = text(edge?.from, 64)
998
+ const to = text(edge?.to, 64)
999
+ if (!from || !to || !ids.has(from) || !ids.has(to)) return null
1000
+ return {
1001
+ from,
1002
+ to,
1003
+ label: text(edge?.label, 64),
1004
+ kind: ['solid', 'dashed', 'accent'].includes(edge?.kind) ? edge.kind : undefined,
1005
+ }
1006
+ }).filter(Boolean)
1007
+ out.direction = raw.direction === 'LR' ? 'LR' : 'TB'
1008
+ } else if (kind === 'sequence') {
1009
+ const ids = new Set()
1010
+ out.participants = (Array.isArray(raw.participants) ? raw.participants : []).slice(0, 8).map((participant) => {
1011
+ const id = text(participant?.id, 64)
1012
+ const label = text(participant?.label, 128)
1013
+ if (!id || !label || ids.has(id)) return null
1014
+ ids.add(id)
1015
+ return { id, label }
1016
+ }).filter(Boolean)
1017
+ if (out.participants.length === 0) return null
1018
+ out.messages = (Array.isArray(raw.messages) ? raw.messages : []).slice(0, 20).map((message) => {
1019
+ const from = text(message?.from, 64)
1020
+ const to = text(message?.to, 64)
1021
+ const label = text(message?.label, 128)
1022
+ if (!from || !to || !label || !ids.has(from) || !ids.has(to)) return null
1023
+ return {
1024
+ from,
1025
+ to,
1026
+ label,
1027
+ kind: ['sync', 'return', 'async'].includes(message?.kind) ? message.kind : undefined,
1028
+ }
1029
+ }).filter(Boolean)
1030
+ if (out.messages.length === 0) return null
1031
+ } else if (kind === 'timeline') {
1032
+ out.items = (Array.isArray(raw.items) ? raw.items : []).slice(0, 12).map((item) => {
1033
+ const itemTitle = text(item?.title, 256)
1034
+ if (!itemTitle) return null
1035
+ return { title: itemTitle, desc: text(item?.desc), time: text(item?.time, 64) }
1036
+ }).filter(Boolean)
1037
+ if (out.items.length === 0) return null
1038
+ } else {
1039
+ out.layers = (Array.isArray(raw.layers) ? raw.layers : []).slice(0, 8).map((layer) => {
1040
+ const layerTitle = text(layer?.title, 128)
1041
+ const layerItems = Array.isArray(layer?.items)
1042
+ ? layer.items.slice(0, 6).map((item) => text(item, 256)).filter(Boolean)
1043
+ : []
1044
+ if (!layerTitle || layerItems.length === 0) return null
1045
+ return { title: layerTitle, items: layerItems }
1046
+ }).filter(Boolean)
1047
+ if (out.layers.length === 0) return null
1048
+ }
1049
+ break
1050
+ }
1051
+ case 'row':
1052
+ case 'col':
1053
+ case 'grid':
1054
+ case 'card':
1055
+ out.items = coerceItems(raw.items ?? [], depth + 1)
1056
+ if (out.items.length === 0) return null
1057
+ break
1058
+ case 'divider':
1059
+ case 'spacer':
1060
+ break
1061
+ default:
1062
+ return null
1063
+ }
1064
+ if (raw.span !== undefined && asNumber(raw.span) !== undefined) out.span = clamp(Math.trunc(raw.span), 1, 6)
1065
+ return out
1066
+ }
1067
+
1068
+ function describeParse(raw) {
1069
+ try {
1070
+ JSON.parse(String(raw ?? ''))
1071
+ return 'JSON parsed but the root has no usable items'
1072
+ } catch (error) {
1073
+ return error instanceof Error ? error.message : String(error)
1074
+ }
1075
+ }
1076
+
1077
+ /* ------------------------------------------------------------------ */
1078
+ /* rich text */
1079
+ /* ------------------------------------------------------------------ */
1080
+
1081
+ const INLINE = /(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^\]\n]+\]\([^)\s]+\)|\r?\n)/g
1082
+
1083
+ function renderRich(value, allowLinks = true, depth = 0) {
1084
+ if (typeof value !== 'string' || value === '' || depth > 4) return value
1085
+ const out = []
1086
+ let last = 0
1087
+ let key = 0
1088
+ for (const match of value.matchAll(INLINE)) {
1089
+ const index = match.index ?? 0
1090
+ const token = match[0]
1091
+ if (index > last) out.push(value.slice(last, index))
1092
+ if (token === '\n' || token === '\r\n') {
1093
+ out.push('\n')
1094
+ } else if (token.startsWith('`')) {
1095
+ out.push(h('code', { key: key += 1, className: 'mosaic-inline-code' }, token.slice(1, -1)))
1096
+ } else if (token.startsWith('**')) {
1097
+ out.push(h('strong', { key: key += 1 }, renderRich(token.slice(2, -2), allowLinks, depth + 1)))
1098
+ } else {
1099
+ const parts = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
1100
+ const href = parts ? safeUrl(parts[2]) : undefined
1101
+ if (parts && href && allowLinks) {
1102
+ out.push(h('a', {
1103
+ key: key += 1,
1104
+ href,
1105
+ target: '_blank',
1106
+ rel: 'noreferrer noopener',
1107
+ }, renderRich(parts[1], false, depth + 1)))
1108
+ } else if (parts) {
1109
+ out.push(renderRich(parts[1], false, depth + 1))
1110
+ } else {
1111
+ out.push(token)
1112
+ }
1113
+ }
1114
+ last = index + token.length
1115
+ }
1116
+ if (last < value.length) out.push(value.slice(last))
1117
+ return out.length === 0 ? value : out
1118
+ }
1119
+
1120
+ /* ------------------------------------------------------------------ */
1121
+ /* local state */
1122
+ /* ------------------------------------------------------------------ */
1123
+
1124
+ const ViewContext = createContext(null)
1125
+
1126
+ function useView() {
1127
+ return useContext(ViewContext)
1128
+ }
1129
+
1130
+ function useDebouncedAction() {
1131
+ const timer = useRef(null)
1132
+ useEffect(() => () => {
1133
+ if (timer.current) clearTimeout(timer.current)
1134
+ }, [])
1135
+ return useCallback((fn) => {
1136
+ if (timer.current) clearTimeout(timer.current)
1137
+ timer.current = setTimeout(fn, 300)
1138
+ }, [])
1139
+ }
1140
+
1141
+ function copyText(text) {
1142
+ try {
1143
+ if (navigator.clipboard?.writeText) {
1144
+ navigator.clipboard.writeText(text).catch(() => {})
1145
+ return
1146
+ }
1147
+ } catch {
1148
+ // Fall through to the legacy path.
1149
+ }
1150
+ try {
1151
+ const area = document.createElement('textarea')
1152
+ area.value = text
1153
+ area.style.position = 'fixed'
1154
+ area.style.opacity = '0'
1155
+ document.body.appendChild(area)
1156
+ area.select()
1157
+ document.execCommand('copy')
1158
+ area.remove()
1159
+ } catch {
1160
+ // Clipboard unavailable; nothing to do.
1161
+ }
1162
+ }
1163
+
1164
+ /* ------------------------------------------------------------------ */
1165
+ /* atomic renderers */
1166
+ /* ------------------------------------------------------------------ */
1167
+
1168
+ function Sparkline({ values }) {
1169
+ const points = values.slice(0, 20)
1170
+ if (points.length < 2) return null
1171
+ const width = 84
1172
+ const height = 22
1173
+ const min = Math.min(...points)
1174
+ const max = Math.max(...points)
1175
+ const span = max - min || 1
1176
+ const step = (width - 4) / (points.length - 1)
1177
+ const coords = points.map((value, index) => [2 + index * step, height - 2 - ((value - min) / span) * (height - 4)])
1178
+ const line = coords.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
1179
+ return h('svg', { className: 'mosaic-spark', viewBox: `0 0 ${width} ${height}`, 'aria-hidden': true }, h('polyline', {
1180
+ points: line,
1181
+ fill: 'none',
1182
+ stroke: 'var(--m-series-1)',
1183
+ strokeWidth: 1.5,
1184
+ strokeLinejoin: 'round',
1185
+ strokeLinecap: 'round',
1186
+ }))
1187
+ }
1188
+
1189
+ function deltaClass(value) {
1190
+ if (typeof value !== 'string') return ''
1191
+ if (value.trim().startsWith('+')) return 'mosaic-delta-pos'
1192
+ if (value.trim().startsWith('-')) return 'mosaic-delta-neg'
1193
+ return 'mosaic-delta-flat'
1194
+ }
1195
+
1196
+ function TextNode({ node }) {
1197
+ const size = ['display', 'h1', 'h2', 'h3', 'body', 'muted', 'caption'].includes(node.size) ? node.size : 'body'
1198
+ const align = ['left', 'center', 'right'].includes(node.align) ? node.align : undefined
1199
+ const tone = ['danger', 'success', 'warning', 'info', 'muted'].includes(node.tone) ? node.tone : undefined
1200
+ return h('div', {
1201
+ className: [
1202
+ 'mosaic-text',
1203
+ size === 'body' ? '' : `mosaic-${size}`,
1204
+ align ? `mosaic-${align}` : '',
1205
+ tone ? `mosaic-tone-${tone}` : '',
1206
+ node.mono ? 'mosaic-mono' : '',
1207
+ ].filter(Boolean).join(' '),
1208
+ }, renderRich(node.content))
1209
+ }
1210
+
1211
+ function StatNode({ node }) {
1212
+ return h('div', { className: 'mosaic-stat' },
1213
+ h('div', { className: 'mosaic-stat-label' }, renderRich(node.label)),
1214
+ h('div', { className: 'mosaic-stat-value' }, renderRich(node.value)),
1215
+ (node.delta || (node.spark && node.spark.length >= 2))
1216
+ ? h('div', { className: 'mosaic-stat-foot' },
1217
+ node.delta ? h('span', { className: `mosaic-delta ${deltaClass(node.delta)}` }, node.delta) : null,
1218
+ node.spark && node.spark.length >= 2 ? h(Sparkline, { values: node.spark }) : null)
1219
+ : null)
1220
+ }
1221
+
1222
+ function BadgeNode({ node }) {
1223
+ return h('span', { className: `mosaic-badge mosaic-badge-${node.tone ?? 'accent'}` }, renderRich(node.label))
1224
+ }
1225
+
1226
+ function ProgressNode({ node }) {
1227
+ const value = clamp(Number(node.value) || 0, 0, 100)
1228
+ if (node.variant === 'ring') {
1229
+ const radius = 24
1230
+ const circumference = 2 * Math.PI * radius
1231
+ const dash = (value / 100) * circumference
1232
+ return h('div', { className: 'mosaic-ring' },
1233
+ h('svg', { viewBox: '0 0 64 64', role: 'img', 'aria-label': `${value}%` },
1234
+ h('circle', { cx: 32, cy: 32, r: radius, fill: 'none', stroke: 'var(--m-bg2)', strokeWidth: 6 }),
1235
+ h('circle', {
1236
+ cx: 32, cy: 32, r: radius, fill: 'none', stroke: 'var(--m-series-1)', strokeWidth: 6,
1237
+ strokeLinecap: 'round', strokeDasharray: `${dash} ${circumference - dash}`,
1238
+ transform: 'rotate(-90 32 32)',
1239
+ }),
1240
+ h('text', { x: 32, y: 36, textAnchor: 'middle', style: { fontSize: 12, fontWeight: 650, fill: 'var(--m-text)' } }, `${Math.round(value)}%`)),
1241
+ h('div', { className: 'mosaic-progress' },
1242
+ node.label ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.label)) : null,
1243
+ node.valueLabel ? h('b', null, node.valueLabel) : null))
1244
+ }
1245
+ return h('div', { className: 'mosaic-progress' },
1246
+ (node.label || node.valueLabel)
1247
+ ? h('div', { className: 'mosaic-progress-head' },
1248
+ h('span', null, node.label ? renderRich(node.label) : ''),
1249
+ h('b', null, node.valueLabel ?? `${Math.round(value)}%`))
1250
+ : null,
1251
+ h('div', { className: 'mosaic-track', role: 'progressbar', 'aria-valuenow': value, 'aria-valuemin': 0, 'aria-valuemax': 100 },
1252
+ h('span', { className: 'mosaic-fill', style: { width: `${value}%` } }),
1253
+ node.target !== undefined ? h('span', { className: 'mosaic-target', style: { left: `${clamp(Number(node.target) || 0, 0, 100)}%` } }) : null))
1254
+ }
1255
+
1256
+ function KeyValueNode({ node }) {
1257
+ return h('div', { className: 'mosaic-kv' }, node.pairs.flatMap((pair, index) => [
1258
+ h('div', { key: `k${index}`, className: 'mosaic-kv-key' }, renderRich(pair.key)),
1259
+ h('div', { key: `v${index}`, className: 'mosaic-kv-value' }, renderRich(pair.value)),
1260
+ ]))
1261
+ }
1262
+
1263
+ function StepsNode({ node }) {
1264
+ return h('ol', { className: 'mosaic-steps' }, node.steps.map((step, index) => {
1265
+ const active = node.current !== undefined && index <= node.current
1266
+ return h('li', { key: index, className: `mosaic-step${active ? ' mosaic-step-active' : ''}` },
1267
+ h('span', { className: 'mosaic-step-index' }, String(index + 1)),
1268
+ h('div', null,
1269
+ h('div', { className: 'mosaic-step-title' }, renderRich(step.title)),
1270
+ step.desc ? h('div', { className: 'mosaic-step-desc' }, renderRich(step.desc)) : null))
1271
+ }))
1272
+ }
1273
+
1274
+ function CalloutNode({ node }) {
1275
+ return h('div', { className: `mosaic-callout${node.tone ? ` mosaic-callout-${node.tone}` : ''}` },
1276
+ node.title ? h('div', { className: 'mosaic-callout-title' }, renderRich(node.title)) : null,
1277
+ h('div', null, renderRich(node.content)))
1278
+ }
1279
+
1280
+ function CodeNode({ node }) {
1281
+ return h('div', { className: 'mosaic-code' },
1282
+ h('button', { type: 'button', className: 'mosaic-copy', onClick: () => copyText(node.code) }, translate('copy')),
1283
+ h('pre', null, h('code', null, node.code)))
1284
+ }
1285
+
1286
+ function DiffSide({ text, kind }) {
1287
+ const lines = String(text ?? '').split('\n')
1288
+ return h('div', { className: `mosaic-diff-side mosaic-diff-${kind}` },
1289
+ lines.map((line, index) => h('div', { className: 'mosaic-diff-line', key: index },
1290
+ h('span', { className: 'mosaic-diff-gutter' }, kind === 'old' ? '-' : '+'),
1291
+ h('span', { className: 'mosaic-diff-num' }, String(index + 1)),
1292
+ h('span', { className: 'mosaic-diff-text' }, line === '' ? ' ' : line))))
1293
+ }
1294
+
1295
+ function DiffNode({ node }) {
1296
+ return h('div', { className: 'mosaic-diff' }, node.diffs.map((diff, index) => h('div', { key: index },
1297
+ h('div', { className: 'mosaic-diff-file' }, diff.path),
1298
+ h('div', { className: 'mosaic-diff-pair' },
1299
+ h(DiffSide, { text: diff.oldText ?? translate('added'), kind: 'old' }),
1300
+ h(DiffSide, { text: diff.newText ?? '', kind: 'new' })))))
1301
+ }
1302
+
1303
+ function DividerNode({ node }) {
1304
+ return h('div', { className: 'mosaic-divider' }, node.label ? h('span', null, renderRich(node.label)) : null)
1305
+ }
1306
+
1307
+ function SpacerNode() {
1308
+ return h('div', { className: 'mosaic-spacer' })
1309
+ }function numberValue(value) {
1310
+ if (typeof value === 'number' && Number.isFinite(value)) return value
1311
+ const text = String(value ?? '').trim().replace(/[,$£€¥%]/g, '')
1312
+ if (text === '') return null
1313
+ const match = /^([+-]?(?:\d+\.?\d*|\.\d+))\s*(k|m|b|万|亿)?$/i.exec(text)
1314
+ if (!match) return null
1315
+ const base = Number(match[1])
1316
+ if (!Number.isFinite(base)) return null
1317
+ const unit = (match[2] ?? '').toLowerCase()
1318
+ const factor = unit === 'k' ? 1e3 : unit === 'm' ? 1e6 : unit === 'b' ? 1e9 : unit === '万' ? 1e4 : unit === '亿' ? 1e8 : 1
1319
+ return base * factor
1320
+ }
1321
+
1322
+ function tableMarkdown(columns, rows) {
1323
+ const escape = (cell) => String(cell ?? '').replace(/\|/g, '\\|')
1324
+ return [
1325
+ `| ${columns.map(escape).join(' | ')} |`,
1326
+ `| ${columns.map(() => '---').join(' | ')} |`,
1327
+ ...rows.map((row) => `| ${columns.map((_, index) => escape(row[index])).join(' | ')} |`),
1328
+ ].join('\n')
1329
+ }
1330
+
1331
+ function tableCsv(rows) {
1332
+ return rows.map((row) => row.map((cell) => {
1333
+ const text = String(cell ?? '')
1334
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
1335
+ }).join(',')).join('\n')
1336
+ }
1337
+
1338
+ function TableNode({ node }) {
1339
+ const view = useView()
1340
+ const [sort, setSort] = useState(null)
1341
+ const columns = Array.isArray(node.columns) ? node.columns : []
1342
+ const rows = Array.isArray(node.rows) ? node.rows : []
1343
+ const types = Array.isArray(node.types) ? node.types : []
1344
+ const filterValue = node.filter && view ? (view.state.fields[node.filter] ?? '') : ''
1345
+ const boundSort = node.sort && view ? view.state.fields[node.sort] : undefined
1346
+ const filtered = filterValue.trim() === ''
1347
+ ? rows.map((row, index) => ({ row, index }))
1348
+ : rows.map((row, index) => ({ row, index })).filter(({ row }) => row.some((cell) => String(cell ?? '').toLowerCase().includes(filterValue.trim().toLowerCase())))
1349
+ const boundColumn = boundSort ? columns.indexOf(boundSort) : -1
1350
+ const effectiveSort = sort
1351
+ ?? (boundColumn >= 0 ? { column: boundColumn, direction: 1 } : null)
1352
+ const sorted = effectiveSort
1353
+ ? [...filtered].sort((left, right) => {
1354
+ const a = left.row[effectiveSort.column]
1355
+ const b = right.row[effectiveSort.column]
1356
+ const na = numberValue(a)
1357
+ const nb = numberValue(b)
1358
+ const order = na !== null && nb !== null ? na - nb : String(a ?? '').localeCompare(String(b ?? ''), 'zh-CN')
1359
+ return order * effectiveSort.direction
1360
+ })
1361
+ : filtered
1362
+ const totals = columns.map((_, index) => {
1363
+ const values = rows.map((row) => numberValue(row[index])).filter((value) => value !== null)
1364
+ return values.length === rows.length && values.length > 0 ? values.reduce((sum, value) => sum + value, 0) : null
1365
+ })
1366
+
1367
+ const toggleSort = (column) => {
1368
+ setSort((current) => {
1369
+ if (!current || current.column !== column) return { column, direction: 1 }
1370
+ if (current.direction === 1) return { column, direction: -1 }
1371
+ return null
1372
+ })
1373
+ }
1374
+
1375
+ const cell = (value, column) => {
1376
+ const type = types[column] ?? 'text'
1377
+ if (type === 'bar') {
1378
+ const numeric = clamp(numberValue(value) ?? 0, 0, 100)
1379
+ return h('span', { className: 'mosaic-cell-bar', title: `${numeric}%` }, h('span', { style: { width: `${numeric}%` } }))
1380
+ }
1381
+ if (type === 'badge') return h('span', { className: 'mosaic-badge mosaic-badge-accent' }, renderRich(String(value ?? '')))
1382
+ if (type === 'num') return h('span', null, renderRich(String(value ?? '')))
1383
+ if (type === 'delta') return h('span', { className: deltaClass(String(value ?? '')) }, renderRich(String(value ?? '')))
1384
+ return renderRich(String(value ?? ''))
1385
+ }
1386
+
1387
+ const header = h('thead', null, h('tr', null, columns.map((column, index) => h('th', {
1388
+ key: index,
1389
+ 'aria-sort': effectiveSort?.column === index ? (effectiveSort.direction === 1 ? 'ascending' : 'descending') : 'none',
1390
+ }, h('button', { type: 'button', onClick: () => toggleSort(index) },
1391
+ renderRich(column),
1392
+ effectiveSort?.column === index ? h('span', { 'aria-hidden': true }, effectiveSort.direction === 1 ? '▲' : '▼') : null)))))
1393
+
1394
+ const bodyRows = sorted.map(({ row, index }) => h('tr', { key: index }, columns.map((column, columnIndex) => h('td', {
1395
+ key: columnIndex,
1396
+ className: types[columnIndex] === 'num' ? 'num' : types[columnIndex] === 'delta' ? deltaClass(String(row[columnIndex] ?? '')) : '',
1397
+ }, cell(row[columnIndex], columnIndex)))))
1398
+
1399
+ const totalText = (value) => {
1400
+ if (typeof value !== 'number') return String(value)
1401
+ if (Number.isInteger(value)) return String(value)
1402
+ return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
1403
+ }
1404
+ const totalRow = node.total && totals.some((value) => value !== null)
1405
+ ? h('tr', { className: 'total' }, columns.map((column, index) => h('td', {
1406
+ key: index,
1407
+ className: types[index] === 'num' || totals[index] !== null ? 'num' : '',
1408
+ }, totals[index] === null ? (index === 0 ? translate('total') : '') : totalText(totals[index]))))
1409
+ : null
1410
+
1411
+ return h('div', null,
1412
+ node.export ? h('div', { className: 'mosaic-table-tools' },
1413
+ h('button', { type: 'button', onClick: () => copyText(tableMarkdown(columns, rows)) }, translate('copyMarkdown')),
1414
+ h('button', { type: 'button', onClick: () => copyText(tableCsv(rows)) }, translate('copyCsv'))) : null,
1415
+ h('div', { className: 'mosaic-table-wrap' },
1416
+ h('table', { className: columns.length >= 4 ? 'mosaic-table mosaic-table-dense' : 'mosaic-table' },
1417
+ header,
1418
+ h('tbody', null, bodyRows, totalRow))))
1419
+ }
1420
+
1421
+ function ListNode({ node }) {
1422
+ const view = useView()
1423
+ const filterId = node.filter
1424
+ const filterValue = filterId && view ? (view.state.fields[filterId] ?? '') : ''
1425
+ const needle = filterValue.trim().toLowerCase()
1426
+ const items = needle === '' ? node.items : node.items.filter((item) => {
1427
+ if (typeof item === 'string') return item.toLowerCase().includes(needle)
1428
+ if (item && typeof item.type === 'string') return false
1429
+ return `${item?.title ?? ''} ${item?.desc ?? ''}`.toLowerCase().includes(needle)
1430
+ })
1431
+ return h('ul', { className: 'mosaic-list' }, items.map((item, index) => {
1432
+ if (typeof item === 'string') {
1433
+ return h('li', { key: index, className: 'mosaic-list-item' }, h('span', { className: 'mosaic-list-dot' }), h('div', null, renderRich(item)))
1434
+ }
1435
+ if (item && typeof item.type === 'string') {
1436
+ return h('li', { key: index, className: 'mosaic-list-item' }, h(NodeView, { node: item, path: `list[${index}]`, depth: 1 }))
1437
+ }
1438
+ return h('li', { key: index, className: 'mosaic-list-item' },
1439
+ h('span', { className: 'mosaic-list-dot' }),
1440
+ h('div', null,
1441
+ h('div', { className: 'mosaic-list-title' }, renderRich(item?.title ?? '')),
1442
+ item?.desc ? h('div', { className: 'mosaic-list-desc' }, renderRich(item.desc)) : null))
1443
+ }))
1444
+ }
1445
+
1446
+ /**
1447
+ * Run `callback` when the host appearance changes.
1448
+ *
1449
+ * A fence lives in its own React root, so host theme state never reaches it; this
1450
+ * observes the one documented signal and is the only place the client reads it.
1451
+ * Components that copy resolved tokens (the sandboxed widget, the Mermaid diagram)
1452
+ * use this to refresh themselves.
1453
+ *
1454
+ * @param {() => void} callback - invoked at most once per 80 ms burst.
1455
+ * @returns {() => void} unsubscribe.
1456
+ */
1457
+ function onAppearanceChange(callback) {
1458
+ if (typeof MutationObserver !== 'function' || typeof document === 'undefined') return () => {}
1459
+ let pending = null
1460
+ const observer = new MutationObserver(() => {
1461
+ if (pending !== null) clearTimeout(pending)
1462
+ pending = setTimeout(callback, 80)
1463
+ })
1464
+ for (const target of [document.documentElement, document.body]) {
1465
+ if (target) observer.observe(target, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'class', 'style'] })
1466
+ }
1467
+ return () => {
1468
+ if (pending !== null) clearTimeout(pending)
1469
+ observer.disconnect()
1470
+ }
1471
+ }
1472
+
1473
+ function chartPalette(index) {
1474
+ return [
1475
+ 'var(--m-series-1)',
1476
+ 'var(--m-series-2)',
1477
+ 'var(--m-series-3)',
1478
+ 'var(--m-series-4)',
1479
+ 'var(--m-series-5)',
1480
+ ][index % 5]
1481
+ }
1482
+
1483
+ function ChartNode({ node }) {
1484
+ const kind = node.kind
1485
+ const data = Array.isArray(node.data) ? node.data : []
1486
+ const series = Array.isArray(node.series) ? node.series : []
1487
+ const labels = series.length > 0
1488
+ ? (series.find((entry) => entry.data.length > 0)?.data ?? []).map((point) => point.label)
1489
+ : data.map((point) => point.label)
1490
+ // Measure the card instead of scaling one fixed viewBox: a `width:100%` SVG
1491
+ // with a fixed viewBox grows every glyph with the card, which made a chart on a
1492
+ // wide window several times too tall with 15-20px axis text.
1493
+ const wrapRef = useRef(null)
1494
+ const [boxWidth, setBoxWidth] = useState(680)
1495
+ useEffect(() => {
1496
+ const element = wrapRef.current
1497
+ if (!element || typeof ResizeObserver !== 'function') return undefined
1498
+ const observer = new ResizeObserver((entries) => {
1499
+ const next = Math.max(320, Math.round(entries[0].contentRect.width))
1500
+ setBoxWidth((current) => (Math.abs(current - next) < 2 ? current : next))
1501
+ })
1502
+ observer.observe(element)
1503
+ return () => observer.disconnect()
1504
+ }, [])
1505
+ const width = boxWidth
1506
+ const height = 250
1507
+ const pad = { top: 18, right: 16, bottom: 34, left: 42 }
1508
+ const plotW = width - pad.left - pad.right
1509
+ const plotH = height - pad.top - pad.bottom
1510
+ const aria = node.title ?? `${kind} chart`
1511
+
1512
+ if (kind === 'donut') {
1513
+ const total = data.reduce((sum, point) => sum + Math.max(0, point.value), 0)
1514
+ const radius = 64
1515
+ const circumference = 2 * Math.PI * radius
1516
+ let offset = 0
1517
+ return h('div', { ref: wrapRef, style: { width: '100%' } },
1518
+ node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null,
1519
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 18 } },
1520
+ h('svg', { viewBox: '0 0 200 200', width: 200, height: 200, role: 'img', 'aria-label': aria },
1521
+ h('circle', { cx: 100, cy: 100, r: radius, fill: 'none', stroke: 'var(--m-bg2)', strokeWidth: 24 }),
1522
+ data.map((point, index) => {
1523
+ const fraction = total <= 0 ? 0 : Math.max(0, point.value) / total
1524
+ const dash = fraction * circumference
1525
+ const segment = h('circle', {
1526
+ key: index,
1527
+ cx: 100, cy: 100, r: radius, fill: 'none',
1528
+ stroke: chartPalette(index), strokeWidth: 24,
1529
+ strokeDasharray: `${dash} ${circumference - dash}`,
1530
+ strokeDashoffset: -offset,
1531
+ transform: 'rotate(-90 100 100)',
1532
+ })
1533
+ offset += dash
1534
+ return segment
1535
+ }),
1536
+ h('text', { x: 100, y: 96, textAnchor: 'middle', style: { fontSize: 18, fontWeight: 650, fill: 'var(--m-text)' } }, String(Math.round(total))),
1537
+ h('text', { x: 100, y: 116, textAnchor: 'middle', style: { fontSize: 10, fill: 'var(--m-muted)' } }, translate('grandTotal'))),
1538
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 5, minWidth: 160 } },
1539
+ data.map((point, index) => h('div', { key: index, style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12 } },
1540
+ h('span', { style: { width: 8, height: 8, borderRadius: 2, background: chartPalette(index), flex: 'none' } }),
1541
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, point.label),
1542
+ h('b', { style: { marginLeft: 'auto', fontVariantNumeric: 'tabular-nums' } }, String(point.value)))))))
1543
+ }
1544
+
1545
+ const multipSeries = series.length > 0
1546
+ const valuesForLabel = (label) => {
1547
+ if (multipSeries) return series.map((entry) => entry.data.find((point) => point.label === label)?.value ?? 0)
1548
+ return [data.find((point) => point.label === label)?.value ?? 0]
1549
+ }
1550
+ const max = Math.max(1, ...labels.flatMap((label) => {
1551
+ const values = valuesForLabel(label)
1552
+ return node.stacked ? [values.reduce((sum, value) => sum + value, 0)] : values
1553
+ }))
1554
+ const y = (value) => pad.top + plotH - (value / max) * plotH
1555
+ const tickText = (value) => {
1556
+ if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}k`
1557
+ return Number.isInteger(value) ? String(value) : value.toFixed(1)
1558
+ }
1559
+ const grid = [0, 1, 2, 3, 4].map((index) => pad.top + (plotH / 4) * index)
1560
+ const gridNode = (gy, index) => h('g', { key: `g${index}` },
1561
+ h('line', { x1: pad.left, x2: width - pad.right, y1: gy, y2: gy, className: 'mosaic-grid-line' }),
1562
+ h('text', { x: pad.left - 8, y: gy + 3, textAnchor: 'end', className: 'mosaic-axis-label' }, tickText(max * ((4 - index) / 4))))
1563
+ const labelStep = Math.max(1, Math.ceil(labels.length / 8))
1564
+
1565
+ if (kind === 'line') {
1566
+ const lines = multipSeries ? series : [{ label: node.title ?? 'value', data }]
1567
+ return h('div', { ref: wrapRef, style: { width: '100%' } },
1568
+ node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null,
1569
+ h('svg', { className: 'mosaic-chart', viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': aria },
1570
+ grid.map((gy, index) => gridNode(gy, index)),
1571
+ h('line', { x1: pad.left, x2: width - pad.right, y1: y(0), y2: y(0), className: 'mosaic-axis' }),
1572
+ lines.map((line, lineIndex) => {
1573
+ const points = labels.map((label, index) => {
1574
+ const value = line.data.find((point) => point.label === label)?.value ?? 0
1575
+ const x = pad.left + (labels.length <= 1 ? plotW / 2 : (index / (labels.length - 1)) * plotW)
1576
+ return [x, y(value)]
1577
+ })
1578
+ return h('g', { key: lineIndex },
1579
+ h('polyline', { points: points.map(([x, py]) => `${x.toFixed(1)},${py.toFixed(1)}`).join(' '), className: 'mosaic-line', stroke: chartPalette(lineIndex) }),
1580
+ points.map(([x, py], index) => h('circle', { key: index, cx: x, cy: py, r: 3, fill: chartPalette(lineIndex), className: 'mosaic-dot' })))
1581
+ }),
1582
+ labels.map((label, index) => index % labelStep === 0
1583
+ ? h('text', { key: `l${index}`, x: pad.left + (labels.length <= 1 ? plotW / 2 : (index / (labels.length - 1)) * plotW), y: height - 12, textAnchor: 'middle' }, label.slice(0, 10))
1584
+ : null)))
1585
+ }
1586
+
1587
+ const groupWidth = labels.length === 0 ? 0 : plotW / labels.length
1588
+ const seriesCount = multipSeries ? Math.max(1, series.length) : 1
1589
+ const barWidth = Math.max(3, (groupWidth * 0.68) / seriesCount)
1590
+ return h('div', { ref: wrapRef, style: { width: '100%' } },
1591
+ node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null,
1592
+ h('svg', { className: 'mosaic-chart', viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': aria },
1593
+ grid.map((gy, index) => gridNode(gy, index)),
1594
+ h('line', { x1: pad.left, x2: width - pad.right, y1: y(0), y2: y(0), className: 'mosaic-axis' }),
1595
+ labels.map((label, labelIndex) => {
1596
+ const values = valuesForLabel(label)
1597
+ const groupX = pad.left + labelIndex * groupWidth + groupWidth * 0.16
1598
+ let accumulated = 0
1599
+ return h('g', { key: labelIndex },
1600
+ values.map((value, seriesIndex) => {
1601
+ const barHeight = Math.max(1, (Math.max(0, value) / max) * plotH)
1602
+ const x = node.stacked
1603
+ ? groupX
1604
+ : groupX + seriesIndex * barWidth
1605
+ const py = node.stacked
1606
+ ? y(accumulated + Math.max(0, value))
1607
+ : y(Math.max(0, value))
1608
+ if (node.stacked) accumulated += Math.max(0, value)
1609
+ return h('rect', {
1610
+ key: seriesIndex,
1611
+ x, y: py, width: node.stacked ? groupWidth * 0.68 : barWidth,
1612
+ height: barHeight, rx: 2,
1613
+ fill: chartPalette(seriesIndex),
1614
+ className: 'mosaic-bar',
1615
+ })
1616
+ }),
1617
+ labelIndex % labelStep === 0
1618
+ ? h('text', { x: pad.left + labelIndex * groupWidth + groupWidth / 2, y: height - 12, textAnchor: 'middle' }, label.slice(0, 10))
1619
+ : null)
1620
+ })))
1621
+ }
1622
+
1623
+ function ImageNode({ node }) {
1624
+ const view = useView()
1625
+ return h('figure', { className: 'mosaic-media', style: { margin: 0 } },
1626
+ h('img', {
1627
+ className: 'mosaic-image',
1628
+ src: node.src,
1629
+ alt: node.alt ?? node.caption ?? '',
1630
+ loading: 'lazy',
1631
+ onClick: () => view?.openOverlay(h('div', { className: 'mosaic-media' },
1632
+ h('img', { className: 'mosaic-overlay-image', src: node.src, alt: node.alt ?? '' }),
1633
+ node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)),
1634
+ }),
1635
+ node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)
1636
+ }
1637
+
1638
+ function VideoNode({ node }) {
1639
+ return h('figure', { className: 'mosaic-media', style: { margin: 0 } },
1640
+ h('video', {
1641
+ className: 'mosaic-video',
1642
+ src: node.src,
1643
+ poster: node.poster,
1644
+ controls: true,
1645
+ playsInline: true,
1646
+ preload: 'metadata',
1647
+ style: node.aspect ? { aspectRatio: node.aspect.replace(':', ' / ') } : undefined,
1648
+ }),
1649
+ node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)
1650
+ }
1651
+
1652
+ function AudioNode({ node }) {
1653
+ return h('figure', { className: 'mosaic-media', style: { margin: 0 } },
1654
+ h('audio', { className: 'mosaic-audio', src: node.src, controls: true, preload: 'metadata' }),
1655
+ node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)
1656
+ }
1657
+
1658
+ function PdfNode({ node }) {
1659
+ return h('div', { className: 'mosaic-media' },
1660
+ h('iframe', { className: 'mosaic-pdf', src: node.src, title: node.title ?? 'PDF' }),
1661
+ h('a', { href: node.src, target: '_blank', rel: 'noreferrer noopener', className: 'mosaic-caption-line' }, translate('openPdf')))
1662
+ }
1663
+
1664
+ function LinkNode({ node }) {
1665
+ return h('a', {
1666
+ className: 'mosaic-link',
1667
+ href: node.href,
1668
+ target: '_blank',
1669
+ rel: 'noreferrer noopener',
1670
+ },
1671
+ h('span', { className: 'mosaic-link-icon', 'aria-hidden': true }, '↗'),
1672
+ h('span', { style: { minWidth: 0 } },
1673
+ h('span', { className: 'mosaic-link-label' }, renderRich(node.label)),
1674
+ node.description ? h('span', { className: 'mosaic-link-desc' }, renderRich(node.description)) : null))
1675
+ }
1676
+
1677
+ function ButtonNode({ node }) {
1678
+ const view = useView()
1679
+ const action = typeof node.action === 'string' && node.action.trim() !== '' ? node.action : undefined
1680
+ const local = ['reset', 'copy', 'open', 'fullscreen'].includes(node.local) ? node.local : undefined
1681
+ const blocked = action !== undefined && view !== null && view?.canAct === false
1682
+ const disabled = blocked || (action === undefined && local === undefined)
1683
+ const activate = () => {
1684
+ if (action !== undefined) {
1685
+ view?.action(action, { type: 'button', label: node.label })
1686
+ return
1687
+ }
1688
+ if (local === 'reset') view?.reset()
1689
+ else if (local === 'copy') copyText(node.text ?? node.label)
1690
+ else if (local === 'open') {
1691
+ const href = safeUrl(node.url)
1692
+ if (href) window.open(href, '_blank', 'noopener,noreferrer')
1693
+ } else if (local === 'fullscreen') {
1694
+ view?.openOverlay(h('pre', { className: 'mosaic-widget-code' }, node.text ?? node.label))
1695
+ }
1696
+ }
1697
+ return h('button', {
1698
+ type: 'button',
1699
+ className: [
1700
+ 'mosaic-button',
1701
+ node.tone ? `mosaic-button-${node.tone}` : '',
1702
+ node.full ? 'mosaic-button-full' : '',
1703
+ ].filter(Boolean).join(' '),
1704
+ disabled,
1705
+ 'aria-disabled': disabled ? 'true' : undefined,
1706
+ onClick: disabled ? undefined : activate,
1707
+ }, renderRich(node.label))
1708
+ }
1709
+
1710
+ function InputNode({ node, path }) {
1711
+ const view = useView()
1712
+ const key = node.id ?? path
1713
+ const [local, setLocal] = useState(node.value ?? '')
1714
+ const stored = node.id && view?.state.fields[node.id] !== undefined ? view.state.fields[node.id] : undefined
1715
+ const value = stored !== undefined ? stored : local
1716
+ const lastSent = useRef(value)
1717
+ const update = (next) => {
1718
+ setLocal(next)
1719
+ if (node.id) view?.setField(node.id, next)
1720
+ }
1721
+ const send = () => {
1722
+ if (!node.action || !view) return
1723
+ if (lastSent.current === value) return
1724
+ lastSent.current = value
1725
+ view.action(node.action, { type: 'input', id: node.id ?? key, value })
1726
+ }
1727
+ return h('label', { className: 'mosaic-form' },
1728
+ node.label ? h('span', { className: 'mosaic-field-label' }, renderRich(node.label)) : null,
1729
+ h('input', {
1730
+ className: 'mosaic-input',
1731
+ value,
1732
+ placeholder: node.placeholder ?? '',
1733
+ onChange: (event) => update(event.target.value),
1734
+ onKeyDown: (event) => { if (event.key === 'Enter') send() },
1735
+ onBlur: send,
1736
+ }))
1737
+ }
1738
+
1739
+ function SelectNode({ node }) {
1740
+ const view = useView()
1741
+ const key = node.id ?? 'select'
1742
+ const selected = node.id && view?.state.fields[node.id] !== undefined
1743
+ ? view.state.fields[node.id]
1744
+ : (typeof node.selected === 'string' && node.options.includes(node.selected)
1745
+ ? node.selected
1746
+ : (node.options[node.selected ?? -1] ?? ''))
1747
+ const update = (value) => {
1748
+ if (node.id) view?.setField(node.id, value)
1749
+ if (node.action && view) view.action(node.action, { type: 'select', id: node.id ?? key, value })
1750
+ }
1751
+ return h('label', { className: 'mosaic-form' },
1752
+ node.label ? h('span', { className: 'mosaic-field-label' }, renderRich(node.label)) : null,
1753
+ h('select', { className: 'mosaic-select', value: selected, onChange: (event) => update(event.target.value) },
1754
+ h('option', { value: '' }, translate('selectPlaceholder')),
1755
+ node.options.map((option, index) => h('option', { key: index, value: option }, option))))
1756
+ }
1757
+
1758
+ function SliderNode({ node }) {
1759
+ const view = useView()
1760
+ const key = node.id ?? 'slider'
1761
+ const initial = node.id && view?.state.fields[node.id] !== undefined
1762
+ ? Number(view.state.fields[node.id])
1763
+ : (node.value ?? node.min ?? 0)
1764
+ const [value, setValue] = useState(Number.isFinite(initial) ? initial : 0)
1765
+ const debounce = useDebouncedAction()
1766
+ const update = (next) => {
1767
+ setValue(next)
1768
+ if (node.id) view?.setField(node.id, String(next))
1769
+ if (node.action && view) debounce(() => view.action(node.action, { type: 'slider', id: node.id ?? key, value: next }))
1770
+ }
1771
+ return h('label', { className: 'mosaic-form' },
1772
+ node.label ? h('span', { className: 'mosaic-field-label' }, renderRich(node.label)) : null,
1773
+ h('div', { className: 'mosaic-slider-row' },
1774
+ h('input', {
1775
+ className: 'mosaic-slider',
1776
+ type: 'range',
1777
+ min: node.min ?? 0,
1778
+ max: node.max ?? 100,
1779
+ step: node.step ?? 1,
1780
+ value,
1781
+ onChange: (event) => update(Number(event.target.value)),
1782
+ }),
1783
+ h('span', { className: 'mosaic-slider-value' }, String(value))))
1784
+ }
1785
+
1786
+ function TabsNode({ node, path }) {
1787
+ const view = useView()
1788
+ const active = clamp(view?.state.tabs[path] ?? 0, 0, node.tabs.length - 1)
1789
+ return h('div', { className: 'mosaic-tabs' },
1790
+ h('div', { className: 'mosaic-tabbar', role: 'tablist' }, node.tabs.map((tab, index) => h('button', {
1791
+ key: index,
1792
+ type: 'button',
1793
+ role: 'tab',
1794
+ 'aria-selected': active === index,
1795
+ className: `mosaic-tab${active === index ? ' mosaic-tab-active' : ''}`,
1796
+ onClick: () => view?.setTab(path, index),
1797
+ }, renderRich(tab.label)))),
1798
+ h('div', { className: 'mosaic-tab-body', role: 'tabpanel' }, (node.tabs[active]?.items ?? []).map((item, index) => h(NodeView, {
1799
+ key: index,
1800
+ node: item,
1801
+ path: `${path}.tabs[${active}].items[${index}]`,
1802
+ depth: 1,
1803
+ }))))
1804
+ }
1805
+
1806
+ function QuizNode({ node }) {
1807
+ const [selected, setSelected] = useState(null)
1808
+ const [submitted, setSubmitted] = useState(false)
1809
+ const correct = node.options.findIndex((option) => option.correct === true)
1810
+ const choose = (index) => {
1811
+ if (submitted) return
1812
+ setSelected(index)
1813
+ setSubmitted(true)
1814
+ }
1815
+ const reset = () => {
1816
+ setSelected(null)
1817
+ setSubmitted(false)
1818
+ }
1819
+ const selection = selected === null ? null : node.options[selected]
1820
+ return h('div', { className: 'mosaic-quiz' },
1821
+ h('div', { className: 'mosaic-quiz-question' }, renderRich(node.question)),
1822
+ h('div', { className: 'mosaic-quiz-options' }, node.options.map((option, index) => {
1823
+ const isSelected = selected === index
1824
+ const isCorrect = submitted && correct === index
1825
+ const isWrong = submitted && isSelected && correct >= 0 && correct !== index
1826
+ return h('button', {
1827
+ key: index,
1828
+ type: 'button',
1829
+ disabled: submitted,
1830
+ className: `mosaic-quiz-option${isCorrect ? ' mosaic-quiz-correct' : ''}${isWrong ? ' mosaic-quiz-wrong' : ''}`,
1831
+ onClick: () => choose(index),
1832
+ },
1833
+ h('span', { className: 'mosaic-quiz-mark' }, isCorrect ? '✓' : isWrong ? '✗' : String.fromCharCode(65 + index)),
1834
+ h('span', null,
1835
+ renderRich(option.label),
1836
+ submitted && isSelected && option.feedback ? h('span', { className: 'mosaic-quiz-feedback' }, renderRich(option.feedback)) : null))
1837
+ })),
1838
+ submitted
1839
+ ? h('div', null,
1840
+ h('div', { className: `mosaic-quiz-result ${correct < 0 ? '' : selected === correct ? 'ok' : 'no'}` },
1841
+ h('span', null, correct < 0 ? translate('answered') : selected === correct ? translate('answerCorrect') : translate('answerWrong')),
1842
+ h('button', { type: 'button', className: 'mosaic-button mosaic-button-ghost', onClick: reset }, translate('tryAgain'))),
1843
+ node.explanation ? h('div', { className: 'mosaic-caption-line' }, renderRich(node.explanation)) : null)
1844
+ : null)
1845
+ }
1846
+
1847
+ /* ------------------------------------------------------------------ */
1848
+ /* sandboxed widget */
1849
+ /* ------------------------------------------------------------------ */
1850
+
1851
+ const WIDGET_CSP = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' blob:; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:;; font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"
1852
+
1853
+ function widgetBridge(nonce, policy) {
1854
+ return `(function(){
1855
+ var nonce=${JSON.stringify(String(nonce))};
1856
+ var policy=${JSON.stringify(policy === 'programmatic' ? 'programmatic' : 'user')};
1857
+ var last=-1;
1858
+ var send=function(message){try{parent.postMessage(Object.assign({nonce:nonce},message),'*')}catch(error){}};
1859
+ var height=function(){var root=document.documentElement;var body=document.body;return Math.max(root?root.scrollHeight:0,body?body.scrollHeight:0,root?root.offsetHeight:0,body?body.offsetHeight:0)};
1860
+ var report=function(){var next=Math.max(80,Math.min(1200,Math.ceil(height())));if(next===last)return;last=next;send({type:'mosaic:height',height:next})};
1861
+ var schedule=function(){requestAnimationFrame(report)};
1862
+ // A browser-verified gesture, not a widget-supplied flag: userActivation cannot be forged
1863
+ // by page script, so an auto-firing widget is refused unless it opted into programmatic.
1864
+ var activated=function(){var ua=navigator.userActivation;return ua===undefined||ua.isActive===true};
1865
+ window.mosaic={action:function(name,payload){if(policy!=='programmatic'&&!activated())return;send({type:'mosaic:action',action:String(name||'action'),payload:payload&&typeof payload==='object'?payload:{}})},resize:report};
1866
+ window.addEventListener('message',function(event){var data=event&&event.data;if(!data||typeof data!=='object'||data.type!=='mosaic:theme')return;if(data.nonce!==undefined&&data.nonce!==nonce)return;try{if(data.theme&&typeof data.theme==='object'){Object.keys(data.theme).forEach(function(key){document.documentElement.style.setProperty('--mosaic-'+key,String(data.theme[key]))})}report()}catch(error){}},false);
1867
+ window.addEventListener('load',function(){schedule();setTimeout(schedule,120);setTimeout(schedule,600)});
1868
+ if(typeof ResizeObserver==='function'){try{new ResizeObserver(schedule).observe(document.documentElement)}catch(error){}if(document.body){try{new ResizeObserver(schedule).observe(document.body)}catch(error){}}}
1869
+ if(document.body&&typeof MutationObserver==='function'){try{new MutationObserver(schedule).observe(document.body,{childList:true,subtree:true,attributes:true,characterData:true})}catch(error){}}
1870
+ if(document.fonts&&document.fonts.ready){document.fonts.ready.then(schedule).catch(function(){})}
1871
+ send({type:'mosaic:ready'});schedule();
1872
+ })();`
1873
+ }
1874
+
1875
+ function widgetDocument(code, nonce, theme, policy) {
1876
+ const safe = String(code).slice(0, 20000)
1877
+ .replace(/<meta\b[^>]*http-equiv\s*=\s*["']?Content-Security-Policy["']?[^>]*>/gi, '')
1878
+ .replace(/<base\b[^>]*>/gi, '')
1879
+ const style = `<style>:root{${Object.keys(theme).map((key) => `--mosaic-${key}:${theme[key]};`).join('')}color-scheme:light dark}html,body{margin:0;padding:0;background:var(--mosaic-bg,transparent);color:var(--mosaic-text,canvastext);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:14px;line-height:1.55}*,*::before,*::after{box-sizing:border-box}a{color:var(--mosaic-accent)}</style>`
1880
+ const head = `<!-- dsh-mosaic sandbox --><meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}">${style}<script>${widgetBridge(nonce, policy)}</script>`
1881
+ if (/<html[\s>]/i.test(safe)) {
1882
+ if (/<head[\s>]/i.test(safe)) {
1883
+ if (/<\/head>/i.test(safe)) return safe.replace(/<\/head>/i, `${head}</head>`)
1884
+ if (/<body[\s>]/i.test(safe)) return safe.replace(/<body[\s>]/i, `${head}<body`)
1885
+ return safe.replace(/<head[^>]*>/i, (match) => `${match}${head}`)
1886
+ }
1887
+ return safe.replace(/<html[^>]*>/i, (match) => `${match}<head>${head}</head>`)
1888
+ }
1889
+ return `<!doctype html><html lang="${(typeof document !== 'undefined' && document.documentElement.lang) || 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${head}</head><body>${safe}</body></html>`
1890
+ }
1891
+
1892
+ /** One widget may send this many actions per window; larger payloads are dropped. */
1893
+ const WIDGET_ACTIONS_PER_WINDOW = 10
1894
+ const WIDGET_ACTION_WINDOW_MS = 60000
1895
+ const WIDGET_ACTION_MAX_CHARS = 16384
1896
+
1897
+ function WidgetFrame({ node, onAction, onFullscreen, fullscreen = false }) {
1898
+ const [height, setHeight] = useState(node.height ?? 320)
1899
+ const [showCode, setShowCode] = useState(false)
1900
+ const [reload, setReload] = useState(0)
1901
+ const iframeRef = useRef(null)
1902
+ const nonce = useMemo(() => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, [reload])
1903
+ // The initial theme is embedded once; later changes ride postMessage so a
1904
+ // theme refresh cannot reload the iframe through a changed srcDoc.
1905
+ const initialTheme = useMemo(() => themeSnapshot(iframeRef.current?.parentElement), [])
1906
+ const policy = node.actions === 'programmatic' ? 'programmatic' : 'user'
1907
+ const html = useMemo(() => widgetDocument(node.code, nonce, initialTheme, policy), [node.code, nonce, initialTheme, policy])
1908
+ const actionTimes = useRef([])
1909
+ const sendTheme = useCallback(() => {
1910
+ const next = themeSnapshot(iframeRef.current?.parentElement)
1911
+ iframeRef.current?.contentWindow?.postMessage({ type: 'mosaic:theme', nonce, theme: next }, '*')
1912
+ }, [nonce])
1913
+ useEffect(() => {
1914
+ const onMessage = (event) => {
1915
+ if (event.source !== iframeRef.current?.contentWindow) return
1916
+ const data = event.data
1917
+ if (!data || typeof data !== 'object' || data.nonce !== nonce) return
1918
+ if (data.type === 'mosaic:height') {
1919
+ setHeight(clamp(Number(data.height) || 320, 120, 900))
1920
+ } else if (data.type === 'mosaic:action') {
1921
+ const now = Date.now()
1922
+ const recent = actionTimes.current.filter((at) => now - at < WIDGET_ACTION_WINDOW_MS)
1923
+ actionTimes.current = recent
1924
+ if (recent.length >= WIDGET_ACTIONS_PER_WINDOW) {
1925
+ reportLog('warn', 'widget.action-dropped', { reason: 'rate', action: String(data.action ?? '').slice(0, 64) })
1926
+ return
1927
+ }
1928
+ const payload = data.payload && typeof data.payload === 'object' ? data.payload : {}
1929
+ let serialized
1930
+ try {
1931
+ serialized = JSON.stringify(payload)
1932
+ } catch {
1933
+ return
1934
+ }
1935
+ if (serialized.length > WIDGET_ACTION_MAX_CHARS) {
1936
+ reportLog('warn', 'widget.action-dropped', { reason: 'size', chars: serialized.length })
1937
+ return
1938
+ }
1939
+ recent.push(now)
1940
+ onAction?.(String(data.action || 'action').slice(0, 64), payload)
1941
+ }
1942
+ }
1943
+ window.addEventListener('message', onMessage)
1944
+ return () => window.removeEventListener('message', onMessage)
1945
+ }, [nonce, onAction])
1946
+ useEffect(() => {
1947
+ const timer = setTimeout(sendTheme, 80)
1948
+ // A fence lives in its own React root, so a host appearance change does not
1949
+ // re-render it; the iframe copies resolved tokens and needs the update posted.
1950
+ // `body[data-ds-dark-theme]` is the documented signal the theme sets, and this
1951
+ // is the only host attribute the plugin reads.
1952
+ const stopWatching = onAppearanceChange(sendTheme)
1953
+ return () => {
1954
+ clearTimeout(timer)
1955
+ stopWatching()
1956
+ }
1957
+ }, [sendTheme])
1958
+ return h('div', { className: 'mosaic-widget' },
1959
+ h('div', { className: 'mosaic-widget-head' },
1960
+ h('span', { className: 'mosaic-widget-title' }, renderRich(node.title ?? 'Interactive widget')),
1961
+ h('span', { className: 'mosaic-widget-actions' },
1962
+ h('button', { type: 'button', className: 'mosaic-widget-action', onClick: () => setShowCode((value) => !value) }, showCode ? translate('preview') : translate('code')),
1963
+ h('button', { type: 'button', className: 'mosaic-widget-action', onClick: () => setReload((value) => value + 1) }, translate('reload')),
1964
+ !fullscreen && onFullscreen ? h('button', { type: 'button', className: 'mosaic-widget-action', onClick: onFullscreen }, translate('fullscreen')) : null)),
1965
+ showCode
1966
+ ? h('pre', { className: 'mosaic-widget-code' }, node.code)
1967
+ : h('iframe', {
1968
+ key: reload,
1969
+ ref: iframeRef,
1970
+ className: 'mosaic-widget-iframe',
1971
+ sandbox: 'allow-scripts allow-forms allow-popups',
1972
+ referrerPolicy: 'no-referrer',
1973
+ srcDoc: html,
1974
+ title: node.title ?? 'Interactive widget',
1975
+ style: { height: fullscreen ? 'calc(86vh - 90px)' : `${height}px` },
1976
+ onLoad: sendTheme,
1977
+ }),
1978
+ node.caption ? h('div', { className: 'mosaic-widget-caption' }, renderRich(node.caption)) : null)
1979
+ }
1980
+
1981
+ /* ------------------------------------------------------------------ */
1982
+ /* layout + dispatcher */
1983
+ /* ------------------------------------------------------------------ */
1984
+
1985
+ function GridItem({ node, path, depth }) {
1986
+ return h('div', {
1987
+ className: 'mosaic-grid-item',
1988
+ style: node.span ? { gridColumn: `span ${node.span}` } : undefined,
1989
+ }, h(NodeView, { node, path, depth }))
1990
+ }
1991
+
1992
+ // Where this package's assets live. The Host publishes the current URL on the
1993
+ // prompt endpoint, so a moved route never strands a browser that loaded an older
1994
+ // bundle; the literal path is the fallback when the endpoint is unreachable.
1995
+ const MERMAID_ROUTE = '/dsh-mosaic/assets/mermaid.min.js'
1996
+ const MERMAID_TIMEOUT_MS = 20000
1997
+ let mermaidAssetPromise = null
1998
+ let mermaidThemeKey = null
1999
+
2000
+ /**
2001
+ * Ask the Host for its asset URL, then append the built-in fallback.
2002
+ *
2003
+ * @returns {Promise<string[]>} candidate script URLs, in the order to try them.
2004
+ */
2005
+ function mermaidCandidates() {
2006
+ const fromHost = fetch(PROMPT_URL, { headers: { accept: 'application/json' } })
2007
+ .then((response) => (response.ok ? response.json() : null))
2008
+ .then((data) => (data && data.assets && typeof data.assets.mermaid === 'string' ? [data.assets.mermaid] : []))
2009
+ .catch(() => [])
2010
+ return fromHost.then((list) => [...new Set([...list, MERMAID_ROUTE])])
2011
+ }
2012
+
2013
+ /**
2014
+ * Load one script and resolve with the engine it registers.
2015
+ *
2016
+ * @param {string} src - absolute same-origin URL.
2017
+ * @returns {Promise<object>} the Mermaid API.
2018
+ */
2019
+ function injectMermaidScript(src) {
2020
+ return new Promise((resolve, reject) => {
2021
+ const script = document.createElement('script')
2022
+ const timer = setTimeout(() => {
2023
+ cleanup()
2024
+ reject(new Error(`timed out after ${MERMAID_TIMEOUT_MS} ms`))
2025
+ }, MERMAID_TIMEOUT_MS)
2026
+ function cleanup() {
2027
+ clearTimeout(timer)
2028
+ script.onload = null
2029
+ script.onerror = null
2030
+ script.remove()
2031
+ }
2032
+ script.src = src
2033
+ script.async = true
2034
+ script.onload = () => {
2035
+ const api = window.mermaid
2036
+ cleanup()
2037
+ if (!api || typeof api.render !== 'function') reject(new Error(`${src} did not expose the Mermaid API`))
2038
+ else resolve(api)
2039
+ }
2040
+ script.onerror = () => {
2041
+ cleanup()
2042
+ reject(new Error(`${src} could not be loaded`))
2043
+ }
2044
+ document.head.appendChild(script)
2045
+ })
2046
+ }
2047
+
2048
+ function loadMermaidAsset() {
2049
+ const existing = window.mermaid
2050
+ if (existing && typeof existing.render === 'function') return Promise.resolve(existing)
2051
+ if (mermaidAssetPromise !== null) return mermaidAssetPromise
2052
+ mermaidAssetPromise = mermaidCandidates()
2053
+ .then(async (candidates) => {
2054
+ const failures = []
2055
+ for (const candidate of candidates) {
2056
+ try {
2057
+ return await injectMermaidScript(candidate)
2058
+ } catch (error) {
2059
+ failures.push(error instanceof Error ? error.message : String(error))
2060
+ }
2061
+ }
2062
+ throw new Error(`Mermaid asset failed to load: ${failures.join('; ')}`)
2063
+ })
2064
+ .catch((error) => {
2065
+ // Let a later diagram retry instead of caching the failure for the session.
2066
+ mermaidAssetPromise = null
2067
+ throw error
2068
+ })
2069
+ return mermaidAssetPromise
2070
+ }
2071
+
2072
+ /** Resolved host tokens mapped onto Mermaid's base theme. */
2073
+ function mermaidThemeVariables(host) {
2074
+ const theme = themeSnapshot(host)
2075
+ return {
2076
+ background: theme.bg ?? 'transparent',
2077
+ primaryColor: theme.card ?? theme.bg ?? 'transparent',
2078
+ primaryTextColor: theme.text ?? 'canvastext',
2079
+ primaryBorderColor: theme.border ?? 'currentColor',
2080
+ lineColor: theme.muted ?? theme.border ?? 'currentColor',
2081
+ secondaryColor: theme.card ?? theme.bg ?? 'transparent',
2082
+ tertiaryColor: theme.bg ?? 'transparent',
2083
+ fontFamily: 'inherit',
2084
+ fontSize: '12px',
2085
+ }
2086
+ }
2087
+
2088
+ /**
2089
+ * Strip anything active from the SVG Mermaid produced before it reaches the page.
2090
+ *
2091
+ * Mermaid runs with `securityLevel: 'strict'` and `htmlLabels: false`, so this is a
2092
+ * second, independent gate against scripts, event handlers, script-bearing URLs and
2093
+ * remote stylesheet imports.
2094
+ *
2095
+ * @param {string} markup - the SVG string returned by `mermaid.render`.
2096
+ * @returns {string} sanitized SVG markup.
2097
+ */
2098
+ function sanitizeMermaidSvg(markup) {
2099
+ const parsed = new DOMParser().parseFromString(String(markup), 'image/svg+xml')
2100
+ const root = parsed.documentElement
2101
+ if (!root || root.nodeName.toLowerCase() !== 'svg' || parsed.querySelector('parsererror')) {
2102
+ throw new Error('Mermaid returned malformed SVG')
2103
+ }
2104
+ for (const element of [...root.querySelectorAll('script, foreignObject, iframe, object, embed')]) element.remove()
2105
+ for (const element of [root, ...root.querySelectorAll('*')]) {
2106
+ for (const attribute of [...element.attributes]) {
2107
+ const name = attribute.name.toLowerCase()
2108
+ if (name.startsWith('on')) {
2109
+ element.removeAttribute(attribute.name)
2110
+ continue
2111
+ }
2112
+ const scriptable = /^\s*(javascript|vbscript|data:text\/html)/i.test(attribute.value)
2113
+ if ((name === 'href' || name === 'xlink:href' || name === 'src') && scriptable) element.removeAttribute(attribute.name)
2114
+ if (name === 'style' && /javascript:|expression\(/i.test(attribute.value)) element.removeAttribute(attribute.name)
2115
+ }
2116
+ }
2117
+ for (const style of [...root.querySelectorAll('style')]) {
2118
+ if (/@import|url\(\s*["']?https?:/i.test(style.textContent ?? '')) style.remove()
2119
+ }
2120
+ return new XMLSerializer().serializeToString(root)
2121
+ }
2122
+
2123
+ /**
2124
+ * Render one diagram with the official Mermaid build.
2125
+ *
2126
+ * @param {string} source - Mermaid source generated from a diagram node.
2127
+ * @param {Element | null} container - the card being rendered, for theme tokens.
2128
+ * @returns {Promise<string>} sanitized SVG markup.
2129
+ */
2130
+ async function renderMermaid(source, container) {
2131
+ const api = await loadMermaidAsset()
2132
+ const host = container instanceof Element ? container : document.body
2133
+ // Mermaid resolves its theme once per initialize, so a new appearance has to
2134
+ // re-initialize before the next diagram can look right.
2135
+ const variables = mermaidThemeVariables(host)
2136
+ const themeKey = `${variables.background}|${variables.primaryColor}|${variables.primaryTextColor}`
2137
+ if (mermaidThemeKey !== themeKey) {
2138
+ api.initialize({
2139
+ startOnLoad: false,
2140
+ securityLevel: 'strict',
2141
+ htmlLabels: false,
2142
+ suppressErrorRendering: true,
2143
+ theme: 'base',
2144
+ themeVariables: variables,
2145
+ })
2146
+ mermaidThemeKey = themeKey
2147
+ }
2148
+ const id = `mosaic-mermaid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
2149
+ const rendered = await api.render(id, String(source))
2150
+ return sanitizeMermaidSvg(rendered.svg)
2151
+ }
2152
+
2153
+ function mermaidLabel(value) {
2154
+ return String(value ?? '')
2155
+ .replace(/\s+/g, ' ')
2156
+ .replace(/"/g, '#quot;')
2157
+ .replace(/\|/g, '#124;')
2158
+ .trim()
2159
+ }
2160
+
2161
+ /** Parse a token colour into `[r, g, b]`; `undefined` when it is not a plain colour. */
2162
+ function parseColor(value) {
2163
+ const text = String(value ?? '').trim()
2164
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text)
2165
+ if (hex) {
2166
+ const digits = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1]
2167
+ return [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16))
2168
+ }
2169
+ const rgb = /^rgba?\(([^)]+)\)$/i.exec(text)
2170
+ if (rgb) {
2171
+ const parts = rgb[1].split(',').slice(0, 3).map((part) => Number.parseFloat(part))
2172
+ if (parts.every((part) => Number.isFinite(part))) return parts.map((part) => Math.max(0, Math.min(255, Math.round(part))))
2173
+ }
2174
+ return undefined
2175
+ }
2176
+
2177
+ /** Mix `color` over `base` by `weight` (0..1) and return a hex string. */
2178
+ function tint(color, base, weight) {
2179
+ const front = parseColor(color)
2180
+ const back = parseColor(base) ?? [255, 255, 255]
2181
+ if (!front) return undefined
2182
+ const mixed = front.map((channel, index) => Math.round(channel * weight + back[index] * (1 - weight)))
2183
+ return `#${mixed.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
2184
+ }
2185
+
2186
+ /**
2187
+ * Resolve the card's tokens into the literal colours Mermaid's style parser accepts.
2188
+ *
2189
+ * Mermaid rejects `var(...)` and `rgba(...)` in `classDef`, so the source has to
2190
+ * carry resolved hex values; diagrams re-render when the appearance changes.
2191
+ *
2192
+ * @param {Element | null} host - the card being rendered.
2193
+ * @returns {{base: object, tones: Record<string, object>}} node styles.
2194
+ */
2195
+ function mermaidPalette(host) {
2196
+ const theme = themeSnapshot(host)
2197
+ const bg = theme.bg ?? '#ffffff'
2198
+ const card = theme.card ?? bg
2199
+ const text = theme.text ?? '#111111'
2200
+ const border = theme.border ?? '#999999'
2201
+ const node = (stroke) => ({
2202
+ fill: tint(stroke, card, 0.12) ?? card,
2203
+ stroke,
2204
+ color: text,
2205
+ })
2206
+ return {
2207
+ base: { fill: card, stroke: border, color: text },
2208
+ tones: {
2209
+ accent: node(theme.accent ?? border),
2210
+ success: node(theme.success ?? border),
2211
+ warning: node(theme.warn ?? border),
2212
+ danger: node(theme.danger ?? border),
2213
+ muted: node(theme.muted ?? border),
2214
+ },
2215
+ }
2216
+ }
2217
+
2218
+ function toMermaid(node, palette) {
2219
+ if (node.kind === 'sequence') {
2220
+ const participants = Array.isArray(node.participants) ? node.participants : []
2221
+ const messages = Array.isArray(node.messages) ? node.messages : []
2222
+ const idOf = new Map(participants.map((participant, index) => [participant.id, `p${index}`]))
2223
+ const lines = ['sequenceDiagram']
2224
+ participants.forEach((participant) => {
2225
+ lines.push(` participant ${idOf.get(participant.id)} as ${mermaidLabel(participant.label)}`)
2226
+ })
2227
+ messages.forEach((message) => {
2228
+ const from = idOf.get(message.from)
2229
+ const to = idOf.get(message.to)
2230
+ if (!from || !to) return
2231
+ const arrow = message.kind === 'return' ? '-->>' : message.kind === 'async' ? '-)' : '->>'
2232
+ lines.push(` ${from}${arrow}${to}: ${mermaidLabel(message.label)}`)
2233
+ })
2234
+ return lines.join('\n')
2235
+ }
2236
+ const nodes = Array.isArray(node.nodes) ? node.nodes : []
2237
+ const edges = Array.isArray(node.edges) ? node.edges : []
2238
+ const idOf = new Map(nodes.map((entry, index) => [entry.id, `n${index}`]))
2239
+ const styleOf = (style) => `fill:${style.fill},stroke:${style.stroke},color:${style.color}`
2240
+ const tones = palette?.tones ?? {}
2241
+ const baseStyle = palette?.base ? styleOf(palette.base) : undefined
2242
+ const usedTones = new Set()
2243
+ const lines = [`flowchart ${node.direction === 'LR' ? 'LR' : 'TB'}`]
2244
+ nodes.forEach((entry) => {
2245
+ const label = mermaidLabel(entry.label)
2246
+ const withSub = entry.sub ? `${label}<br/>${mermaidLabel(entry.sub)}` : label
2247
+ const id = idOf.get(entry.id)
2248
+ if (entry.shape === 'diamond') lines.push(` ${id}{${withSub}}`)
2249
+ else if (entry.shape === 'round') lines.push(` ${id}(${withSub})`)
2250
+ else lines.push(` ${id}[${withSub}]`)
2251
+ if (entry.tone === undefined && baseStyle !== undefined) lines.push(` class ${id} mosaic_base`)
2252
+ if (tones[entry.tone]) {
2253
+ usedTones.add(entry.tone)
2254
+ lines.push(` class ${id} mosaic_${entry.tone}`)
2255
+ }
2256
+ })
2257
+ edges.forEach((edge) => {
2258
+ const from = idOf.get(edge.from)
2259
+ const to = idOf.get(edge.to)
2260
+ if (!from || !to) return
2261
+ const arrow = edge.kind === 'dashed' ? '-.->' : edge.kind === 'accent' ? '==>' : '-->'
2262
+ const label = edge.label ? `|${mermaidLabel(edge.label)}|` : ''
2263
+ lines.push(` ${from} ${arrow}${label} ${to}`)
2264
+ })
2265
+ if (baseStyle !== undefined) lines.push(` classDef mosaic_base ${baseStyle}`)
2266
+ for (const tone of usedTones) lines.push(` classDef mosaic_${tone} ${styleOf(tones[tone])}`)
2267
+ return lines.join('\n')
2268
+ }
2269
+
2270
+ function escapeXml(value) {
2271
+ return String(value ?? '')
2272
+ .replace(/&/g, '&amp;')
2273
+ .replace(/</g, '&lt;')
2274
+ .replace(/>/g, '&gt;')
2275
+ .replace(/"/g, '&quot;')
2276
+ .replace(/'/g, '&apos;')
2277
+ }
2278
+
2279
+ function drawioTone(tone) {
2280
+ if (tone === 'accent') return 'fillColor=#dae8fc;strokeColor=#6c8ebf;'
2281
+ if (tone === 'success') return 'fillColor=#d5e8d4;strokeColor=#82b366;'
2282
+ if (tone === 'warning') return 'fillColor=#fff2cc;strokeColor=#d6b656;'
2283
+ if (tone === 'danger') return 'fillColor=#f8cecc;strokeColor=#b85450;'
2284
+ if (tone === 'muted') return 'fillColor=#f5f5f5;strokeColor=#999999;'
2285
+ return 'fillColor=#ffffff;strokeColor=#999999;'
2286
+ }
2287
+
2288
+ /**
2289
+ * Build a portable draw.io (mxGraph XML) file from a diagram node.
2290
+ * Positions are deliberately simple: the file is for opening and
2291
+ * polishing in draw.io, where the user can run its own layout.
2292
+ */
2293
+ function drawioXml(node) {
2294
+ const cells = []
2295
+ let index = 0
2296
+ const nextId = (prefix) => `${prefix}${index += 1}`
2297
+ const vertex = (id, value, style, x, y, w, h) => {
2298
+ cells.push(`<mxCell id="${escapeXml(id)}" value="${escapeXml(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/></mxCell>`)
2299
+ }
2300
+ const edge = (id, from, to, label, style) => {
2301
+ cells.push(`<mxCell id="${escapeXml(id)}" value="${escapeXml(label ?? '')}" style="${style}" edge="1" parent="1" source="${escapeXml(from)}" target="${escapeXml(to)}"><mxGeometry relative="1" as="geometry"/></mxCell>`)
2302
+ }
2303
+ const baseStyle = 'whiteSpace=wrap;html=1;rounded=1;'
2304
+ const edgeStyle = 'edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;'
2305
+
2306
+ if (node.kind === 'sequence') {
2307
+ const ids = new Map()
2308
+ ;(node.participants ?? []).forEach((participant, i) => {
2309
+ const id = nextId('p')
2310
+ ids.set(participant.id, id)
2311
+ vertex(id, participant.label, `${baseStyle}fillColor=#dae8fc;strokeColor=#6c8ebf;`, 40 + i * 210, 40, 160, 44)
2312
+ })
2313
+ ;(node.messages ?? []).forEach((message) => {
2314
+ const from = ids.get(message.from)
2315
+ const to = ids.get(message.to)
2316
+ if (!from || !to) return
2317
+ edge(nextId('e'), from, to, message.label, `${edgeStyle}${message.kind === 'return' ? 'dashed=1;' : ''}`)
2318
+ })
2319
+ } else if (node.kind === 'timeline') {
2320
+ ;(node.items ?? []).forEach((item, i) => {
2321
+ vertex(nextId('t'), `${item.time ? `${item.time} · ` : ''}${item.title}${item.desc ? `<br/>${item.desc}` : ''}`, `${baseStyle}fillColor=#ffffff;strokeColor=#999999;`, 40, 40 + i * 86, 420, 64)
2322
+ })
2323
+ } else if (node.kind === 'layers') {
2324
+ ;(node.layers ?? []).forEach((layer, i) => {
2325
+ vertex(nextId('l'), `${layer.title}<br/>${(layer.items ?? []).join(' · ')}`, `${baseStyle}fillColor=#f5f5f5;strokeColor=#999999;`, 40, 40 + i * 96, 460, 70)
2326
+ })
2327
+ } else {
2328
+ const ids = new Map()
2329
+ ;(node.nodes ?? []).forEach((entry, i) => {
2330
+ const id = nextId('n')
2331
+ ids.set(entry.id, id)
2332
+ const x = 40 + (i % 3) * 220
2333
+ const y = 40 + Math.floor(i / 3) * 100
2334
+ const style = `${baseStyle}${drawioTone(entry.tone)}${entry.shape === 'diamond' ? 'rhombus;' : ''}`
2335
+ vertex(id, `${entry.label}${entry.sub ? `<br/>${entry.sub}` : ''}`, style, x, y, 170, 60)
2336
+ })
2337
+ ;(node.edges ?? []).forEach((entry) => {
2338
+ const from = ids.get(entry.from)
2339
+ const to = ids.get(entry.to)
2340
+ if (!from || !to) return
2341
+ const style = `${edgeStyle}${entry.kind === 'dashed' ? 'dashed=1;' : ''}${entry.kind === 'accent' ? 'strokeColor=#3b82f6;strokeWidth=2;' : ''}`
2342
+ edge(nextId('e'), from, to, entry.label, style)
2343
+ })
2344
+ }
2345
+
2346
+ const name = node.title ?? 'Mosaic diagram'
2347
+ return `<?xml version="1.0" encoding="UTF-8"?>
2348
+ <mxfile host="app.diagrams.net" agent="dsh-mosaic" version="24.7.17">
2349
+ <diagram id="mosaic-${Date.now().toString(36)}" name="${escapeXml(name)}">
2350
+ <mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
2351
+ <root>
2352
+ <mxCell id="0"/>
2353
+ <mxCell id="1" parent="0"/>
2354
+ ${cells.join('\n ')}
2355
+ </root>
2356
+ </mxGraphModel>
2357
+ </diagram>
2358
+ </mxfile>`
2359
+ }
2360
+
2361
+ function downloadDrawio(node) {
2362
+ const xml = drawioXml(node)
2363
+ const blob = new Blob([xml], { type: 'application/vnd.jgraph.mxfile' })
2364
+ const url = URL.createObjectURL(blob)
2365
+ const anchor = document.createElement('a')
2366
+ anchor.href = url
2367
+ anchor.download = `${(node.title ?? 'mosaic-diagram').replace(/[\\/:*?"<>|]+/g, '-')}.drawio`
2368
+ anchor.click()
2369
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
2370
+ }
2371
+
2372
+ function MermaidDiagram({ node, fallback }) {
2373
+ const containerRef = useRef(null)
2374
+ const [state, setState] = useState({ status: 'loading', svg: '' })
2375
+ const [revision, setRevision] = useState(0)
2376
+ // A fence lives in its own root, so an appearance switch would otherwise leave
2377
+ // the diagram in the previous palette. Only this component re-renders; widgets
2378
+ // keep their iframe and state.
2379
+ useEffect(() => onAppearanceChange(() => setRevision((value) => value + 1)), [])
2380
+ useEffect(() => {
2381
+ let alive = true
2382
+ setState({ status: 'loading', svg: '' })
2383
+ const source = toMermaid(node, mermaidPalette(containerRef.current ?? document.body))
2384
+ renderMermaid(source, containerRef.current)
2385
+ .then((svg) => { if (alive) setState({ status: 'ready', svg }) })
2386
+ .catch((error) => {
2387
+ const message = error instanceof Error ? error.message : String(error)
2388
+ reportLog('warn', 'mermaid.failed', { message: message.slice(0, 200) })
2389
+ if (alive) setState({ status: 'error', svg: '', message })
2390
+ })
2391
+ return () => { alive = false }
2392
+ }, [node, revision])
2393
+ if (state.status === 'ready') {
2394
+ return h('div', {
2395
+ ref: containerRef,
2396
+ className: 'mosaic-mermaid',
2397
+ 'data-diagram-engine': 'mermaid',
2398
+ // The string already passed sanitizeMermaidSvg; Mermaid itself runs with
2399
+ // securityLevel 'strict' and htmlLabels off.
2400
+ dangerouslySetInnerHTML: { __html: state.svg },
2401
+ })
2402
+ }
2403
+ if (state.status === 'error') {
2404
+ return h('div', null, fallback, h('div', { className: 'mosaic-caption-line' }, translate('mermaidFallback', { message: state.message })))
2405
+ }
2406
+ return h('div', { className: 'mosaic-skeleton', 'data-diagram-engine': 'mermaid' }, h('span'), h('span'), h('span'))
2407
+ }
2408
+
2409
+ function shorten(text, max) {
2410
+ const value = String(text ?? '')
2411
+ return value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value
2412
+ }
2413
+
2414
+ function diagramTone(tone) {
2415
+ if (tone === 'accent') return { fill: 'color-mix(in srgb,var(--m-accent) 12%,var(--m-bg))', stroke: 'var(--m-accent)' }
2416
+ if (tone === 'success') return { fill: 'color-mix(in srgb,var(--m-success) 12%,var(--m-bg))', stroke: 'var(--m-success)' }
2417
+ if (tone === 'warning') return { fill: 'color-mix(in srgb,var(--m-warn) 12%,var(--m-bg))', stroke: 'var(--m-warn)' }
2418
+ if (tone === 'danger') return { fill: 'color-mix(in srgb,var(--m-danger) 12%,var(--m-bg))', stroke: 'var(--m-danger)' }
2419
+ if (tone === 'muted') return { fill: 'var(--m-bg2)', stroke: 'var(--m-border)' }
2420
+ return { fill: 'var(--m-bg)', stroke: 'var(--m-border)' }
2421
+ }
2422
+
2423
+ function diagramDisplaySize(width, height) {
2424
+ const scale = Math.min(1, 560 / Math.max(1, height))
2425
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }
2426
+ }
2427
+
2428
+ function FlowchartDiagram({ node }) {
2429
+ const layout = useMemo(() => {
2430
+ const nodes = Array.isArray(node.nodes) ? node.nodes : []
2431
+ const edges = Array.isArray(node.edges) ? node.edges : []
2432
+ const direction = node.direction === 'LR' ? 'LR' : 'TB'
2433
+ const byId = new Map(nodes.map((entry) => [entry.id, entry]))
2434
+ const next = new Map(nodes.map((entry) => [entry.id, []]))
2435
+ const incoming = new Map(nodes.map((entry) => [entry.id, 0]))
2436
+ for (const edge of edges) {
2437
+ if (!byId.has(edge.from) || !byId.has(edge.to)) continue
2438
+ next.get(edge.from).push(edge.to)
2439
+ incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1)
2440
+ }
2441
+ const layer = new Map()
2442
+ const roots = nodes.filter((entry) => (incoming.get(entry.id) ?? 0) === 0).map((entry) => entry.id)
2443
+ const queue = [...roots]
2444
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
2445
+ const id = queue[cursor]
2446
+ if (!layer.has(id)) layer.set(id, 0)
2447
+ for (const child of next.get(id) ?? []) {
2448
+ layer.set(child, Math.max(layer.get(child) ?? 0, (layer.get(id) ?? 0) + 1))
2449
+ incoming.set(child, (incoming.get(child) ?? 1) - 1)
2450
+ if ((incoming.get(child) ?? 0) === 0) queue.push(child)
2451
+ }
2452
+ }
2453
+ // Kahn's pass cannot rank nodes behind a cycle (for example a cache
2454
+ // backfill edge). Walk from every root once, ignoring back edges, so
2455
+ // those nodes still get a rank instead of collapsing onto layer 0.
2456
+ const visited = new Set()
2457
+ const visit = (id, depth) => {
2458
+ if (depth > nodes.length) return
2459
+ layer.set(id, Math.max(layer.get(id) ?? 0, depth))
2460
+ if (visited.has(id)) return
2461
+ visited.add(id)
2462
+ for (const child of next.get(id) ?? []) {
2463
+ if (!visited.has(child)) visit(child, depth + 1)
2464
+ }
2465
+ }
2466
+ for (const id of roots) visit(id, 0)
2467
+ for (const entry of nodes) if (!visited.has(entry.id)) visit(entry.id, 0)
2468
+ for (const entry of nodes) if (!layer.has(entry.id)) layer.set(entry.id, 0)
2469
+ const maxLayer = Math.max(0, ...layer.values())
2470
+ const groups = Array.from({ length: maxLayer + 1 }, () => [])
2471
+ for (const entry of nodes) groups[layer.get(entry.id) ?? 0].push(entry)
2472
+ const NODE_W = 150
2473
+ const NODE_H = 48
2474
+ const GAP_X = 28
2475
+ const GAP_Y = 54
2476
+ const maxCount = Math.max(1, ...groups.map((group) => group.length))
2477
+ const width = direction === 'TB'
2478
+ ? maxCount * NODE_W + (maxCount - 1) * GAP_X
2479
+ : (maxLayer + 1) * NODE_W + maxLayer * GAP_Y
2480
+ const height = direction === 'TB'
2481
+ ? (maxLayer + 1) * NODE_H + maxLayer * GAP_Y
2482
+ : maxCount * NODE_H + (maxCount - 1) * GAP_X
2483
+ const positions = new Map()
2484
+ groups.forEach((group, layerIndex) => {
2485
+ const groupWidth = group.length * NODE_W + (group.length - 1) * GAP_X
2486
+ const groupHeight = group.length * NODE_H + (group.length - 1) * GAP_X
2487
+ group.forEach((entry, index) => {
2488
+ const x = direction === 'TB'
2489
+ ? (width - groupWidth) / 2 + index * (NODE_W + GAP_X)
2490
+ : layerIndex * (NODE_W + GAP_Y)
2491
+ const y = direction === 'TB'
2492
+ ? layerIndex * (NODE_H + GAP_Y)
2493
+ : (height - groupHeight) / 2 + index * (NODE_H + GAP_X)
2494
+ positions.set(entry.id, { x, y, w: NODE_W, h: NODE_H })
2495
+ })
2496
+ })
2497
+ const paths = []
2498
+ let maxX = width
2499
+ let maxY = height
2500
+ const layerStep = NODE_H + GAP_Y
2501
+ const rankStep = NODE_W + GAP_Y
2502
+ for (const edge of edges) {
2503
+ const from = positions.get(edge.from)
2504
+ const to = positions.get(edge.to)
2505
+ if (!from || !to) continue
2506
+ let path
2507
+ let labelX
2508
+ let labelY
2509
+ if (direction === 'TB') {
2510
+ const x1 = from.x + from.w / 2
2511
+ const y1 = from.y + from.h
2512
+ const x2 = to.x + to.w / 2
2513
+ const y2 = to.y
2514
+ if (to.y - from.y > layerStep * 1.5) {
2515
+ // A skip edge is routed around the right side so it does not
2516
+ // pass through the nodes on the intermediate layers.
2517
+ const lane = Math.max(from.x + from.w, to.x + to.w) + 24
2518
+ path = `M ${x1} ${y1} L ${lane} ${y1} L ${lane} ${y2} L ${x2} ${y2}`
2519
+ labelX = lane + 10
2520
+ labelY = (y1 + y2) / 2
2521
+ maxX = Math.max(maxX, lane + 52)
2522
+ } else if (to.y > from.y) {
2523
+ path = `M ${x1} ${y1} L ${x2} ${y2}`
2524
+ labelX = (x1 + x2) / 2
2525
+ labelY = (y1 + y2) / 2
2526
+ } else {
2527
+ const bend = Math.max(from.x + from.w, to.x + to.w) + 28
2528
+ path = `M ${x1} ${y1} C ${bend} ${y1 + 18}, ${bend} ${y2 - 18}, ${x2} ${y2}`
2529
+ labelX = (x1 + x2) / 2
2530
+ labelY = (y1 + y2) / 2
2531
+ maxX = Math.max(maxX, bend + 34)
2532
+ }
2533
+ } else {
2534
+ const x1 = from.x + from.w
2535
+ const y1 = from.y + from.h / 2
2536
+ const x2 = to.x
2537
+ const y2 = to.y + to.h / 2
2538
+ if (to.x - from.x > rankStep * 1.5) {
2539
+ const lane = Math.max(from.y + from.h, to.y + to.h) + 24
2540
+ path = `M ${x1} ${y1} L ${x1} ${lane} L ${x2} ${lane} L ${x2} ${y2}`
2541
+ labelX = (x1 + x2) / 2
2542
+ labelY = lane + 14
2543
+ maxY = Math.max(maxY, lane + 42)
2544
+ } else if (to.x > from.x) {
2545
+ path = `M ${x1} ${y1} L ${x2} ${y2}`
2546
+ labelX = (x1 + x2) / 2
2547
+ labelY = (y1 + y2) / 2 - 5
2548
+ } else {
2549
+ const bend = Math.max(from.y + from.h, to.y + to.h) + 28
2550
+ path = `M ${x1} ${y1} C ${x1 + 18} ${bend}, ${x2 - 18} ${bend}, ${x2} ${y2}`
2551
+ labelX = (x1 + x2) / 2
2552
+ labelY = (y1 + y2) / 2 - 5
2553
+ maxY = Math.max(maxY, bend + 34)
2554
+ }
2555
+ }
2556
+ paths.push({ ...edge, path, labelX, labelY })
2557
+ }
2558
+ return { nodes, positions, paths, width: maxX, height: maxY }
2559
+ }, [node.nodes, node.edges, node.direction])
2560
+
2561
+ const display = diagramDisplaySize(layout.width, layout.height)
2562
+ return h('svg', {
2563
+ className: 'mosaic-diagram',
2564
+ width: display.width,
2565
+ height: display.height,
2566
+ viewBox: `0 0 ${layout.width} ${layout.height}`,
2567
+ style: { maxWidth: 'none', height: 'auto' },
2568
+ role: 'img',
2569
+ 'aria-label': node.title ?? 'flowchart',
2570
+ },
2571
+ h('defs', null,
2572
+ h('marker', { id: 'mosaic-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
2573
+ h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-muted)' })),
2574
+ h('marker', { id: 'mosaic-arrow-accent', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
2575
+ h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-accent)' }))),
2576
+ layout.paths.map((edge, index) => h('path', {
2577
+ key: `e${index}`,
2578
+ d: edge.path,
2579
+ className: `diagram-edge${edge.kind === 'accent' ? ' diagram-edge-accent' : ''}${edge.kind === 'dashed' ? ' diagram-edge-dashed' : ''}`,
2580
+ markerEnd: edge.kind === 'accent' ? 'url(#mosaic-arrow-accent)' : 'url(#mosaic-arrow)',
2581
+ })),
2582
+ layout.paths.map((edge, index) => edge.label
2583
+ ? h('text', { key: `l${index}`, x: edge.labelX, y: edge.labelY, textAnchor: 'middle', className: 'diagram-edge-label' }, shorten(edge.label, 16))
2584
+ : null),
2585
+ layout.nodes.map((entry) => {
2586
+ const position = layout.positions.get(entry.id)
2587
+ if (!position) return null
2588
+ const tone = diagramTone(entry.tone)
2589
+ const shape = entry.shape ?? 'box'
2590
+ const centerX = position.x + position.w / 2
2591
+ const centerY = position.y + position.h / 2
2592
+ const labelY = entry.sub ? centerY - 4 : centerY + 4
2593
+ return h('g', { key: entry.id },
2594
+ shape === 'diamond'
2595
+ ? h('polygon', {
2596
+ className: 'diagram-node',
2597
+ points: `${centerX},${position.y} ${position.x + position.w},${centerY} ${centerX},${position.y + position.h} ${position.x},${centerY}`,
2598
+ fill: tone.fill, stroke: tone.stroke,
2599
+ })
2600
+ : h('rect', {
2601
+ className: 'diagram-node',
2602
+ x: position.x, y: position.y, width: position.w, height: position.h,
2603
+ rx: shape === 'round' ? position.h / 2 : 8,
2604
+ fill: tone.fill, stroke: tone.stroke,
2605
+ }),
2606
+ h('text', { x: centerX, y: labelY, textAnchor: 'middle', className: 'diagram-node-label' }, shorten(entry.label, 20)),
2607
+ entry.sub ? h('text', { x: centerX, y: centerY + 13, textAnchor: 'middle', className: 'diagram-node-sub' }, shorten(entry.sub, 22)) : null)
2608
+ }))
2609
+ }
2610
+
2611
+ function SequenceDiagram({ node }) {
2612
+ const layout = useMemo(() => {
2613
+ const participants = Array.isArray(node.participants) ? node.participants : []
2614
+ const messages = Array.isArray(node.messages) ? node.messages : []
2615
+ const column = 160
2616
+ const width = Math.max(360, participants.length * column)
2617
+ const height = 80 + messages.length * 36 + 20
2618
+ const xOf = (id) => {
2619
+ const index = participants.findIndex((entry) => entry.id === id)
2620
+ return index < 0 ? 0 : 80 + index * column
2621
+ }
2622
+ return { participants, messages, width, height, xOf }
2623
+ }, [node.participants, node.messages])
2624
+ const display = diagramDisplaySize(layout.width, layout.height)
2625
+ return h('svg', {
2626
+ className: 'mosaic-diagram',
2627
+ width: display.width,
2628
+ height: display.height,
2629
+ viewBox: `0 0 ${layout.width} ${layout.height}`,
2630
+ style: { maxWidth: 'none', height: 'auto' },
2631
+ role: 'img',
2632
+ 'aria-label': node.title ?? 'sequence diagram',
2633
+ },
2634
+ h('defs', null,
2635
+ h('marker', { id: 'mosaic-seq-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
2636
+ h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-text)' })),
2637
+ h('marker', { id: 'mosaic-seq-arrow-muted', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
2638
+ h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-muted)' })),
2639
+ h('marker', { id: 'mosaic-seq-arrow-accent', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
2640
+ h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-accent)' }))),
2641
+ layout.participants.map((participant) => {
2642
+ const x = layout.xOf(participant.id)
2643
+ return h('g', { key: participant.id },
2644
+ h('rect', { className: 'diagram-participant', x: x - 65, y: 8, width: 130, height: 30, rx: 8 }),
2645
+ h('text', { x, y: 27, textAnchor: 'middle', className: 'diagram-participant-label' }, shorten(participant.label, 16)),
2646
+ h('line', { className: 'diagram-lifeline', x1: x, y1: 38, x2: x, y2: layout.height - 12 }))
2647
+ }),
2648
+ layout.messages.map((message, index) => {
2649
+ const x1 = layout.xOf(message.from)
2650
+ const x2 = layout.xOf(message.to)
2651
+ const y = 78 + index * 36
2652
+ const marker = message.kind === 'return'
2653
+ ? 'url(#mosaic-seq-arrow-muted)'
2654
+ : message.kind === 'async' ? 'url(#mosaic-seq-arrow-accent)' : 'url(#mosaic-seq-arrow)'
2655
+ return h('g', { key: index },
2656
+ x1 === x2
2657
+ ? h('path', {
2658
+ className: `diagram-message${message.kind === 'return' ? ' diagram-message-return' : ''}${message.kind === 'async' ? ' diagram-message-async' : ''}`,
2659
+ d: `M ${x1} ${y} C ${x1 + 34} ${y - 14}, ${x1 + 34} ${y + 14}, ${x1} ${y + 12}`,
2660
+ markerEnd: marker,
2661
+ })
2662
+ : h('line', {
2663
+ className: `diagram-message${message.kind === 'return' ? ' diagram-message-return' : ''}${message.kind === 'async' ? ' diagram-message-async' : ''}`,
2664
+ x1, y1: y, x2, y2: y, markerEnd: marker,
2665
+ }),
2666
+ h('text', { x: (x1 + x2) / 2, y: y - 6, textAnchor: 'middle', className: 'diagram-msg-label' }, shorten(message.label, 26)))
2667
+ }))
2668
+ }
2669
+
2670
+ function TimelineDiagram({ node }) {
2671
+ return h('ol', { className: 'mosaic-timeline' }, (node.items ?? []).map((item, index) => h('li', { key: index, className: 'mosaic-timeline-item' },
2672
+ h('span', { className: 'mosaic-timeline-dot', 'aria-hidden': true }),
2673
+ item.time ? h('div', { className: 'mosaic-timeline-time' }, renderRich(item.time)) : null,
2674
+ h('div', { className: 'mosaic-timeline-title' }, renderRich(item.title)),
2675
+ item.desc ? h('div', { className: 'mosaic-timeline-desc' }, renderRich(item.desc)) : null)))
2676
+ }
2677
+
2678
+ function LayersDiagram({ node }) {
2679
+ return h('div', { className: 'mosaic-layers' }, (node.layers ?? []).map((layer, index) => h('div', { key: index, className: 'mosaic-layer' },
2680
+ h('div', { className: 'mosaic-layer-title' }, renderRich(layer.title)),
2681
+ h('ul', { className: 'mosaic-layer-items' }, (layer.items ?? []).map((item, itemIndex) => h('li', { key: itemIndex }, renderRich(item)))))))
2682
+ }
2683
+
2684
+ function DiagramNode({ node }) {
2685
+ const title = node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null
2686
+ const head = h('div', { className: 'mosaic-diagram-head' },
2687
+ title ?? h('span'),
2688
+ h('button', {
2689
+ type: 'button',
2690
+ className: 'mosaic-diagram-export',
2691
+ title: translate('drawio'),
2692
+ onClick: () => downloadDrawio(node),
2693
+ }, 'drawio'))
2694
+ if (node.kind === 'sequence') {
2695
+ return h('div', null, head, h(MermaidDiagram, {
2696
+ node,
2697
+ fallback: h('div', { className: 'mosaic-diagram-wrap' }, h(SequenceDiagram, { node })),
2698
+ }))
2699
+ }
2700
+ if (node.kind === 'timeline') return h('div', null, head, h(TimelineDiagram, { node }))
2701
+ if (node.kind === 'layers') return h('div', null, head, h(LayersDiagram, { node }))
2702
+ return h('div', null, head, h(MermaidDiagram, {
2703
+ node,
2704
+ fallback: h('div', { className: 'mosaic-diagram-wrap' }, h(FlowchartDiagram, { node })),
2705
+ }))
2706
+ }
2707
+
2708
+ function NodeView({ node, path = 'items[0]', depth = 0 }) {
2709
+ if (!node || depth > MAX_DEPTH) return null
2710
+ const view = useView()
2711
+ switch (node.type) {
2712
+ case 'text': return h(TextNode, { node })
2713
+ case 'row':
2714
+ return h('div', {
2715
+ className: `mosaic-row${node.wrap ? ' mosaic-row-wrap' : ''}`,
2716
+ style: {
2717
+ gap: node.gap !== undefined ? `${node.gap}px` : undefined,
2718
+ justifyContent: node.align === 'center' ? 'center' : node.align === 'end' ? 'flex-end' : node.align === 'between' ? 'space-between' : undefined,
2719
+ },
2720
+ }, node.items.map((child, index) => h(GridItem, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 })))
2721
+ case 'col':
2722
+ return h('div', {
2723
+ className: 'mosaic-col',
2724
+ style: {
2725
+ gap: node.gap !== undefined ? `${node.gap}px` : undefined,
2726
+ ...(node.width ? { width: `${node.width}%`, flex: '0 0 auto' } : { flex: '1 1 0' }),
2727
+ },
2728
+ }, node.items.map((child, index) => h(NodeView, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 })))
2729
+ case 'grid':
2730
+ return h('div', {
2731
+ className: 'mosaic-grid',
2732
+ style: {
2733
+ gridTemplateColumns: `repeat(${node.cols ?? 2}, minmax(0, 1fr))`,
2734
+ gap: node.gap !== undefined ? `${node.gap}px` : undefined,
2735
+ },
2736
+ }, node.items.map((child, index) => h(GridItem, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 })))
2737
+ case 'card':
2738
+ return h('div', { className: `mosaic-card${node.flat ? ' mosaic-card-flat' : ''}${node.tone ? ` mosaic-card-${node.tone}` : ''}` },
2739
+ node.eyebrow ? h('div', { className: 'mosaic-eyebrow' }, renderRich(node.eyebrow)) : null,
2740
+ node.title ? h('div', { className: 'mosaic-card-title' }, renderRich(node.title)) : null,
2741
+ h('div', { className: 'mosaic-col', style: { gap: '10px' } },
2742
+ node.items.map((child, index) => h(NodeView, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 }))))
2743
+ case 'divider': return h(DividerNode, { node })
2744
+ case 'spacer': return h(SpacerNode, { node })
2745
+ case 'stat': return h(StatNode, { node })
2746
+ case 'badge': return h(BadgeNode, { node })
2747
+ case 'progress': return h(ProgressNode, { node })
2748
+ case 'table': return h(TableNode, { node })
2749
+ case 'list': return h(ListNode, { node })
2750
+ case 'keyvalue': return h(KeyValueNode, { node })
2751
+ case 'steps': return h(StepsNode, { node })
2752
+ case 'callout': return h(CalloutNode, { node })
2753
+ case 'code': return h(CodeNode, { node })
2754
+ case 'diff': return h(DiffNode, { node })
2755
+ case 'chart': return h(ChartNode, { node })
2756
+ case 'diagram': return h(DiagramNode, { node })
2757
+ case 'image': return h(ImageNode, { node })
2758
+ case 'video': return h(VideoNode, { node })
2759
+ case 'audio': return h(AudioNode, { node })
2760
+ case 'pdf': return h(PdfNode, { node })
2761
+ case 'link': return h(LinkNode, { node })
2762
+ case 'button': return h(ButtonNode, { node })
2763
+ case 'input': return h(InputNode, { node, path })
2764
+ case 'select': return h(SelectNode, { node })
2765
+ case 'slider': return h(SliderNode, { node })
2766
+ case 'tabs': return h(TabsNode, { node, path })
2767
+ case 'quiz': return h(QuizNode, { key: `quiz-${view?.state.round ?? 0}`, node })
2768
+ case 'widget':
2769
+ return h(WidgetFrame, {
2770
+ node,
2771
+ onAction: (action, payload) => view?.action(action, payload),
2772
+ onFullscreen: view ? () => view.openOverlay(h(WidgetFrame, {
2773
+ node,
2774
+ fullscreen: true,
2775
+ onAction: (action, payload) => view.action(action, payload),
2776
+ })) : undefined,
2777
+ })
2778
+ default: return null
2779
+ }
2780
+ }
2781
+
2782
+ function Block({ spec, stateKey, onAgentAction, sessionId, sourceJson }) {
2783
+ const [state, setState] = useState(() => {
2784
+ const base = { fields: {}, tabs: {}, round: 0 }
2785
+ if (!stateKey) return base
2786
+ const stored = loadState(stateKey)
2787
+ return stored ? { ...base, ...stored } : base
2788
+ })
2789
+ const [fullscreen, setFullscreen] = useState(null)
2790
+ const [copiedJson, setCopiedJson] = useState(false)
2791
+ const copyTimer = useRef(null)
2792
+ const closeOverlay = useCallback(() => setFullscreen(null), [])
2793
+ const openOverlay = useCallback((node) => setFullscreen(node), [])
2794
+ const copyJson = useCallback(() => {
2795
+ if (!sourceJson) return
2796
+ Promise.resolve(copyText(sourceJson)).then(() => {
2797
+ setCopiedJson(true)
2798
+ if (copyTimer.current) clearTimeout(copyTimer.current)
2799
+ copyTimer.current = setTimeout(() => setCopiedJson(false), 1600)
2800
+ })
2801
+ }, [sourceJson])
2802
+ useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
2803
+ useEffect(() => {
2804
+ if (!stateKey) return
2805
+ const timer = setTimeout(() => saveState(stateKey, state), 300)
2806
+ return () => clearTimeout(timer)
2807
+ }, [stateKey, state])
2808
+ const setField = useCallback((id, value) => {
2809
+ setState((current) => current.fields[id] === value ? current : { ...current, fields: { ...current.fields, [id]: value } })
2810
+ }, [])
2811
+ const setTab = useCallback((key, index) => {
2812
+ setState((current) => ({ ...current, tabs: { ...current.tabs, [key]: index } }))
2813
+ }, [])
2814
+ const reset = useCallback(() => {
2815
+ setState((current) => ({ fields: {}, tabs: {}, round: (current.round ?? 0) + 1 }))
2816
+ if (stateKey) clearState(stateKey)
2817
+ }, [stateKey])
2818
+ const action = useCallback((name, payload) => {
2819
+ if (sessionId === undefined) return
2820
+ onAgentAction?.(name, payload)
2821
+ }, [onAgentAction, sessionId])
2822
+ const api = useMemo(() => ({
2823
+ state,
2824
+ setField,
2825
+ setTab,
2826
+ reset,
2827
+ action,
2828
+ canAct: sessionId !== undefined,
2829
+ openOverlay,
2830
+ }), [state, setField, setTab, reset, action, sessionId, openOverlay])
2831
+ // The fullscreen surface stays inside this component tree and enters the page
2832
+ // through a portal, so it owns no extra root and unmounts with the block.
2833
+ return h(Fragment, null,
2834
+ h(ViewContext.Provider, { value: api },
2835
+ h('div', { className: 'mosaic-root', 'data-mosaic-root': '' },
2836
+ h('div', { className: 'mosaic-head' },
2837
+ spec.title ? h('div', { className: 'mosaic-banner' }, renderRich(spec.title)) : h('span', { className: 'mosaic-banner' }),
2838
+ sourceJson ? h('button', {
2839
+ type: 'button',
2840
+ className: 'mosaic-json-copy',
2841
+ title: translate('copyJson'),
2842
+ onClick: copyJson,
2843
+ }, copiedJson ? translate('copied') : translate('copyJson')) : null),
2844
+ h('div', { className: 'mosaic-col', style: { gap: `${spec.gap ?? 12}px` } },
2845
+ spec.items.map((node, index) => h(NodeView, {
2846
+ key: index,
2847
+ node,
2848
+ path: `items[${index}]`,
2849
+ depth: 0,
2850
+ })))))
2851
+ , fullscreen !== null ? ReactDOM.createPortal(
2852
+ h(Overlay, { onClose: closeOverlay }, fullscreen),
2853
+ document.body,
2854
+ ) : null)
2855
+ }
2856
+
2857
+ function Overlay({ children, onClose }) {
2858
+ const panel = useRef(null)
2859
+ useEffect(() => {
2860
+ const onKey = (event) => { if (event.key === 'Escape') onClose() }
2861
+ document.addEventListener('keydown', onKey)
2862
+ const previous = document.activeElement
2863
+ panel.current?.focus()
2864
+ return () => {
2865
+ document.removeEventListener('keydown', onKey)
2866
+ if (previous instanceof HTMLElement) previous.focus()
2867
+ }
2868
+ }, [onClose])
2869
+ return h('div', {
2870
+ ref: panel,
2871
+ tabIndex: -1,
2872
+ className: 'mosaic-overlay',
2873
+ role: 'dialog',
2874
+ 'aria-modal': 'true',
2875
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onClose() },
2876
+ }, h('div', { className: 'mosaic-overlay-body' },
2877
+ h('button', { type: 'button', className: 'mosaic-overlay-close', onClick: onClose, 'aria-label': translate('close') }, '×'),
2878
+ children))
2879
+ }
2880
+
2881
+ /* ------------------------------------------------------------------ */
2882
+ /* fence + tool view + apply */
2883
+ /* ------------------------------------------------------------------ */
2884
+
2885
+ function FenceView({ raw, settled, fenceKey, sessionId, onAgentAction }) {
2886
+ const spec = useMemo(() => parseView(raw), [raw])
2887
+ if (!spec) return h('div', { className: 'mosaic-skeleton' }, h('span'), h('span'), h('span'))
2888
+ const stateKey = settled && sessionId
2889
+ ? `mosaic:f:${sessionId}:${fenceKey}:${fingerprint(raw)}`
2890
+ : undefined
2891
+ return h(Block, {
2892
+ key: stateKey ?? 'stream',
2893
+ spec,
2894
+ stateKey,
2895
+ sessionId,
2896
+ onAgentAction,
2897
+ sourceJson: raw,
2898
+ })
2899
+ }
2900
+
2901
+ function ToolFallback({ toolName, callId }) {
2902
+ return h('div', { className: 'mosaic-tool-fallback' },
2903
+ h('b', null, toolName || 'render_view'),
2904
+ h('span', null, callId || 'replayed call has no presentation meta'))
2905
+ }
2906
+
2907
+ function ToolView(props) {
2908
+ const block = props.block
2909
+ const meta = block && typeof block === 'object' && 'meta' in block ? block.meta : undefined
2910
+ const spec = meta && typeof meta === 'object' && Array.isArray(meta.items) && meta.items.length > 0 ? meta : null
2911
+ if (!spec) return h(ToolFallback, { toolName: props.toolName, callId: block?.callId })
2912
+ const sessionId = props.sessionId
2913
+ const stateKey = sessionId && block?.callId ? `mosaic:t:${sessionId}:${block.callId}` : undefined
2914
+ return h(Block, {
2915
+ spec,
2916
+ stateKey,
2917
+ sessionId,
2918
+ onAgentAction: (action, payload) => props.sendAction(sessionId, action, payload),
2919
+ })
2920
+ }
2921
+
2922
+ function blockOf(pre) {
2923
+ if (!(pre instanceof Element)) return null
2924
+ // Prefer the outer code-block surface: the host nests the content holder
2925
+ // (`[data-code-block-content]`) inside the banner-bearing `.md-code-block`.
2926
+ let surface = pre.closest('.md-code-block, .code-block, .code-block-small')
2927
+ if (!surface) {
2928
+ const content = pre.closest('[data-code-block-content]')
2929
+ surface = content?.parentElement
2930
+ ?? (pre.parentElement && pre.parentElement.querySelectorAll('pre').length === 1 ? pre.parentElement : null)
2931
+ for (let hops = 0; surface && hops < 3; hops += 1) {
2932
+ if (surface.querySelector('[data-code-block-banner]')
2933
+ || surface.matches('.md-code-block, .code-block, .code-block-small')) break
2934
+ surface = surface.parentElement
2935
+ }
2936
+ }
2937
+ if (!surface) return null
2938
+ if (surface.closest('.mosaic-fence, .mosaic-overlay-host, [data-chat-group-part="reasoning"], [data-tool], [data-sidebar-chat], [data-sidebar-right-session], [data-sidebar-right-panel], [data-panel-conversation], [data-plugin-panel]')) return null
2939
+ if (surface.querySelectorAll('pre').length !== 1) return null
2940
+ for (const element of surface.querySelectorAll('p, ul, ol, dl, table, h1, h2, h3, h4, h5, h6, blockquote, hr, img, figure')) {
2941
+ if (!pre.contains(element)) return null
2942
+ }
2943
+ return surface
2944
+ }
2945
+
2946
+ const VIEW_TYPES = new Set([
2947
+ 'text', 'row', 'col', 'grid', 'card', 'divider', 'spacer',
2948
+ 'stat', 'badge', 'progress', 'list', 'table', 'keyvalue', 'steps',
2949
+ 'callout', 'code', 'diff', 'chart', 'diagram',
2950
+ 'image', 'video', 'audio', 'pdf', 'link',
2951
+ 'button', 'input', 'select', 'slider', 'tabs', 'quiz', 'widget',
2952
+ ])
2953
+
2954
+ function looksLikeViewBody(raw) {
2955
+ const spec = parseView(raw)
2956
+ return spec !== null && spec.items.some((node) => node && typeof node.type === 'string' && VIEW_TYPES.has(node.type))
2957
+ }
2958
+
2959
+ function languageOf(surface, pre) {
2960
+ const code = pre.querySelector('code')
2961
+ const className = typeof code?.className === 'string' ? code.className : ''
2962
+ if (/(?:^|\s)language-dsh-view(?:\s|$)/.test(className)) return 'dsh-view'
2963
+ for (const element of surface.querySelectorAll('*')) {
2964
+ if (pre.contains(element)) continue
2965
+ const text = (element.textContent ?? '').trim()
2966
+ if (text === 'dsh-view' && element.childElementCount === 0) return 'dsh-view'
2967
+ }
2968
+ const dataLanguage = pre.getAttribute('data-language') ?? code?.getAttribute('data-language')
2969
+ if (dataLanguage === 'dsh-view') return 'dsh-view'
2970
+ const jsonTagged = /(?:^|\s)language-jsonc?(?:\s|$)/.test(className)
2971
+ || dataLanguage === 'json'
2972
+ || Array.from(surface.querySelectorAll('*')).some((element) => !pre.contains(element)
2973
+ && element.childElementCount === 0
2974
+ && element.textContent.trim() === 'json')
2975
+ // The model sometimes labels the fence ```json even though the body is a
2976
+ // complete dsh-view spec. Render it only in an assistant row, and only
2977
+ // when the body actually contains a known Mosaic component.
2978
+ if ((jsonTagged || className === '')
2979
+ && surface.closest('[data-chat-flow-kind="assistant-step"]') !== null
2980
+ && looksLikeViewBody(rawOf(pre))) return 'dsh-view'
2981
+ return null
2982
+ }
2983
+
2984
+ function rawOf(pre) {
2985
+ const code = pre.querySelector('code')
2986
+ return (code ? code.textContent : pre.textContent ?? '').replace(/\n$/, '')
2987
+ }
2988
+
2989
+ function installFenceRenderer(ctx, sendAction) {
2990
+ if (typeof document === 'undefined') return () => {}
2991
+ const mounts = new Map()
2992
+ const diagnostics = new Map()
2993
+ const ids = new WeakMap()
2994
+ let nextId = 1
2995
+ let timer = null
2996
+ let disposed = false
2997
+
2998
+ const sessionId = () => currentSessionId(ctx)
2999
+
3000
+ function idOf(block) {
3001
+ if (!ids.has(block)) ids.set(block, nextId += 1)
3002
+ return ids.get(block)
3003
+ }
3004
+
3005
+ function showDiagnostic(block, raw) {
3006
+ const existing = diagnostics.get(block)
3007
+ if (existing && existing.raw === raw) return
3008
+ const reason = describeParse(raw)
3009
+ reportLog('warn', 'fence.parse-failed', { chars: String(raw ?? '').length, reason })
3010
+ const text = translate('parseFailed', { detail: reason })
3011
+ const sibling = block.previousElementSibling
3012
+ if (sibling?.classList.contains('mosaic-diagnostic')) {
3013
+ sibling.textContent = text
3014
+ diagnostics.set(block, { strip: sibling, raw })
3015
+ return
3016
+ }
3017
+ if (existing) clearDiagnostic(block)
3018
+ const strip = document.createElement('div')
3019
+ strip.className = 'mosaic-diagnostic'
3020
+ strip.setAttribute('role', 'alert')
3021
+ strip.textContent = text
3022
+ block.before(strip)
3023
+ diagnostics.set(block, { strip, raw })
3024
+ }
3025
+
3026
+ function clearDiagnostic(block) {
3027
+ const existing = diagnostics.get(block)
3028
+ if (!existing) return
3029
+ existing.strip.remove()
3030
+ diagnostics.delete(block)
3031
+ }
3032
+
3033
+ function unmount(block) {
3034
+ const mount = mounts.get(block)
3035
+ if (!mount) return
3036
+ mounts.delete(block)
3037
+ try { mount.root.unmount() } catch {}
3038
+ mount.container.remove()
3039
+ block.style.display = ''
3040
+ block.removeAttribute('data-mosaic-rendered')
3041
+ clearDiagnostic(block)
3042
+ }
3043
+
3044
+ function mount(block, raw, settled) {
3045
+ // Another observer generation may already own this block. The marker
3046
+ // is shared in the DOM, so a duplicate apply cannot stack fences.
3047
+ if (block.hasAttribute('data-mosaic-rendered')) return
3048
+ const spec = parseView(raw)
3049
+ if (!spec) {
3050
+ if (settled) showDiagnostic(block, raw)
3051
+ return
3052
+ }
3053
+ clearDiagnostic(block)
3054
+ const container = document.createElement('div')
3055
+ container.className = 'mosaic-fence'
3056
+ block.after(container)
3057
+ const root = ReactDOMClient.createRoot(container)
3058
+ const fenceSession = sessionId()
3059
+ const render = (nextRaw, nextSettled) => root.render(h(FenceView, {
3060
+ raw: nextRaw,
3061
+ settled: nextSettled,
3062
+ fenceKey: idOf(block),
3063
+ sessionId: fenceSession,
3064
+ onAgentAction: (action, payload) => sendAction(fenceSession, action, payload),
3065
+ }))
3066
+ try {
3067
+ render(raw, settled)
3068
+ } catch {
3069
+ try { root.unmount() } catch {}
3070
+ container.remove()
3071
+ return
3072
+ }
3073
+ block.style.display = 'none'
3074
+ block.setAttribute('data-mosaic-rendered', '')
3075
+ mounts.set(block, { container, root, raw, settled, render })
3076
+ }
3077
+
3078
+ function update(block, raw, settled) {
3079
+ const mount = mounts.get(block)
3080
+ if (!mount) return
3081
+ const spec = parseView(raw)
3082
+ if (!spec) {
3083
+ if (!settled) return
3084
+ unmount(block)
3085
+ showDiagnostic(block, raw)
3086
+ return
3087
+ }
3088
+ mount.raw = raw
3089
+ mount.settled = settled
3090
+ // A host re-render can restore the original block's display without
3091
+ // touching structure; re-apply the surgery before the next paint.
3092
+ block.style.display = 'none'
3093
+ block.setAttribute('data-mosaic-rendered', '')
3094
+ try {
3095
+ mount.render(raw, settled)
3096
+ } catch {
3097
+ unmount(block)
3098
+ }
3099
+ }
3100
+
3101
+ function scan() {
3102
+ if (disposed) return
3103
+ const seen = new Set()
3104
+ for (const pre of document.querySelectorAll('pre')) {
3105
+ const block = blockOf(pre)
3106
+ if (!block || seen.has(block)) continue
3107
+ seen.add(block)
3108
+ if (languageOf(block, pre) !== 'dsh-view') {
3109
+ if (mounts.has(block)) unmount(block)
3110
+ continue
3111
+ }
3112
+ const raw = rawOf(pre)
3113
+ if (raw.trim() === '') continue
3114
+ const settled = pre.closest('[data-streaming]') === null
3115
+ const existing = mounts.get(block)
3116
+ if (existing && (!existing.container.isConnected || !block.isConnected)) {
3117
+ unmount(block)
3118
+ }
3119
+ if (mounts.has(block)) update(block, raw, settled)
3120
+ else if (!block.hasAttribute('data-mosaic-rendered')) mount(block, raw, settled)
3121
+ }
3122
+ for (const block of [...mounts.keys()]) {
3123
+ if (!block.isConnected) unmount(block)
3124
+ }
3125
+ for (const block of [...diagnostics.keys()]) {
3126
+ if (!block.isConnected) clearDiagnostic(block)
3127
+ }
3128
+ }
3129
+
3130
+ function schedule() {
3131
+ if (disposed || timer !== null) return
3132
+ timer = setTimeout(() => {
3133
+ timer = null
3134
+ scan()
3135
+ }, 40)
3136
+ }
3137
+
3138
+ const onLocale = () => {
3139
+ for (const mount of mounts.values()) {
3140
+ try { mount.render(mount.raw, mount.settled) } catch {}
3141
+ }
3142
+ }
3143
+ const unsubscribeLocale = ctx.locale.subscribe(onLocale)
3144
+ const observer = new MutationObserver(schedule)
3145
+ observer.observe(document.body, {
3146
+ childList: true,
3147
+ subtree: true,
3148
+ characterData: true,
3149
+ attributes: true,
3150
+ attributeFilter: ['class', 'data-streaming'],
3151
+ })
3152
+ scan()
3153
+
3154
+ return () => {
3155
+ disposed = true
3156
+ unsubscribeLocale()
3157
+ observer.disconnect()
3158
+ if (timer !== null) clearTimeout(timer)
3159
+ for (const block of [...mounts.keys()]) unmount(block)
3160
+ for (const block of [...diagnostics.keys()]) clearDiagnostic(block)
3161
+ }
3162
+ }
3163
+
3164
+ function apply(ctx) {
3165
+ clearLegacyStorage()
3166
+ ctx.effect(ensureStyles, 'dsh-mosaic: styles')
3167
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-mosaic: dictionaries')
3168
+ // A bound translate keeps its identity and resolves the active language per
3169
+ // call, so a component can hold it without subscribing on its own.
3170
+ translate = ctx.locale.bind(NS)
3171
+ const sendAction = makeSender(ctx)
3172
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
3173
+ name: 'tool.call.toolview',
3174
+ key: 'render_view',
3175
+ locale: NS,
3176
+ inject: () => ({ sendAction }),
3177
+ }, ToolView))
3178
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
3179
+ name: 'settings.section',
3180
+ id: 'mosaic',
3181
+ order: 30,
3182
+ label: () => translate('settingsTitle'),
3183
+ locale: NS,
3184
+ }, MosaicSettings))
3185
+ ctx.effect(() => installFenceRenderer(ctx, sendAction), 'dsh-mosaic: fence')
3186
+ console.info('[mosaic] client active; fence=dsh-view')
3187
+ }
3188
+
3189
+ return { inject: ['slots', 'sessions', 'locale'], apply }
3190
+ },
3191
+ })