@celestia-island/hikari 0.55.170 → 0.55.171

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.170",
3
+ "version": "0.55.171",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,338 @@
1
+ import { computed, defineComponent, onUnmounted, ref, watch, type PropType } from "vue";
2
+ import { Check, Monitor, MoonStar, Palette, Sun, Trash as Trash2 } from "lucide-vue-next";
3
+ import {
4
+ HDivider,
5
+ solarAltitude,
6
+ themePresets,
7
+ useI18n,
8
+ useTheme,
9
+ type CustomThemePreset,
10
+ type ThemeId,
11
+ type ThemePreset,
12
+ } from "@celestia-island/hikari";
13
+
14
+ import { HColorSchemeDialog, type HCustomTheme } from "./HkColorSchemeDialog";
15
+ import HTabs, { type TabItem } from "./HkTabs";
16
+ import "./HkThemeToggle.scss";
17
+
18
+ /** The auto-mode merged run, hoisted: a fresh array literal per render
19
+ * would re-fire HTabs' mergeKeys watcher (one redundant geometry
20
+ * refresh) on every altitude tick. */
21
+ const AUTO_MERGE_KEYS = ["light", "dark"];
22
+
23
+ /** Scoped payload handed to the `item-leading` / `item-trailing` slots:
24
+ * one object per theme row, carrying enough identity for the host to
25
+ * render swatches or affordances without re-deriving the preset.
26
+ * `name` is the RAW stored string — it may be a message key (see
27
+ * `ThemePreset.name`); the row cell resolves it through the catalog. */
28
+ export interface ThemeItemScope {
29
+ id: ThemeId;
30
+ name: string;
31
+ isCustom: boolean;
32
+ /** The row's full definition — the built-in preset table entry for
33
+ * stock/registered themes, the user's stored scheme for customs. */
34
+ preset: ThemePreset | CustomThemePreset | undefined;
35
+ }
36
+
37
+ /**
38
+ * HkThemePanelContent — the theme panel's CONTENT, extracted from
39
+ * HkThemeToggle's popover (2026-10-15 user direction: hosts embed the
40
+ * panel in their own settings surfaces; the trigger+popover chrome and
41
+ * the content are separate concerns and must be separately usable).
42
+ *
43
+ * Everything between the popover's padding lives here: the color-mode
44
+ * group (HTabs segmented with the solar-altitude merged cell), the
45
+ * `mode-extra` host strip, the preset/custom theme rows (three scoped
46
+ * host slots), the customize entry and the `menu-extra` slot. The
47
+ * built-in HColorSchemeDialog rides along when `externalCustomize` is
48
+ * false — dialogs teleport, so hosting this inside any surface is safe.
49
+ *
50
+ * The component talks to `useTheme()` directly (the composable is a
51
+ * singleton); the owning surface only supplies slots and reacts to the
52
+ * two events:
53
+ * · `request-close` — a theme was applied or the customize entry was
54
+ * pressed; a popover host closes its layer, an inline host ignores it.
55
+ * · `open-customize` — pressed with `externalCustomize: true`.
56
+ */
57
+ export const HkThemePanelContent = defineComponent({
58
+ name: "HkThemePanelContent",
59
+ props: {
60
+ /**
61
+ * When true, the "Customize" entry emits `open-customize` (and
62
+ * `request-close`) instead of opening the built-in HColorSchemeDialog
63
+ * — the host then owns the customization surface. The internal
64
+ * dialog is not rendered in this mode.
65
+ */
66
+ externalCustomize: { type: Boolean, default: false },
67
+ },
68
+ emits: {
69
+ "update:scheme": (_theme: HCustomTheme) => true,
70
+ "open-customize": () => true,
71
+ "request-close": () => true,
72
+ },
73
+ setup(props, { emit, slots }) {
74
+ const { t } = useI18n();
75
+ const {
76
+ currentTheme,
77
+ currentMode,
78
+ effectiveMode,
79
+ geo,
80
+ setTheme,
81
+ setMode,
82
+ allThemeList,
83
+ addCustomTheme,
84
+ removeCustomTheme,
85
+ customThemes,
86
+ } = useTheme();
87
+
88
+ /** Resolve a row's full definition for the item slots: the user's
89
+ * stored custom schemes first, then the live preset table — the SAME
90
+ * precedence getAllThemePresets applies at apply time, so a row that
91
+ * shadows a builtin id (in-place preset override) shows the anatomy
92
+ * of the scheme that actually renders, not the shadowed factory. */
93
+ function presetOf(id: ThemeId): ThemePreset | CustomThemePreset | undefined {
94
+ const custom = customThemes.value.find((c) => c.id === id);
95
+ if (custom) return custom;
96
+ if (id in themePresets) return themePresets[id as keyof typeof themePresets];
97
+ return undefined;
98
+ }
99
+
100
+ const schemeDialogOpen = ref(false);
101
+
102
+ // ── Solar-altitude readout (auto-mode merged cell) ──────────────
103
+ // `geo` comes from the theme clock (timezone estimate until the real
104
+ // fix lands) — the engine and this readout share one resolution.
105
+ // The timer runs only while this content is MOUNTED (a popover host
106
+ // unmounts it on close; an inline host keeps it live — the readout
107
+ // staying current there is a feature, not a leak).
108
+ const altTick = ref(0);
109
+ let altTimer: ReturnType<typeof setInterval> | null = null;
110
+ watch(
111
+ () => props.externalCustomize,
112
+ () => void 0,
113
+ );
114
+ onUnmounted(() => {
115
+ if (altTimer) clearInterval(altTimer);
116
+ });
117
+ // Mounted ⇒ ticking (see the comment above).
118
+ altTimer = setInterval(() => { altTick.value += 1; }, 60000);
119
+
120
+ /** Current sun altitude formatted like "+32.5°". */
121
+ const altitudeText = computed(() => {
122
+ void altTick.value;
123
+ void geo.value;
124
+ const alt = solarAltitude(geo.value.lat, geo.value.lng, new Date());
125
+ return `${alt >= 0 ? "+" : ""}${alt.toFixed(1)}°`;
126
+ });
127
+
128
+ /** Glyph hints which side a press will land on: sun while auto
129
+ * resolves light, moon once dusk/night flip it to dark. */
130
+ const altitudeNight = computed(() => {
131
+ void altTick.value;
132
+ void geo.value;
133
+ return effectiveMode.value === "dark";
134
+ });
135
+
136
+ const isAutoMode = computed(() => currentMode.value === "system");
137
+
138
+ /**
139
+ * Fresh option objects (and fresh icon vnodes) on every call — never
140
+ * cache icon vnodes across renders, or re-mounting this content
141
+ * would re-mount the same vnode instances. In AUTO mode the Light/
142
+ * Dark options collapse into the group's merged cell (the altitude
143
+ * strip) via `mergeKeys`. They stay ENABLED (2026-10-01 user report:
144
+ * disabled Light/Dark read as the mode being locked to the sun): in
145
+ * the merged rendering they are not rendered at all, and on the
146
+ * degradation path (merge requested without the #merged slot) a
147
+ * click must drop OUT of auto onto the pressed side instead of
148
+ * doing nothing.
149
+ */
150
+ function modeOptions(): TabItem[] {
151
+ return [
152
+ { key: "system", label: t("hikari::theme.modeAuto"), icon: <Monitor size={16} /> },
153
+ { key: "light", label: t("hikari::theme.modeLight"), icon: <Sun size={16} /> },
154
+ { key: "dark", label: t("hikari::theme.modeDark"), icon: <MoonStar size={16} /> },
155
+ ];
156
+ }
157
+
158
+ function onSelectMode(value: string) {
159
+ setMode(value as "light" | "dark" | "system");
160
+ }
161
+
162
+ /**
163
+ * Pressing the altitude strip in auto mode drops back to manual and
164
+ * selects whichever side auto currently resolves to (day → light,
165
+ * night → dark). Debounced: input synthesis may deliver both a
166
+ * pointer sequence and a click for one physical press.
167
+ */
168
+ let lastManualSwitchAt = 0;
169
+ function resolveAutoToManual() {
170
+ const now = Date.now();
171
+ if (now - lastManualSwitchAt < 700) return;
172
+ lastManualSwitchAt = now;
173
+ setMode(effectiveMode.value);
174
+ }
175
+
176
+ function onSelectTheme(id: ThemeId) {
177
+ setTheme(id);
178
+ emit("request-close");
179
+ }
180
+
181
+ function onConfirmScheme(theme: HCustomTheme) {
182
+ addCustomTheme(theme);
183
+ setTheme(theme.id);
184
+ emit("update:scheme", theme);
185
+ }
186
+
187
+ return () => (
188
+ <>
189
+ <div class="s-theme-menu">
190
+ <div class="s-theme-menu-label">{t("hikari::theme.mode")}</div>
191
+ <div class="s-theme-mode-row">
192
+ <HTabs
193
+ variant="segmented"
194
+ block
195
+ tabs={modeOptions()}
196
+ modelValue={currentMode.value}
197
+ onUpdate:modelValue={onSelectMode}
198
+ mergeKeys={isAutoMode.value ? AUTO_MERGE_KEYS : undefined}
199
+ >
200
+ {{
201
+ merged: () => (
202
+ <button
203
+ type="button"
204
+ class="s-theme-mode-autoalt"
205
+ title={t("hikari::theme.autoAltitudeTip")}
206
+ aria-label={t("hikari::theme.autoAltitudeTip")}
207
+ onClick={resolveAutoToManual}
208
+ >
209
+ {altitudeNight.value ? <MoonStar size={16} /> : <Sun size={16} />}
210
+ <span class="s-theme-mode-autoalt-value">{altitudeText.value}</span>
211
+ </button>
212
+ ),
213
+ }}
214
+ </HTabs>
215
+ </div>
216
+
217
+ {/* Host extension point directly under the mode group (P98: the
218
+ chest DPI control lives here — mode-adjacent chrome, not a theme
219
+ editor tab). Rendered only when the host provides the slot. */}
220
+ {slots["mode-extra"] ? (
221
+ <div class="s-theme-mode-extra">{slots["mode-extra"]()}</div>
222
+ ) : null}
223
+
224
+ <HDivider spacing="md" />
225
+
226
+ <div class="s-theme-menu-label">{t("hikari::theme.themes")}</div>
227
+ {allThemeList.value.map((th) => {
228
+ // Host-customizable row anatomy. `item-leading` replaces the
229
+ // selected-check cell and renders for EVERY row (fixed icon
230
+ // cell, so names stay aligned); `item-name-suffix` renders
231
+ // INSIDE the row button right after the name, so a host mark
232
+ // (e.g. a provenance/sync glyph) reads as part of the name
233
+ // instead of as an affordance; `item-trailing` reserves a
234
+ // real trailing column for every row and takes full
235
+ // ownership of the affordances there — the built-in custom
236
+ // delete overlay is suppressed in that mode, so the host
237
+ // renders delete (and anything else) itself. Without the
238
+ // slots the rows behave exactly as before: check only on the
239
+ // active row, delete overlaid on custom rows only.
240
+ const scope: ThemeItemScope = {
241
+ id: th.id,
242
+ name: th.name,
243
+ isCustom: th.isCustom,
244
+ preset: presetOf(th.id),
245
+ };
246
+ const leadingSlot = slots["item-leading"];
247
+ const nameSuffixSlot = slots["item-name-suffix"];
248
+ const trailingSlot = slots["item-trailing"];
249
+ // A preset/custom name may be a message key into hikari's own
250
+ // catalog (the documented ThemePreset.name convention — hosts
251
+ // ship localized built-ins like "hikari::theme.defaultThemeName").
252
+ // `t()` falls back to the raw string for anything that is not a
253
+ // key, so literal display names pass through unchanged, and the
254
+ // reactive message lookup re-renders rows on locale switches.
255
+ const displayName = t(th.name);
256
+ return (
257
+ <div
258
+ key={th.id}
259
+ class="s-theme-item-row"
260
+ data-custom={th.isCustom || undefined}
261
+ data-trailing={trailingSlot ? "slot" : undefined}
262
+ data-name-suffix={nameSuffixSlot ? "slot" : undefined}
263
+ // The ROW is the pill: the highlight state lives here so
264
+ // the hover/active wash spans the full row width — a
265
+ // trailing slot column must not shrink it (2026-10-01
266
+ // user report: theme rows and the 自定义 row hovered at
267
+ // different widths). The button keeps the click/focus
268
+ // semantics; the stylesheet strips its own chrome.
269
+ data-active={currentTheme.value === th.id || undefined}
270
+ >
271
+ <button
272
+ type="button"
273
+ class="s-theme-item-btn"
274
+ data-active={currentTheme.value === th.id || undefined}
275
+ onClick={() => onSelectTheme(th.id)}
276
+ >
277
+ {leadingSlot ? (
278
+ <span class="hk-menu-item-icon s-theme-item-lead">
279
+ {leadingSlot(scope)}
280
+ </span>
281
+ ) : currentTheme.value === th.id ? (
282
+ <span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
283
+ ) : null}
284
+ <span class="s-theme-item-name">{displayName}</span>
285
+ {nameSuffixSlot ? (
286
+ <span class="s-theme-item-name-suffix">{nameSuffixSlot(scope)}</span>
287
+ ) : null}
288
+ </button>
289
+ {trailingSlot ? (
290
+ <span class="s-theme-item-trailing">{trailingSlot(scope)}</span>
291
+ ) : th.isCustom ? (
292
+ <button
293
+ type="button"
294
+ class="s-theme-item-delete"
295
+ title={t("hikari::theme.deleteTheme")}
296
+ onClick={() => removeCustomTheme(th.id)}
297
+ >
298
+ <Trash2 size={12} />
299
+ </button>
300
+ ) : null}
301
+ </div>
302
+ );
303
+ })}
304
+
305
+ <button
306
+ type="button"
307
+ class="s-theme-item-btn s-theme-item-customize"
308
+ onClick={() => {
309
+ if (props.externalCustomize) {
310
+ emit("request-close");
311
+ emit("open-customize");
312
+ } else {
313
+ schemeDialogOpen.value = true;
314
+ }
315
+ }}
316
+ >
317
+ <span class="hk-menu-item-icon"><Palette size={14} /></span>
318
+ <span class="s-theme-item-name">{t("hikari::theme.customize")}</span>
319
+ </button>
320
+ </div>
321
+ {(() => {
322
+ const extra = slots["menu-extra"]?.() ?? [];
323
+ return extra.length ? (
324
+ <div class="hk-menu-slot hk-menu-slot--extra">{extra}</div>
325
+ ) : null;
326
+ })()}
327
+
328
+ {!props.externalCustomize && (
329
+ <HColorSchemeDialog
330
+ modelValue={schemeDialogOpen.value}
331
+ onUpdate:modelValue={(v: boolean) => { schemeDialogOpen.value = v; }}
332
+ onConfirm={onConfirmScheme}
333
+ />
334
+ )}
335
+ </>
336
+ );
337
+ },
338
+ });
@@ -1,47 +1,20 @@
1
- import { computed, defineComponent, onUnmounted, ref, watch, type PropType } from "vue";
2
- import { Check, Monitor, MoonStar, Palette, Sun, Trash as Trash2 } from "lucide-vue-next";
3
- import {
4
- HDivider,
5
- HPopover,
6
- solarAltitude,
7
- themePresets,
8
- useI18n,
9
- useTheme,
10
- type CustomThemePreset,
11
- type PopupPlacement,
12
- type ThemeId,
13
- type ThemePreset,
14
- } from "@celestia-island/hikari";
1
+ import { defineComponent, onUnmounted, ref, watch, type PropType } from "vue";
2
+ import { Palette } from "lucide-vue-next";
3
+ import { HPopover, useI18n, type PopupPlacement } from "@celestia-island/hikari";
15
4
 
