@kidli1412/dsh-token-heatmap 0.1.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -2,21 +2,23 @@
2
2
  * dsh-token-heatmap — browser half.
3
3
  *
4
4
  * Hand-written `__ModuleLoader__` bundle (no build step): registers into the
5
- * conversation `input.dock` list slot and renders a GitHub-style heatmap of a
6
- * SELECTABLE CALENDAR YEAR (Jan 1 – Dec 31; columns = weeks, rows = Mon..Sun,
7
- * all seven weekday labels on the left) plus today / this-month / all-time
8
- * totals and a ‹year› year selector next to the stats. It also registers a
9
- * settings card into the official `settings.plugin.item` seat
10
- * (设置 → 插件 → 插件配置) with a display switch and a green/blue palette
11
- * choice, persisted through the `token-heatmap` settings namespace (settings
12
- * scope).
5
+ * conversation `input.dock` list slot and renders a GitHub-style token-usage
6
+ * card on the new-session (hero) screen — a calendar-year grid (Jan 1 – Dec 31;
7
+ * columns = weeks, rows = Mon..Sun, all seven weekday labels on the left) or a
8
+ * single-month calendar, switchable from the header, plus today / this-month /
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
13
+ * settings live on the card.
13
14
  *
14
- * The card is shown ONLY on the new-session (hero) screen — gated on
15
- * `session.composerPhase === "blank"` — and is positioned visually BELOW the
16
- * composer input card via a flex `order` on the dock wrapper (the dock's DOM
17
- * site sits above the bar, but the composer stack is a flex column and the
18
- * framework's true below-card seat `composer.dock` is disabled on the hero
19
- * screen).
15
+ * The card is shown ONLY on the new-session (hero) screen — gated on the
16
+ * boolean `session.blank`, with `composerPhase === "blank"` as the pre-0.1.2
17
+ * fallback — and is positioned visually BELOW the composer input card via a
18
+ * flex `order` on the dock wrapper (the dock's DOM site sits above the bar, but
19
+ * the composer stack is a flex column and the framework's true below-card seat
20
+ * `composer.dock` is disabled on the hero screen). There is no display switch:
21
+ * the hero screen always renders it.
20
22
  *
21
23
  * Cell colors use ABSOLUTE token thresholds (not window-relative ranks), so
22
24
  * a huge day is always dark regardless of its neighbours:
