@kidli1412/dsh-token-heatmap 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,16 +31,16 @@ GitHub 风格自然年热力图:覆盖所选自然年 1月–12月(`‹ 2026
31
31
 
32
32
  ### 卡片设置 / In-card settings
33
33
 
34
- ![⚙ 设置面板](docs/卡片设置面板.jpg)
34
+ ![⚙ 悬浮设置面板](docs/卡片设置面板.jpg)
35
35
 
36
- - ⚙️ **设置就在卡片上**:点标题行最右端的 **⚙**(`刷新 [⚙]`)在卡片底部展开设置面板,再点一次、点面板的 **×**、按 **Esc**、或把焦点移出面板都会收起。**插件不再往 DSH 设置(设置 → 插件 → 插件配置)里注册任何卡片**,所以那里看不到本插件。
37
- - **配色方案**:六个色板按钮,**点击即时生效**(不需要"保存");**默认视图**:年 / 月,决定新会话页面首次打开时显示哪个视图(当次会话手动切换只影响当前页面)。
36
+ - ⚙️ **设置就在卡片上**:点标题行最右端的 **⚙**(`刷新 [⚙]`)弹出**悬浮设置面板** —— 位置在 ⚙ 正上方 8px、水平居中对齐、贴边留 12px,超出视口会自动钳制;点面板外的任意位置、按 **Esc**、或再点一次 ⚙ 都会收起。**插件不再往 DSH 设置(设置 → 插件 → 插件配置)里注册任何卡片**,所以那里看不到本插件。
37
+ - **配色方案**:六个色板按钮,**点击即时生效**(不需要"保存");**默认视图**:年 / 月,决定新会话页面首次打开时显示哪个视图(当次会话手动切换只影响当前页面)。面板底部是阈值图例(悬停看各档范围)与一行说明。
38
38
  - 写入失败时面板底部会红字提示"保存失败,已回到服务端的值"(settings scope 复核后回滚乐观值)。
39
- - 配置经 `token-heatmap` settings namespace 持久化到 `<DSH_HOME>/settings.yaml`(0.1.1 及更早版本存在 `<DSH_HOME>/storages/token-heatmap-config.json` 的旧配置会在启动时自动迁移)。
39
+ - 配置作为**本插件的 profile 配置**持久化:DSH 0.2.0 用插件导出的 `Config` schema 生成设置表单,表单以 **profile entry id**(`token-heatmap`)为键,值写进 profile patch(旧的 `<DSH_HOME>/settings.yaml` 段与 0.1.1 及更早的 `<DSH_HOME>/storages/token-heatmap-config.json` 都会在启动时**一次性迁移/抢救**回配置)。
40
40
 
41
41
  ## 安装 / Install
42
42
 
43
- 需要 `web` profile 与 `pnpm`。DSH 兼容版本见下方「兼容性 / Compatibility」;运行于 `@deepseek-ai/dsh >= 0.1.2-alpha.4`(0.1.2 版本线)。
43
+ 需要 `web` profile 与 `pnpm`。DSH 兼容版本见下方「兼容性 / Compatibility」;运行于 `@deepseek-ai/dsh ^0.2.0-rc.2`(0.2.0 版本线;0.1.2 线请用 0.4.2)。
44
44
 
45
45
  从 npm 安装:
46
46
 
@@ -68,8 +68,8 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
68
68
 
69
69
  ## 工作原理 / How it works
70
70
 
71
- - **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,**实时折叠会话事件**(监听官方 `session/event`,每个 `assistant/chunk`/`assistant/message` 的 `usage` 事件即时写入缓存,不依赖 hero 屏挂载);启动时一次性补折叠已存在的 live 会话(如 resumed 会话);请求时 `collectUsage` 再做一次增量同步兜底,并枚举 **已归档(stored)会话**补齐历史——两种 `sessionPersistence` 接口都支持:0.1.2 线的 `listSnapshots()` + `readFrom()`,以及 0.1.3 起取代它们的 `list()` + `open()`/`handle.read()`。同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天;**fork(`isSeeded`)会话从它的继承切点开始折叠**——日志开头那批属于父会话的事件在父会话侧已折叠,跳过它们才不会把同一批 token 计两次;按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`。通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(配色 + 默认视图)由插件注册的 `token-heatmap` settings namespace 持有(settings.yaml),`GET/POST /api/token-heatmap/config` 作为回环兼容 API 读写同一 namespace(0.1.x 的 `enabled` 开关已废弃,该字段只作为常量 `true` 回给旧客户端),0.1.1 及更早的 `token-heatmap-config.json` 文档在启动时一次性迁移。
72
- - **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅在 `session.blank`(新会话 hero 屏;旧宿主回退 `composerPhase === "blank"`)时渲染——卡片没有显示开关,hero 屏上始终显示。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。同一份数据由 `buildGrid()`(年,53 列 × 7 行)与 `buildMonthGrid()`(月,7 列 × 5–6 行,带日号)两个纯函数分别铺格,共用 `levelOf()` 的绝对阈值分档与 `palette` 配色;`‹ ›` 按钮按当前视图步进年或月,边界取"当前年/月"与"数据里最早的月",年/月分段按钮紧跟在步进器后面,**⚙ 设置面板**在卡片底部展开(内嵌面板,动作:⚙ 切换 / × / Esc / 焦点移出)。**不注册 `settings.plugin.item`**(官方"插件配置"页签只渲染"Host 实际 serve 的 namespace ∩ 客户端已注册 key"的卡片,本插件不再占这个位置),只经 settings scope 读写 `token-heatmap` namespace(该 namespace 仍由服务端注册,是配置的校验与持久化管道)。
71
+ - **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,**实时折叠会话事件**(监听官方 `session/event`,每个 `assistant/chunk`/`assistant/message` 的 `usage` 事件即时写入缓存,不依赖 hero 屏挂载);启动时一次性补折叠已存在的 live 会话(如 resumed 会话);请求时 `collectUsage` 再做一次增量同步兜底,并枚举 **已归档(stored)会话**补齐历史——两种 `sessionPersistence` 接口都支持:0.1.2 线的 `listSnapshots()` + `readFrom()`,以及 0.1.3 起取代它们的 `list()` + `open()`/`handle.read()`。同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天;**fork(`isSeeded`)会话从它的继承切点开始折叠**——日志开头那批属于父会话的事件在父会话侧已折叠,跳过它们才不会把同一批 token 计两次;按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`。通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(配色 + 默认视图)由**导出的 `Config` schema** 持有(0.2.0 的设置模型:以 entry id `token-heatmap` 为键的表单,值写进 profile patch),`GET/POST /api/token-heatmap/config` 作为回环兼容 API 读写同一份配置(读经 `settings.describe()`,写经 `settings.update()`;0.1.x 的 `enabled` 开关已废弃,该字段只作为常量 `true` 回给旧客户端),0.1.1 及更早的 `token-heatmap-config.json` 文档在启动时一次性迁移。
72
+ - **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅在 `session.blank`(新会话 hero 屏;旧宿主回退 `composerPhase === "blank"`)时渲染——卡片没有显示开关,hero 屏上始终显示。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。同一份数据由 `buildGrid()`(年,53 列 × 7 行)与 `buildMonthGrid()`(月,7 列 × 5–6 行,带日号)两个纯函数分别铺格,共用 `levelOf()` 的绝对阈值分档与 `palette` 配色;`‹ ›` 按钮按当前视图步进年或月,边界取"当前年/月"与"数据里最早的月",年/月分段按钮紧跟在步进器后面,**⚙ 设置面板**在卡片底部展开(内嵌面板,动作:⚙ 切换 / × / Esc / 焦点移出)。**不注册 `settings.plugin.item`**(官方"插件配置"页签只渲染"Host 实际 serve 的 namespace ∩ 客户端已注册 key"的卡片,本插件不再占这个位置),配置经 `ctx.configForms.get("token-heatmap")` 读写本插件配置(0.2.0 的设置模型,取代 0.1.x 的 settings scope)。
73
73
  - 语义与 `dsh-token-meter` 的 `tokenUsage` 投影一致(参考插件 [dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats),MIT)。
74
74
 
75
75
  ## 说明 / Notes
@@ -80,10 +80,17 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
80
80
 
81
81
  ## 兼容性 / Compatibility
82
82
 
83
- - **DSH**:manifest 通过 `dsh.compatibility.dshReleases` 将官方最新三个版本 `0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1` 逐项声明为 `compatible`(DSH STORE 的精确逐版本兼容证据;仅范围声明不会恢复上架)。插件使用的客户端注入(`dsh-api-remotes` / `dsh-client-connection` / `dsh-client-locale` / `dsh-client-ui-conversation` / `dsh-client-ui-settings`)与 Host 服务(`settings` namespace、`webServer` 精确路由)在这条版本线上保持稳定。
83
+ - **DSH**:manifest 通过 `dsh.compatibility.dshReleases` 将当前版本线 `0.2.0-rc.2` 声明为 `compatible`(DSH STORE 的精确逐版本兼容证据;仅范围声明不会恢复上架)。插件使用的客户端注入(`dsh-api-remotes` / `dsh-client-connection` / `dsh-client-locale` / `dsh-client-ui-conversation` / `dsh-client-ui-settings`)、客户端服务(`slots` / `locale` / `configForms`)与 Host 服务(`webServer` 精确路由、`settings.describe` / `settings.update`、`sessions`、`sessionPersistence`)在 0.2.0 版本线上均已在真实安装中核对。
84
84
  - **Node**:`^22.19.0 || >=24.0.0`(与 DSH 一致)。
85
- - **宿主要求(dsh-market 显示)**:`engines.dsh: ^0.1.2-rc.1`,并将运行时依赖的 lockstep 宿主包声明为 `peerDependencies`(`dsh-host-webserver` / `dsh-session` / `dsh-session-persistence` / `dsh-settings` 与客户端模块 `dsh-api-remotes` / `dsh-client-connection` / `dsh-client-locale` / `dsh-client-ui-conversation` / `dsh-client-ui-settings`,均为 `^0.1.2-rc.1`);插件市场会据此显示"宿主要求"并判断与当前 DSH 是否匹配。
86
- - **依赖**:`@deepseek-ai/dsh-settings` 自 0.1.3 起提升为 `^0.1.2-rc.1`、`@deepseek-ai/schemastery` 提升为 `^3.18.2`,与 DSH 0.1.2 版本线对齐。npm 的 prerelease 解析规则下 `^0.1.0-rc.7` 不会解析到 `0.1.2-rc.1`(只会装 `0.1.0-rc.8`),因此较低的范围会拉到与新版 DSH 不同 train 的 settings 副本。
85
+ - **宿主要求(dsh-market 显示)**:`engines.dsh: ^0.2.0-rc.2`,并将运行时依赖的 lockstep 宿主包声明为 `peerDependencies`(`dsh-host-webserver` / `dsh-session` / `dsh-session-persistence` / `dsh-settings` 与客户端模块 `dsh-api-remotes` / `dsh-client-connection` / `dsh-client-locale` / `dsh-client-ui-conversation` / `dsh-client-ui-settings`,均为 `^0.2.0-rc.2`);插件市场会据此显示"宿主要求"并判断与当前 DSH 是否匹配。这些包由 DSH 运行时提供、本插件**从不 import**,所以同时标为 `peerDependenciesMeta.optional`——npm/pnpm 不再把它们装进依赖树(0.2.0 起它们彼此还有 peer 关系,沿用旧的自动安装会直接解析冲突),而"宿主要求"的语义不受影响。
86
+ - **依赖**:`@deepseek-ai/schemastery ^3.18.2`(唯一的真实依赖;`dsh-settings` 自 0.2.0 起不再需要作为 peer 之外的运行时依赖)。
87
+ - **0.5.0(DSH 0.2.0 适配)**:DSH 0.2.0 换了三套契约,宿主兼容闸门会**直接拒绝挂载**声明不符的 bundle(现象:插件在市场里显示"不兼容"、`rows: []`、hero 屏上什么都没有)——
88
+ 1. **宿主要求必须落在 0.2.0 线上**:闸门逐条比对 `peerDependencies` 里的 `@deepseek-ai/dsh*` 与运行时版本(`semver.satisfies(..., { includePrerelease: true })`),`^0.1.2-rc.1` 不满足 `0.2.0-rc.2`,因此本版把 `engines.dsh` 与 9 个宿主 peer 一起提到 `^0.2.0-rc.2`(并把宿主包标为 optional peer,见上)。
89
+ 2. **设置模型重做**:0.1.x 的"插件自建 settings namespace + `settings.register(ns, schema)`"没了,`settings.get(ns)` 也没了。现在**插件导出的 `Config`(schemastery)就是设置表单**,以 **profile entry id**(bundle patch 里的 `id: token-heatmap`)为键;Host 侧读别人用 `settings.describe()`(`serveConfig()` 已改,否则配置端点会静默回默认值)、写自己用 `settings.update(ns, patch)`,浏览器端用 `ctx.configForms.get("token-heatmap")`(`getSnapshot` / `subscribe` / `set`),旧的 `ctx.settingsScope.bind({namespace})` 已从客户端消失。卡片 ⚙ 面板的 UI 与"点击即时生效"完全不变。
90
+ 3. **弹层皮肤同步**:0.2.0 的原生 stat dialog 改用 `border-radius:var(--dsw-radius-lg)` 并加 `backdrop-filter:var(--dsw-menu-backdrop-filter)`,本插件的 ⚙ 悬浮面板跟着改(带 fallback,旧宿主仍渲染 12px 圆角)。
91
+ > 升级时会**自动抢救旧配置**:DSH 自己的 `settings.yaml` 导入按 entry id 进行,而当时本插件正因上面的 peer 不符被拒挂载,导入失败后值只留在 `~/.dsh/settings.yaml.imported` 里。本版 Host 在启动后(等 Loader settle)读一次该文档,若条目还没有用户值就把非默认的 `colorScheme` / `defaultView` 写进配置——一次性、只读、失败即跳过。(`enabled` 字段自 0.3.0 起已废弃,不参与抢救。)
92
+ > 另外提醒:0.2.0 给 `dsh web` 的每个请求加了**签名 cookie 鉴权**,插件的两个端点因此只有带 cookie 的同源页面能到达;用 node/curl 直接探端点拿到 401 是 DSH 的鉴权层,**不代表插件没挂载**。Host 侧的 loopback 精确路由 fence 保留为第二道防线。
93
+ - **0.4.0(设置搬进卡片)**:不再注册官方 `settings.plugin.item` 插槽——设置页(设置 → 插件 → 插件配置)里不再有本插件的卡片,配色与默认视图改在卡片自己的 ⚙ 面板里改,**点击即时生效**(去掉了草稿/保存/放弃那套)。当时 Host 侧的 `settings.register("token-heatmap", schema)` 保留下来当 settings.yaml 的校验与持久化管道;**该注册在 0.5.0 已随 0.2.0 的设置模型一起移除**(导出的 `Config` 就是管道)。升级只影响设置入口位置,已有配置不动。
87
94
  - **0.1.4(DSH 0.1.2 适配)**:rc.1 起 live session 不再携带 `.events` 数组(改用 `session.seq` + `session.eventAt(seq)`,与官方 `dsh-token-meter` 相同),新会话判断从 `composerPhase === "blank"` 改为布尔 `session.blank`;`sessionPersistence` 的 stored 会话枚举在 0.1.3-alpha.2 被替换(`listSnapshots`/`readFrom` → `list()` + `open()`/`handle.read()`),两条接口见 0.1.6 条目。客户端注入模块列表同步为新架构模块(见上)。
88
95
  - **0.1.6(session/event 实时折叠 + stored 会话枚举修复)**:`apply()` 注册官方 `session/event` 监听器,每个 usage 事件即时折叠进缓存,解决 live 会话仅在 hero 屏挂载时才折叠而漏计同一日其他会话用量的问题(表现为当日总量偏小、历史天数丢失);启动时一次性补折叠已存在的 live 会话(如 resumed 会话)。**stored 会话枚举修复**:0.1.3-alpha.2 起 `sessionPersistence` 移除了 `readFrom()` 与 `listSnapshots()`,只保留 `list()` + `open()`/`handle.read()`;旧实现只探测 `list`/`listSnapshots` 却无条件调用 `readFrom`,导致每个 stored 会话抛错并被吞成一条 warn —— 表现为热力图只剩进程内 live 的几天。现在两条接口都支持(`list()` 的 `revision` 同样用于跳过未变更的日志,增量仍按 `seq` 去重与连续性校验),stored 会话可完整补齐历史;两者都不可用时不再误判为"日志被截断",而是保留已折叠天数并告警。token 口径与 `dsh-token-meter` 一致(input + output + cacheRead + cacheWrite,不含 reasoningTokens)。
89
96
  - **0.2.0(月视图 + 默认视图设置)**:新增 `buildMonthGrid()` 月视图(周一起、5–6 行、日号 + 当日 token 数)与 年/月 分段切换,`‹ ›` 按当前视图步进年或月;settings namespace 新增 `defaultView`(`"year" | "month"`)字段——与 `colorScheme` 的"只约束 shape"不同,**`defaultView` 是枚举校验**(未知视图没有可回退的渲染器),旧 Host 上该字段会被 schema 丢弃、旧客户端读到未知值时回退为"年"。0.1.x 的 `settings.yaml` 无需迁移(缺字段即取默认 `year`)。
@@ -91,6 +98,7 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
91
98
  - **0.4.0(设置搬进卡片)**:不再注册官方 `settings.plugin.item` 插槽——设置页(设置 → 插件 → 插件配置)里不再有本插件的卡片,配色与默认视图改在卡片自己的 ⚙ 面板里改,**点击即时生效**(去掉了草稿/保存/放弃那套)。Host 侧 `settings.register("token-heatmap", schema)` 保留:它是 settings.yaml 的校验与持久化管道,与 UI 卡片无关(官方 `settings` 服务的 `get`/`update` 只对已注册 namespace 生效)。升级只影响设置入口位置,已有配置不动。
92
99
 
93
100
  - **0.4.1(fork 会话不再重复计入父会话用量)**:DSH 的 fork 子会话(header `isSeeded=true`)日志以父会话事件的完整复制开头,其前 `inheritedEventCount` 个事件是**父会话**的 usage(父会话折叠时已计入)。此前折叠从 seq 0 读整份日志,同一批 token 被计两次——实测 2026-09-14 由 8.34 亿虚增到 13.15 亿。现在三条折叠路径(`collectUsage` 的 live 折叠、`session/event` 实时监听、stored 日志读取)都从 fork 切点开始:live 会话用官方 `session.inheritedEventCount`;stored 日志用最后一个带 `data.inherited === true` 的 `session/end-seed` 的 `seq + 1`(未打标记的 `session/end-seed` 是 compaction 边界,不算切点,与 `dsh-session-format-v2-to-v3` 的切点推导一致)。**resume 不是 fork**:`isSeeded=false` 的会话种子是它自己的历史,仍整份折叠。缓存格式版本提升到 2,旧缓存(可能含重复计入的天数)会被丢弃重建;父会话日志不可得的极端情况下会少计而非多计。
101
+ - **0.4.2(设置改成悬浮面板)**:⚙ 面板从"卡片底部的内嵌条"改为**悬浮面板**——portal 到 `document.body`、`position:fixed`,锚在 ⚙ 上方 8px 且水平居中对齐,按视口钳制(12px 边距),关闭方式为点外部/Esc/再点 ⚙;表面沿用 DSH 原生弹层 token(`--dsw-specific-menu` + `--dsw-elevation-prominent`,配合 `--dsw-elevation-stroke-color` 的发丝边),与底部统计 pill 的弹层一致。为此客户端 bundle 新增 `require("react-dom")`(原生的 `createPortal`),DSH 的模块图里 react-dom 一直存在,旧宿主不受影响。
94
102
 
95
103
  ## License
96
104
 
package/lib/client.js CHANGED
@@ -7,9 +7,10 @@
7
7
  * columns = weeks, rows = Mon..Sun, all seven weekday labels on the left) or a
8
8
  * single-month calendar, switchable from the header, plus today / this-month /
9
9
  * all-time totals and a ‹period› stepper. The card configures ITSELF through a
10
- * ⚙ panel (palette + default view) persisted in the `token-heatmap` settings
11
- * namespace (settings scope); it deliberately does not register into the
12
- * official `settings.plugin.item` seat (设置 → 插件 → 插件配置), so all of its
10
+ * ⚙ panel (palette + default view) persisted in this plugin's profile CONFIG —
11
+ * DSH 0.2.0 derives the form from the Host's exported `Config` and the card
12
+ * reads/writes it through `ctx.configForms.get("token-heatmap")`; there is no
13
+ * `settings.plugin.item` card and no settings scope any more, so all of its
13
14
  * settings live on the card.
14
15
  *
15
16
  * The card is shown ONLY on the new-session (hero) screen — gated on the
@@ -37,6 +38,9 @@ window.__ModuleLoader__.load({
37
38
 
38
39
  let react = require("react");
39
40
  let react_jsx_runtime = require("react/jsx-runtime");
41
+ // Only for the settings panel, which is PORTALED to document.body so the
42
+ // card's own box can never clip it (the pattern the stat pills use).
43
+ let reactDom = require("react-dom");
40
44
 
41
45
  //#region css
42
46
  const css = [
@@ -88,23 +92,25 @@ window.__ModuleLoader__.load({
88
92
  ".thm_error{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger);border-radius:8px;padding:7px 8px;font-size:11px;line-height:16px;display:flex;justify-content:space-between;align-items:center;gap:8px}",
89
93
  ".thm_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0}",
90
94
  ".thm_loading{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;padding:6px 2px}",
91
- // In-card settings panel (⚙ at the row's right end) — the card
92
- // configures itself; nothing registers into 设置 → 插件 → 插件配置.
95
+ // The ⚙ at the row's right end opens a floating pill panel — the
96
+ // card configures itself; nothing registers into 设置 → 插件 → 插件配置.
93
97
  ".thm_gear{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:6px;align-items:center;padding:2px 4px;display:inline-flex}",
94
98
  ".thm_gear:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
95
99
  ".thm_gear[data-active=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
96
- // The panel is an inset surface on the card: the light theme resolves
97
- // every bg-layer token to the same white, so the fill is a translucent
98
- // gray that reads as a nested surface in both themes.
99
- ".thm_panel{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:rgba(128,128,128,.08);border-radius:12px;flex-direction:column;gap:10px;margin-top:2px;padding:10px 12px 12px;display:flex}",
100
- ".thm_panelHead{align-items:center;gap:8px;display:flex}",
101
- ".thm_panelTitle{color:var(--dsw-alias-label-primary);flex:1;font-size:12px;font-weight:600;line-height:18px}",
102
- ".thm_panelClose{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:4px;flex:none;padding:0 5px;font-size:16px;line-height:20px}",
103
- ".thm_panelClose:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
100
+ // The settings panel mirrors DSH's own pill dialogs: portaled to
101
+ // document.body, position:fixed (so the card can never clip it),
102
+ // measure-then-place above the ⚙ and clamped to the viewport, with
103
+ // the shipped dialog surface + elevation tokens. DSH 0.2.0's stat
104
+ // dialog moved to `--dsw-radius-lg` and a backdrop filter, so the
105
+ // surface follows (the fallbacks keep it rendering on an older host).
106
+ ".thm_panel{position:fixed;z-index:1100;box-sizing:border-box;background:var(--dsw-specific-menu,#fff);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:400px;max-width:calc(100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter);box-shadow:var(--dsw-elevation-prominent,0 8px 32px rgba(0,0,0,.16));color:var(--dsw-alias-label-secondary);cursor:default;border:0;border-radius:var(--dsw-radius-lg,12px);padding:16px;font-size:12px;line-height:18px;text-align:left;font-weight:400}",
107
+ ".thm_panelHead{color:var(--dsw-alias-label-primary);justify-content:space-between;align-items:center;gap:16px;margin-bottom:8px;font-weight:500;display:flex}",
108
+ ".thm_panelTitleLabel{align-items:center;gap:6px;display:inline-flex}",
109
+ ".thm_panelTitleLabel svg{flex:none;width:14px;height:14px}",
110
+ ".thm_panelRule{border-top:.5px solid var(--dsw-alias-border-l2);margin-bottom:12px}",
104
111
  ".thm_group{flex-direction:column;gap:5px;display:flex}",
112
+ ".thm_group+.thm_group{margin-top:14px}",
105
113
  ".thm_groupLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
106
- ".thm_hint{color:var(--dsw-alias-label-caption);font-size:10px;line-height:14px}",
107
- ".thm_hintError{color:var(--dsw-alias-state-error-primary)}",
108
114
  ".thm_swatches{flex-wrap:wrap;gap:6px;display:flex}",
109
115
  ".thm_swatch{cursor:pointer;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);background:0 0;border-radius:6px;padding:3px 8px;font:inherit;display:flex}",
110
116
  ".thm_swatch:hover{border-color:var(--dsw-alias-label-dimmed)}",
@@ -115,7 +121,11 @@ window.__ModuleLoader__.load({
115
121
  ".thm_seg{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;align-items:center;display:inline-flex;overflow:hidden;width:max-content}",
116
122
  ".thm_segBtn{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;padding:1px 10px;font:inherit;font-size:11px;line-height:18px}",
117
123
  ".thm_segBtn:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
118
- ".thm_segBtn[data-active=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);font-weight:600}"
124
+ ".thm_segBtn[data-active=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);font-weight:600}",
125
+ ".thm_panelFoot{border-top:.5px solid var(--dsw-alias-border-l2);margin-top:14px;padding-top:10px}",
126
+ ".thm_footLegend{align-items:center;gap:3px;font-size:10px;line-height:14px;display:flex;color:var(--dsw-alias-label-tertiary)}",
127
+ ".thm_footRule{color:var(--dsw-alias-label-caption);margin-top:6px;font-size:10px;line-height:14px}",
128
+ ".thm_footError{color:var(--dsw-alias-state-error-primary);margin-top:6px;font-size:10px;line-height:14px}"
119
129
  ].join("");
120
130
  const tagId = "@kidli1412/dsh-token-heatmap/TokenHeatmap.module.css";
121
131
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -162,12 +172,14 @@ window.__ModuleLoader__.load({
162
172
  gear: "thm_gear",
163
173
  settingsPanel: "thm_panel",
164
174
  panelHead: "thm_panelHead",
165
- panelTitle: "thm_panelTitle",
166
- panelClose: "thm_panelClose",
175
+ panelTitleLabel: "thm_panelTitleLabel",
176
+ panelRule: "thm_panelRule",
177
+ panelFoot: "thm_panelFoot",
178
+ footLegend: "thm_footLegend",
179
+ footRule: "thm_footRule",
180
+ footError: "thm_footError",
167
181
  settingsGroup: "thm_group",
168
182
  settingsGroupLabel: "thm_groupLabel",
169
- settingsHint: "thm_hint",
170
- settingsHintError: "thm_hintError",
171
183
  settingsSwatches: "thm_swatches",
172
184
  settingsSwatch: "thm_swatch",
173
185
  settingsSwatchCells: "thm_swatchCells",
@@ -217,7 +229,7 @@ window.__ModuleLoader__.load({
217
229
  settingsRed: "红色",
218
230
  settingsPurple: "紫色",
219
231
  settingsTeal: "青色",
220
- settingsPaletteHint: "颜色按每天 token 数的绝对阈值分档:{levels}。",
232
+ settingsThresholdNote: "颜色按每天 token 数的绝对阈值分档(悬停图例看各档范围)。",
221
233
  settingsSaveFailed: "保存失败,已回到服务端的值,请重试。",
222
234
  settingsExpand: "展开设置",
223
235
  settingsCollapse: "收起设置"
@@ -258,7 +270,7 @@ window.__ModuleLoader__.load({
258
270
  settingsRed: "Red",
259
271
  settingsPurple: "Purple",
260
272
  settingsTeal: "Teal",
261
- settingsPaletteHint: "Colors follow absolute per-day token thresholds: {levels}.",
273
+ settingsThresholdNote: "Colors follow absolute per-day token thresholds (hover the legend for each range).",
262
274
  settingsSaveFailed: "Save failed; the server value was restored.",
263
275
  settingsExpand: "Show settings",
264
276
  settingsCollapse: "Hide settings"
@@ -397,13 +409,14 @@ window.__ModuleLoader__.load({
397
409
  return VIEW_MODES.includes(view) ? view : CONFIG_DEFAULTS.defaultView;
398
410
  }
399
411
  /**
400
- * Reactive adapter over one bound settings scope: a
401
- * useSyncExternalStore-compatible store whose snapshot is
402
- * `{ status, colorScheme, defaultView }` derived from the scope.
403
- * `set(patch)` writes the touched fields through scope.set (async; the
404
- * scope fences revisions and reloads on failure), and the snapshot
405
- * only changes after the Host confirms. `reload()` re-reads from the
406
- * Host; `dispose()` removes the subscription.
412
+ * Reactive adapter over one config form (`ctx.configForms.get(entryId)`):
413
+ * a useSyncExternalStore-compatible store whose snapshot is
414
+ * `{ status, colorScheme, defaultView }` derived from the form.
415
+ * `set(patch)` writes the touched fields through `form.set(field, value)`
416
+ * (async; the form fences revisions and reloads on failure), and the
417
+ * snapshot only changes after the Host confirms. `dispose()` removes the
418
+ * subscription. (The pre-0.2.0 `reload()` wrapper is gone: the shared
419
+ * form re-reads on its own, and its API has no `load()`.)
407
420
  */
408
421
  function createConfigStore(scope) {
409
422
  const listeners = [];
@@ -445,7 +458,6 @@ window.__ModuleLoader__.load({
445
458
  await scope.set("defaultView", sanitizeView(patch.defaultView));
446
459
  }
447
460
  },
448
- reload: () => scope.load(),
449
461
  dispose: () => unsubscribe()
450
462
  };
451
463
  }
@@ -606,16 +618,30 @@ window.__ModuleLoader__.load({
606
618
  // Month-view cursor (`YYYY-MM`); initialised from the current month
607
619
  // and re-anchored when it would fall outside the navigable bounds.
608
620
  const [monthCursor, setMonthCursor] = react.useState(() => monthKey(Date.now()));
609
- // In-card settings panel (⚙ at the row's right end); the card
610
- // configures itself here instead of living in 设置 → 插件 → 插件配置.
621
+ // The ⚙ at the row's right end opens the floating settings panel;
622
+ // the card configures itself here instead of living in
623
+ // 设置 → 插件 → 插件配置. The panel is PORTALED to document.body and
624
+ // anchored to this button, so the open state lives here with it.
625
+ const gearRef = react.useRef(null);
611
626
  const [settingsOpen, setSettingsOpen] = react.useState(false);
612
627
  react.useEffect(() => {
613
628
  if (!settingsOpen) return;
614
629
  const onKeyDown = (event) => {
615
630
  if (event.key === "Escape") setSettingsOpen(false);
616
631
  };
632
+ const onPointerDown = (event) => {
633
+ const gear = gearRef.current;
634
+ if (gear !== null && gear.contains(event.target)) return;
635
+ const panel = document.querySelector(`.${S.settingsPanel}`);
636
+ if (panel !== null && panel.contains(event.target)) return;
637
+ setSettingsOpen(false);
638
+ };
617
639
  document.addEventListener("keydown", onKeyDown);
618
- return () => document.removeEventListener("keydown", onKeyDown);
640
+ document.addEventListener("pointerdown", onPointerDown, true);
641
+ return () => {
642
+ document.removeEventListener("keydown", onKeyDown);
643
+ document.removeEventListener("pointerdown", onPointerDown, true);
644
+ };
619
645
  }, [settingsOpen]);
620
646
  const loaderRef = react.useRef(null);
621
647
  if (loaderRef.current === null) loaderRef.current = createLoader();
@@ -805,16 +831,19 @@ window.__ModuleLoader__.load({
805
831
  onClick: load,
806
832
  children: dict("refresh")
807
833
  }),
808
- // The setting button owns the row's right end; the ‹ ›
809
- // stepper and the view switch sit left of it, the
810
- // stepper next to the stats it moves through.
834
+ // The ⚙ owns the row's right end; the ‹ › stepper
835
+ // and the view switch sit left of it, the stepper
836
+ // next to the stats it moves through. This button is
837
+ // also the settings panel's anchor.
811
838
  react_jsx_runtime.jsx("button", {
812
839
  type: "button",
840
+ ref: gearRef,
813
841
  className: S.gear,
814
842
  "data-active": settingsOpen,
843
+ "aria-haspopup": "dialog",
815
844
  "aria-expanded": settingsOpen,
816
- "aria-label": dict("settingsTitle"),
817
- title: dict("settingsTitle"),
845
+ "aria-label": dict(settingsOpen ? "settingsCollapse" : "settingsExpand"),
846
+ title: dict(settingsOpen ? "settingsCollapse" : "settingsExpand"),
818
847
  onClick: () => setSettingsOpen(!settingsOpen),
819
848
  children: react_jsx_runtime.jsx("svg", {
820
849
  width: 14,
@@ -933,6 +962,8 @@ window.__ModuleLoader__.load({
933
962
  store: configStore,
934
963
  palette,
935
964
  translate,
965
+ legend: levelLegend,
966
+ anchor: gearRef.current,
936
967
  onClose: () => setSettingsOpen(false)
937
968
  }) : null
938
969
  ]
@@ -948,10 +979,45 @@ window.__ModuleLoader__.load({
948
979
  //#endregion
949
980
 
950
981
  //#region TokenHeatmapInlineSettings
982
+ /** Distance between the ⚙ and the panel's bottom edge (native dialog spec). */
983
+ const PANEL_GAP = 8;
984
+ /** Viewport margin the placement clamp keeps (native dialog spec). */
985
+ const PANEL_MARGIN = 12;
986
+
987
+ /**
988
+ * Place the floating panel above its ⚙ trigger, clamped to the viewport —
989
+ * the same measure-then-place flow (and the same 8px gap / 12px margin)
990
+ * the built-in stat dialogs use. The panel is `position:fixed` and lives
991
+ * in `document.body`, so the card can never clip it.
992
+ * @param trigger - the ⚙ button the panel is anchored to.
993
+ * @param panel - the `position:fixed` panel element.
994
+ */
995
+ function placePanel(trigger, panel) {
996
+ const anchor = trigger.getBoundingClientRect();
997
+ const width = panel.offsetWidth;
998
+ const height = panel.offsetHeight;
999
+ if (width === 0 || height === 0) return;
1000
+ const viewportWidth = window.innerWidth;
1001
+ const viewportHeight = window.innerHeight;
1002
+ const left = Math.min(
1003
+ Math.max(PANEL_MARGIN, anchor.left + anchor.width / 2 - width / 2),
1004
+ Math.max(PANEL_MARGIN, viewportWidth - width - PANEL_MARGIN)
1005
+ );
1006
+ let top = anchor.top - height - PANEL_GAP;
1007
+ if (top < PANEL_MARGIN) top = Math.min(anchor.bottom + PANEL_GAP, viewportHeight - height - PANEL_MARGIN);
1008
+ panel.style.left = `${Math.round(left)}px`;
1009
+ panel.style.top = `${Math.round(Math.max(PANEL_MARGIN, top))}px`;
1010
+ panel.style.visibility = "visible";
1011
+ }
1012
+
951
1013
  /**
952
- * The card's own settings panel: 配色方案 and 默认视图, edited where the
953
- * card lives instead of inside 设置 → 插件 → 插件配置 (this plugin no
954
- * longer registers into the `settings.plugin.item` seat).
1014
+ * The card's settings panel: 配色方案 and 默认视图, edited from the card
1015
+ * itself instead of inside 设置 → 插件 → 插件配置 (this plugin no longer
1016
+ * registers into the `settings.plugin.item` seat).
1017
+ *
1018
+ * It is PORTALED to `document.body` (the card's own box can never clip
1019
+ * it) and anchored to the ⚙ through `anchor`; the owner closes it on
1020
+ * outside pointerdown and Escape, so this component only draws.
955
1021
  *
956
1022
  * Every control applies immediately through the bound settings scope —
957
1023
  * there is no draft/save step, so the component owns only a local save
@@ -964,9 +1030,12 @@ window.__ModuleLoader__.load({
964
1030
  * @param props.store - config store (set writes through the scope).
965
1031
  * @param props.palette - active palette (level 0..4 → css color).
966
1032
  * @param props.translate - interpolation helper for translated labels.
967
- * @param props.onClose - collapse the panel (the × button).
1033
+ * @param props.legend - level labels for the palette footnote.
1034
+ * @param props.anchor - the ⚙ button the panel is placed against.
1035
+ * @param props.onClose - collapse the panel.
968
1036
  */
969
- function TokenHeatmapInlineSettings({ dict, config, store, palette, translate, onClose }) {
1037
+ function TokenHeatmapInlineSettings({ dict, config, store, palette, translate, legend, anchor, onClose }) {
1038
+ const panelRef = react.useRef(null);
970
1039
  const [saveFailed, setSaveFailed] = react.useState(false);
971
1040
  const schemes = Object.keys(COLOR_SCHEMES);
972
1041
  // Optimistic local echo: the settings scope only reports the new value
@@ -974,6 +1043,26 @@ window.__ModuleLoader__.load({
974
1043
  const [pending, setPending] = react.useState({});
975
1044
  const scheme = pending.colorScheme ?? config.colorScheme;
976
1045
  const defaultView = pending.defaultView ?? config.defaultView;
1046
+ // Place before paint (and again when the palette reflows the panel):
1047
+ // the trigger's box is only meaningful once the card is laid out.
1048
+ react.useLayoutEffect(() => {
1049
+ const panel = panelRef.current;
1050
+ if (panel === null || anchor === null || anchor === void 0) return;
1051
+ placePanel(anchor, panel);
1052
+ });
1053
+ react.useEffect(() => {
1054
+ const replace = () => {
1055
+ const panel = panelRef.current;
1056
+ if (panel === null || anchor === null || anchor === void 0) return;
1057
+ placePanel(anchor, panel);
1058
+ };
1059
+ window.addEventListener("resize", replace);
1060
+ window.addEventListener("scroll", replace, true);
1061
+ return () => {
1062
+ window.removeEventListener("resize", replace);
1063
+ window.removeEventListener("scroll", replace, true);
1064
+ };
1065
+ }, [anchor]);
977
1066
  const commit = async (patch) => {
978
1067
  setPending((current) => ({ ...current, ...patch }));
979
1068
  setSaveFailed(false);
@@ -990,28 +1079,43 @@ window.__ModuleLoader__.load({
990
1079
  if (value !== defaultView) commit({ defaultView: value });
991
1080
  };
992
1081
  const labelOf = (name) => dict(`settings${name[0].toUpperCase()}${name.slice(1)}`);
993
- return react_jsx_runtime.jsxs("div", {
1082
+ const title = dict("settingsTitle");
1083
+ const panel = react_jsx_runtime.jsxs("div", {
1084
+ ref: panelRef,
994
1085
  className: S.settingsPanel,
995
- // Escape and "focus left the panel" both collapse it, so the
996
- // panel never lingers behind an unrelated interaction.
997
- onBlur: (event) => {
998
- if (!event.currentTarget.contains(event.relatedTarget)) onClose();
999
- },
1086
+ role: "dialog",
1087
+ "aria-label": title,
1088
+ // Nothing may paint at the viewport origin before placement runs.
1089
+ style: { visibility: "hidden" },
1000
1090
  children: [
1001
1091
  react_jsx_runtime.jsxs("div", {
1002
1092
  className: S.panelHead,
1003
1093
  children: [
1004
- react_jsx_runtime.jsx("span", { className: S.panelTitle, children: dict("settingsTitle") }),
1005
- react_jsx_runtime.jsx("button", {
1006
- type: "button",
1007
- className: S.panelClose,
1008
- "aria-label": dict("settingsCollapse"),
1009
- title: dict("settingsCollapse"),
1010
- onClick: onClose,
1011
- children: "\u00d7"
1094
+ react_jsx_runtime.jsxs("span", {
1095
+ className: S.panelTitleLabel,
1096
+ children: [
1097
+ react_jsx_runtime.jsxs("svg", {
1098
+ width: 14,
1099
+ height: 14,
1100
+ viewBox: "0 0 24 24",
1101
+ fill: "none",
1102
+ stroke: "currentColor",
1103
+ "stroke-width": 2,
1104
+ "stroke-linecap": "round",
1105
+ "stroke-linejoin": "round",
1106
+ "aria-hidden": "true",
1107
+ children: [
1108
+ react_jsx_runtime.jsx("circle", { cx: 12, cy: 12, r: 9 }),
1109
+ react_jsx_runtime.jsx("path", { d: "M12 11v5" }),
1110
+ react_jsx_runtime.jsx("path", { d: "M12 8h.01" })
1111
+ ]
1112
+ }),
1113
+ react_jsx_runtime.jsx("span", { children: title })
1114
+ ]
1012
1115
  })
1013
1116
  ]
1014
1117
  }),
1118
+ react_jsx_runtime.jsx("div", { className: S.panelRule, "aria-hidden": "true" }),
1015
1119
  react_jsx_runtime.jsxs("div", {
1016
1120
  className: S.settingsGroup,
1017
1121
  children: [
@@ -1058,54 +1162,68 @@ window.__ModuleLoader__.load({
1058
1162
  onClick: () => chooseView(mode),
1059
1163
  children: dict(mode === "month" ? "viewMonth" : "viewYear")
1060
1164
  }, mode))
1061
- }),
1062
- react_jsx_runtime.jsx("span", {
1063
- className: S.settingsHint,
1064
- children: dict("settingsDefaultViewHint")
1065
1165
  })
1066
1166
  ]
1067
1167
  }),
1068
- react_jsx_runtime.jsx("span", {
1069
- className: saveFailed ? `${S.settingsHint} ${S.settingsHintError}` : S.settingsHint,
1070
- children: saveFailed
1071
- ? dict("settingsSaveFailed")
1072
- : translate("settingsPaletteHint", { levels: uiDict().levelLegend.join(" / ") })
1168
+ react_jsx_runtime.jsxs("div", {
1169
+ className: S.panelFoot,
1170
+ children: [
1171
+ react_jsx_runtime.jsxs("div", {
1172
+ className: S.footLegend,
1173
+ children: [
1174
+ dict("less"),
1175
+ ...palette.map((color, level) => react_jsx_runtime.jsx("span", {
1176
+ className: S.legendCell,
1177
+ style: { background: color },
1178
+ title: legend[level] ?? ""
1179
+ }, `foot-${level}`)),
1180
+ dict("more")
1181
+ ]
1182
+ }),
1183
+ saveFailed ? react_jsx_runtime.jsx("div", {
1184
+ className: S.footError,
1185
+ children: dict("settingsSaveFailed")
1186
+ }) : react_jsx_runtime.jsx("div", {
1187
+ className: S.footRule,
1188
+ children: dict("settingsThresholdNote")
1189
+ })
1190
+ ]
1073
1191
  })
1074
1192
  ]
1075
1193
  });
1194
+ return reactDom.createPortal(panel, document.body);
1076
1195
  }
1077
1196
  //#endregion
1078
1197
 
1079
1198
  //#region plugin body
1080
1199
  /**
1081
- * Settings namespace the card reads/writes — must match the namespace
1082
- * the Host registers (lib/index.js). Spelled here rather than imported:
1083
- * a client package must not depend on a Host package.
1200
+ * Profile entry id this plugin's config form is keyed by. DSH 0.2.0
1201
+ * replaced the plugin-owned settings namespace with schema-derived plugin
1202
+ * CONFIG: the Host exports `Config`, the form is keyed by the profile
1203
+ * entry id — the `id` of the row our bundle patch inserts, which is what
1204
+ * the browser asks for here. Spelled as a literal rather than imported: a
1205
+ * client package must not depend on a Host package.
1084
1206
  */
1085
1207
  const SETTINGS_NS = "token-heatmap";
1086
1208
  /** Services required by the client plugin body. */
1087
- const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
1209
+ const inject = ["slots", "locale", "configForms"];
1088
1210
 
1089
1211
  /**
1090
1212
  * Client plugin body: register the dictionaries and the input-dock entry
1091
- * (the heatmap below the composer card on the new-session screen), backed
1092
- * by the `token-heatmap` settings namespace through a bound settings
1093
- * scope. The card configures itself through its own ⚙ panel, so this
1094
- * plugin does NOT register into `settings.plugin.item` (the official
1095
- * 设置 → 插件 → 插件配置 card seat) any more — the Host still serves the
1096
- * namespace, it just has no configuration card to dispatch.
1213
+ * (the heatmap below the composer card on the new-session screen). The
1214
+ * card configures itself through its own ⚙ panel, which reads and writes
1215
+ * the plugin's config form (`ctx.configForms.get("token-heatmap")`) —
1216
+ * the shared form owns transport, revisions, and recovery.
1097
1217
  *
1098
1218
  * Slot-kind contract: `conversation.input.dock` is a LIST slot, so its
1099
- * registration is keyed by `id` (a `settings.plugin.item` registration
1100
- * would instead need `key`, the settings namespace the card edits).
1219
+ * registration is keyed by `id`.
1101
1220
  * @param ctx - client root context.
1102
1221
  */
1103
1222
  function apply(ctx) {
1104
- // Bind the namespace scope on THIS plugin's fiber (settingsScope.bind
1105
- // registers the disposer itself); the scope auto-loads and refreshes
1106
- // on settings/document-updated and connection/reset. Requires the
1107
- // injected connection (transport) and remote (invalidation) services.
1108
- const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
1223
+ // The shared form for our entry: `getSnapshot()`/`subscribe()` for
1224
+ // reads, `set(field, value)` for writes. `createConfigStore` is
1225
+ // duck-typed over exactly that surface, so the ⚙ panel is unchanged.
1226
+ const scope = ctx.configForms.get(SETTINGS_NS);
1109
1227
  configStore = createConfigStore(scope);
1110
1228
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "token-heatmap: dictionaries");
1111
1229
  ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
package/lib/index.js CHANGED
@@ -11,11 +11,13 @@
11
11
  * its own peer-socket loopback fence (the exact route bypasses the RPC trust
12
12
  * fence); Host is checked only as an additional defense.
13
13
  *
14
- * Display settings (enabled + colorScheme) are owned by the plugin's
15
- * `token-heatmap` settings namespace — the config card in
16
- * 设置 → 插件 → 插件配置 reads/writes it through the settings scope, and the
17
- * legacy `<DSH_HOME>/storages/token-heatmap-config.json` document is
18
- * migrated into the namespace once at startup.
14
+ * Display settings (colorScheme + defaultView) are owned by this plugin's
15
+ * profile CONFIG — DSH 0.2.0 derives the settings form from the exported
16
+ * `Config` schema, keys it by the profile entry id (`token-heatmap`, the id of
17
+ * the row our bundle patch inserts), and the browser edits it through
18
+ * `ctx.configForms.get("token-heatmap")`. The plugin's own ⚙ panel writes the
19
+ * same values; the legacy `<DSH_HOME>/storages/token-heatmap-config.json`
20
+ * document is migrated into it once at startup.
19
21
  *
20
22
  * Usage aggregation is INCREMENTAL: per-session fold state (day/model
21
23
  * buckets plus the last usage sample) is cached in memory and persisted to
@@ -33,6 +35,7 @@
33
35
  */
34
36
 
35
37
  import { homedir } from "node:os";
38
+ import { readFileSync } from "node:fs";
36
39
  import { join, dirname } from "node:path";
37
40
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
38
41
  import { applyUsageDelta, createUsageState, mergeInto, renderUsage, zeroBuckets } from "./usage.js";
@@ -45,24 +48,17 @@ const name = "token-heatmap";
45
48
  /** Services required before this plugin activates. */
46
49
  const inject = ["webServer", "sessions", "sessionPersistence", "settings"];
47
50
 
48
- //#region settings namespace
51
+ //#region plugin config
49
52
  /**
50
- * Settings namespace owned by this plugin. Registering it makes the Host
51
- * serve a `token-heatmap` section (resolved from schema defaults, then any
52
- * composition `base`, then the user settings.yaml layer), which is exactly
53
- * what the official 设置 → 插件 → 插件配置 tab dispatches on: it renders the
54
- * card registered into `settings.plugin.item` whose `key` matches a served
55
- * namespace. The card edits `enabled` + `colorScheme` through the settings
56
- * scope; the legacy `<DSH_HOME>/storages/token-heatmap-config.json` document
57
- * is migrated once at startup (see migrateLegacyConfig).
58
- * DSH 0.1.2 起 `@deepseek-ai/dsh-settings` 不再导出 settingsNamespace 帮助函数:
59
- * namespace 直接以字面量传入(register/get/update 内部仍按
60
- * /^[a-z][a-z0-9-]*$/ 校验)。
53
+ * Profile entry id this plugin's config form is keyed by: the `id` of the row
54
+ * the bundle patch inserts, which is also what the browser asks
55
+ * `ctx.configForms.get(...)` for. DSH 0.2.0 derives the form from the exported
56
+ * `Config` schema, so no registration call exists (nor does `settings.get`).
61
57
  */
62
58
  const SETTINGS_NAMESPACE = "token-heatmap";
63
59
 
64
60
  /**
65
- * Durable display preferences; also the wire envelope the browser scope
61
+ * Durable display preferences; also the wire envelope the browser form
66
62
  * validates against. Scheme membership is deliberately NOT enforced (a newer
67
63
  * client may know a palette the server does not — the client falls back to
68
64
  * green); only the same shape bounds parseConfig applies: a short, non-blank
@@ -72,10 +68,12 @@ const SETTINGS_NAMESPACE = "token-heatmap";
72
68
  * the hero screen, so the field is neither accepted nor served (an old
73
69
  * settings.yaml keeps its dead `enabled` key, and nothing reads it).
74
70
  */
75
- const TokenHeatmapSettingsSchema = z.object({
71
+ const Config = z.object({
76
72
  colorScheme: z.string().min(1).max(32).default("green"),
77
73
  defaultView: z.union(VIEW_MODES.map((mode) => z.const(mode))).default("year")
78
74
  });
75
+ /** @deprecated 0.1.x name; the schema IS the config now. Kept for old importers. */
76
+ const TokenHeatmapSettingsSchema = Config;
79
77
  //#endregion
80
78
 
81
79
  const USAGE_PATH = "/api/token-heatmap/usage";
@@ -329,14 +327,127 @@ function configPath() {
329
327
  return join(home, "storages", "token-heatmap-config.json");
330
328
  }
331
329
 
330
+ /** DSH home resolution, mirroring `@deepseek-ai/dsh-home-paths` (no extra peer). */
331
+ function dshHomeDirectory(env = process.env) {
332
+ const configured = env.DSH_HOME;
333
+ if (typeof configured === "string" && configured.trim() !== "") return configured.trim();
334
+ return join(homedir(), ".dsh");
335
+ }
336
+
337
+ /**
338
+ * One profile entry's descriptor from the settings service, or undefined when
339
+ * the service is absent or the entry is not live. DSH 0.2.0 dropped the old
340
+ * `settings.get(ns)` accessor — a plugin's settings ARE its profile entry
341
+ * config now — so reads go through the same `describe()` view the settings UI
342
+ * renders (its `value` is the resolved section, its `user` the raw user layer).
343
+ * @param ctx - plugin context.
344
+ * @param entryId - profile entry id (`token-heatmap`).
345
+ * @returns the descriptor, or undefined.
346
+ */
347
+ function entryDescriptor(ctx, entryId) {
348
+ const settings = typeof ctx.get === "function" ? ctx.get("settings") : ctx.settings;
349
+ if (settings === null || settings === void 0 || typeof settings.describe !== "function") return void 0;
350
+ try {
351
+ return settings.describe().find((row) => row.ns === entryId);
352
+ } catch {
353
+ return void 0;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * One profile entry's resolved config section, read through the settings
359
+ * service.
360
+ * @param ctx - plugin context.
361
+ * @param entryId - profile entry id (`token-heatmap`).
362
+ * @returns the resolved section, or undefined when the entry is absent.
363
+ */
364
+ function entryConfig(ctx, entryId) {
365
+ const value = entryDescriptor(ctx, entryId)?.value;
366
+ return value !== null && typeof value === "object" ? value : void 0;
367
+ }
368
+
369
+ /**
370
+ * Best-effort read of one scalar out of the pre-0.2.0 `settings.yaml` section
371
+ * for this entry. DSH's own importer moves those sections into the profile
372
+ * once, keyed by entry id — and skipped ours while the bundle was refused for
373
+ * incompatible peers, leaving the value only in the renamed document. This is
374
+ * a deliberately narrow scan (one top-level section, one scalar) rather than a
375
+ * YAML dependency: anything it cannot read is simply left for the user.
376
+ * @param text - contents of `settings.yaml` or `settings.yaml.imported`.
377
+ * @param section - top-level section name (`token-heatmap`).
378
+ * @param field - scalar key inside it.
379
+ * @returns the raw trimmed string value, or null.
380
+ */
381
+ function legacySettingFromDocument(text, section, field) {
382
+ if (typeof text !== "string") return null;
383
+ const lines = text.split(/\r?\n/);
384
+ const inSection = /^[^\s#][^:]*:/;
385
+ let inside = false;
386
+ for (const line of lines) {
387
+ if (inSection.test(line)) {
388
+ inside = line.trim() === `${section}:` && !line.startsWith(" ");
389
+ continue;
390
+ }
391
+ if (!inside) continue;
392
+ const match = /^\s+([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line);
393
+ if (match === null || match[1] !== field) continue;
394
+ return match[2].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "");
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /** Files the legacy value may still live in, newest name first. */
400
+ const LEGACY_SETTINGS_FILES = ["settings.yaml.imported", "settings.yaml"];
401
+
402
+ /**
403
+ * One-time rescue of display preferences from the removed settings document.
404
+ *
405
+ * DSH 0.2.0 imported the old `settings.yaml` sections per entry id, but this
406
+ * plugin was refused (incompatible peers) at that moment, so its section —
407
+ * `colorScheme` / `defaultView`, plus the dead `enabled` — only survives in
408
+ * the renamed document. Runs only when the entry has no user value yet, only
409
+ * for non-default fields, best effort, never blocking.
410
+ * @param ctx - plugin context carrying the settings service.
411
+ */
412
+ async function rescueRemovedSettingsDocument(ctx) {
413
+ try {
414
+ const descriptor = entryDescriptor(ctx, SETTINGS_NAMESPACE);
415
+ if (descriptor === void 0) return;
416
+ const settings = typeof ctx.get === "function" ? ctx.get("settings") : ctx.settings;
417
+ const user = descriptor.user;
418
+ if (user !== null && typeof user === "object" && (Object.hasOwn(user, "colorScheme") || Object.hasOwn(user, "defaultView"))) return;
419
+ const home = dshHomeDirectory();
420
+ let text = null;
421
+ for (const file of LEGACY_SETTINGS_FILES) {
422
+ try {
423
+ text = readFileSync(join(home, file), "utf8");
424
+ } catch {
425
+ continue;
426
+ }
427
+ if (text !== null) break;
428
+ }
429
+ if (text === null) return;
430
+ const patch = {};
431
+ const scheme = legacySettingFromDocument(text, SETTINGS_NAMESPACE, "colorScheme");
432
+ if (scheme !== null && scheme !== DEFAULT_CONFIG.colorScheme) patch.colorScheme = scheme;
433
+ const view = legacySettingFromDocument(text, SETTINGS_NAMESPACE, "defaultView");
434
+ if (view !== null && VIEW_MODES.includes(view) && view !== DEFAULT_CONFIG.defaultView) patch.defaultView = view;
435
+ if (Object.keys(patch).length === 0) return;
436
+ await settings.update(SETTINGS_NAMESPACE, patch);
437
+ ctx.logger.info(`token-heatmap: carried ${JSON.stringify(patch)} over from the removed settings.yaml`);
438
+ } catch (error) {
439
+ ctx.logger.warn(`token-heatmap: legacy settings rescue skipped: ${String(error)}`);
440
+ }
441
+ }
442
+
332
443
  /**
333
444
  * One-time migration from the legacy config document
334
- * (`<DSH_HOME>/storages/token-heatmap-config.json`) into the registered
335
- * settings namespace. Runs once per process, best-effort: when the user has
336
- * no settings.yaml section yet, non-default values are imported through the
337
- * settings write path; the legacy file is removed either way (the namespace
338
- * becomes the single source of truth). A corrupt legacy document is dropped,
339
- * never imported. Failures are logged and never fatal.
445
+ * (`<DSH_HOME>/storages/token-heatmap-config.json`) into the plugin config.
446
+ * Runs once per process, best-effort: when the user has no config value yet,
447
+ * non-default values are imported through the settings write path; the legacy
448
+ * file is removed either way (the config entry becomes the single source of
449
+ * truth). A corrupt legacy document is dropped, never imported. Failures are
450
+ * logged and never fatal.
340
451
  * @param ctx - plugin context carrying the settings service.
341
452
  */
342
453
  async function migrateLegacyConfig(ctx) {
@@ -355,15 +466,16 @@ async function migrateLegacyConfig(ctx) {
355
466
  await rm(path, { force: true });
356
467
  return;
357
468
  }
358
- const descriptor = ctx.settings.describe().find((entry) => entry.ns === SETTINGS_NAMESPACE);
469
+ const descriptor = entryDescriptor(ctx, SETTINGS_NAMESPACE);
470
+ const settings = typeof ctx.get === "function" ? ctx.get("settings") : ctx.settings;
359
471
  const userExists = descriptor !== void 0 && descriptor.user !== void 0;
360
- if (!userExists) {
472
+ if (!userExists && settings !== void 0 && typeof settings.update === "function") {
361
473
  const patch = {};
362
474
  // The legacy document's `enabled` switch died with 0.2.0: import only
363
475
  // what the card still owns.
364
476
  if (legacy.colorScheme !== DEFAULT_CONFIG.colorScheme) patch.colorScheme = legacy.colorScheme;
365
477
  if (legacy.defaultView !== DEFAULT_CONFIG.defaultView) patch.defaultView = legacy.defaultView;
366
- if (Object.keys(patch).length > 0) await ctx.settings.update(SETTINGS_NAMESPACE, patch);
478
+ if (Object.keys(patch).length > 0) await settings.update(SETTINGS_NAMESPACE, patch);
367
479
  }
368
480
  await rm(path, { force: true });
369
481
  } catch (error) {
@@ -372,13 +484,13 @@ async function migrateLegacyConfig(ctx) {
372
484
  }
373
485
 
374
486
  /**
375
- * Serve the resolved settings section (schema defaults + user layer).
487
+ * Serve the resolved config section (schema defaults + user layer).
376
488
  * `enabled` stays in the payload as a constant true: the legacy loopback
377
489
  * endpoint is a back-compat API, and a pre-0.2.0 client that reads it must not
378
490
  * lose the card over a switch this version no longer has.
379
491
  */
380
492
  function serveConfig(ctx) {
381
- const section = ctx.settings.get(SETTINGS_NAMESPACE);
493
+ const section = entryConfig(ctx, SETTINGS_NAMESPACE);
382
494
  const scheme = typeof section?.colorScheme === "string" && section.colorScheme.length > 0 ? section.colorScheme : DEFAULT_CONFIG.colorScheme;
383
495
  const view = typeof section?.defaultView === "string" && VIEW_MODES.includes(section.defaultView) ? section.defaultView : DEFAULT_CONFIG.defaultView;
384
496
  return { enabled: true, colorScheme: scheme, defaultView: view };
@@ -687,19 +799,22 @@ async function handleUsage(ctx, req, res) {
687
799
  }
688
800
 
689
801
  /**
690
- * Plugin body: register the usage and config routes, the settings namespace
691
- * that backs the plugin configuration card (设置 → 插件 → 插件配置), and the
692
- * one-time legacy-config migration.
802
+ * Plugin body: register the usage and config routes and run the two one-time
803
+ * legacy migrations. DSH 0.2.0 needs no namespace registration — the exported
804
+ * `Config` is the form — so nothing here touches the settings document beyond
805
+ * the rescues.
693
806
  * @param ctx - plugin context carrying webServer, sessions, sessionPersistence, and settings.
694
807
  */
695
808
  function apply(ctx) {
696
- // The registration is fiber-bound: disposing this plugin removes the
697
- // namespace and its observers. `settings` is a hard dependency (inject),
698
- // so ctx.settings is available here unconditionally.
699
- ctx.settings.register(SETTINGS_NAMESPACE, TokenHeatmapSettingsSchema);
700
809
  // Best-effort, fire-and-forget: import the pre-0.1.2 config document into
701
- // the namespace and drop the file (see migrateLegacyConfig).
810
+ // the config entry and drop the file (see migrateLegacyConfig), then rescue
811
+ // whatever the removed settings.yaml still holds. The rescue waits for the
812
+ // Loader to settle, exactly like DSH's own importer did, so the entry it
813
+ // inspects is the live one.
702
814
  migrateLegacyConfig(ctx);
815
+ const loader = ctx.root?.loader;
816
+ const settled = typeof loader?.await === "function" ? Promise.resolve(loader.await()).catch(() => void 0) : Promise.resolve();
817
+ settled.then(() => rescueRemovedSettingsDocument(ctx)).catch(() => void 0);
703
818
  // Real-time fold: listen to session/event and fold each usage event into
704
819
  // the cache immediately, so live session usage is captured regardless of
705
820
  // whether the hero screen is mounted (the client polls the usage endpoint
@@ -779,4 +894,18 @@ function apply(ctx) {
779
894
  }), "token-heatmap: config route");
780
895
  }
781
896
 
782
- export { apply, inject, name, CONFIG_PATH, USAGE_PATH, SETTINGS_NAMESPACE, TokenHeatmapSettingsSchema, migrateLegacyConfig };
897
+ export {
898
+ apply,
899
+ inject,
900
+ name,
901
+ Config,
902
+ CONFIG_PATH,
903
+ USAGE_PATH,
904
+ SETTINGS_NAMESPACE,
905
+ TokenHeatmapSettingsSchema,
906
+ migrateLegacyConfig,
907
+ entryConfig,
908
+ dshHomeDirectory,
909
+ legacySettingFromDocument,
910
+ rescueRemovedSettingsDocument
911
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kidli1412/dsh-token-heatmap",
3
- "version": "0.4.1",
4
- "description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with switchable year/month views and six color schemes. The card configures itself (⚙ palette + default view) — no DSH settings card — and shows today / this-month / all-time totals.",
3
+ "version": "0.5.0",
4
+ "description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with switchable year/month views and six color schemes. The card configures itself through a floating ⚙ panel (palette + default view) — no DSH settings card — and shows today / this-month / all-time totals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -20,15 +20,26 @@
20
20
  "@deepseek-ai/schemastery": "^3.18.2"
21
21
  },
22
22
  "peerDependencies": {
23
- "@deepseek-ai/dsh-api-remotes": "^0.1.2-rc.1",
24
- "@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1",
25
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
26
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
27
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
28
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
29
- "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
30
- "@deepseek-ai/dsh-session-persistence": "^0.1.2-rc.1",
31
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1"
23
+ "@deepseek-ai/dsh-api-remotes": "^0.2.0-rc.2",
24
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
25
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
26
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.2",
27
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
28
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
29
+ "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
30
+ "@deepseek-ai/dsh-session-persistence": "^0.2.0-rc.2",
31
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@deepseek-ai/dsh-api-remotes": { "optional": true },
35
+ "@deepseek-ai/dsh-client-connection": { "optional": true },
36
+ "@deepseek-ai/dsh-client-locale": { "optional": true },
37
+ "@deepseek-ai/dsh-client-ui-conversation": { "optional": true },
38
+ "@deepseek-ai/dsh-client-ui-settings": { "optional": true },
39
+ "@deepseek-ai/dsh-host-webserver": { "optional": true },
40
+ "@deepseek-ai/dsh-session": { "optional": true },
41
+ "@deepseek-ai/dsh-session-persistence": { "optional": true },
42
+ "@deepseek-ai/dsh-settings": { "optional": true }
32
43
  },
33
44
  "dsh": {
34
45
  "bundle": {
@@ -46,9 +57,7 @@
46
57
  },
47
58
  "compatibility": {
48
59
  "dshReleases": {
49
- "0.1.2-alpha.4": "compatible",
50
- "0.1.2-alpha.5": "compatible",
51
- "0.1.2-rc.1": "compatible"
60
+ "0.2.0-rc.2": "compatible"
52
61
  }
53
62
  }
54
63
  },
@@ -60,7 +69,7 @@
60
69
  "license": "MIT",
61
70
  "engines": {
62
71
  "node": "^22.19.0 || >=24.0.0",
63
- "dsh": "^0.1.2-rc.1"
72
+ "dsh": "^0.2.0-rc.2"
64
73
  },
65
74
  "repository": {
66
75
  "type": "git",