16
- import { HColorSchemeDialog, type HCustomTheme } from "./HkColorSchemeDialog";
17
5
  import HkIconButton from "./HkIconButton";
18
- import HTabs, { type TabItem } from "./HkTabs";
6
+ import { HkThemePanelContent } from "./HkThemePanelContent";
7
+ import type { HCustomTheme } from "./HkColorSchemeDialog";
19
8
  import "./HkThemeToggle.scss";
20
9
 
21
- /** The auto-mode merged run, hoisted: a fresh array literal per render
22
- * would re-fire HTabs' mergeKeys watcher (one redundant geometry
23
- * refresh) on every altitude tick. */
24
- const AUTO_MERGE_KEYS = ["light", "dark"];
25
-
26
- /** Scoped payload handed to the `item-leading` / `item-trailing` slots:
27
- * one object per theme row, carrying enough identity for the host to
28
- * render swatches or affordances without re-deriving the preset.
29
- * `name` is the RAW stored string — it may be a message key (see
30
- * `ThemePreset.name`); the row cell resolves it through the catalog. */
31
- export interface ThemeItemScope {
32
- id: ThemeId;
33
- name: string;
34
- isCustom: boolean;
35
- /** The row's full definition — the built-in preset table entry for
36
- * stock/registered themes, the user's stored scheme for customs. */
37
- preset: ThemePreset | CustomThemePreset | undefined;
38
- }
10
+ export { type ThemeItemScope } from "./HkThemePanelContent";
39
11
 
