dsh-mosaic 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,7 +1,35 @@
1
1
  # Changelog
2
2
 
3
- > 版本规则:插件版本与**最近一次验证通过的 DeepSeek Harness 版本**保持一致。当前对齐 `0.2.1`(实测 rc.3 与 rc.5)。
4
- > 同一 Harness 版本下的插件自身修复按补丁号递增(`0.2.1` → `0.2.2` ……);一旦换到新的 Harness 版本验证通过,版本号立即改成该 Harness 版本。
3
+ > 版本规则:插件版本按 npm 语义独立递增,**不再与 DeepSeek Harness 版本号对齐**。
4
+ > 兼容性分两处表达:`package.json` 的 `engines.dsh` 给机器可读的范围,README「安装」一节给「插件版本 ↔ 实测 Harness 版本」对照表;每次在真实 Harness 上验证后更新这两处。
5
+ > `0.2.1` 及各 `0.2.1-<标签>` 是改规则前的历史版本,保留用于追溯。
6
+
7
+ ## 1.0.0
8
+
9
+ 首个稳定版,也是 npm 上第一次发布的完整版本(`0.2.1` 之后的所有改动都在这里)。
10
+
11
+ - 版本规则变更:不再与 DeepSeek Harness 版本号对齐。此前用 `0.2.1-<标签>` 表示「对齐 0.2.1 的本地改动」,但这类版本在 semver 里是**预发布**(`0.2.1-harden < 0.2.1`),既不会被 `npm i` 选中,发布时还必须显式 `--tag`。兼容范围改由 `engines.dsh` 与 README 的对照表表达。
12
+ - 发布自动化:新增 tag 触发的发布工作流(`.github/workflows/release.yml`),使用 npm trusted publishing(OIDC,无需长期 token),并在发布前校验 tag 与 `package.json` 版本一致、该版本尚未发布。
13
+
14
+ ### 功能(此前未发布)
15
+
16
+ - 示意图与图表支持缩放/平移:`−`/`+`/`⟲`/`⤢`、拖动平移、`Ctrl/⌘ + 滚轮`、双击复位、键盘 `+`/`-`/`0`;示意图按原始尺寸渲染,超出部分裁切可拖动。
17
+
18
+ ### 安全与状态修正
19
+
20
+ - Widget 沙箱重建:整份文档(`doctype`/`html`/`head`/`body`)由宿主统一生成,模型内容只投放到宿主给出的 head/body 槽位,CSP 因此永远排在模型脚本之前。此前完整 HTML 文档走 `<\/head>` 前插入,模型写在 `<head>` 里的 `<script>` 会在 CSP 生效前执行;模型自带的 CSP `<meta>` 与 `<base>` 一并剥离。
21
+ - Widget 动作校验补上父窗口一侧:nonce 写在 Widget 自己的文档里,模型代码能读出来直接 `postMessage` 伪造,所以除 iframe 内的判断之外,父页面独立校验自己文档的 `navigator.userActivation`;iframe 内的判断同时改为 fail-closed。额度从「每个 Widget 一份」改为**整个会话共享**,payload 上限按 UTF-8 **字节**(16 KB)而不是字符数计算。
22
+ - 自有路由增加 Host 权威校验:`/dsh-mosaic/prompt` 与 `/dsh-mosaic/log` 只接受 loopback、监听地址或 `config.trustedHosts` 声明的授权。仅比较 Origin 与 Host 挡不住 DNS rebinding——被 rebinding 的请求两者都等于攻击者域名。
23
+ - Prompt 状态:`reset` 不再被迟到的 `hydrate` 覆盖(用独立标志区分「还没读过」和「用户刚清空」);`GET` 返回 `revision`,`POST` 可带 `expectedRevision`,对不上返回 409 并附当前内容,跨浏览器编辑不再静默覆盖。
24
+ - 日志路由在 `flush` 之后才回 204,204 变成真正的落盘回执;测试改为等待具体事件而不是「目录里有任意 jsonl」,修掉那条在 CI 上必然失败的竞态。
25
+ - 缩放/平移:触屏改为 `touch-action: pan-y`,手指上下划可以继续滚动聊天页面;ResizeObserver 里不再在 `setZoom` 的 updater 内调用 `setOverflowing`,两个更新都在回调顶层发出。
26
+ - 测试:新增 `test/client.test.mjs`(Widget 文档构造:CSP 顺序、外壳唯一性、剥离模型 CSP/base、bridge 策略),以及 Host 权威、reset/hydrate 竞态、revision 冲突的回归用例。`npm test` 现为 78 项。
27
+
28
+ ## 0.2.1-zoom
29
+
30
+ - 示意图与图表可以缩放和拖动:右上角 `−`/`+`/`⟲`/`⤢`,视图内按住拖动平移,`Ctrl/⌘ + 滚轮`缩放,双击或 `⟲` 复位,键盘 `+`/`-`/`0` 同样可用;`⤢` 在遮罩层里打开更大的画布。
31
+ - 示意图保留原始尺寸(不再被压缩到卡片宽度),超出部分在视口里裁切、可拖动查看。
32
+ - 修复外观切换后 Mermaid 重渲染报 `Unsupported color format: "currentColor"`、随后掉到内置布局的问题:主题变量的兜底改用具体颜色。
5
33
 
6
34
  ## 0.2.1
7
35
 
package/README.md CHANGED
@@ -39,7 +39,7 @@ Mosaic 是一个轻量的 DeepSeek Harness 可视化回答插件。它把模型
39
39
 
40
40
  插件向模型贡献一段说明可视化能力的 Prompt,默认是包内文本。
41
41
 
42
- **Settings → Mosaic** 就是这段文本的编辑页:打开即显示当前生效的内容(默认就是内置文案),可以直接改、点「保存」,随时点「恢复默认」回到插件自带文案。保存后从下一轮模型请求开始生效,所有浏览器看到的是同一份,重启也在。
42
+ **Settings → Mosaic** 就是这段文本的编辑页:打开即显示当前生效的内容(默认就是内置文案),可以直接改、点「保存」,随时点「恢复默认」回到插件自带文案。保存后从下一轮模型请求开始生效,所有浏览器看到的是同一份,重启也在。两个浏览器同时编辑时,后保存的那个会收到「已在别处被修改」,页面载入最新内容而不覆盖别人的修改。
43
43
 
44
44
  生效顺序是「你保存的覆盖 > 配置文件里的 `config.prompt` > 内置文本」。想在 profile 里批量固定一份基线,仍然可以在 `cordis.patch.yml` 给 mosaic 行加 `config.prompt`;留空、类型不对或超过 128 KB 会回落。
45
45
 
@@ -71,7 +71,7 @@ $DSH_HOME/logs/dsh-mosaic/YYYY-MM-DD.jsonl # 未设置 DSH_HOME 时是 ~/.dsh/
71
71
  - 记录什么:激活/停用、Prompt 的读取与保存(来源、字符数、是否落盘)、工具执行(名称、结果状态、耗时)、路由请求(方法、状态码、耗时)、技能被读取、以及浏览器侧上报的失败(`client.` 前缀:围栏解析失败、Mermaid 失败、Prompt 读写失败、Widget 动作被限流/超限丢弃)。
72
72
  - 不记录什么:Prompt 正文、组件 JSON、任何文本字段的内容。字段名为 `prompt`/`text`/`code`/`content`/`secret`/`token`/`password`/`apiKey` 时只写字符数;其它字符串超过 300 字符会被截断。
73
73
  - 体积:单文件超过 2 MB 滚动为 `<日期>.1.jsonl`;超过 7 天的文件在下次启动时删除;文件权限 `0600`。写不进去时不会影响插件,只在宿主日志里告警一次。
74
- - 浏览器侧的失败通过同源 `POST /dsh-mosaic/log` 上报(请求体上限 4 KB,跨站 403,客户端每分钟最多 20 条)。
74
+ - 浏览器侧的失败通过同源 `POST /dsh-mosaic/log` 上报(请求体上限 4 KB,非本机权威或跨站一律 403,客户端每分钟最多 20 条);接口在日志落盘之后才回 204,看到成功就是真的写进去了。
75
75
  - 默认级别 `info`;想要更细可以给 mosaic 行加 `config.logLevel: debug`(或 `warn` 减少噪音)。