@@ -38,18 +40,28 @@ window.__ModuleLoader__.load({
38
40
 
39
41
  //#region css
40
42
  const css = [
41
- ".thm_dock{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width,780px);margin:0 auto;padding:0 16px 2px}",
42
- ".thm_card{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);border-radius:12px;padding:10px 12px 12px;display:flex;flex-direction:column;gap:8px}",
43
+ // Surface matched to the composer card above it (measured computed
44
+ // styles): same 22px radius, no hard border — the edge is the same
45
+ // hairline + soft drop the composer uses — and the same 16px inner
46
+ // inset, so the card's text lines up with the composer's placeholder.
47
+ ".thm_dock{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width,780px);margin:0 auto;padding:0;display:flex;flex-direction:column;gap:8px}",
48
+ ".thm_card{box-sizing:border-box;background:var(--dsw-alias-bg-base);border:none;border-radius:22px;box-shadow:0 0 0 .5px rgba(0,0,0,.1),0 4px 16px rgba(0,0,0,.03),0 0 24px rgba(0,0,0,.03);padding:14px 16px 16px;display:flex;flex-direction:column;gap:8px}",
43
49
  ".thm_head{flex-wrap:wrap;align-items:baseline;gap:4px 12px;display:flex}",
44
50
  ".thm_title{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;line-height:18px}",
45
51
  ".thm_stat{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
46
52
  ".thm_statLabel{margin-right:4px}",
47
53
  ".thm_statValue{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;line-height:18px;font-variant-numeric:tabular-nums}",
48
- ".thm_yearNav{align-items:center;gap:2px;display:inline-flex}",
54
+ ".thm_nav{align-items:center;gap:2px;display:inline-flex}",
49
55
  ".thm_yearBtn{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:4px;padding:0 5px;font-size:13px;line-height:18px}",
50
56
  ".thm_yearBtn:hover:not(:disabled){color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
51
57
  ".thm_yearBtn:disabled{opacity:.35;cursor:default}",
52
58
  ".thm_yearLabel{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;min-width:36px;text-align:center;font-variant-numeric:tabular-nums}",
59
+ ".thm_monthLabelNav{min-width:62px}",
60
+ ".thm_viewNav{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;align-items:center;display:inline-flex;overflow:hidden}",
61
+ ".thm_viewBtn{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;padding:1px 10px;font:inherit;font-size:11px;line-height:18px}",
62
+ ".thm_viewBtn:hover:not(:disabled){color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
63
+ ".thm_viewBtn[data-active=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);font-weight:600}",
64
+ ".thm_viewBtn:disabled{opacity:.4;cursor:default}",
53
65
  ".thm_refresh{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:6px;margin-left:auto;padding:2px 6px;font:inherit;font-size:11px;line-height:16px}",
54
66
  ".thm_refresh:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
55
67
  ".thm_refresh:disabled{opacity:.5;cursor:default}",
@@ -60,49 +72,50 @@ window.__ModuleLoader__.load({
60
72
  ".thm_row{display:flex;gap:2px}",
61
73
  ".thm_rowLabel{color:var(--dsw-alias-label-caption);width:16px;flex:none;font-size:9px;line-height:12px;text-align:center}",
62
74
  ".thm_cell{box-sizing:border-box;width:10px;height:10px;border-radius:2px;flex:none}",
63
- ".thm_legend{align-items:center;gap:3px;margin-left:auto;font-size:10px;line-height:14px;display:inline-flex;color:var(--dsw-alias-label-tertiary)}",
64
- ".thm_legendCell{box-sizing:border-box;width:9px;height:9px;border-radius:2px}",
75
+ // The calendar spans the card's full content width, so the weekday
76
+ // rail (一) starts exactly under the "Token 用量" title.
77
+ ".thm_monthCal{display:flex;flex-direction:column;gap:3px}",
78
+ ".thm_weekHead{display:flex;gap:4px}",
79
+ ".thm_weekHeadCell{color:var(--dsw-alias-label-tertiary);flex:1;min-width:0;font-size:9px;line-height:12px;text-align:center}",
80
+ ".thm_week{display:flex;gap:4px}",
81
+ ".thm_day{box-sizing:border-box;flex:1;min-width:0;height:38px;border-radius:5px;flex-direction:column;align-items:center;justify-content:center;gap:1px;display:flex}",
82
+ ".thm_day[data-weekend=true]:not([data-empty=true]){background-color:var(--dsw-alias-interactive-bg-hover)}",
83
+ ".thm_dayNum{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:13px;font-variant-numeric:tabular-nums}",
84
+ ".thm_dayTokens{color:var(--dsw-alias-label-tertiary);font-size:8px;line-height:10px;font-variant-numeric:tabular-nums}",
85
+ ".thm_day[data-level=\"2\"] .thm_dayNum,.thm_day[data-level=\"3\"] .thm_dayNum,.thm_day[data-level=\"4\"] .thm_dayNum{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.28)}",
86
+ ".thm_day[data-level=\"2\"] .thm_dayTokens,.thm_day[data-level=\"3\"] .thm_dayTokens,.thm_day[data-level=\"4\"] .thm_dayTokens{color:rgba(255,255,255,.82)}",
87
+ ".thm_legend{align-items:center;justify-content:flex-end;gap:3px;font-size:10px;line-height:14px;display:flex;color:var(--dsw-alias-label-tertiary)}", ".thm_legendCell{box-sizing:border-box;width:9px;height:9px;border-radius:2px}",
65
88
  ".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}",
66
89
  ".thm_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0}",
67
90
  ".thm_loading{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;padding:6px 2px}",
68
- ".thms_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}",
69
- ".thms_card:hover{border-color:var(--dsw-alias-label-dimmed)}",
70
- ".thms_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
71
- ".thms_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
72
- ".thms_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
73
- ".thms_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}",
74
- ".thms_desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}",
75
- ".thms_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}",
76
- ".thms_chevronOpen{transform:rotate(180deg)}",
77
- ".thms_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}",
78
- ".thms_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px;display:flex;flex-direction:column;gap:12px}",
79
- ".thms_loading{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;margin:0}",
80
- ".thms_error{color:var(--dsw-alias-label-error);font-size:12px;line-height:1.5;margin:0}",
81
- ".thms_retryBtn{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;text-decoration:underline}",
82
- ".thms_field{display:flex;flex-direction:column;gap:4px}",
83
- ".thms_fieldLabel{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:1.5}",
84
- ".thms_fieldHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;margin:0}",
85
- ".thms_switchRow{display:flex;align-items:center;gap:10px}",
86
- ".thms_switch{position:relative;width:36px;height:20px;flex:none;border-radius:999px;border:0;padding:0;cursor:pointer;background:var(--dsw-alias-interactive-bg-hover-solid);transition:background .15s}",
87
- ".thms_switch:disabled{opacity:.5;cursor:default}",
88
- ".thms_switchOn{background:var(--dsw-alias-state-business-primary)}",
89
- ".thms_thumb{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}",
90
- ".thms_switchOn .thms_thumb{transform:translateX(16px)}",
91
- ".thms_swatches{display:flex;flex-wrap:wrap;gap:8px}",
92
- ".thms_swatch{appearance:none;font:inherit;cursor:pointer;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:0 0;border-radius:8px;padding:6px 10px;display:flex}",
93
- ".thms_swatch:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}",
94
- ".thms_swatch:disabled{opacity:.5;cursor:default}",
95
- ".thms_swatch[data-active=true]{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 1px var(--dsw-alias-state-business-primary) inset}",
96
- ".thms_swatchCells{display:flex;gap:2px}",
97
- ".thms_swatchCell{width:10px;height:10px;border-radius:2px}",
98
- ".thms_swatchLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}",
99
- ".thms_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}",
100
- ".thms_footerError{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}",
101
- ".thms_btn{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}",
102
- ".thms_btn:disabled{opacity:.4;cursor:default}",
103
- ".thms_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}",
104
- ".thms_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}",
105
- ".thms_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}"
91
+ // In-card settings panel (⚙ at the row's right end) — the card
92
+ // configures itself; nothing registers into 设置 → 插件 → 插件配置.
93
+ ".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
+ ".thm_gear:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
95
+ ".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)}",
104
+ ".thm_group{flex-direction:column;gap:5px;display:flex}",
105
+ ".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
+ ".thm_swatches{flex-wrap:wrap;gap:6px;display:flex}",
109
+ ".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
+ ".thm_swatch:hover{border-color:var(--dsw-alias-label-dimmed)}",
111
+ ".thm_swatch[data-active=true]{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 1px var(--dsw-alias-state-business-primary) inset}",
112
+ ".thm_swatchCells{display:flex;gap:2px}",
113
+ ".thm_swatchCell{width:9px;height:9px;border-radius:2px}",
114
+ ".thm_swatchLabel{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px}",
115
+ ".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
+ ".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
+ ".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}"
106
119
  ].join("");
107
120
  const tagId = "@kidli1412/dsh-token-heatmap/TokenHeatmap.module.css";
108
121
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -120,9 +133,12 @@ window.__ModuleLoader__.load({
120
133
  stat: "thm_stat",
121
134
  statLabel: "thm_statLabel",
122
135
  statValue: "thm_statValue",
123
- yearNav: "thm_yearNav",
136
+ nav: "thm_nav",
124
137
  yearBtn: "thm_yearBtn",
125
138
  yearLabel: "thm_yearLabel",
139
+ monthLabelNav: "thm_monthLabelNav",
140
+ viewNav: "thm_viewNav",
141
+ viewBtn: "thm_viewBtn",
126
142
  refresh: "thm_refresh",
127
143
  gridWrap: "thm_gridWrap",
128
144
  grid: "thm_grid",
@@ -131,41 +147,34 @@ window.__ModuleLoader__.load({
131
147
  row: "thm_row",
132
148
  rowLabel: "thm_rowLabel",
133
149
  cell: "thm_cell",
150
+ monthCal: "thm_monthCal",
151
+ weekHead: "thm_weekHead",
152
+ weekHeadCell: "thm_weekHeadCell",
153
+ week: "thm_week",
154
+ day: "thm_day",
155
+ dayNum: "thm_dayNum",
156
+ dayTokens: "thm_dayTokens",
134
157
  legend: "thm_legend",
135
158
  legendCell: "thm_legendCell",
136
159
  error: "thm_error",
137
160
  retry: "thm_retry",
138
161
  loading: "thm_loading",
139
- settingsCard: "thms_card",
140
- settingsCardOpen: "thms_cardOpen",
141
- settingsHeader: "thms_header",
142
- settingsHeadText: "thms_headText",
143
- settingsName: "thms_name",
144
- settingsDesc: "thms_desc",
145
- settingsChevron: "thms_chevron",
146
- settingsChevronOpen: "thms_chevronOpen",
147
- settingsPending: "thms_pending",
148
- settingsBody: "thms_body",
149
- settingsLoading: "thms_loading",
150
- settingsError: "thms_error",
151
- settingsRetry: "thms_retryBtn",
152
- settingsField: "thms_field",
153
- settingsFieldLabel: "thms_fieldLabel",
154
- settingsFieldHint: "thms_fieldHint",
155
- settingsSwitchRow: "thms_switchRow",
156
- settingsSwitch: "thms_switch",
157
- settingsSwitchOn: "thms_switchOn",
158
- settingsThumb: "thms_thumb",
159
- settingsSwatches: "thms_swatches",
160
- settingsSwatch: "thms_swatch",
161
- settingsSwatchCells: "thms_swatchCells",
162
- settingsSwatchCell: "thms_swatchCell",
163
- settingsSwatchLabel: "thms_swatchLabel",
164
- settingsFooter: "thms_footer",
165
- settingsFooterError: "thms_footerError",
166
- settingsBtn: "thms_btn",
167
- settingsDiscard: "thms_discard",
168
- settingsSave: "thms_save"
162
+ gear: "thm_gear",
163
+ settingsPanel: "thm_panel",
164
+ panelHead: "thm_panelHead",
165
+ panelTitle: "thm_panelTitle",
166
+ panelClose: "thm_panelClose",
167
+ settingsGroup: "thm_group",
168
+ settingsGroupLabel: "thm_groupLabel",
169
+ settingsHint: "thm_hint",
170
+ settingsHintError: "thm_hintError",
171
+ settingsSwatches: "thm_swatches",
172
+ settingsSwatch: "thm_swatch",
173
+ settingsSwatchCells: "thm_swatchCells",
174
+ settingsSwatchCell: "thm_swatchCell",
175
+ settingsSwatchLabel: "thm_swatchLabel",
176
+ seg: "thm_seg",
177
+ segBtn: "thm_segBtn"
169
178
  };
170
179
  //#endregion
171
180
 
@@ -184,31 +193,32 @@ window.__ModuleLoader__.load({
184
193
  total: "累计",
185
194
  prevYear: "上一年",
186
195
  nextYear: "下一年",
196
+ prevMonth: "上一月",
197
+ nextMonth: "下一月",
198
+ viewYear: "年",
199
+ viewMonth: "月",
200
+ viewLabel: "视图",
187
201
  less: "少",
188
202
  more: "多",
189
203
  cellTokens: "{date} · {tokens} tokens",
190
204
  weekdayLabel: ["一", "二", "三", "四", "五", "六", "日"],
191
205
  monthName: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
206
+ dayLabel: (monthName, day) => `${monthName}${day}日`,
207
+ monthYear: (year, monthName) => `${year}年${monthName}`,
192
208
  levelLegend: ["0", "<1M", "1M–10M", "10M–100M", "≥100M"],
193
209
  settingsTitle: "Token 用量热力图",
194
- settingsDescription: "新会话页面的 Token 用量热力图显示与配色。",
195
- settingsEnabled: "显示热力图",
196
- settingsEnabledHint: "关闭后,新会话输入框下方的 Token 用量热力图不再显示。",
197
210
  settingsScheme: "配色方案",
198
211
  settingsSchemeHint: "热力图格子的颜色主题:绿色为经典 GitHub 风格,其余为新增配色。",
212
+ settingsDefaultView: "默认视图",
213
+ settingsDefaultViewHint: "新会话页面首次打开时显示的视图(标题行右端可随时切换)。",
199
214
  settingsGreen: "绿色",
200
215
  settingsBlue: "蓝色",
201
216
  settingsOrange: "橙色",
202
217
  settingsRed: "红色",
203
218
  settingsPurple: "紫色",
204
219
  settingsTeal: "青色",
205
- settingsSave: "保存",
206
- settingsSaving: "保存中…",
207
- settingsDiscard: "放弃修改",
208
- settingsUnsaved: "未保存",
209
- settingsSaveFailed: "保存失败,修改已保留,请重试。",
210
- settingsLoadFailed: "配置加载失败。",
211
- settingsRetry: "重试",
220
+ settingsPaletteHint: "颜色按每天 token 数的绝对阈值分档:{levels}。",
221
+ settingsSaveFailed: "保存失败,已回到服务端的值,请重试。",
212
222
  settingsExpand: "展开设置",
213
223
  settingsCollapse: "收起设置"
214
224
  };
@@ -224,31 +234,32 @@ window.__ModuleLoader__.load({
224
234
  total: "All time",
225
235
  prevYear: "Previous year",
226
236
  nextYear: "Next year",
237
+ prevMonth: "Previous month",
238
+ nextMonth: "Next month",
239
+ viewYear: "Year",
240
+ viewMonth: "Month",
241
+ viewLabel: "View",
227
242
  less: "Less",
228
243
  more: "More",
229
244
  cellTokens: "{date} · {tokens} tokens",
230
245
  weekdayLabel: ["M", "T", "W", "T", "F", "S", "S"],
231
246
  monthName: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
247
+ dayLabel: (monthName, day) => `${monthName} ${day}`,
248
+ monthYear: (year, monthName) => `${monthName} ${year}`,
232
249
  levelLegend: ["0", "<1M", "1M–10M", "10M–100M", "≥100M"],
233
250
  settingsTitle: "Token usage heatmap",
234
- settingsDescription: "Display and colors of the token heatmap on the new-session screen.",
235
- settingsEnabled: "Show heatmap",
236
- settingsEnabledHint: "When off, the token heatmap below the composer is hidden.",
237
251
  settingsScheme: "Color scheme",
238
252
  settingsSchemeHint: "Cell palette: green is the classic GitHub style; the rest are new themes.",
253
+ settingsDefaultView: "Default view",
254
+ settingsDefaultViewHint: "The view the new-session screen opens with (switchable anytime at the row's right end).",
239
255
  settingsGreen: "Green",
240
256
  settingsBlue: "Blue",
241
257
  settingsOrange: "Orange",
242
258
  settingsRed: "Red",
243
259
  settingsPurple: "Purple",
244
260
  settingsTeal: "Teal",
245
- settingsSave: "Save",
246
- settingsSaving: "Saving…",
247
- settingsDiscard: "Discard",
248
- settingsUnsaved: "Unsaved",
249
- settingsSaveFailed: "Save failed; your changes were kept.",
250
- settingsLoadFailed: "Failed to load configuration.",
251
- settingsRetry: "Retry",
261
+ settingsPaletteHint: "Colors follow absolute per-day token thresholds: {levels}.",
262
+ settingsSaveFailed: "Save failed; the server value was restored.",
252
263
  settingsExpand: "Show settings",
253
264
  settingsCollapse: "Hide settings"
254
265
  };
@@ -293,6 +304,19 @@ window.__ModuleLoader__.load({
293
304
  return key.slice(0, 7);
294
305
  }
295
306
 
307
+ /**
308
+ * Step a `YYYY-MM` key by whole months ("2026-01" -1 → "2025-12"),
309
+ * returning the input unchanged when it is not a month key.
310
+ * @param key - `YYYY-MM` month key.
311
+ * @param delta - signed month count.
312
+ */
313
+ function shiftMonthKey(key, delta) {
314
+ const [year, month] = key.split("-").map(Number);
315
+ if (!Number.isFinite(year) || !Number.isFinite(month)) return key;
316
+ const date = new Date(year, month - 1 + delta, 1);
317
+ return monthKey(date.getTime());
318
+ }
319
+
296
320
  /**
297
321
  * Pick the display dictionary for locale-shaped helpers (month names,
298
322
  * weekday labels, date formatting). The locale plugin resolves the UI
@@ -305,13 +329,31 @@ window.__ModuleLoader__.load({
305
329
  return candidates.some((tag) => tag.toLowerCase().startsWith("zh")) ? zh : en;
306
330
  }
307
331
 
308
- /** Display date "8月13日" / "Aug 13". */
332
+ /**
333
+ * Display date "8月13日" / "Aug 13" — the whole shape comes from the
334
+ * dictionary, so an English UI never renders a Chinese 日 and the
335
+ * Chinese one never grows a space before it.
336
+ */
309
337
  function dateLabel(key) {
310
338
  const [year, month, day] = key.split("-").map(Number);
311
- const names = uiDict().monthName;
312
- return `${names[month - 1]} ${day}日`;
339
+ const dict = uiDict();
340
+ return dict.dayLabel(dict.monthName[month - 1], day);
313
341
  }
314
342
 
343
+ /**
344
+ * Month-key navigation label, ordered per locale: "2026年8月" in Chinese
345
+ * (year first), "Aug 2026" in English (month first) — each dictionary
346
+ * owns its ordering rather than both sharing a Chinese-shaped template.
347
+ */
348
+ function monthLabelFull(key) {
349
+ const [year, month] = key.split("-").map(Number);
350
+ const dict = uiDict();
351
+ return dict.monthYear(year, dict.monthName[month - 1]);
352
+ }
353
+
354
+ /** Heatmap view modes: the calendar-year grid or a single-month calendar. */
355
+ const VIEW_MODES = ["year", "month"];
356
+
315
357
  /** Per-request staleness guard: only the most recent start may `isCurrent()`. */
316
358
  function createLoader() {
317
359
  let current = 0;
@@ -332,25 +374,32 @@ window.__ModuleLoader__.load({
332
374
 
333
375
  //#region config store (settings-scope backed)
334
376
  /**
335
- * Display preferences (enabled + colorScheme), persisted through the
336
- * Host `token-heatmap` settings namespace (settings.yaml) via the bound
337
- * settings scope — NOT the legacy loopback config endpoint. The
377
+ * Display preferences (colorScheme + defaultView), persisted through
378
+ * the Host `token-heatmap` settings namespace (settings.yaml) via the
379
+ * bound settings scope — NOT the legacy loopback config endpoint. The
338
380
  * heatmap and the settings card read the store through
339
381
  * useSyncExternalStore; while the scope is loading or unavailable the
340
- * store falls back to the defaults (enabled / green), so the UI never
341
- * blocks on the settings transport.
382
+ * store falls back to the defaults (green / year view), so the UI never
383
+ * blocks on the settings transport. The 0.1.x display switch is gone:
384
+ * its `enabled` key is simply no longer read (see the settings card).
342
385
  */
343
- const CONFIG_DEFAULTS = { enabled: true, colorScheme: "green" };
386
+ const CONFIG_DEFAULTS = { colorScheme: "green", defaultView: "year" };
344
387
  /** Coerce a scheme to a non-blank string, else the default. */
345
388
  function sanitizeScheme(value) {
346
389
  if (typeof value !== "string") return CONFIG_DEFAULTS.colorScheme;
347
390
  const scheme = value.trim();
348
391
  return scheme.length > 0 && scheme.length <= 32 ? scheme : CONFIG_DEFAULTS.colorScheme;
349
392
  }
393
+ /** Coerce a view mode to a known mode, else the default (year). */
394
+ function sanitizeView(value) {
395
+ if (typeof value !== "string") return CONFIG_DEFAULTS.defaultView;
396
+ const view = value.trim();
397
+ return VIEW_MODES.includes(view) ? view : CONFIG_DEFAULTS.defaultView;
398
+ }
350
399
  /**
351
400
  * Reactive adapter over one bound settings scope: a
352
401
  * useSyncExternalStore-compatible store whose snapshot is
353
- * `{ status, enabled, colorScheme }` derived from the scope.
402
+ * `{ status, colorScheme, defaultView }` derived from the scope.
354
403
  * `set(patch)` writes the touched fields through scope.set (async; the
355
404
  * scope fences revisions and reloads on failure), and the snapshot
356
405
  * only changes after the Host confirms. `reload()` re-reads from the
@@ -367,8 +416,8 @@ window.__ModuleLoader__.load({
367
416
  const v = s.value;
368
417
  return {
369
418
  status: s.status,
370
- enabled: v.enabled !== false,
371
- colorScheme: sanitizeScheme(v.colorScheme)
419
+ colorScheme: sanitizeScheme(v.colorScheme),
420
+ defaultView: sanitizeView(v.defaultView)
372
421
  };
373
422
  };
374
423
  const notify = () => {
@@ -389,12 +438,12 @@ window.__ModuleLoader__.load({
389
438
  };
390
439
  },
391
440
  set: async (patch) => {
392
- if (Object.hasOwn(patch, "enabled")) {
393
- await scope.set("enabled", patch.enabled === true);
394
- }
395
441
  if (Object.hasOwn(patch, "colorScheme")) {
396
442
  await scope.set("colorScheme", sanitizeScheme(patch.colorScheme));
397
443
  }
444
+ if (Object.hasOwn(patch, "defaultView")) {
445
+ await scope.set("defaultView", sanitizeView(patch.defaultView));
446
+ }
398
447
  },
399
448
  reload: () => scope.load(),
400
449
  dispose: () => unsubscribe()
@@ -490,13 +539,56 @@ window.__ModuleLoader__.load({
490
539
  }
491
540
  return { columns, monthStarts };
492
541
  }
542
+
543
+ /**
544
+ * Build the calendar grid for ONE MONTH (the 年/月 view switch's month
545
+ * view): Monday-first weeks, exactly as many week rows as the month
546
+ * spans (5 or 6), cells outside the month null. Days after today are
547
+ * empty level-0 cells (today itself is always rendered with its real
548
+ * value), and every cell carries `day` (day of month) so the calendar
549
+ * can print numbers on top of the same absolute-threshold heat colors
550
+ * the year view uses.
551
+ * @param dayMap - date key → tokens.
552
+ * @param now - reference timestamp.
553
+ * @param month - `YYYY-MM` to render; defaults to the month of `now`.
554
+ * @returns `{ month, weeks, weekdays }` where each week is
555
+ * `Array<{key, tokens, level, day} | null>` of 7 cells.
556
+ */
557
+ function buildMonthGrid(dayMap, now, month) {
558
+ const nowDate = new Date(now);
559
+ const todayKey = dayKey(nowDate.getTime());
560
+ const target = month ?? monthKey(nowDate.getTime());
561
+ const [year, monthNumber] = target.split("-").map(Number);
562
+ const monthStart = new Date(year, monthNumber - 1, 1);
563
+ const monthEnd = new Date(year, monthNumber, 0);
564
+ const startKey = dayKey(monthStart.getTime());
565
+ const endKey = dayKey(monthEnd.getTime());
566
+ const cursor = new Date(year, monthNumber - 1, 1 - ((monthStart.getDay() + 6) % 7));
567
+ const weeks = [];
568
+ while (cursor.getTime() <= monthEnd.getTime()) {
569
+ const cells = [];
570
+ for (let d = 0; d < 7; d += 1) {
571
+ const key = dayKey(cursor.getTime());
572
+ if (key < startKey || key > endKey) {
573
+ cells.push(null);
574
+ } else {
575
+ const future = key > todayKey;
576
+ const tokens = future ? 0 : (dayMap.get(key) ?? 0);
577
+ cells.push({ key, tokens, level: levelOf(tokens), day: cursor.getDate() });
578
+ }
579
+ cursor.setDate(cursor.getDate() + 1);
580
+ }
581
+ weeks.push(cells);
582
+ }
583
+ return { month: target, weeks, weekdays: 7 };
584
+ }
493
585
  //#endregion
494
586
 
495
587
  //#region TokenHeatmap
496
588
  /**
497
589
  * The heatmap card. Shown only on the new-session hero screen
498
- * (`composerPhase === "blank"`); a flex `order` on the wrapper places
499
- * it below the composer input card.
590
+ * (`session.blank`, or `composerPhase === "blank"` on older hosts); a
591
+ * flex `order` on the wrapper places it below the composer input card.
500
592
  * @param props - `session`/`input` from the InputZone owner share, `t`
501
593
  * bound by the slot runtime locale seat.
502
594
  */
@@ -507,6 +599,24 @@ window.__ModuleLoader__.load({
507
599
  const [error, setError] = react.useState(null);
508
600
  const [loading, setLoading] = react.useState(false);
509
601
  const [year, setYear] = react.useState(() => new Date().getFullYear());
602
+ // Active view mode, seeded from the persisted default (the 年/月
603
+ // switch at the row's right end; a manual switch wins for this
604
+ // mount).
605
+ const [view, setView] = react.useState(() => sanitizeView(configStore?.getSnapshot().defaultView));
606
+ // Month-view cursor (`YYYY-MM`); initialised from the current month
607
+ // and re-anchored when it would fall outside the navigable bounds.
608
+ 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 设置 → 插件 → 插件配置.
611
+ const [settingsOpen, setSettingsOpen] = react.useState(false);
612
+ react.useEffect(() => {
613
+ if (!settingsOpen) return;
614
+ const onKeyDown = (event) => {
615
+ if (event.key === "Escape") setSettingsOpen(false);
616
+ };
617
+ document.addEventListener("keydown", onKeyDown);
618
+ return () => document.removeEventListener("keydown", onKeyDown);
619
+ }, [settingsOpen]);
510
620
  const loaderRef = react.useRef(null);
511
621
  if (loaderRef.current === null) loaderRef.current = createLoader();
512
622
  const load = react.useCallback(async () => {
@@ -545,17 +655,18 @@ window.__ModuleLoader__.load({
545
655
  // New-session hero only. DSH 0.1.2+ (rc.1) exposes the state as
546
656
  // the boolean `session.blank` (the `composerPhase` string is
547
657
  // gone); older harnesses keep `composerPhase === "blank"`.
548
- // Conversed sessions hide the card.
658
+ // Conversed sessions hide the card. There is no display switch any
659
+ // more: the card is always shown on the hero screen, and the
660
+ // settings card only carries presentation choices.
549
661
  if (session === void 0 || session === null || input === void 0 || input === null) return null;
550
662
  const heroBlank = typeof session.blank === "boolean" ? session.blank === true : session.composerPhase === "blank";
551
663
  if (!heroBlank) return null;
552
- // Master switch from the settings card (设置 → 插件 → 插件配置).
553
- if (config.enabled === false) return null;
554
664
 
555
665
  const now = Date.now();
556
- // Year-selector bounds: the current year at most (no future usage),
557
- // and at least the earliest year present in the data so the selector
558
- // cannot wander into guaranteed-empty territory.
666
+ // Navigation bounds, derived from the data itself: at most the
667
+ // current year/month (future usage cannot exist) and at least the
668
+ // earliest one present, so the ‹ › selectors never wander into
669
+ // guaranteed-empty territory.
559
670
  const nowYear = new Date(now).getFullYear();
560
671
  let minYear = nowYear;
561
672
  for (const day of data?.days ?? []) {
@@ -563,21 +674,47 @@ window.__ModuleLoader__.load({
563
674
  if (y < minYear) minYear = y;
564
675
  }
565
676
  const maxYear = nowYear;
677
+ const currentMonthKey = monthKey(now);
678
+ let minMonth = currentMonthKey;
679
+ for (const day of data?.days ?? []) {
680
+ const key = monthOfDateKey(day.date);
681
+ if (key < minMonth) minMonth = key;
682
+ }
683
+ const maxMonth = currentMonthKey;
566
684
  const todayTokens = data?.days?.find((day) => day.date === dayKey(now))?.tokens ?? 0;
567
- const monthPrefix = monthKey(now);
685
+ const monthPrefix = currentMonthKey;
568
686
  let monthTokens = 0;
569
687
  for (const day of data?.days ?? []) {
570
688
  if (day.date.startsWith(monthPrefix)) monthTokens += day.tokens;
571
689
  }
572
690
  const totalTokens = data?.total ?? 0;
691
+ // Keep the last known cursor, but never outside the bounds (a
692
+ // brand-new store of only old months must not open on next month).
693
+ const activeMonth = monthCursor < minMonth
694
+ ? minMonth
695
+ : monthCursor > maxMonth ? maxMonth : monthCursor;
573
696
 
574
697
  const dayMap = new Map();
575
698
  for (const day of data?.days ?? []) dayMap.set(day.date, day.tokens);
576
699
  const grid = buildGrid(dayMap, now, year);
700
+ const monthGrid = buildMonthGrid(dayMap, now, activeMonth);
577
701
  const levelLegend = uiDict().levelLegend;
578
- // Palette from the settings card; unknown schemes fall back to green.
702
+ // Palette from the in-card settings panel; unknown schemes fall back
703
+ // to green.
579
704
  const palette = COLOR_SCHEMES[config.colorScheme] ?? COLOR_SCHEMES.green;
580
705
 
706
+ // ‹ › step the active view: years in year view, months in month view.
707
+ const stepBack = () => {
708
+ if (view === "month") setMonthCursor(shiftMonthKey(activeMonth, -1));
709
+ else setYear(year - 1);
710
+ };
711
+ const stepForward = () => {
712
+ if (view === "month") setMonthCursor(shiftMonthKey(activeMonth, 1));
713
+ else setYear(year + 1);
714
+ };
715
+ const backDisabled = view === "month" ? activeMonth <= minMonth : year <= minYear;
716
+ const forwardDisabled = view === "month" ? activeMonth >= maxMonth : year >= maxYear;
717
+
581
718
  const stats = [
582
719
  { label: dict("today"), value: todayTokens },
583
720
  { label: dict("month"), value: monthTokens },
@@ -622,37 +759,120 @@ window.__ModuleLoader__.load({
622
759
  ]
623
760
  }, stat.label)),
624
761
  react_jsx_runtime.jsxs("span", {
625
- className: S.yearNav,
762
+ className: S.nav,
626
763
  children: [
627
764
  react_jsx_runtime.jsx("button", {
628
765
  type: "button",
629
766
  className: S.yearBtn,
630
- disabled: year <= minYear,
631
- onClick: () => setYear(year - 1),
632
- "aria-label": dict("prevYear"),
767
+ disabled: backDisabled,
768
+ onClick: stepBack,
769
+ "aria-label": dict(view === "month" ? "prevMonth" : "prevYear"),
633
770
  children: "\u2039"
634
771
  }),
635
- react_jsx_runtime.jsx("span", { className: S.yearLabel, children: String(year) }),
772
+ view === "month" ? react_jsx_runtime.jsx("span", {
773
+ className: `${S.yearLabel} ${S.monthLabelNav}`,
774
+ children: monthLabelFull(activeMonth)
775
+ }) : react_jsx_runtime.jsx("span", { className: S.yearLabel, children: String(year) }),
636
776
  react_jsx_runtime.jsx("button", {
637
777
  type: "button",
638
778
  className: S.yearBtn,
639
- disabled: year >= maxYear,
640
- onClick: () => setYear(year + 1),
641
- "aria-label": dict("nextYear"),
779
+ disabled: forwardDisabled,
780
+ onClick: stepForward,
781
+ "aria-label": dict(view === "month" ? "nextMonth" : "nextYear"),
642
782
  children: "\u203a"
643
783
  })
644
784
  ]
645
785
  }),
786
+ // The view switch sits next to the ‹ › stepper it pairs
787
+ // with; the ⚙ closes the row on the right.
788
+ react_jsx_runtime.jsx("span", {
789
+ className: S.viewNav,
790
+ role: "group",
791
+ "aria-label": dict("viewLabel"),
792
+ children: VIEW_MODES.map((mode) => react_jsx_runtime.jsx("button", {
793
+ type: "button",
794
+ className: S.viewBtn,
795
+ "data-active": view === mode,
796
+ "aria-pressed": view === mode,
797
+ onClick: () => setView(mode),
798
+ children: dict(mode === "month" ? "viewMonth" : "viewYear")
799
+ }, mode))
800
+ }),
646
801
  react_jsx_runtime.jsx("button", {
647
802
  type: "button",
648
803
  className: S.refresh,
649
804
  disabled: loading,
650
805
  onClick: load,
651
806
  children: dict("refresh")
807
+ }),
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.
811
+ react_jsx_runtime.jsx("button", {
812
+ type: "button",
813
+ className: S.gear,
814
+ "data-active": settingsOpen,
815
+ "aria-expanded": settingsOpen,
816
+ "aria-label": dict("settingsTitle"),
817
+ title: dict("settingsTitle"),
818
+ onClick: () => setSettingsOpen(!settingsOpen),
819
+ children: react_jsx_runtime.jsx("svg", {
820
+ width: 14,
821
+ height: 14,
822
+ viewBox: "0 0 24 24",
823
+ fill: "none",
824
+ stroke: "currentColor",
825
+ "aria-hidden": "true",
826
+ children: react_jsx_runtime.jsx("path", {
827
+ d: "M10.9 2.6h2.2l.4 2.5 1.9.8 2-1.5 1.6 1.6-1.5 2 .8 1.9 2.5.4v2.2l-2.5.4-.8 1.9 1.5 2-1.6 1.6-2-1.5-1.9.8-.4 2.5h-2.2l-.4-2.5-1.9-.8-2 1.5-1.6-1.6 1.5-2-.8-1.9-2.5-.4v-2.2l2.5-.4.8-1.9-1.5-2 1.6-1.6 2 1.5 1.9-.8.4-2.5Z",
828
+ "stroke-width": 1.5,
829
+ "stroke-linejoin": "round",
830
+ "stroke-linecap": "round"
831
+ })
832
+ })
652
833
  })
653
834
  ]
654
835
  }),
655
- react_jsx_runtime.jsx("div", {
836
+ view === "month" ? react_jsx_runtime.jsx("div", {
837
+ className: S.gridWrap,
838
+ children: react_jsx_runtime.jsxs("div", {
839
+ className: S.monthCal,
840
+ children: [
841
+ react_jsx_runtime.jsx("div", {
842
+ className: S.weekHead,
843
+ children: uiDict().weekdayLabel.map((label, index) => react_jsx_runtime.jsx("span", {
844
+ className: S.weekHeadCell,
845
+ children: label
846
+ }, `weekhead-${index}`))
847
+ }),
848
+ ...monthGrid.weeks.map((week, weekIndex) => react_jsx_runtime.jsx("div", {
849
+ className: S.week,
850
+ children: week.map((cell, dayIndex) => {
851
+ if (cell === null) {
852
+ return react_jsx_runtime.jsx("span", {
853
+ className: S.day,
854
+ "data-empty": "true"
855
+ }, `empty-${weekIndex}-${dayIndex}`);
856
+ }
857
+ return react_jsx_runtime.jsxs("span", {
858
+ className: S.day,
859
+ "data-level": String(cell.level),
860
+ "data-weekend": dayIndex >= 5 ? "true" : "false",
861
+ style: { background: palette[cell.level] },
862
+ title: translate("cellTokens", { date: dateLabel(cell.key), tokens: fmt(cell.tokens) }),
863
+ children: [
864
+ react_jsx_runtime.jsx("span", { className: S.dayNum, children: String(cell.day) }),
865
+ cell.tokens > 0 ? react_jsx_runtime.jsx("span", {
866
+ className: S.dayTokens,
867
+ children: fmtCompact(cell.tokens)
868
+ }) : null
869
+ ]
870
+ }, cell.key);
871
+ })
872
+ }, `week-${weekIndex}`))
873
+ ]
874
+ })
875
+ }) : react_jsx_runtime.jsx("div", {
656
876
  className: S.gridWrap,
657
877
  children: react_jsx_runtime.jsxs("div", {
658
878
  className: S.grid,
@@ -691,28 +911,30 @@ window.__ModuleLoader__.load({
691
911
  })
692
912
  ]
693
913
  }, `row-${row}`);
694
- }),
695
- react_jsx_runtime.jsx("div", {
696
- className: S.row,
697
- children: [
698
- react_jsx_runtime.jsx("span", { className: S.rowLabel, children: "" }),
699
- react_jsx_runtime.jsx("span", {
700
- className: S.legend,
701
- children: [
702
- dict("less"),
703
- ...palette.map((color, level) => react_jsx_runtime.jsx("span", {
704
- className: S.legendCell,
705
- style: { background: color },
706
- title: levelLegend[level] ?? ""
707
- }, `lvl-${level}`)),
708
- dict("more")
709
- ]
710
- })
711
- ]
712
914
  })
713
915
  ]
714
916
  })
715
- })
917
+ }),
918
+ react_jsx_runtime.jsx("div", {
919
+ className: S.legend,
920
+ children: [
921
+ dict("less"),
922
+ ...palette.map((color, level) => react_jsx_runtime.jsx("span", {
923
+ className: S.legendCell,
924
+ style: { background: color },
925
+ title: levelLegend[level] ?? ""
926
+ }, `lvl-${level}`)),
927
+ dict("more")
928
+ ]
929
+ }),
930
+ settingsOpen ? react_jsx_runtime.jsx(TokenHeatmapInlineSettings, {
931
+ dict,
932
+ config,
933
+ store: configStore,
934
+ palette,
935
+ translate,
936
+ onClose: () => setSettingsOpen(false)
937
+ }) : null
716
938
  ]
717
939
  });
718
940
  }
@@ -725,213 +947,130 @@ window.__ModuleLoader__.load({
725
947
  }
726
948
  //#endregion
727
949
 
728
- //#region TokenHeatmapSettingsCard
950
+ //#region TokenHeatmapInlineSettings
729
951
  /**
730
- * The plugin settings card, rendered inside the official
731
- * 设置 → 插件 → 插件配置 tab (the `settings.plugin.item` seat).
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).
732
955
  *
733
- * The card edits the `token-heatmap` settings namespace through the
734
- * bound settings scope (the tab only dispatches it while the Host
735
- * serves that namespace) and stages a draft until the user saves,
736
- * mirroring the official card chrome (disclosure header, unsaved
737
- * badge, discard/save footer). The legacy loopback config endpoint
738
- * remains as a back-compat API, but the card no longer uses it.
739
- * @param props - `t` bound by the slot runtime locale seat.
956
+ * Every control applies immediately through the bound settings scope —
957
+ * there is no draft/save step, so the component owns only a local save
958
+ * error. The 0.1.x 显示热力图 switch is gone (the card is always shown on
959
+ * the hero screen), so its stored `enabled` key is neither read nor
960
+ * written. The legacy loopback config endpoint remains as a back-compat
961
+ * API, but nothing in the UI uses it.
962
+ * @param props.dict - bound translator.
963
+ * @param props.config - current config store snapshot.
964
+ * @param props.store - config store (set writes through the scope).
965
+ * @param props.palette - active palette (level 0..4 → css color).
966
+ * @param props.translate - interpolation helper for translated labels.
967
+ * @param props.onClose - collapse the panel (the × button).
740
968
  */
741
- function TokenHeatmapSettingsCard({ t }) {
742
- const dict = t !== void 0 ? t : (key) => zh[key] ?? key;
743
- const [open, setOpen] = react.useState(false);
744
- const [loaded, setLoaded] = react.useState(null);
745
- const [draft, setDraft] = react.useState(null);
746
- const [saving, setSaving] = react.useState(false);
969
+ function TokenHeatmapInlineSettings({ dict, config, store, palette, translate, onClose }) {
747
970
  const [saveFailed, setSaveFailed] = react.useState(false);
748
- const config = react.useSyncExternalStore(configStore.subscribe, configStore.getSnapshot);
749
- // Seed the staged form from the confirmed scope snapshot once (a
750
- // later scope change is the card's own save confirmation; external
751
- // edits land in loaded via the explicit sync in save()).
752
- react.useEffect(() => {
753
- if (config.status !== "ready" || loaded !== null) return;
754
- setLoaded({ enabled: config.enabled, colorScheme: config.colorScheme });
755
- setDraft({ enabled: config.enabled, colorScheme: config.colorScheme });
756
- }, [config, loaded]);
757
-
758
- const dirty = loaded !== null && draft !== null && (draft.enabled !== loaded.enabled || draft.colorScheme !== loaded.colorScheme);
759
- const save = react.useCallback(async () => {
760
- if (draft === null || saving) return;
761
- setSaving(true);
971
+ const schemes = Object.keys(COLOR_SCHEMES);
972
+ // Optimistic local echo: the settings scope only reports the new value
973
+ // after the Host confirms, and a control must not lag behind the click.
974
+ const [pending, setPending] = react.useState({});
975
+ const scheme = pending.colorScheme ?? config.colorScheme;
976
+ const defaultView = pending.defaultView ?? config.defaultView;
977
+ const commit = async (patch) => {
978
+ setPending((current) => ({ ...current, ...patch }));
762
979
  setSaveFailed(false);
763
980
  try {
764
- await configStore.set({ enabled: draft.enabled, colorScheme: draft.colorScheme });
765
- // The scope confirms the write and refreshes its snapshot
766
- // synchronously before settling; sync the staged form.
767
- const confirmed = configStore.getSnapshot();
768
- if (confirmed.status === "ready") {
769
- setLoaded({ enabled: confirmed.enabled, colorScheme: confirmed.colorScheme });
770
- setDraft({ enabled: confirmed.enabled, colorScheme: confirmed.colorScheme });
771
- }
981
+ await store.set(patch);
772
982
  } catch {
773
983
  setSaveFailed(true);
774
- } finally {
775
- setSaving(false);
776
984
  }
777
- }, [draft, saving]);
778
- const discard = () => {
779
- if (loaded !== null) setDraft(loaded);
780
- setSaveFailed(false);
781
985
  };
782
- const toggleEnabled = () => {
783
- if (draft !== null) setDraft({ ...draft, enabled: !draft.enabled });
986
+ const chooseScheme = (value) => {
987
+ if (value !== scheme) commit({ colorScheme: value });
784
988
  };
785
- const chooseScheme = (scheme) => {
786
- if (draft !== null) setDraft({ ...draft, colorScheme: scheme });
989
+ const chooseView = (value) => {
990
+ if (value !== defaultView) commit({ defaultView: value });
787
991
  };
788
- const title = dict("settingsTitle");
789
-
790
- let bodyContent;
791
- if (config.status === "unavailable") {
792
- bodyContent = react_jsx_runtime.jsxs("p", {
793
- className: S.settingsError,
794
- children: [
795
- dict("settingsLoadFailed"),
796
- " ",
797
- react_jsx_runtime.jsx("button", {
798
- type: "button",
799
- className: S.settingsRetry,
800
- onClick: () => configStore.reload(),
801
- children: dict("settingsRetry")
802
- })
803
- ]
804
- });
805
- } else if (loaded === null || draft === null) {
806
- bodyContent = react_jsx_runtime.jsx("p", {
807
- className: S.settingsLoading,
808
- children: dict("loading")
809
- });
810
- } else {
811
- bodyContent = react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
812
- children: [
813
- react_jsx_runtime.jsx("div", {
814
- className: S.settingsField,
815
- children: react_jsx_runtime.jsxs("div", {
816
- className: S.settingsSwitchRow,
817
- children: [
818
- react_jsx_runtime.jsx("button", {
819
- type: "button",
820
- role: "switch",
821
- "aria-checked": draft.enabled,
822
- "aria-label": dict("settingsEnabled"),
823
- disabled: saving,
824
- className: draft.enabled ? `${S.settingsSwitch} ${S.settingsSwitchOn}` : S.settingsSwitch,
825
- onClick: toggleEnabled,
826
- children: react_jsx_runtime.jsx("span", { className: S.settingsThumb })
827
- }),
828
- react_jsx_runtime.jsx("span", { className: S.settingsFieldLabel, children: dict("settingsEnabled") })
829
- ]
992
+ const labelOf = (name) => dict(`settings${name[0].toUpperCase()}${name.slice(1)}`);
993
+ return react_jsx_runtime.jsxs("div", {
994
+ 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
+ },
1000
+ children: [
1001
+ react_jsx_runtime.jsxs("div", {
1002
+ className: S.panelHead,
1003
+ 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"
830
1012
  })
831
- }),
832
- react_jsx_runtime.jsx("p", {
833
- className: S.settingsFieldHint,
834
- children: dict("settingsEnabledHint")
835
- }),
836
- react_jsx_runtime.jsxs("div", {
837
- className: S.settingsField,
838
- children: [
839
- react_jsx_runtime.jsx("span", { className: S.settingsFieldLabel, children: dict("settingsScheme") }),
840
- react_jsx_runtime.jsx("div", {
841
- className: S.settingsSwatches,
842
- children: Object.keys(COLOR_SCHEMES).map((scheme) => react_jsx_runtime.jsx("button", {
843
- type: "button",
844
- disabled: saving,
845
- className: S.settingsSwatch,
846
- "data-active": draft.colorScheme === scheme,
847
- onClick: () => chooseScheme(scheme),
848
- children: [
849
- react_jsx_runtime.jsx("span", {
850
- className: S.settingsSwatchCells,
851
- children: COLOR_SCHEMES[scheme].slice(1).map((color, index) => react_jsx_runtime.jsx("span", {
852
- className: S.settingsSwatchCell,
853
- style: { background: color }
854
- }, `cell-${index}`))
855
- }),
856
- react_jsx_runtime.jsx("span", {
857
- className: S.settingsSwatchLabel,
858
- children: dict(`settings${scheme[0].toUpperCase()}${scheme.slice(1)}`)
859
- })
860
- ]
861
- }, scheme))
862
- })
863
- ]
864
- }),
865
- react_jsx_runtime.jsx("p", {
866
- className: S.settingsFieldHint,
867
- children: dict("settingsSchemeHint")
868
- }),
869
- react_jsx_runtime.jsxs("div", {
870
- className: S.settingsFooter,
871
- children: [
872
- saveFailed ? react_jsx_runtime.jsx("p", {
873
- className: S.settingsFooterError,
874
- children: dict("settingsSaveFailed")
875
- }) : null,
876
- react_jsx_runtime.jsx("button", {
877
- type: "button",
878
- className: `${S.settingsBtn} ${S.settingsDiscard}`,
879
- disabled: !dirty || saving,
880
- onClick: discard,
881
- children: dict("settingsDiscard")
882
- }),
883
- react_jsx_runtime.jsx("button", {
1013
+ ]
1014
+ }),
1015
+ react_jsx_runtime.jsxs("div", {
1016
+ className: S.settingsGroup,
1017
+ children: [
1018
+ react_jsx_runtime.jsx("span", { className: S.settingsGroupLabel, children: dict("settingsScheme") }),
1019
+ react_jsx_runtime.jsx("div", {
1020
+ className: S.settingsSwatches,
1021
+ children: schemes.map((name) => react_jsx_runtime.jsxs("button", {
884
1022
  type: "button",
885
- className: `${S.settingsBtn} ${S.settingsSave}`,
886
- disabled: !dirty || saving,
887
- onClick: save,
888
- children: dict(saving ? "settingsSaving" : "settingsSave")
889
- })
890
- ]
891
- })
892
- ]
893
- });
894
- }
895
-
896
- return react_jsx_runtime.jsxs("li", {
897
- className: open ? `${S.settingsCard} ${S.settingsCardOpen}` : S.settingsCard,
898
- children: [
899
- react_jsx_runtime.jsxs("button", {
900
- type: "button",
901
- className: S.settingsHeader,
902
- "aria-expanded": open,
903
- "aria-label": `${dict(open ? "settingsCollapse" : "settingsExpand")}: ${title}`,
904
- onClick: () => setOpen(!open),
1023
+ className: S.settingsSwatch,
1024
+ "data-active": scheme === name,
1025
+ "aria-pressed": scheme === name,
1026
+ title: labelOf(name),
1027
+ onClick: () => chooseScheme(name),
1028
+ children: [
1029
+ react_jsx_runtime.jsx("span", {
1030
+ className: S.settingsSwatchCells,
1031
+ children: COLOR_SCHEMES[name].slice(1).map((color, index) => react_jsx_runtime.jsx("span", {
1032
+ className: S.settingsSwatchCell,
1033
+ style: { background: color }
1034
+ }, `cell-${index}`))
1035
+ }),
1036
+ react_jsx_runtime.jsx("span", {
1037
+ className: S.settingsSwatchLabel,
1038
+ children: labelOf(name)
1039
+ })
1040
+ ]
1041
+ }, name))
1042
+ })
1043
+ ]
1044
+ }),
1045
+ react_jsx_runtime.jsxs("div", {
1046
+ className: S.settingsGroup,
905
1047
  children: [
906
- react_jsx_runtime.jsxs("span", {
907
- className: S.settingsHeadText,
908
- children: [
909
- react_jsx_runtime.jsx("span", { className: S.settingsName, children: title }),
910
- react_jsx_runtime.jsx("span", { className: S.settingsDesc, children: dict("settingsDescription") })
911
- ]
1048
+ react_jsx_runtime.jsx("span", { className: S.settingsGroupLabel, children: dict("settingsDefaultView") }),
1049
+ react_jsx_runtime.jsx("div", {
1050
+ className: S.seg,
1051
+ role: "group",
1052
+ "aria-label": dict("settingsDefaultView"),
1053
+ children: VIEW_MODES.map((mode) => react_jsx_runtime.jsx("button", {
1054
+ type: "button",
1055
+ className: S.segBtn,
1056
+ "data-active": defaultView === mode,
1057
+ "aria-pressed": defaultView === mode,
1058
+ onClick: () => chooseView(mode),
1059
+ children: dict(mode === "month" ? "viewMonth" : "viewYear")
1060
+ }, mode))
912
1061
  }),
913
- dirty ? react_jsx_runtime.jsx("span", {
914
- className: S.settingsPending,
915
- children: dict("settingsUnsaved")
916
- }) : null,
917
- react_jsx_runtime.jsx("svg", {
918
- width: 14,
919
- height: 14,
920
- className: open ? `${S.settingsChevron} ${S.settingsChevronOpen}` : S.settingsChevron,
921
- viewBox: "0 0 14 14",
922
- fill: "none",
923
- xmlns: "http://www.w3.org/2000/svg",
924
- children: react_jsx_runtime.jsx("path", {
925
- d: "M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z",
926
- fill: "currentColor"
927
- })
1062
+ react_jsx_runtime.jsx("span", {
1063
+ className: S.settingsHint,
1064
+ children: dict("settingsDefaultViewHint")
928
1065
  })
929
1066
  ]
930
1067
  }),
931
- open ? react_jsx_runtime.jsx("div", {
932
- className: S.settingsBody,
933
- children: bodyContent
934
- }) : null
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(" / ") })
1073
+ })
935
1074
  ]
936
1075
  });
937
1076
  }
@@ -939,28 +1078,26 @@ window.__ModuleLoader__.load({
939
1078
 
940
1079
  //#region plugin body
941
1080
  /**
942
- * Settings namespace this plugin's card edits — must match the
943
- * namespace the Host registers (lib/index.js). Spelled here rather
944
- * than imported: a client package must not depend on a Host package.
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.
945
1084
  */
946
1085
  const SETTINGS_NS = "token-heatmap";
947
1086
  /** Services required by the client plugin body. */
948
1087
  const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
949
1088
 
950
1089
  /**
951
- * Client plugin body: register the dictionaries, the input-dock entry
952
- * (heatmap below the composer card on the new-session screen) and the
953
- * plugin settings card (设置 → 插件 → 插件配置), all backed by the
954
- * `token-heatmap` settings namespace through a bound settings scope.
1090
+ * 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.
955
1097
  *
956
1098
  * Slot-kind contract: `conversation.input.dock` is a LIST slot, so its
957
- * registration is keyed by `id`; `settings.plugin.item` is a KEYED slot,
958
- * so its registration must carry `key` (the settings namespace the card
959
- * edits) — registering with `id` instead throws
960
- * `keyed slot "settings.plugin.item" requires options.key` and fails
961
- * the whole plugin load. The card is only dispatched by the official
962
- * 插件配置 tab when the Host actually SERVES that namespace, which is
963
- * why this plugin registers it on the Host side too.
1099
+ * registration is keyed by `id` (a `settings.plugin.item` registration
1100
+ * would instead need `key`, the settings namespace the card edits).
964
1101
  * @param ctx - client root context.
965
1102
  */
966
1103
  function apply(ctx) {
@@ -977,21 +1114,19 @@ window.__ModuleLoader__.load({
977
1114
  order: 10,
978
1115
  locale: NS
979
1116
  }, TokenHeatmap));
980
- ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
981
- name: "settings.plugin.item",
982
- key: SETTINGS_NS,
983
- order: 30,
984
- locale: NS
985
- }, TokenHeatmapSettingsCard));
986
1117
  }
987
1118
  //#endregion
988
1119
 
989
1120
  exports.apply = apply;
990
1121
  exports.inject = inject;
991
1122
  exports.TokenHeatmap = TokenHeatmap;
992
- exports.TokenHeatmapSettingsCard = TokenHeatmapSettingsCard;
1123
+ exports.TokenHeatmapInlineSettings = TokenHeatmapInlineSettings;
993
1124
  exports.buildGrid = buildGrid;
1125
+ exports.buildMonthGrid = buildMonthGrid;
994
1126
  exports.levelOf = levelOf;
1127
+ exports.monthLabelFull = monthLabelFull;
1128
+ exports.dateLabel = dateLabel;
1129
+ exports.shiftMonthKey = shiftMonthKey;
995
1130
  exports.CELL_COLORS = CELL_COLORS;
996
1131
  exports.COLOR_SCHEMES = COLOR_SCHEMES;
997
1132
  exports.createConfigStore = createConfigStore;