40
12
  /**
41
13
  * HkThemeToggle — light/dark/auto theme control over hikari's theme engine.
42
14
  *
43
- * Composes hikari's `useTheme()` (theme presets + custom themes + mode
44
- * persistence); it does NOT reimplement the theme engine. ONE trigger —
15
+ * Composes the shared HkThemePanelContent (the panel body both this
16
+ * popover and host settings surfaces render); it does NOT reimplement the
17
+ * theme engine. ONE trigger —
45
18
  * a standard HkIconButton ghost/28 with a palette glyph, chrome-identical
46
19
  * to the functional buttons hosts place beside it — opens the popover,
47
20
  * which offers the color-mode group and preset/custom theme selection
@@ -107,140 +80,28 @@ export const HkThemeToggle = defineComponent({
107
80
  },
108
81
  setup(props, { emit, slots, expose }) {
109
82
  const { t } = useI18n();
110
- const { currentTheme, currentMode, effectiveMode, geo, setTheme, setMode, allThemeList, addCustomTheme, removeCustomTheme, customThemes } = useTheme();
111
-
112
- /** Resolve a row's full definition for the item slots: the user's
113
- * stored custom schemes first, then the live preset table — the SAME
114
- * precedence getAllThemePresets applies at apply time, so a row that
115
- * shadows a builtin id (in-place preset override) shows the anatomy
116
- * of the scheme that actually renders, not the shadowed factory. */
117
- function presetOf(id: ThemeId): ThemePreset | CustomThemePreset | undefined {
118
- const custom = customThemes.value.find((c) => c.id === id);
119
- if (custom) return custom;
120
- if (id in themePresets) return themePresets[id as keyof typeof themePresets];
121
- return undefined;
122
- }
123
-
124
83
  const menuOpen = ref(false);