76
76
 
77
77
  排查示例:
@@ -87,15 +87,17 @@ grep -c '"event":"tool.failed"' "$DSH_HOME"/logs/dsh-mosaic/*.jsonl
87
87
 
88
88
  ## 已知边界
89
89
 
90
- - 版本号跟随「最近一次验证通过的 DeepSeek Harness 版本」:当前是 `0.2.1`(实测 rc.3、rc.5)。同一 Harness 版本下的插件修复递增补丁号;换到新 Harness 版本验证后再把版本号对齐过去。
90
+ - 本插件的 npm 版本独立递增(当前 `1.0.0`),不再与 Harness 版本号对齐;兼容范围由 `package.json` 的 `engines.dsh` 声明,最近一次实测的 Harness 版本见「安装」一节。
91
91
  - 围栏渲染靠扫描页面里的代码块实现:0.2.1 全系列(实测 rc.3 与 rc.5,master 同样)都没有给插件开放「渲染 fenced code block」的扩展点。工具通道 `render_view` + `tool.call.toolview` 是原生做法,围栏是它的补充。
92
92
  - 从 0.2.1 起代码块由 Shiki 渲染,`<code>` 上**没有** `language-*` 类;识别靠「代码块位于 assistant 行内 + 正文能解析成合法视图 spec」这条路径,用户消息、工具结果、推理块里的代码块都被排除。
93
- - `widget` 里调用 `window.mosaic.action()` 只有**真实用户手势**之后才会把动作发回模型(用浏览器的 `navigator.userActivation` 判断,Widget 自己伪造不了);确实需要自行提交的 Widget 要在节点上写 `actions: "programmatic"`,并且仍受「每分钟 10 次、单次 16 KB」的限制。
93
+ - `diagram` 与 `chart` 自带缩放/平移:卡片右上角有 `−`/`+`/`⟲`/`⤢`,放大后按住图面拖动即可平移,`Ctrl/⌘ + 滚轮`缩放,双击复位;示意图按原始尺寸渲染,不会被压缩到卡片宽度。触屏上图表区域只拦横向拖动(`touch-action: pan-y`),手指上下划仍然滚动聊天页面。
94
+ - `widget` 里调用 `window.mosaic.action()` 只有**真实用户手势**之后才会把动作发回模型:Widget 自己也在判断 `navigator.userActivation`,但决定权在宿主——消息的 nonce 就写在 Widget 自己的文档里,模型代码能读出来直接伪造,所以父页面独立再校验一次自己文档的激活状态。确实需要自行提交的 Widget 要在节点上写 `actions: "programmatic"`;无论哪种,整个会话共享「每分钟 10 次、单次 payload 16 KB(按 UTF-8 字节算)」的额度,新开一个 Widget 不会重置。浏览器若不提供 `navigator.userActivation`,非 `programmatic` 的 Widget 会被按「没有手势」处理,按钮不会回模型。
94
95
  - `widget` 是 iframe,拿不到宿主的 `ctx.locale`,因此内部文案由调用方自己写;主题靠宿主令牌值传入。围栏是独立的 React root,切换外观不会触发它重渲染,所以 widget 会观察 `body[data-ds-dark-theme]` 这一个外观属性并在变化时重发令牌——这是插件唯一读取的宿主属性。
95
96
  - Mermaid 资产走包内私有路由 `/dsh-mosaic/assets`;没有 Web 服务时 diagram 回落到内置 SVG 布局。
97
+ - 自有路由(`/prompt`、`/log`)先验 **Host 必须是本机权威**(loopback、监听地址,或 `config.trustedHosts` 里声明的),再验 `Sec-Fetch-Site` 与 `Origin`。只比 Origin 和 Host 挡不住 DNS rebinding:被 rebinding 的页面这两者都等于攻击者域名,看起来完全同源。宿主把 Web 服务绑到非 loopback 地址时,请把对应主机名写进 `config.trustedHosts`,否则这些路由会拒绝请求。
96
98
  - bundle patch 只插入 Mosaic 自己这一行,不改动任何其他插件。若你还装了别的围栏类插件,两者可以对同一段围栏各显一套,按需在 profile 里关掉其中一个即可。
97
99
  - 持久化只用「Host 数据目录里的一个 JSON 文件」,没有走 DSH 的 storageDomain。实测(rc.5):`storageDomain` 可用,但记录校验只在**重新打开**时执行(`put` 不校验);`single` 布局遇到版本变化会直接拒绝打开,`compatibleVersions` 也不适用。对一份 Prompt 来说这些能力换不来收益,反而多一个依赖和三条失败分支。
98
- - Prompt 编辑页是对平台形态的**补充,不是平台标准做法**。DSH 把 Prompt 视为 composition 配置(system-prompt 的 README 明确写着没有终端用户编辑 Prompt 的 API),标准平面是 `Config` + `cordis.patch.yml`,客户端改值应走 `@Remote`,插件自有数据应走存储服务。这里为了「只装一个包、不引入 peer 依赖」,写入用了自建的同源校验路由、持久化用了自建的 `mosaic/prompt.json`。将来宿主提供多行配置表单或标准 Remote 时,这块应当迁移过去。
100
+ - Prompt 编辑页是对平台形态的**补充,不是平台标准做法**。DSH 把 Prompt 视为 composition 配置(system-prompt 的 README 明确写着没有终端用户编辑 Prompt 的 API),标准平面是 `Config` + `cordis.patch.yml`,客户端改值应走 `@Remote`,插件自有数据应走存储服务。这里为了「只装一个包、不引入 peer 依赖」,写入用了自建路由(Host 权威校验 + 同源校验)、持久化用了自建的 `mosaic/prompt.json`。将来宿主提供多行配置表单或标准 Remote 时,这块应当迁移过去。
99
101
 
100
102
  ## 本地优先
101
103
 
@@ -131,6 +133,15 @@ dsh plugin --profile desktop add link:/absolute/path/to/dsh-mosaic
131
133
 
132
134
  安装后重启或刷新 DSH Web,新会话即可使用。bundle patch 只插入本插件一行,不会关闭或覆盖别人的插件。
133
135
 
136
+ **兼容性**:npm 版本独立于 DeepSeek Harness 版本,`1.0.x` 对应下方实测的 Harness 范围;声明式范围见 `package.json` 的 `engines.dsh`(当前 `>=0.2.1-rc.1 <0.3.0-0`)。
137
+
138
+ | 插件版本 | 实测通过的 DeepSeek Harness | 说明 |
139
+ | --- | --- | --- |
140
+ | `1.0.0` | `0.2.1-rc.5`(同时复核 rc.3 行为) | 首个稳定版;含 Widget 沙箱、路由权威、Prompt 并发等修正 |
141
+ | `0.2.1` | `0.2.1-rc.3`、`0.2.1-rc.5` | 上一版,npm 上的历史发布 |
142
+
143
+ Harness 升级到 `0.3.x` 后,请先在新版本上验证再放宽 `engines.dsh`,并在此表补一行。
144
+
134
145
  ## 限制
135
146
 
136
147
  - 80 节点、6 层嵌套、表格 40×8、图表 40 点、diagram 12 节点/20 边、widget 代码 20k。
package/SKILL.md CHANGED
@@ -122,7 +122,7 @@ A widget is for content the fixed components cannot express: a game, a physics/m
122
122
  - CSP: no network; inline scripts/styles and `data:`/`blob:` images or media only
123
123
  - no access to the host page, cookies, or local storage
124
124
 
125
- `window.mosaic.action(name, payload)` reaches the model only after a real user gesture inside the widget; set `actions: "programmatic"` when the widget must submit on its own (still capped at 10 calls a minute and 16 KB per payload).
125
+ `window.mosaic.action(name, payload)` reaches the model only after a real user gesture; the Host re-checks that gesture, because a widget can read the message nonce out of its own document. Set `actions: "programmatic"` when the widget must submit on its own. Either way the whole session shares a budget of 10 actions a minute and 16 KB per payload, so a widget cannot reset it by re-rendering.
126
126
 
127
127
  Inside the widget:
128
128
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  1. Create the GitHub repository. Add the topic `dsh-plugin`; the catalog's automated check looks for it.
6
6
  2. Fill `repository`, `homepage`, and `bugs` in `package.json` after the repository URL exists.
7
- 3. Keep at least one version published on npm before submitting to the catalog. The package version tracks the most recently verified DeepSeek Harness version (`0.2.1` today); plugin-only fixes bump the patch number until the next Harness verification realigns it. `npm pack --dry-run` must include `lib/assets/mermaid.min.js`, `screenshots.json`, and `assets/screenshots/`.
7
+ 3. Keep at least one version published on npm before submitting to the catalog. The package version follows plain semver (currently `1.0.0`) and is independent of the DeepSeek Harness version; the verified Harness range lives in `engines.dsh` and in the README compatibility table, and both are updated after each real verification. `npm pack --dry-run` must include `lib/assets/mermaid.min.js`, `screenshots.json`, and `assets/screenshots/`.
8
8
  4. Add the repository to the catalog with `docs/catalog-entry.example.yml` copied to `data/plugins/imwhatiam__dsh-mosaic.yml` in `awesome-dsh-plugin/awesome-dsh-plugin`. One entry per PR; the repo must be at least one day old.
9
9
  5. Add real screenshots under `assets/screenshots/` and list them in `screenshots.json`. Verified against installed DSH 0.2.1-rc.5: the Plugins page reads only `meta.title`, `meta.description` and the icon, so `screenshots.json` is a **catalog/community** convention, not a DSH one. Keep the paths inside the package and in sync with the filenames.
10
10
  6. The catalog `icon` must be a GitHub-hosted raster image. This repo ships `assets/icon-180.png`; SVG is rejected by the catalog.
package/lib/client.js CHANGED
@@ -61,6 +61,11 @@ window.__ModuleLoader__.load({
61
61
  reload: '重载',
62
62
  fullscreen: '全屏',
63
63
  close: '关闭',
64
+ zoomIn: '放大',
65
+ zoomOut: '缩小',
66
+ zoomReset: '恢复 100%',
67
+ zoomFullscreen: '放大查看',
68
+ zoomHint: '按住拖动查看,Ctrl/⌘ + 滚轮缩放,双击复位',
64
69
  drawio: '下载 draw.io 文件',
65
70
  mermaidFallback: 'Mermaid 渲染失败,已使用内置布局:{message}',
66
71
  parseFailed: 'dsh-view 解析失败:{detail}(已保留代码块)',
@@ -81,6 +86,7 @@ window.__ModuleLoader__.load({
81
86
  promptSourceBuiltin: '内置默认',
82
87
  promptLoadFailed: '读取失败:{reason}',
83
88
  promptSaveFailed: '保存失败:{reason}',
89
+ promptConflict: '这份 Prompt 已在别处被修改,已载入最新内容;确认后再保存一次即可。',
84
90
  promptMemoryOnly: '未能写入 Host 数据目录,本次仅在进程内生效。',
85
91
  promptReadCorrupt: '保存的内容无法解析,已按内置内容运行;原文件已保留,可手工修复。',
86
92
  promptReadUnreadable: '保存的内容读取失败(权限或路径问题),已按内置内容运行。',
@@ -105,6 +111,11 @@ window.__ModuleLoader__.load({
105
111
  reload: 'Reload',
106
112
  fullscreen: 'Fullscreen',
107
113
  close: 'Close',
114
+ zoomIn: 'Zoom in',
115
+ zoomOut: 'Zoom out',
116
+ zoomReset: 'Reset to 100%',
117
+ zoomFullscreen: 'Open larger',
118
+ zoomHint: 'Drag to pan, Ctrl/⌘ + wheel to zoom, double-click to reset',
108
119
  drawio: 'Download a draw.io file',
109
120
  mermaidFallback: 'Mermaid rendering failed; the built-in layout was used: {message}',
110
121
  parseFailed: 'dsh-view could not be parsed: {detail} (the code block is kept)',
@@ -125,6 +136,7 @@ window.__ModuleLoader__.load({
125
136
  promptSourceBuiltin: 'Built-in default',
126
137
  promptLoadFailed: 'Could not read the prompt: {reason}',
127
138
  promptSaveFailed: 'Could not save: {reason}',
139
+ promptConflict: 'Another tab changed this prompt. The latest text is loaded; review it and save again.',
128
140
  promptMemoryOnly: 'Could not write the Host data directory, so this change only lives in this process.',
129
141
  promptReadCorrupt: 'The saved override is not valid JSON, so the built-in text is used. The file was kept for manual repair.',
130
142
  promptReadUnreadable: 'The saved override could not be read (permissions or path), so the built-in text is used.',
@@ -329,10 +341,23 @@ window.__ModuleLoader__.load({
329
341
  .mosaic-chart .mosaic-dot{stroke:var(--m-bg);stroke-width:1.5}
330
342
  .mosaic-chart .mosaic-bar{shape-rendering:geometricPrecision}
331
343
  .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}
344
+ .mosaic-diagram-wrap{max-width:100%;overflow:hidden;padding-bottom:2px}
333
345
  .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}
346
+ .mosaic-mermaid{max-width:100%;padding:2px 0}
347
+ .mosaic-mermaid svg{display:block;max-width:none!important;height:auto;margin:0 auto}
348
+ .mosaic-zoom{display:flex;flex-direction:column;gap:6px;min-width:0}
349
+ .mosaic-zoom-bar{display:flex;align-items:center;gap:6px;justify-content:flex-end}
350
+ .mosaic-zoom-btn{border:1px solid var(--m-border);background:var(--m-bg);color:var(--m-muted);border-radius:6px;min-width:26px;height:24px;padding:0 6px;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}
351
+ .mosaic-zoom-btn:hover:not(:disabled){color:var(--m-text);border-color:var(--m-accent)}
352
+ .mosaic-zoom-btn:disabled{opacity:.45;cursor:default}
353
+ .mosaic-zoom-value{min-width:46px;text-align:center;font:11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--m-muted);font-variant-numeric:tabular-nums}
354
+ .mosaic-zoom-viewport{position:relative;overflow:hidden;max-height:min(70vh,560px);border-radius:8px;outline:none;touch-action:pan-y}
355
+ .mosaic-zoom-viewport[data-pannable="true"]{cursor:grab}
356
+ .mosaic-zoom-viewport[data-panning="true"]{cursor:grabbing}
357
+ .mosaic-zoom-viewport:focus-visible{box-shadow:0 0 0 2px var(--m-accent)}
358
+ .mosaic-zoom-stage{transform-origin:0 0;display:inline-block;min-width:100%;will-change:transform}
359
+ .mosaic-zoom-stage[data-panning="true"]{user-select:none}
360
+ .mosaic-zoom-full .mosaic-zoom-viewport{max-height:calc(92vh - 96px);min-height:min(70vh,560px)}
336
361
  .mosaic-mermaid .labelBkg{background:var(--m-bg)!important;color:var(--m-text)!important;padding:0 3px;border-radius:3px}
337
362
  .mosaic-mermaid .edgeLabel{color:var(--m-text)!important}
338
363
  .mosaic-diagram-head{display:flex;align-items:center;gap:8px;margin:0 0 6px}
@@ -427,6 +452,7 @@ window.__ModuleLoader__.load({
427
452
  .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
453
  .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
454
  .mosaic-overlay-close:hover{background:var(--m-bg2)}
455
+ .mosaic-overlay-body[data-figure="true"]{width:min(1440px,97vw);overflow:hidden}
430
456
  .mosaic-overlay-image{display:block;max-width:100%;max-height:76vh;margin:0 auto;border-radius:10px;object-fit:contain}
431
457
  .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
458
  .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}
@@ -555,6 +581,11 @@ window.__ModuleLoader__.load({
555
581
  function MosaicSettings({ t }) {
556
582
  const alive = useRef(true)
557
583
  const [state, setState] = useState({ loading: true, text: '', saved: '', info: null, notice: null, saving: false })
584
+ // The save callback is cached on `t`, so the revision it echoes comes from a ref.
585
+ const infoRef = useRef(null)
586
+ useEffect(() => {
587
+ infoRef.current = state.info
588
+ }, [state.info])
558
589
  useEffect(() => () => { alive.current = false }, [])
559
590
  const reason = (error) => (error instanceof Error ? error.message : String(error))
560
591
 
@@ -575,8 +606,12 @@ window.__ModuleLoader__.load({
575
606
  return () => { current = false }
576
607
  }, [t])
577
608
 
578
- const submit = useCallback(async (body) => {
609
+ const submit = useCallback(async (payload) => {
579
610
  setState((previous) => ({ ...previous, saving: true, notice: null }))
611
+ // Name the version this tab read: another tab may have saved since, and the
612
+ // Host refuses the write instead of silently replacing that edit.
613
+ const revision = infoRef.current?.revision
614
+ const body = revision === undefined ? payload : { ...payload, expectedRevision: revision }
580
615
  try {
581
616
  const response = await fetch(PROMPT_URL, {
582
617
  method: 'POST',
@@ -584,6 +619,19 @@ window.__ModuleLoader__.load({
584
619
  body: JSON.stringify(body),
585
620
  })
586
621
  const data = await response.json().catch(() => ({}))
622
+ if (response.status === 409) {
623
+ if (!alive.current) return
624
+ const text = typeof data.prompt === 'string' ? data.prompt : ''
625
+ setState({
626
+ loading: false,
627
+ text,
628
+ saved: text,
629
+ info: data,
630
+ saving: false,
631
+ notice: { kind: 'error', text: t('promptConflict') },
632
+ })
633
+ return
634
+ }
587
635
  if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
588
636
  if (!alive.current) return
589
637
  const text = typeof data.prompt === 'string' ? data.prompt : ''
@@ -1861,7 +1909,7 @@ window.__ModuleLoader__.load({
1861
1909
  var schedule=function(){requestAnimationFrame(report)};
1862
1910
  // A browser-verified gesture, not a widget-supplied flag: userActivation cannot be forged
1863
1911
  // 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};
1912
+ var activated=function(){var ua=navigator.userActivation;return ua!==undefined&&ua.isActive===true};
1865
1913
  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
1914
  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
1915
  window.addEventListener('load',function(){schedule();setTimeout(schedule,120);setTimeout(schedule,600)});
@@ -1872,27 +1920,89 @@ window.__ModuleLoader__.load({
1872
1920
  })();`
1873
1921
  }
1874
1922
 
1875
- function widgetDocument(code, nonce, theme, policy) {
1876
- const safe = String(code).slice(0, 20000)
1923
+ /**
1924
+ * Split model-authored HTML into the two places the Host will accept it.
1925
+ *
1926
+ * A complete document keeps its head material (styles, metas, title) and its body
1927
+ * material separate, but neither keeps its own wrapper tags: the Host emits
1928
+ * `html`/`head`/`body` itself, so no model tag can appear before the policy.
1929
+ *
1930
+ * @param {string} code - model-authored widget source.
1931
+ * @returns {{ head: string, body: string }} content for the two Host-owned slots.
1932
+ */
1933
+ function widgetParts(code) {
1934
+ const text = String(code).slice(0, 20000)
1877
1935
  .replace(/<meta\b[^>]*http-equiv\s*=\s*["']?Content-Security-Policy["']?[^>]*>/gi, '')
1878
1936
  .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>`)
1937
+ .replace(/<!doctype[^>]*>/gi, '')
1938
+ .replace(/<\/?html\b[^>]*>/gi, '')
1939
+ .replace(/<\/?head\b[^>]*>/gi, '')
1940
+ const document = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(text)
1941
+ if (document === null) {
1942
+ return /<body\b[^>]*>/i.test(text)
1943
+ ? { head: '', body: text.replace(/<\/?body\b[^>]*>/gi, '') }
1944
+ : { head: '', body: text }
1888
1945
  }
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>`
1946
+ return { head: text.replace(/<body\b[^>]*>[\s\S]*?<\/body>/i, ''), body: document[1] }
1947
+ }
1948
+
1949
+ /**
1950
+ * Build the sandboxed document the widget iframe runs.
1951
+ *
1952
+ * The Host owns the whole shell and the policy comes first, because a `<meta>` CSP
1953
+ * only governs what the parser reaches after it: anything the model put earlier —
1954
+ * a `<script>` in `<head>` is the ordinary case, not an exotic one — would run
1955
+ * before the network ban existed. Model content therefore only ever lands after
1956
+ * the policy, in the head slot or the body slot.
1957
+ *
1958
+ * @param {string} code - model-authored widget source.
1959
+ * @param {string} nonce - per-frame message tag.
1960
+ * @param {Record<string, string>} theme - resolved Host tokens.
1961
+ * @param {'user' | 'programmatic'} policy - whether `mosaic.action` needs a gesture.
1962
+ * @returns {string} the complete iframe document.
1963
+ */
1964
+ function widgetDocument(code, nonce, theme, policy) {
1965
+ const parts = widgetParts(code)
1966
+ 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>`
1967
+ const lang = (typeof document !== 'undefined' && document.documentElement.lang) || 'en'
1968
+ return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><!-- dsh-mosaic sandbox --><meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}">${style}<script>${widgetBridge(nonce, policy)}</script>${parts.head}</head><body>${parts.body}</body></html>`
1890
1969
  }
1891
1970
 
1892
- /** One widget may send this many actions per window; larger payloads are dropped. */
1971
+ /**
1972
+ * Widget actions are capped per *session*, not per frame: a reopened or newly
1973
+ * rendered widget used to start with a fresh allowance, which is exactly what an
1974
+ * action loop needs.
1975
+ */
1893
1976
  const WIDGET_ACTIONS_PER_WINDOW = 10
1894
1977
  const WIDGET_ACTION_WINDOW_MS = 60000
1895
- const WIDGET_ACTION_MAX_CHARS = 16384
1978
+ const WIDGET_ACTION_MAX_BYTES = 16 * 1024
1979
+ let widgetActionTimes = []
1980
+
1981
+ /** Whether one widget action fits this window's allowance; consumes it when it does. */
1982
+ function admitWidgetAction() {
1983
+ const now = Date.now()
1984
+ widgetActionTimes = widgetActionTimes.filter((at) => now - at < WIDGET_ACTION_WINDOW_MS)
1985
+ if (widgetActionTimes.length >= WIDGET_ACTIONS_PER_WINDOW) return false
1986
+ widgetActionTimes.push(now)
1987
+ return true
1988
+ }
1989
+
1990
+ /**
1991
+ * Whether this document currently holds transient user activation.
1992
+ *
1993
+ * The widget's own copy of this check cannot be trusted: the policy tag, the bridge
1994
+ * and model code share one document, so a widget can read the nonce and post the
1995
+ * same shape itself. The parent answers about its own document instead, and user
1996
+ * activation propagates from a focused subframe to its ancestors, so a real click
1997
+ * inside the iframe still counts here.
1998
+ */
1999
+ function hasUserGesture() {
2000
+ try {
2001
+ return window.navigator.userActivation?.isActive === true
2002
+ } catch {
2003
+ return false
2004
+ }
2005
+ }
1896
2006
 
1897
2007
  function WidgetFrame({ node, onAction, onFullscreen, fullscreen = false }) {
1898
2008
  const [height, setHeight] = useState(node.height ?? 320)
@@ -1905,7 +2015,6 @@ window.__ModuleLoader__.load({
1905
2015
  const initialTheme = useMemo(() => themeSnapshot(iframeRef.current?.parentElement), [])
1906
2016
  const policy = node.actions === 'programmatic' ? 'programmatic' : 'user'
1907
2017
  const html = useMemo(() => widgetDocument(node.code, nonce, initialTheme, policy), [node.code, nonce, initialTheme, policy])
1908
- const actionTimes = useRef([])
1909
2018
  const sendTheme = useCallback(() => {
1910
2019
  const next = themeSnapshot(iframeRef.current?.parentElement)
1911
2020
  iframeRef.current?.contentWindow?.postMessage({ type: 'mosaic:theme', nonce, theme: next }, '*')
@@ -1918,11 +2027,16 @@ window.__ModuleLoader__.load({
1918
2027
  if (data.type === 'mosaic:height') {
1919
2028
  setHeight(clamp(Number(data.height) || 320, 120, 900))
1920
2029
  } 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) })
2030
+ const action = String(data.action || 'action').slice(0, 64)
2031
+ // The nonce proves which frame spoke, not that a person acted: a widget can
2032
+ // read it out of its own document. A non-programmatic widget therefore still
2033
+ // needs activation in *this* document, which model code cannot fake.
2034
+ if (policy !== 'programmatic' && !hasUserGesture()) {
2035
+ reportLog('warn', 'widget.action-dropped', { reason: 'gesture', action })
2036
+ return
2037
+ }
2038
+ if (!admitWidgetAction()) {
2039
+ reportLog('warn', 'widget.action-dropped', { reason: 'rate', action })
1926
2040
  return
1927
2041
  }
1928
2042
  const payload = data.payload && typeof data.payload === 'object' ? data.payload : {}
@@ -1932,12 +2046,14 @@ window.__ModuleLoader__.load({
1932
2046
  } catch {
1933
2047
  return
1934
2048
  }
1935
- if (serialized.length > WIDGET_ACTION_MAX_CHARS) {
1936
- reportLog('warn', 'widget.action-dropped', { reason: 'size', chars: serialized.length })
2049
+ const bytes = typeof TextEncoder === 'function'
2050
+ ? new TextEncoder().encode(serialized).byteLength
2051
+ : serialized.length
2052
+ if (bytes > WIDGET_ACTION_MAX_BYTES) {
2053
+ reportLog('warn', 'widget.action-dropped', { reason: 'size', bytes })
1937
2054
  return
1938
2055
  }
1939
- recent.push(now)
1940
- onAction?.(String(data.action || 'action').slice(0, 64), payload)
2056
+ onAction?.(action, payload)
1941
2057
  }
1942
2058
  }
1943
2059
  window.addEventListener('message', onMessage)
@@ -2072,14 +2188,21 @@ window.__ModuleLoader__.load({
2072
2188
  /** Resolved host tokens mapped onto Mermaid's base theme. */
2073
2189
  function mermaidThemeVariables(host) {
2074
2190
  const theme = themeSnapshot(host)
2191
+ // Mermaid parses every value as a colour and rejects `currentColor`, so a token
2192
+ // that is briefly unreadable (an appearance switch mid-render) must still fall
2193
+ // back to a concrete colour instead of failing the whole diagram.
2194
+ const background = theme.bg ?? '#ffffff'
2195
+ const card = theme.card ?? background
2196
+ const text = theme.text ?? '#111111'
2197
+ const border = theme.border ?? '#8a8a8a'
2075
2198
  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',
2199
+ background,
2200
+ primaryColor: card,
2201
+ primaryTextColor: text,
2202
+ primaryBorderColor: border,
2203
+ lineColor: theme.muted ?? border,
2204
+ secondaryColor: card,
2205
+ tertiaryColor: background,
2083
2206
  fontFamily: 'inherit',
2084
2207
  fontSize: '12px',
2085
2208
  }
@@ -2369,6 +2492,181 @@ window.__ModuleLoader__.load({
2369
2492
  setTimeout(() => URL.revokeObjectURL(url), 1000)
2370
2493
  }
2371
2494
 
2495
+ const ZOOM_MIN = 1
2496
+ const ZOOM_MAX = 4
2497
+ const ZOOM_STEP = 1.25
2498
+
2499
+ /**
2500
+ * Zoom and pan for an SVG or rich figure.
2501
+ *
2502
+ * The figure keeps its natural layout size; only a transform changes, so text stays
2503
+ * crisp and the card never reflows. Dragging pans the clipped viewport, and plain
2504
+ * wheel scrolling is left alone so the page still scrolls over the card.
2505
+ */
2506
+ function ZoomPan({ children, label, fullscreen = false }) {
2507
+ const view = useView()
2508
+ const [zoom, setZoom] = useState({ scale: 1, x: 0, y: 0 })
2509
+ const [panning, setPanning] = useState(false)
2510
+ const [overflowing, setOverflowing] = useState(false)
2511
+ const viewportRef = useRef(null)
2512
+ const stageRef = useRef(null)
2513
+ const drag = useRef(null)
2514
+ // The observer below reads the current view without re-subscribing per frame.
2515
+ const zoomRef = useRef(zoom)
2516
+ useEffect(() => {
2517
+ zoomRef.current = zoom
2518
+ }, [zoom])
2519
+
2520
+ const sizes = () => {
2521
+ const stage = stageRef.current
2522
+ const viewport = viewportRef.current
2523
+ return {
2524
+ width: stage?.offsetWidth ?? 0,
2525
+ height: stage?.offsetHeight ?? 0,
2526
+ viewW: viewport?.clientWidth ?? 0,
2527
+ viewH: viewport?.clientHeight ?? 0,
2528
+ }
2529
+ }
2530
+ const clampOffset = (next, scale) => {
2531
+ const box = sizes()
2532
+ const limitX = Math.max(0, box.width * scale - box.viewW)
2533
+ const limitY = Math.max(0, box.height * scale - box.viewH)
2534
+ return {
2535
+ x: Math.min(0, Math.max(-limitX, next.x)),
2536
+ y: Math.min(0, Math.max(-limitY, next.y)),
2537
+ }
2538
+ }
2539
+ const zoomBy = useCallback((factor) => {
2540
+ setZoom((current) => {
2541
+ const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, current.scale * factor))
2542
+ if (Math.abs(next - current.scale) < 0.001) return current
2543
+ if (next === ZOOM_MIN) return { scale: ZOOM_MIN, x: 0, y: 0 }
2544
+ const box = sizes()
2545
+ const ratio = next / current.scale
2546
+ // Keep the viewport center fixed while the figure grows around it.
2547
+ const centered = {
2548
+ x: (current.x - box.viewW / 2) * ratio + box.viewW / 2,
2549
+ y: (current.y - box.viewH / 2) * ratio + box.viewH / 2,
2550
+ }
2551
+ return { scale: next, ...clampOffset(centered, next) }
2552
+ })
2553
+ }, [])
2554
+ const reset = useCallback(() => setZoom({ scale: ZOOM_MIN, x: 0, y: 0 }), [])
2555
+
2556
+ useEffect(() => {
2557
+ const element = viewportRef.current
2558
+ if (!element) return undefined
2559
+ const onWheel = (event) => {
2560
+ if (!(event.ctrlKey || event.metaKey)) return
2561
+ event.preventDefault()
2562
+ zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP)
2563
+ }
2564
+ element.addEventListener('wheel', onWheel, { passive: false })
2565
+ return () => element.removeEventListener('wheel', onWheel)
2566
+ }, [zoomBy])
2567
+
2568
+ // A redraw or a card resize changes the pannable area; keep the offset in range.
2569
+ useEffect(() => {
2570
+ const stage = stageRef.current
2571
+ const viewport = viewportRef.current
2572
+ if (typeof ResizeObserver !== 'function' || !stage || !viewport) return undefined
2573
+ const observer = new ResizeObserver(() => {
2574
+ const current = zoomRef.current
2575
+ const box = sizes()
2576
+ // Both updates are issued here rather than inside a state updater: an updater
2577
+ // must stay pure, and React may run it twice while detecting impurity.
2578
+ setOverflowing(box.width * current.scale > box.viewW + 1 || box.height * current.scale > box.viewH + 1)
2579
+ if (current.scale === ZOOM_MIN && current.x === 0 && current.y === 0) return
2580
+ const clamped = clampOffset({ x: current.x, y: current.y }, current.scale)
2581
+ if (clamped.x !== current.x || clamped.y !== current.y) setZoom({ ...current, ...clamped })
2582
+ })
2583
+ observer.observe(stage)
2584
+ observer.observe(viewport)
2585
+ return () => observer.disconnect()
2586
+ }, [])
2587
+
2588
+ useEffect(() => {
2589
+ if (!fullscreen) return undefined
2590
+ const body = viewportRef.current?.closest('.mosaic-overlay-body')
2591
+ if (!body) return undefined
2592
+ body.setAttribute('data-figure', 'true')
2593
+ return () => body.removeAttribute('data-figure')
2594
+ }, [fullscreen])
2595
+
2596
+ const pannable = zoom.scale > ZOOM_MIN || overflowing
2597
+ const onPointerDown = (event) => {
2598
+ if (event.button !== 0 || !pannable) return
2599
+ drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, origin: { x: zoom.x, y: zoom.y } }
2600
+ try { event.currentTarget.setPointerCapture?.(event.pointerId) } catch {}
2601
+ setPanning(true)
2602
+ }
2603
+ const onPointerMove = (event) => {
2604
+ const current = drag.current
2605
+ if (!current || current.pointerId !== event.pointerId) return
2606
+ const next = clampOffset({
2607
+ x: current.origin.x + (event.clientX - current.x),
2608
+ y: current.origin.y + (event.clientY - current.y),
2609
+ }, zoom.scale)
2610
+ setZoom((state) => ({ ...state, ...next }))
2611
+ }
2612
+ const endDrag = (event) => {
2613
+ if (!drag.current || drag.current.pointerId !== event.pointerId) return
2614
+ drag.current = null
2615
+ setPanning(false)
2616
+ try { event.currentTarget.releasePointerCapture?.(event.pointerId) } catch {}
2617
+ }
2618
+ const onKeyDown = (event) => {
2619
+ if (event.key === '+' || event.key === '=') { event.preventDefault(); zoomBy(ZOOM_STEP) }
2620
+ else if (event.key === '-') { event.preventDefault(); zoomBy(1 / ZOOM_STEP) }
2621
+ else if (event.key === '0') { event.preventDefault(); reset() }
2622
+ }
2623
+
2624
+ return h('div', {
2625
+ className: `mosaic-zoom${fullscreen ? ' mosaic-zoom-full' : ''}`,
2626
+ 'data-mosaic-zoom': '',
2627
+ role: 'group',
2628
+ 'aria-label': label ?? translate('zoomIn'),
2629
+ },
2630
+ h('div', { className: 'mosaic-zoom-bar' },
2631
+ h('button', {
2632
+ type: 'button', className: 'mosaic-zoom-btn', disabled: zoom.scale <= ZOOM_MIN,
2633
+ title: translate('zoomOut'), 'aria-label': translate('zoomOut'), onClick: () => zoomBy(1 / ZOOM_STEP),
2634
+ }, '−'),
2635
+ h('span', { className: 'mosaic-zoom-value', 'aria-live': 'polite' }, `${Math.round(zoom.scale * 100)}%`),
2636
+ h('button', {
2637
+ type: 'button', className: 'mosaic-zoom-btn', disabled: zoom.scale >= ZOOM_MAX,
2638
+ title: translate('zoomIn'), 'aria-label': translate('zoomIn'), onClick: () => zoomBy(ZOOM_STEP),
2639
+ }, '+'),
2640
+ h('button', {
2641
+ type: 'button', className: 'mosaic-zoom-btn', disabled: zoom.scale === ZOOM_MIN && zoom.x === 0 && zoom.y === 0,
2642
+ title: translate('zoomReset'), 'aria-label': translate('zoomReset'), onClick: reset,
2643
+ }, '⟲'),
2644
+ !fullscreen && view ? h('button', {
2645
+ type: 'button', className: 'mosaic-zoom-btn', title: translate('zoomFullscreen'), 'aria-label': translate('zoomFullscreen'),
2646
+ onClick: () => view.openOverlay(h(ZoomPan, { key: 'zoom-fullscreen', label, fullscreen: true }, children)),
2647
+ }, '⤢') : null),
2648
+ h('div', {
2649
+ ref: viewportRef,
2650
+ className: 'mosaic-zoom-viewport',
2651
+ tabIndex: 0,
2652
+ 'data-pannable': String(pannable),
2653
+ 'data-panning': String(panning),
2654
+ title: translate('zoomHint'),
2655
+ onPointerDown,
2656
+ onPointerMove,
2657
+ onPointerUp: endDrag,
2658
+ onPointerCancel: endDrag,
2659
+ onDoubleClick: reset,
2660
+ onKeyDown,
2661
+ },
2662
+ h('div', {
2663
+ ref: stageRef,
2664
+ className: 'mosaic-zoom-stage',
2665
+ 'data-panning': String(panning),
2666
+ style: { transform: `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.scale})` },
2667
+ }, children)))
2668
+ }
2669
+
2372
2670
  function MermaidDiagram({ node, fallback }) {
2373
2671
  const containerRef = useRef(null)
2374
2672
  const [state, setState] = useState({ status: 'loading', svg: '' })
@@ -2391,14 +2689,15 @@ window.__ModuleLoader__.load({
2391
2689
  return () => { alive = false }
2392
2690
  }, [node, revision])
2393
2691
  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
- })
2692
+ return h(ZoomPan, { label: node.title ?? 'diagram' },
2693
+ h('div', {
2694
+ ref: containerRef,
2695
+ className: 'mosaic-mermaid',
2696
+ 'data-diagram-engine': 'mermaid',
2697
+ // The string already passed sanitizeMermaidSvg; Mermaid itself runs with
2698
+ // securityLevel 'strict' and htmlLabels off.
2699
+ dangerouslySetInnerHTML: { __html: state.svg },
2700
+ }))
2402
2701
  }
2403
2702
  if (state.status === 'error') {
2404
2703
  return h('div', null, fallback, h('div', { className: 'mosaic-caption-line' }, translate('mermaidFallback', { message: state.message })))
@@ -2694,14 +2993,20 @@ window.__ModuleLoader__.load({
2694
2993
  if (node.kind === 'sequence') {
2695
2994
  return h('div', null, head, h(MermaidDiagram, {
2696
2995
  node,
2697
- fallback: h('div', { className: 'mosaic-diagram-wrap' }, h(SequenceDiagram, { node })),
2996
+ fallback: h(ZoomPan, { label: node.title ?? 'diagram' },
2997
+ h('div', { className: 'mosaic-diagram-wrap' }, h(SequenceDiagram, { node }))),
2698
2998
  }))
2699
2999
  }
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 }))
3000
+ if (node.kind === 'timeline') {
3001
+ return h('div', null, head, h(ZoomPan, { label: node.title ?? 'diagram' }, h(TimelineDiagram, { node })))
3002
+ }
3003
+ if (node.kind === 'layers') {
3004
+ return h('div', null, head, h(ZoomPan, { label: node.title ?? 'diagram' }, h(LayersDiagram, { node })))
3005
+ }
2702
3006
  return h('div', null, head, h(MermaidDiagram, {
2703
3007
  node,
2704
- fallback: h('div', { className: 'mosaic-diagram-wrap' }, h(FlowchartDiagram, { node })),
3008
+ fallback: h(ZoomPan, { label: node.title ?? 'diagram' },
3009
+ h('div', { className: 'mosaic-diagram-wrap' }, h(FlowchartDiagram, { node }))),
2705
3010
  }))
2706
3011
  }
2707
3012
 
@@ -2752,7 +3057,7 @@ window.__ModuleLoader__.load({
2752
3057
  case 'callout': return h(CalloutNode, { node })
2753
3058
  case 'code': return h(CodeNode, { node })
2754
3059
  case 'diff': return h(DiffNode, { node })
2755
- case 'chart': return h(ChartNode, { node })
3060
+ case 'chart': return h(ZoomPan, { label: node.title ?? `chart` }, h(ChartNode, { node }))
2756
3061
  case 'diagram': return h(DiagramNode, { node })
2757
3062
  case 'image': return h(ImageNode, { node })
2758
3063
  case 'video': return h(VideoNode, { node })
@@ -3186,6 +3491,10 @@ window.__ModuleLoader__.load({
3186
3491
  console.info('[mosaic] client active; fence=dsh-view')
3187
3492
  }
3188
3493
 
3189
- return { inject: ['slots', 'sessions', 'locale'], apply }
3494
+ // Pure helpers the Node test suite can reach; the loader reads only `apply` and
3495
+ // `inject`, so this extra key is inert at runtime.
3496
+ const __test = { widgetDocument, widgetParts, WIDGET_CSP, widgetBridge }
3497
+
3498
+ return { inject: ['slots', 'sessions', 'locale'], apply, __test }
3190
3499
  },
3191
3500
  })
package/lib/index.js CHANGED
@@ -312,18 +312,25 @@ export function apply(ctx, config) {
312
312
  }
313
313
  }
314
314
 
315
- /** Accept one bounded diagnostic line from the browser half. */
316
- async function handleClientLog(req, res) {
315
+ /**
316
+ * Accept one bounded diagnostic line from the browser half.
317
+ *
318
+ * @param {import('node:http').IncomingMessage} req - request.
319
+ * @param {import('node:http').ServerResponse} res - response.
320
+ * @param {{ bindHost?: string, trustedHosts?: readonly string[] }} policy - the
321
+ * authorities this listener answers.
322
+ */
323
+ async function handleClientLog(req, res, policy) {
317
324
  if (req.method !== 'POST') {
318
325
  res.writeHead(405)
319
326
  res.end()
320
327
  return
321
328
  }
322
- const policy = crossSiteError(req)
323
- if (policy !== undefined) {
324
- log.warn('client.log-rejected', { reason: policy })
329
+ const rejection = crossSiteError(req, policy)
330
+ if (rejection !== undefined) {
331
+ log.warn('client.log-rejected', { reason: rejection })
325
332
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
326
- res.end(JSON.stringify({ error: policy }))
333
+ res.end(JSON.stringify({ error: rejection }))
327
334
  return
328
335
  }
329
336
  let body
@@ -339,6 +346,11 @@ export function apply(ctx, config) {
339
346
  const fields = { source: 'client', ...detail }
340
347
  if (body?.level === 'error') log.error(`client.${event}`, body?.message === undefined ? undefined : new Error(String(body.message)), fields)
341
348
  else log.warn(`client.${event}`, fields)
349
+ // Answer only once the line is durable, so 204 is a receipt rather than a promise
350
+ // the caller has to confirm by polling the file.
351
+ try {
352
+ await log.flush()
353
+ } catch {}
342
354
  res.writeHead(204)
343
355
  res.end()
344
356
  }
@@ -347,18 +359,33 @@ export function apply(ctx, config) {
347
359
  // A host without a webserver keeps the built-in SVG fallback.
348
360
  ctx.inject(['webServer'], (webCtx) => {
349
361
  const webServer = webCtx.webServer
362
+ // A request authority is ours when it names loopback, this listener's own bind
363
+ // address, or a host the deployment declared. Comparing Origin to Host is not
364
+ // enough: under DNS rebinding both carry the attacker's name.
365
+ const authority = {
366
+ ...typeof webServer.host === 'string' && webServer.host !== '' ? { bindHost: webServer.host } : {},
367
+ ...Array.isArray(config?.trustedHosts) ? { trustedHosts: config.trustedHosts } : {},
368
+ }
350
369
  webCtx.effect(
351
370
  () => webServer.register({ kind: 'prefix', path: ASSET_ROUTE_PATH, handler: tracedRoute('assets', serveMosaicAsset) }),
352
371
  'dsh-mosaic: asset route',
353
372
  )
354
- // The settings page reads and writes the prompt here. State-changing requests are
355
- // same-origin only; see requestPolicyError for the exact checks.
373
+ // The settings page reads and writes the prompt here. Requests must name this
374
+ // listener and come from its own origin; see requestPolicyError for the checks.
356
375
  webCtx.effect(
357
- () => webServer.register({ kind: 'exact', path: PROMPT_ROUTE, handler: tracedRoute('prompt', (req, res) => prompts.handle(req, res)) }),
376
+ () => webServer.register({
377
+ kind: 'exact',
378
+ path: PROMPT_ROUTE,
379
+ handler: tracedRoute('prompt', (req, res) => prompts.handle(req, res, authority)),
380
+ }),
358
381
  'dsh-mosaic: prompt route',
359
382
  )
360
383
  webCtx.effect(
361
- () => webServer.register({ kind: 'exact', path: LOG_ROUTE, handler: (req, res) => handleClientLog(req, res) }),
384
+ () => webServer.register({
385
+ kind: 'exact',
386
+ path: LOG_ROUTE,
387
+ handler: tracedRoute('log', (req, res) => handleClientLog(req, res, authority)),
388
+ }),
362
389
  'dsh-mosaic: log route',
363
390
  )
364
391
  })
@@ -120,17 +120,90 @@ export function parsePromptBody(body) {
120
120
  return { ok: true, prompt }
121
121
  }
122
122
 
123
+ /**
124
+ * Parse one bare `Host` authority into a lowercase hostname, or `undefined` when the
125
+ * value is not a plain `host[:port]`.
126
+ *
127
+ * @param {unknown} authority - the raw header value.
128
+ * @returns {string | undefined} the hostname, without IPv6 brackets.
129
+ */
130
+ function hostnameOf(authority) {
131
+ if (typeof authority !== 'string' || authority.trim() === '') return undefined
132
+ let url
133
+ try {
134
+ url = new URL(`http://${authority}`)
135
+ } catch {
136
+ return undefined
137
+ }
138
+ // A bare authority cannot carry a path, query or fragment.
139
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '') return undefined
140
+ return url.hostname.replace(/^\[/u, '').replace(/\]$/u, '').toLowerCase()
141
+ }
142
+
143
+ /** The port one authority names, or `undefined` when it omits one. */
144
+ function portOf(authority) {
145
+ try {
146
+ const url = new URL(`http://${authority}`)
147
+ return url.port === '' ? undefined : url.port
148
+ } catch {
149
+ return undefined
150
+ }
151
+ }
152
+
153
+ /** Whether a hostname names this machine's loopback interface. */
154
+ function isLoopbackHostname(name) {
155
+ if (name === 'localhost' || name === '::1') return true
156
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(name)) return true
157
+ return /^::ffff:127\./u.test(name)
158
+ }
159
+
160
+ /**
161
+ * Reject a request whose `Host` this deployment does not answer.
162
+ *
163
+ * The `Origin`/`Host` comparison alone cannot stop DNS rebinding: an attacker page at
164
+ * `evil.example` that resolves to this listener sends `Host: evil.example`,
165
+ * `Origin: http://evil.example` and `Sec-Fetch-Site: same-origin`, so every one of those
166
+ * agrees while the request is foreign. The authority itself therefore has to be one this
167
+ * listener owns — loopback, the configured bind address, or a declared trusted host —
168
+ * which is the fence `dsh-client-connection` already applies to `/api`.
169
+ *
170
+ * @param {import('node:http').IncomingMessage} req - the request under test.
171
+ * @param {{ bindHost?: string, trustedHosts?: readonly string[] }} [options] - the
172
+ * authorities this deployment answers beyond loopback.
173
+ * @returns {string | undefined} a rejection reason, or `undefined` when it is ours.
174
+ */
175
+ export function foreignHostError(req, options = {}) {
176
+ const raw = req.headers.host
177
+ const name = hostnameOf(raw)
178
+ if (name === undefined) return 'invalid_host'
179
+ if (isLoopbackHostname(name)) return undefined
180
+ const bind = hostnameOf(options.bindHost)
181
+ if (bind !== undefined && name === bind) return undefined
182
+ for (const entry of options.trustedHosts ?? []) {
183
+ const trusted = hostnameOf(entry)
184
+ if (trusted === undefined || trusted !== name) continue
185
+ const port = portOf(entry)
186
+ if (port === undefined || port === portOf(raw)) return undefined
187
+ }
188
+ return 'foreign_host'
189
+ }
190
+
123
191
  /**
124
192
  * Reject a state-changing request that a foreign page could have triggered.
125
193
  *
126
194
  * `Sec-Fetch-Site` is sent by the browser and cannot be forged by page script; a
127
195
  * foreign origin also fails the `Origin` comparison, and a JSON content type forces a
128
- * preflight that this route never approves.
196
+ * preflight that this route never approves. The `Host` fence runs first, because every
197
+ * other check agrees with itself once the browser has been rebound to this listener.
129
198
  *
130
199
  * @param {import('node:http').IncomingMessage} req - the request under test.
200
+ * @param {{ bindHost?: string, trustedHosts?: readonly string[] }} [options] - the
201
+ * authorities this deployment answers beyond loopback.
131
202
  * @returns {string | undefined} a rejection reason, or `undefined` when allowed.
132
203
  */
133
- export function crossSiteError(req) {
204
+ export function crossSiteError(req, options = {}) {
205
+ const foreign = foreignHostError(req, options)
206
+ if (foreign !== undefined) return foreign
134
207
  const site = req.headers['sec-fetch-site']
135
208
  if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return 'cross_site'
136
209
  const origin = req.headers.origin
@@ -147,8 +220,8 @@ export function crossSiteError(req) {
147
220
  }
148
221
 
149
222
  /** Policy for a state-changing request: same-origin plus a JSON body. */
150
- export function requestPolicyError(req) {
151
- const crossSite = crossSiteError(req)
223
+ export function requestPolicyError(req, options = {}) {
224
+ const crossSite = crossSiteError(req, options)
152
225
  if (crossSite !== undefined) return crossSite
153
226
  const type = req.headers['content-type']
154
227
  if (typeof type !== 'string' || !type.toLowerCase().startsWith('application/json')) return 'json_required'
@@ -190,6 +263,10 @@ function sendJson(res, status, body) {
190
263
  * @param {(ctx: object, text: string) => () => void} options.registerSection - registers
191
264
  * the prompt section and returns its disposer.
192
265
  * @param {string} [options.path] - override file path; defaults to the Host data dir.
266
+ * @param {string} [options.bindHost] - the listener's own bind address, accepted as a
267
+ * request authority in addition to loopback.
268
+ * @param {readonly string[]} [options.trustedHosts] - further authorities this
269
+ * deployment answers.
193
270
  */
194
271
  export function createPromptController(options) {
195
272
  const { ctx, config, resolveConfigPrompt, builtin, registerSection } = options
@@ -200,9 +277,23 @@ export function createPromptController(options) {
200
277
  // browser half working after a route change.
201
278
  const assets = options.assets ?? {}
202
279
 
280
+ // The authorities this listener answers beyond loopback; both routes share them.
281
+ const policy = {
282
+ ...options.bindHost === undefined ? {} : { bindHost: options.bindHost },
283
+ ...Array.isArray(options.trustedHosts) ? { trustedHosts: options.trustedHosts } : {},
284
+ }
285
+
203
286
  const state = {
204
287
  /** Saved by the user in this profile, or `undefined`. */
205
288
  user: undefined,
289
+ /**
290
+ * Whether a user action — a save *or* a reset — has landed in this process.
291
+ * `user === undefined` cannot carry that meaning, because a reset sets it too,
292
+ * so a hydrate that resolves after a reset would otherwise restore the old text.
293
+ */
294
+ userModified: false,
295
+ /** Monotonic version of the effective prompt; clients echo it back to save. */
296
+ revision: 0,
206
297
  /** From the row's `config.prompt`, or `undefined`. */
207
298
  configured: resolveConfigPrompt(config) === builtin ? undefined : resolveConfigPrompt(config),
208
299
  /** Whether the last write reached disk. */
@@ -231,6 +322,7 @@ export function createPromptController(options) {
231
322
  configuredPrompt: state.configured,
232
323
  customized: source() !== 'builtin',
233
324
  source: source(),
325
+ revision: state.revision,
234
326
  persisted: state.persisted,
235
327
  ...state.persistenceError === undefined ? {} : { persistenceError: state.persistenceError },
236
328
  ...state.readError === undefined ? {} : { readError: state.readError },
@@ -270,8 +362,10 @@ export function createPromptController(options) {
270
362
  /** Pick up a saved override on activation. */
271
363
  async hydrate() {
272
364
  const stored = await readPromptFile(state.path)
273
- // A `start()` may have been revoked, or a save may have landed first.
274
- if (!state.active || state.user !== undefined) return snapshot()
365
+ // A `start()` may have been revoked, or the user may have saved or reset while
366
+ // this read was in flight. A reset leaves `user` undefined, so only the flag can
367
+ // tell "nothing saved yet" apart from "the user cleared it".
368
+ if (!state.active || state.userModified) return snapshot()
275
369
  if (stored.error !== undefined) {
276
370
  // Keep the file: the user may want to recover it, and a later save overwrites it.
277
371
  state.readError = stored.error
@@ -284,6 +378,7 @@ export function createPromptController(options) {
284
378
  }
285
379
  state.user = stored.prompt
286
380
  state.persisted = true
381
+ state.revision += 1
287
382
  applySection()
288
383
  log.info('prompt.hydrate', { source: source(), stored: true, chars: stored.prompt.length })
289
384
  return snapshot()
@@ -299,8 +394,19 @@ export function createPromptController(options) {
299
394
  log.warn('prompt.update-rejected', { reason: parsed.error })
300
395
  return { status: 400, body: { error: parsed.error } }
301
396
  }
397
+ // Two browsers share this one file. A save that names the revision it read is
398
+ // refused when another tab has moved on, instead of silently overwriting it.
399
+ const expected = body !== null && typeof body === 'object' && Number.isFinite(body.expectedRevision)
400
+ ? body.expectedRevision
401
+ : undefined
402
+ if (expected !== undefined && expected !== state.revision) {
403
+ log.warn('prompt.update-rejected', { reason: 'revision_conflict' })
404
+ return { status: 409, body: { error: 'revision_conflict', ...snapshot() } }
405
+ }
302
406
  const next = parsed.reset ? undefined : parsed.prompt
407
+ state.userModified = true
303
408
  state.user = next
409
+ state.revision += 1
304
410
  const write = await (writes = writes.then(() => writeStoredPrompt(state.path, next)))
305
411
  if (!state.active) return { status: 503, body: { error: 'inactive' } }
306
412
  state.persisted = write.persisted
@@ -319,10 +425,13 @@ export function createPromptController(options) {
319
425
  *
320
426
  * @param {import('node:http').IncomingMessage} req - request.
321
427
  * @param {import('node:http').ServerResponse} res - response.
428
+ * @param {{ bindHost?: string, trustedHosts?: readonly string[] }} [overrides] -
429
+ * authorities known only once the route is registered.
322
430
  */
323
- async handle(req, res) {
431
+ async handle(req, res, overrides) {
432
+ const active = overrides === undefined ? policy : { ...policy, ...overrides }
324
433
  if (req.method === 'GET' || req.method === 'HEAD') {
325
- const readPolicy = crossSiteError(req)
434
+ const readPolicy = crossSiteError(req, active)
326
435
  if (readPolicy !== undefined) {
327
436
  sendJson(res, 403, { error: readPolicy })
328
437
  return
@@ -334,9 +443,9 @@ export function createPromptController(options) {
334
443
  sendJson(res, 405, { error: 'method_not_allowed' })
335
444
  return
336
445
  }
337
- const policy = requestPolicyError(req)
338
- if (policy !== undefined) {
339
- sendJson(res, 403, { error: policy })
446
+ const rejection = requestPolicyError(req, active)
447
+ if (rejection !== undefined) {
448
+ sendJson(res, 403, { error: rejection })
340
449
  return
341
450
  }
342
451
  let body
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mosaic",
3
- "version": "0.2.1",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Lightweight visual answers for DeepSeek Harness: one compact visual style, an editable model prompt, dsh-view components, Mermaid diagrams, a sandboxed HTML/JS escape hatch, local-first interactions, and link-only media cards.",
6
6
  "author": "imwhatiam",