@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
|
@@ -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 {
|
|
2
|
-
import {
|
|
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
|
|
6
|
+
import { HkThemePanelContent } from "./HkThemePanelContent";
|
|
7
|
+
import type { HCustomTheme } from "./HkColorSchemeDialog";
|
|
19
8
|
import "./HkThemeToggle.scss";
|
|
20
9
|
|
|
21
|
-
|
|
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
|
|
44
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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):
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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, "
|
|
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";
|