125
84
  const triggerRef = ref<HTMLElement | null>(null);
126
- const schemeDialogOpen = ref(false);
127
85
 
128
86
  /** Programmatic close for host content inside the menu (row slot
129
- * affordances): a host opening a delegated second-level window from
130
- * a theme row calls this to dismiss the menu. The menu is ONE state
131
- * in both its forms — desktop popover and mobile bottom sheet — so
87
+ * affordances): a host opening a delegated second-level window from a
88
+ * theme row calls this to dismiss the menu. The menu is ONE state in
89
+ * both its forms — desktop popover and mobile bottom sheet — so
132
90
  * this closes whichever is showing, with no breakpoint branching
133
- * here; a mobile-aware host decides whether to call it at all.
134
- * Same teardown as every internal close — the alt-timer cleanup
135
- * rides the menuOpen watcher. */
91
+ * here; a mobile-aware host decides whether to call it at all. */
136
92
  function closeMenu() {
137
93
  menuOpen.value = false;
138
94
  }
139
95
  expose({ closeMenu });
140
96
 
141
- // ── Solar-altitude readout (auto-mode merged cell) ──────────────
142
- // `geo` comes from the theme clock (timezone estimate until the real
143
- // fix lands) — the engine and this readout share one resolution.
144
- const altTick = ref(0);
145
-
146
- let altTimer: ReturnType<typeof setInterval> | null = null;
147
- watch(menuOpen, (open) => {
148
- if (open) {
149
- if (!altTimer) altTimer = setInterval(() => { altTick.value += 1; }, 60000);
150
- } else if (altTimer) {
151
- clearInterval(altTimer);
152
- altTimer = null;
153
- }
154
- });
155
- onUnmounted(() => {
156
- if (altTimer) clearInterval(altTimer);
157
- });
158
-
159
- /** Current sun altitude formatted like "+32.5°". */
160
- const altitudeText = computed(() => {
161
- void menuOpen.value;
162
- void altTick.value;
163
- void geo.value;
164
- const alt = solarAltitude(geo.value.lat, geo.value.lng, new Date());
165
- return `${alt >= 0 ? "+" : ""}${alt.toFixed(1)}°`;
166
- });
167
-
168
- /** Glyph hints which side a press will land on: sun while auto
169
- * resolves light, moon once dusk/night flip it to dark. */
170
- const altitudeNight = computed(() => {
171
- void menuOpen.value;
172
- void altTick.value;
173
- void geo.value;
174
- return effectiveMode.value === "dark";
175
- });
176
-
177
- const isAutoMode = computed(() => currentMode.value === "system");
178
-
179
- /**
180
- * Fresh option objects (and fresh icon vnodes) on every call — never
181
- * cache icon vnodes across renders, or closing/reopening the popover
182
- * would re-mount the same vnode instances. In AUTO mode the Light/
183
- * Dark options collapse into the group's merged cell (the altitude
184
- * strip) via `mergeKeys`. They stay ENABLED (2026-10-01 user report:
185
- * disabled Light/Dark read as the mode being locked to the sun): in
186
- * the merged rendering they are not rendered at all, and on the
187
- * degradation path (merge requested without the #merged slot) a
188
- * click must drop OUT of auto onto the pressed side instead of
189
- * doing nothing.
190
- */
191
- function modeOptions(): TabItem[] {
192
- return [
193
- { key: "system", label: t("hikari::theme.modeAuto"), icon: <Monitor size={16} /> },
194
- { key: "light", label: t("hikari::theme.modeLight"), icon: <Sun size={16} /> },
195
- { key: "dark", label: t("hikari::theme.modeDark"), icon: <MoonStar size={16} /> },
196
- ];
197
- }
198
-
199
- function onSelectMode(value: string) {
200
- setMode(value as "light" | "dark" | "system");
201
- }
202
-
203
- /**
204
- * Pressing the altitude strip in auto mode drops back to manual and
205
- * selects whichever side auto currently resolves to (day → light,
206
- * night → dark). Debounced: input synthesis may deliver both a
207
- * pointer sequence and a click for one physical press.
208
- */
209
- let lastManualSwitchAt = 0;
210
- function resolveAutoToManual() {
211
- const now = Date.now();
212
- if (now - lastManualSwitchAt < 700) return;
213
- lastManualSwitchAt = now;
214
- setMode(effectiveMode.value);
215
- }
216
-
217
- function onSelectTheme(id: ThemeId) {
218
- setTheme(id);
219
- closeMenu();
220
- }
221
-
222
- function onConfirmScheme(theme: HCustomTheme) {
223
- addCustomTheme(theme);
224
- setTheme(theme.id);
225
- emit("update:scheme", theme);
226
- }
227
-
228
97
  return () => (
229
98
  <div class="s-theme-toggle" ref={triggerRef}>
230
- {/* ONE trigger (user direction 2026-09-30): the old main/arrow pair
231
- * read as two separate header buttons whose hover washes disagreed
232
- * (bespoke primary wash here vs the ghost gray beside them). The
233
- * trigger is now a STANDARD HkIconButton ghost/28 with a palette
234
- * glyph — identical chrome to the functional buttons it sits
235
- * beside (transparent rest, ghost wash on hover/press, shared
236
- * focus ring) at the SAME header chrome step (user report
237
- * 2026-09-28: the cluster reads as one row only at ghost/28 with
238
- * the sm 16px glyph, which the 28 step maps). And it only opens
239
- * the menu; mode switching lives in the menu's color-mode strip
240
- * (auto altitude strip resolves auto to a manual side), which
241
- * loses nothing the old direct mode-cycle press offered. The
242
- * `s-theme-toggle-btn` class stays as a stable consumer/test
243
- * hook; it carries NO css of its own. */}
99
+ {/* ONE trigger (user direction 2026-09-30): a STANDARD HkIconButton
100
+ * ghost/28 with a palette glyph — identical chrome to the functional
101
+ * buttons it sits beside. It only opens the menu; mode switching
102
+ * lives in the menu's color-mode strip. The `s-theme-toggle-btn`
103
+ * class stays as a stable consumer/test hook; it carries NO css of
104
+ * its own. */}
244
105
  <HkIconButton
245
106
  class="s-theme-toggle-btn"
246
107
  size={28}
@@ -260,157 +121,27 @@ export const HkThemeToggle = defineComponent({
260
121
  placement={props.popoverPlacement}
261
122
  anchorRef={triggerRef.value ?? null}
262
123
  sheetOnMobile
263
- // Names the breadcrumb layer / sheet heading when this menu
264
- // blocks as a mobile bottom sheet.
265
124
  title={t("hikari::theme.themes")}
266
125
  >
267
- <div class="s-theme-menu">
268
- <div class="s-theme-menu-label">{t("hikari::theme.mode")}</div>
269
- <div class="s-theme-mode-row">
270
- <HTabs
271
- variant="segmented"
272
- block
273
- tabs={modeOptions()}
274
- modelValue={currentMode.value}
275
- onUpdate:modelValue={onSelectMode}
276
- mergeKeys={isAutoMode.value ? AUTO_MERGE_KEYS : undefined}
277
- >
278
- {{
279
- merged: () => (
280
- <button
281
- type="button"
282
- class="s-theme-mode-autoalt"
283
- title={t("hikari::theme.autoAltitudeTip")}
284
- aria-label={t("hikari::theme.autoAltitudeTip")}
285
- onClick={resolveAutoToManual}
286
- >
287
- {altitudeNight.value ? <MoonStar size={16} /> : <Sun size={16} />}
288
- <span class="s-theme-mode-autoalt-value">{altitudeText.value}</span>
289
- </button>
290
- ),
291
- }}
292
- </HTabs>
293
- </div>
294
-
295
- {/* Host extension point directly under the mode group (P98: the
296
- chest DPI control lives here — mode-adjacent chrome, not a theme
297
- editor tab). Rendered only when the host provides the slot. */}
298
- {slots["mode-extra"] ? (
299
- <div class="s-theme-mode-extra">{slots["mode-extra"]()}</div>
300
- ) : null}
301
-
302
- <HDivider spacing="md" />
303
-
304
- <div class="s-theme-menu-label">{t("hikari::theme.themes")}</div>
305
- {allThemeList.value.map((th) => {
306
- // Host-customizable row anatomy. `item-leading` replaces the
307
- // selected-check cell and renders for EVERY row (fixed icon
308
- // cell, so names stay aligned); `item-name-suffix` renders
309
- // INSIDE the row button right after the name, so a host mark
310
- // (e.g. a provenance/sync glyph) reads as part of the name
311
- // instead of as an affordance; `item-trailing` reserves a
312
- // real trailing column for every row and takes full
313
- // ownership of the affordances there — the built-in custom
314
- // delete overlay is suppressed in that mode, so the host
315
- // renders delete (and anything else) itself. Without the
316
- // slots the rows behave exactly as before: check only on
317
- // the active row, delete overlaid on custom rows only.
318
- const scope: ThemeItemScope = {
319
- id: th.id,
320
- name: th.name,
321
- isCustom: th.isCustom,
322
- preset: presetOf(th.id),
323
- };
324
- const leadingSlot = slots["item-leading"];
325
- const nameSuffixSlot = slots["item-name-suffix"];
326
- const trailingSlot = slots["item-trailing"];
327
- // A preset/custom name may be a message key into hikari's own
328
- // catalog (the documented ThemePreset.name convention — hosts
329
- // ship localized built-ins like "hikari::theme.defaultThemeName").
330
- // `t()` falls back to the raw string for anything that is not a
331
- // key, so literal display names pass through unchanged, and the
332
- // reactive message lookup re-renders rows on locale switches.
333
- const displayName = t(th.name);
334
- return (
335
- <div
336
- key={th.id}
337
- class="s-theme-item-row"
338
- data-custom={th.isCustom || undefined}
339
- data-trailing={trailingSlot ? "slot" : undefined}
340
- data-name-suffix={nameSuffixSlot ? "slot" : undefined}
341
- // The ROW is the pill: the highlight state lives here so
342
- // the hover/active wash spans the full row width — a
343
- // trailing slot column must not shrink it (2026-10-01
344
- // user report: theme rows and the 自定义 row hovered at
345
- // different widths). The button keeps the click/focus
346
- // semantics; the stylesheet strips its own chrome.
347
- data-active={currentTheme.value === th.id || undefined}
348
- >
349
- <button
350
- type="button"
351
- class="s-theme-item-btn"
352
- data-active={currentTheme.value === th.id || undefined}
353
- onClick={() => onSelectTheme(th.id)}
354
- >
355
- {leadingSlot ? (
356
- <span class="hk-menu-item-icon s-theme-item-lead">
357
- {leadingSlot(scope)}
358
- </span>
359
- ) : currentTheme.value === th.id ? (
360
- <span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
361
- ) : null}
362
- <span class="s-theme-item-name">{displayName}</span>
363
- {nameSuffixSlot ? (
364
- <span class="s-theme-item-name-suffix">{nameSuffixSlot(scope)}</span>
365
- ) : null}
366
- </button>
367
- {trailingSlot ? (
368
- <span class="s-theme-item-trailing">{trailingSlot(scope)}</span>
369
- ) : th.isCustom ? (
370
- <button
371
- type="button"
372
- class="s-theme-item-delete"
373
- title={t("hikari::theme.deleteTheme")}
374
- onClick={() => removeCustomTheme(th.id)}
375
- >
376
- <Trash2 size={12} />
377
- </button>
378
- ) : null}
379
- </div>
380
- );
381
- })}
382
-
383
- <button
384
- type="button"
385
- class="s-theme-item-btn s-theme-item-customize"
386
- onClick={() => {
387
- closeMenu();
388
- if (props.externalCustomize) {
389
- emit("open-customize");
390
- } else {
391
- schemeDialogOpen.value = true;
392
- }
393
- }}
394
- >
395
- <span class="hk-menu-item-icon"><Palette size={14} /></span>
396
- <span class="s-theme-item-name">{t("hikari::theme.customize")}</span>
397
- </button>
398
- </div>
399
- {(() => {
400
- const extra = slots["menu-extra"]?.() ?? [];
401
- return extra.length ? (
402
- <div class="hk-menu-slot hk-menu-slot--extra">{extra}</div>
403
- ) : null;
404
- })()}
126
+ {/* The panel content is the SHARED HkThemePanelContent (2026-10-15
127
+ * extraction): hosts embed the same component in their own
128
+ * settings surfaces, so the popover and any inline host render
129
+ * one implementation. Slots and events pass straight through. */}
130
+ <HkThemePanelContent
131
+ externalCustomize={props.externalCustomize}
132
+ onRequest-close={closeMenu}
133
+ onOpen-customize={() => emit("open-customize")}
134
+ onUpdate:scheme={(theme: HCustomTheme) => emit("update:scheme", theme)}
135
+ >
136
+ {{
137
+ ...(slots["mode-extra"] ? { "mode-extra": slots["mode-extra"] } : {}),
138
+ ...(slots["item-leading"] ? { "item-leading": slots["item-leading"] } : {}),
139
+ ...(slots["item-name-suffix"] ? { "item-name-suffix": slots["item-name-suffix"] } : {}),
140
+ ...(slots["item-trailing"] ? { "item-trailing": slots["item-trailing"] } : {}),
141
+ ...(slots["menu-extra"] ? { "menu-extra": slots["menu-extra"] } : {}),
142
+ }}
143
+ </HkThemePanelContent>
405
144
  </HPopover>
406
-
407
- {!props.externalCustomize && (
408
- <HColorSchemeDialog
409
- modelValue={schemeDialogOpen.value}
410
- onUpdate:modelValue={(v: boolean) => { schemeDialogOpen.value = v; }}
411
- onConfirm={onConfirmScheme}
412
- />
413
- )}
414
145
  </div>
415
146
  );
416
147
  },
@@ -24,7 +24,7 @@ import { fileURLToPath } from "node:url";
24
24
  const here = dirname(fileURLToPath(import.meta.url));
25
25
  const grammar = readFileSync(join(here, "_menu-item.scss"), "utf-8");
26
26
  const menuTsx = readFileSync(join(here, "HkMenu.tsx"), "utf-8");
27
- const themeTsx = readFileSync(join(here, "HkThemeToggle.tsx"), "utf-8");
27
+ const themeTsx = readFileSync(join(here, "HkThemePanelContent.tsx"), "utf8");
28
28
  const adminTokens = readFileSync(join(here, "../styles/admin-tokens.scss"), "utf-8");
29
29
 
30
30
  describe("menu-item grammar contract", () => {
package/src/index.ts CHANGED
@@ -491,6 +491,7 @@ export { HkAdminShell as HAdminShell } from "./components/HkAdminShell";
491
491
  export { HkAdminHeader as HAdminHeader } from "./components/HkAdminHeader";
492
492
  export { HkNavSidebar as HNavSidebar } from "./components/HkNavSidebar";
493
493
  export { HkThemeToggle as HThemeToggle, type ThemeItemScope } from "./components/HkThemeToggle";
494
+ export { HkThemePanelContent as HThemePanelContent } from "./components/HkThemePanelContent";
494
495
  export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
495
496
  export { default as HAuthMethodList } from "./components/HkAuthMethodList";
496
497
  export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
@@ -735,6 +736,7 @@ export { HkAdminShell as HkAdminShell } from "./components/HkAdminShell";
735
736
  export { HkAdminHeader as HkAdminHeader } from "./components/HkAdminHeader";
736
737
  export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
737
738
  export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
739
+ export { HkThemePanelContent as HkThemePanelContent } from "./components/HkThemePanelContent";
738
740
  export { HkAuthCard as HkAuthCard } from "./components/HkAuthCard";
739
741
  export { HkSignInCard as HkSignInCard } from "./components/HkSignInCard";
740
742
  export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";