@kidli1412/dsh-token-heatmap 0.4.1 → 0.4.2

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,10 +31,10 @@ 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
39
  - 配置经 `token-heatmap` settings namespace 持久化到 `<DSH_HOME>/settings.yaml`(0.1.1 及更早版本存在 `<DSH_HOME>/storages/token-heatmap-config.json` 的旧配置会在启动时自动迁移)。
40
40
 
@@ -91,6 +91,7 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
91
91
  - **0.4.0(设置搬进卡片)**:不再注册官方 `settings.plugin.item` 插槽——设置页(设置 → 插件 → 插件配置)里不再有本插件的卡片,配色与默认视图改在卡片自己的 ⚙ 面板里改,**点击即时生效**(去掉了草稿/保存/放弃那套)。Host 侧 `settings.register("token-heatmap", schema)` 保留:它是 settings.yaml 的校验与持久化管道,与 UI 卡片无关(官方 `settings` 服务的 `get`/`update` 只对已注册 namespace 生效)。升级只影响设置入口位置,已有配置不动。
92
92
 
93
93
  - **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,旧缓存(可能含重复计入的天数)会被丢弃重建;父会话日志不可得的极端情况下会少计而非多计。
94
+ - **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
95
 
95
96
  ## License
96
97
 
package/lib/client.js CHANGED
@@ -37,6 +37,9 @@ window.__ModuleLoader__.load({
37
37
 
38
38
  let react = require("react");
39
39
  let react_jsx_runtime = require("react/jsx-runtime");
40
+ // Only for the settings panel, which is PORTALED to document.body so the
41
+ // card's own box can never clip it (the pattern the stat pills use).
42
+ let reactDom = require("react-dom");
40
43
 
41
44
  //#region css
42
45
  const css = [
@@ -88,23 +91,23 @@ window.__ModuleLoader__.load({
88
91
  ".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
92
  ".thm_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0}",
90
93
  ".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 设置 → 插件 → 插件配置.
94
+ // The ⚙ at the row's right end opens a floating pill panel — the
95
+ // card configures itself; nothing registers into 设置 → 插件 → 插件配置.
93
96
  ".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
97
  ".thm_gear:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
95
98
  ".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)}",
99
+ // The settings panel mirrors DSH's own pill dialogs: portaled to
100
+ // document.body, position:fixed (so the card can never clip it),
101
+ // measure-then-place above the ⚙ and clamped to the viewport, with
102
+ // the shipped dialog surface + elevation tokens.
103
+ ".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);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:12px;padding:16px;font-size:12px;line-height:18px;text-align:left;font-weight:400}",
104
+ ".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}",
105
+ ".thm_panelTitleLabel{align-items:center;gap:6px;display:inline-flex}",
106
+ ".thm_panelTitleLabel svg{flex:none;width:14px;height:14px}",
107
+ ".thm_panelRule{border-top:.5px solid var(--dsw-alias-border-l2);margin-bottom:12px}",
104
108
  ".thm_group{flex-direction:column;gap:5px;display:flex}",
109
+ ".thm_group+.thm_group{margin-top:14px}",
105
110
  ".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
111
  ".thm_swatches{flex-wrap:wrap;gap:6px;display:flex}",
109
112
  ".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
113
  ".thm_swatch:hover{border-color:var(--dsw-alias-label-dimmed)}",
@@ -115,7 +118,11 @@ window.__ModuleLoader__.load({
115
118
  ".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
119
  ".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
120
  ".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}"
121
+ ".thm_segBtn[data-active=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);font-weight:600}",
122
+ ".thm_panelFoot{border-top:.5px solid var(--dsw-alias-border-l2);margin-top:14px;padding-top:10px}",
123
+ ".thm_footLegend{align-items:center;gap:3px;font-size:10px;line-height:14px;display:flex;color:var(--dsw-alias-label-tertiary)}",
124
+ ".thm_footRule{color:var(--dsw-alias-label-caption);margin-top:6px;font-size:10px;line-height:14px}",
125
+ ".thm_footError{color:var(--dsw-alias-state-error-primary);margin-top:6px;font-size:10px;line-height:14px}"
119
126
  ].join("");
120
127
  const tagId = "@kidli1412/dsh-token-heatmap/TokenHeatmap.module.css";
121
128
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -162,12 +169,14 @@ window.__ModuleLoader__.load({
162
169
  gear: "thm_gear",
163
170
  settingsPanel: "thm_panel",
164
171
  panelHead: "thm_panelHead",
165
- panelTitle: "thm_panelTitle",
166
- panelClose: "thm_panelClose",
172
+ panelTitleLabel: "thm_panelTitleLabel",
173
+ panelRule: "thm_panelRule",
174
+ panelFoot: "thm_panelFoot",
175
+ footLegend: "thm_footLegend",
176
+ footRule: "thm_footRule",
177
+ footError: "thm_footError",
167
178
  settingsGroup: "thm_group",
168
179
  settingsGroupLabel: "thm_groupLabel",
169
- settingsHint: "thm_hint",
170
- settingsHintError: "thm_hintError",
171
180
  settingsSwatches: "thm_swatches",
172
181
  settingsSwatch: "thm_swatch",
173
182
  settingsSwatchCells: "thm_swatchCells",
@@ -217,7 +226,7 @@ window.__ModuleLoader__.load({
217
226
  settingsRed: "红色",
218
227
  settingsPurple: "紫色",
219
228
  settingsTeal: "青色",
220
- settingsPaletteHint: "颜色按每天 token 数的绝对阈值分档:{levels}。",
229
+ settingsThresholdNote: "颜色按每天 token 数的绝对阈值分档(悬停图例看各档范围)。",
221
230
  settingsSaveFailed: "保存失败,已回到服务端的值,请重试。",
222
231
  settingsExpand: "展开设置",
223
232
  settingsCollapse: "收起设置"
@@ -258,7 +267,7 @@ window.__ModuleLoader__.load({
258
267
  settingsRed: "Red",
259
268
  settingsPurple: "Purple",
260
269
  settingsTeal: "Teal",
261
- settingsPaletteHint: "Colors follow absolute per-day token thresholds: {levels}.",
270
+ settingsThresholdNote: "Colors follow absolute per-day token thresholds (hover the legend for each range).",
262
271
  settingsSaveFailed: "Save failed; the server value was restored.",
263
272
  settingsExpand: "Show settings",
264
273
  settingsCollapse: "Hide settings"
@@ -606,16 +615,30 @@ window.__ModuleLoader__.load({
606
615
  // Month-view cursor (`YYYY-MM`); initialised from the current month
607
616
  // and re-anchored when it would fall outside the navigable bounds.
608
617
  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 设置 → 插件 → 插件配置.
618
+ // The ⚙ at the row's right end opens the floating settings panel;
619
+ // the card configures itself here instead of living in
620
+ // 设置 → 插件 → 插件配置. The panel is PORTALED to document.body and
621
+ // anchored to this button, so the open state lives here with it.
622
+ const gearRef = react.useRef(null);
611
623
  const [settingsOpen, setSettingsOpen] = react.useState(false);
612
624
  react.useEffect(() => {
613
625
  if (!settingsOpen) return;
614
626
  const onKeyDown = (event) => {
615
627
  if (event.key === "Escape") setSettingsOpen(false);
616
628
  };
629
+ const onPointerDown = (event) => {
630
+ const gear = gearRef.current;
631
+ if (gear !== null && gear.contains(event.target)) return;
632
+ const panel = document.querySelector(`.${S.settingsPanel}`);
633
+ if (panel !== null && panel.contains(event.target)) return;
634
+ setSettingsOpen(false);
635
+ };
617
636
  document.addEventListener("keydown", onKeyDown);
618
- return () => document.removeEventListener("keydown", onKeyDown);
637
+ document.addEventListener("pointerdown", onPointerDown, true);
638
+ return () => {
639
+ document.removeEventListener("keydown", onKeyDown);
640
+ document.removeEventListener("pointerdown", onPointerDown, true);
641
+ };
619
642
  }, [settingsOpen]);
620
643
  const loaderRef = react.useRef(null);
621
644
  if (loaderRef.current === null) loaderRef.current = createLoader();
@@ -805,16 +828,19 @@ window.__ModuleLoader__.load({
805
828
  onClick: load,
806
829
  children: dict("refresh")
807
830
  }),
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.
831
+ // The ⚙ owns the row's right end; the ‹ › stepper
832
+ // and the view switch sit left of it, the stepper
833
+ // next to the stats it moves through. This button is
834
+ // also the settings panel's anchor.
811
835
  react_jsx_runtime.jsx("button", {
812
836
  type: "button",
837
+ ref: gearRef,
813
838
  className: S.gear,
814
839
  "data-active": settingsOpen,
840
+ "aria-haspopup": "dialog",
815
841
  "aria-expanded": settingsOpen,
816
- "aria-label": dict("settingsTitle"),
817
- title: dict("settingsTitle"),
842
+ "aria-label": dict(settingsOpen ? "settingsCollapse" : "settingsExpand"),
843
+ title: dict(settingsOpen ? "settingsCollapse" : "settingsExpand"),
818
844
  onClick: () => setSettingsOpen(!settingsOpen),
819
845
  children: react_jsx_runtime.jsx("svg", {
820
846
  width: 14,
@@ -933,6 +959,8 @@ window.__ModuleLoader__.load({
933
959
  store: configStore,
934
960
  palette,
935
961
  translate,
962
+ legend: levelLegend,
963
+ anchor: gearRef.current,
936
964
  onClose: () => setSettingsOpen(false)
937
965
  }) : null
938
966
  ]
@@ -948,10 +976,45 @@ window.__ModuleLoader__.load({
948
976
  //#endregion
949
977
 
950
978
  //#region TokenHeatmapInlineSettings
979
+ /** Distance between the ⚙ and the panel's bottom edge (native dialog spec). */
980
+ const PANEL_GAP = 8;
981
+ /** Viewport margin the placement clamp keeps (native dialog spec). */
982
+ const PANEL_MARGIN = 12;
983
+
984
+ /**
985
+ * Place the floating panel above its ⚙ trigger, clamped to the viewport —
986
+ * the same measure-then-place flow (and the same 8px gap / 12px margin)
987
+ * the built-in stat dialogs use. The panel is `position:fixed` and lives
988
+ * in `document.body`, so the card can never clip it.
989
+ * @param trigger - the ⚙ button the panel is anchored to.
990
+ * @param panel - the `position:fixed` panel element.
991
+ */
992
+ function placePanel(trigger, panel) {
993
+ const anchor = trigger.getBoundingClientRect();
994
+ const width = panel.offsetWidth;
995
+ const height = panel.offsetHeight;
996
+ if (width === 0 || height === 0) return;
997
+ const viewportWidth = window.innerWidth;
998
+ const viewportHeight = window.innerHeight;
999
+ const left = Math.min(
1000
+ Math.max(PANEL_MARGIN, anchor.left + anchor.width / 2 - width / 2),
1001
+ Math.max(PANEL_MARGIN, viewportWidth - width - PANEL_MARGIN)
1002
+ );
1003
+ let top = anchor.top - height - PANEL_GAP;
1004
+ if (top < PANEL_MARGIN) top = Math.min(anchor.bottom + PANEL_GAP, viewportHeight - height - PANEL_MARGIN);
1005
+ panel.style.left = `${Math.round(left)}px`;
1006
+ panel.style.top = `${Math.round(Math.max(PANEL_MARGIN, top))}px`;
1007
+ panel.style.visibility = "visible";
1008
+ }
1009
+
951
1010
  /**
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).
1011
+ * The card's settings panel: 配色方案 and 默认视图, edited from the card
1012
+ * itself instead of inside 设置 → 插件 → 插件配置 (this plugin no longer
1013
+ * registers into the `settings.plugin.item` seat).
1014
+ *
1015
+ * It is PORTALED to `document.body` (the card's own box can never clip
1016
+ * it) and anchored to the ⚙ through `anchor`; the owner closes it on
1017
+ * outside pointerdown and Escape, so this component only draws.
955
1018
  *
956
1019
  * Every control applies immediately through the bound settings scope —
957
1020
  * there is no draft/save step, so the component owns only a local save
@@ -964,9 +1027,12 @@ window.__ModuleLoader__.load({
964
1027
  * @param props.store - config store (set writes through the scope).
965
1028
  * @param props.palette - active palette (level 0..4 → css color).
966
1029
  * @param props.translate - interpolation helper for translated labels.
967
- * @param props.onClose - collapse the panel (the × button).
1030
+ * @param props.legend - level labels for the palette footnote.
1031
+ * @param props.anchor - the ⚙ button the panel is placed against.
1032
+ * @param props.onClose - collapse the panel.
968
1033
  */
969
- function TokenHeatmapInlineSettings({ dict, config, store, palette, translate, onClose }) {
1034
+ function TokenHeatmapInlineSettings({ dict, config, store, palette, translate, legend, anchor, onClose }) {
1035
+ const panelRef = react.useRef(null);
970
1036
  const [saveFailed, setSaveFailed] = react.useState(false);
971
1037
  const schemes = Object.keys(COLOR_SCHEMES);
972
1038
  // Optimistic local echo: the settings scope only reports the new value
@@ -974,6 +1040,26 @@ window.__ModuleLoader__.load({
974
1040
  const [pending, setPending] = react.useState({});
975
1041
  const scheme = pending.colorScheme ?? config.colorScheme;
976
1042
  const defaultView = pending.defaultView ?? config.defaultView;
1043
+ // Place before paint (and again when the palette reflows the panel):
1044
+ // the trigger's box is only meaningful once the card is laid out.
1045
+ react.useLayoutEffect(() => {
1046
+ const panel = panelRef.current;
1047
+ if (panel === null || anchor === null || anchor === void 0) return;
1048
+ placePanel(anchor, panel);
1049
+ });
1050
+ react.useEffect(() => {
1051
+ const replace = () => {
1052
+ const panel = panelRef.current;
1053
+ if (panel === null || anchor === null || anchor === void 0) return;
1054
+ placePanel(anchor, panel);
1055
+ };
1056
+ window.addEventListener("resize", replace);
1057
+ window.addEventListener("scroll", replace, true);
1058
+ return () => {
1059
+ window.removeEventListener("resize", replace);
1060
+ window.removeEventListener("scroll", replace, true);
1061
+ };
1062
+ }, [anchor]);
977
1063
  const commit = async (patch) => {
978
1064
  setPending((current) => ({ ...current, ...patch }));
979
1065
  setSaveFailed(false);
@@ -990,28 +1076,43 @@ window.__ModuleLoader__.load({
990
1076
  if (value !== defaultView) commit({ defaultView: value });
991
1077
  };
992
1078
  const labelOf = (name) => dict(`settings${name[0].toUpperCase()}${name.slice(1)}`);
993
- return react_jsx_runtime.jsxs("div", {
1079
+ const title = dict("settingsTitle");
1080
+ const panel = react_jsx_runtime.jsxs("div", {
1081
+ ref: panelRef,
994
1082
  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
- },
1083
+ role: "dialog",
1084
+ "aria-label": title,
1085
+ // Nothing may paint at the viewport origin before placement runs.
1086
+ style: { visibility: "hidden" },
1000
1087
  children: [
1001
1088
  react_jsx_runtime.jsxs("div", {
1002
1089
  className: S.panelHead,
1003
1090
  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"
1091
+ react_jsx_runtime.jsxs("span", {
1092
+ className: S.panelTitleLabel,
1093
+ children: [
1094
+ react_jsx_runtime.jsxs("svg", {
1095
+ width: 14,
1096
+ height: 14,
1097
+ viewBox: "0 0 24 24",
1098
+ fill: "none",
1099
+ stroke: "currentColor",
1100
+ "stroke-width": 2,
1101
+ "stroke-linecap": "round",
1102
+ "stroke-linejoin": "round",
1103
+ "aria-hidden": "true",
1104
+ children: [
1105
+ react_jsx_runtime.jsx("circle", { cx: 12, cy: 12, r: 9 }),
1106
+ react_jsx_runtime.jsx("path", { d: "M12 11v5" }),
1107
+ react_jsx_runtime.jsx("path", { d: "M12 8h.01" })
1108
+ ]
1109
+ }),
1110
+ react_jsx_runtime.jsx("span", { children: title })
1111
+ ]
1012
1112
  })
1013
1113
  ]
1014
1114
  }),
1115
+ react_jsx_runtime.jsx("div", { className: S.panelRule, "aria-hidden": "true" }),
1015
1116
  react_jsx_runtime.jsxs("div", {
1016
1117
  className: S.settingsGroup,
1017
1118
  children: [
@@ -1058,21 +1159,36 @@ window.__ModuleLoader__.load({
1058
1159
  onClick: () => chooseView(mode),
1059
1160
  children: dict(mode === "month" ? "viewMonth" : "viewYear")
1060
1161
  }, mode))
1061
- }),
1062
- react_jsx_runtime.jsx("span", {
1063
- className: S.settingsHint,
1064
- children: dict("settingsDefaultViewHint")
1065
1162
  })
1066
1163
  ]
1067
1164
  }),
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(" / ") })
1165
+ react_jsx_runtime.jsxs("div", {
1166
+ className: S.panelFoot,
1167
+ children: [
1168
+ react_jsx_runtime.jsxs("div", {
1169
+ className: S.footLegend,
1170
+ children: [
1171
+ dict("less"),
1172
+ ...palette.map((color, level) => react_jsx_runtime.jsx("span", {
1173
+ className: S.legendCell,
1174
+ style: { background: color },
1175
+ title: legend[level] ?? ""
1176
+ }, `foot-${level}`)),
1177
+ dict("more")
1178
+ ]
1179
+ }),
1180
+ saveFailed ? react_jsx_runtime.jsx("div", {
1181
+ className: S.footError,
1182
+ children: dict("settingsSaveFailed")
1183
+ }) : react_jsx_runtime.jsx("div", {
1184
+ className: S.footRule,
1185
+ children: dict("settingsThresholdNote")
1186
+ })
1187
+ ]
1073
1188
  })
1074
1189
  ]
1075
1190
  });
1191
+ return reactDom.createPortal(panel, document.body);
1076
1192
  }
1077
1193
  //#endregion
1078
1194
 
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.4.2",
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": {