@celestia-island/hikari 0.55.169 → 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 +1 -1
- package/src/components/HkAdminHeader.test.tsx +19 -3
- package/src/components/HkAdminHeader.tsx +31 -19
- package/src/components/HkThemePanelContent.tsx +338 -0
- package/src/components/HkThemeToggle.tsx +37 -306
- package/src/components/_menu-item.contract.test.ts +1 -1
- package/src/components/adminHeaderChrome.test.ts +62 -28
- package/src/index.ts +2 -0
- package/src/styles/admin-tokens.scss +78 -45
package/package.json
CHANGED
|
@@ -255,15 +255,31 @@ describe("HkAdminHeader", () => {
|
|
|
255
255
|
const centre = c.querySelector(".s-admin-header-center");
|
|
256
256
|
expect(centre, "the centre wrapper must render when the slot is provided").not.toBeNull();
|
|
257
257
|
expect(centre?.querySelector(".centre-stub")?.textContent).toBe("filter group");
|
|
258
|
-
// Wing order: identity (left) → centre → actions (right).
|
|
258
|
+
// Wing order: identity (left) → centre → actions (right). The wings
|
|
259
|
+
// carry their chrome class PLUS the shared balanced-grid wing class,
|
|
260
|
+
// so match per-class instead of whole-className equality.
|
|
259
261
|
const header = c.querySelector("header")!;
|
|
260
262
|
const kids = [...header.children].map((el) => el.className);
|
|
261
|
-
|
|
262
|
-
expect(
|
|
263
|
+
const wingIdx = (cls: string) => kids.findIndex((c) => c.split(/\s+/).includes(cls));
|
|
264
|
+
expect(wingIdx("s-admin-header-user")).toBeLessThan(wingIdx("s-admin-header-center"));
|
|
265
|
+
expect(wingIdx("s-admin-header-center")).toBeLessThan(wingIdx("s-admin-header-actions"));
|
|
266
|
+
// The header hangs the shared balanced-grid contract — the same
|
|
267
|
+
// geometry the chat frontend's header uses (user report 2026-10-15:
|
|
268
|
+
// the console strip sat left of centre on phones).
|
|
269
|
+
expect(header.className).toContain("s-balanced-header");
|
|
270
|
+
expect(kids.find((c) => c.includes("s-admin-header-user"))).toContain("s-header-wing-start");
|
|
271
|
+
expect(kids.find((c) => c.includes("s-admin-header-center"))).toContain("s-header-wing-center");
|
|
272
|
+
expect(kids.find((c) => c.includes("s-admin-header-actions"))).toContain("s-header-wing-end");
|
|
263
273
|
});
|
|
264
274
|
|
|
265
275
|
it("renders no centre wrapper without the slot (DOM unchanged for hosts that skip it)", () => {
|
|
266
276
|
const c = mount(headerNode({ title: "Providers" }));
|
|
267
277
|
expect(c.querySelector(".s-admin-header-center")).toBeNull();
|
|
278
|
+
// Centre-less hosts are the MAJORITY of admin pages: the actions wing
|
|
279
|
+
// must still carry its explicit grid-column class, or auto-placement
|
|
280
|
+
// drops it into the empty middle track and it drifts to the viewport
|
|
281
|
+
// centre (R2, 2026-10-15 — the class IS the column).
|
|
282
|
+
const actions = c.querySelector(".s-admin-header-actions");
|
|
283
|
+
expect(actions?.className).toContain("s-header-wing-end");
|
|
268
284
|
});
|
|
269
285
|
});
|
|
@@ -162,24 +162,36 @@ export const HkAdminHeader = defineComponent({
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// The header hangs the shared three-zone geometry (grid: two equal
|
|
166
|
+
// side tracks around a shrinkable centre) — the same contract the
|
|
167
|
+
// chat frontend top bar uses, so a page header-centre group sits on
|
|
168
|
+
// the true viewport middle on BOTH faces at every width. (Comment
|
|
169
|
+
// lives OUTSIDE the class expression: the self-sufficiency audit
|
|
170
|
+
// reads that expression's literals, and prose apostrophes inside it
|
|
171
|
+
// parse as string boundaries.)
|
|
165
172
|
return () => (
|
|
166
173
|
<header
|
|
167
174
|
class={[
|
|
168
175
|
"s-glass-header",
|
|
176
|
+
"s-balanced-header",
|
|
169
177
|
props.compact ? "s-glass-header--compact" : "",
|
|
170
178
|
]}
|
|
171
179
|
>
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
<div ref={userTriggerRef} class="s-admin-header-user s-header-wing-start">
|
|
181
|
+
{/* The hamburger rides INSIDE the left wing: the balanced grid
|
|
182
|
+
* above owns the header's columns, and a fourth in-flow child
|
|
183
|
+
* would fall into the left wing's track and skew every column
|
|
184
|
+
* after it. Inside the wing it keeps its place (leftmost, ahead
|
|
185
|
+
* of the avatar) at the wing's own 16px rhythm. */}
|
|
186
|
+
{props.showHamburger && (
|
|
187
|
+
<HButton
|
|
188
|
+
variant="ghost"
|
|
189
|
+
size="sm"
|
|
190
|
+
onClick={() => emit("hamburger")}
|
|
191
|
+
>
|
|
192
|
+
<Menu size={20} class="s-admin-header-hamburger-icon" />
|
|
193
|
+
</HButton>
|
|
194
|
+
)}
|
|
183
195
|
<button
|
|
184
196
|
class="s-admin-header-avatar"
|
|
185
197
|
aria-label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
|
|
@@ -284,19 +296,19 @@ export const HkAdminHeader = defineComponent({
|
|
|
284
296
|
</HkMenu>
|
|
285
297
|
|
|
286
298
|
{/* Page-owned centre group (page filter tab strips, segmented
|
|
287
|
-
* option pickers) — the console face of the balanced
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* the host provides it, so headers without a centre keep
|
|
292
|
-
* exact DOM they always had. */}
|
|
299
|
+
* option pickers) — the console face of the balanced header the
|
|
300
|
+
* chat frontend's top bar already uses: the shared grid pins
|
|
301
|
+
* this column to the true viewport middle and lets the strip
|
|
302
|
+
* slide inside its own box when space runs short. Rendered only
|
|
303
|
+
* when the host provides it, so headers without a centre keep
|
|
304
|
+
* the exact DOM they always had. */}
|
|
293
305
|
{slots.center && (
|
|
294
|
-
<div class="s-admin-header-center">
|
|
306
|
+
<div class="s-admin-header-center s-header-wing-center">
|
|
295
307
|
{slots.center()}
|
|
296
308
|
</div>
|
|
297
309
|
)}
|
|
298
310
|
|
|
299
|
-
<div class="s-admin-header-actions">
|
|
311
|
+
<div class="s-admin-header-actions s-header-wing-end">
|
|
300
312
|
{props.showEmergencyStop && (
|
|
301
313
|
<button
|
|
302
314
|
class={[
|
|
@@ -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", () => {
|
|
@@ -93,20 +93,59 @@ describe("admin header chrome geometry (the two user-requested fixes)", () => {
|
|
|
93
93
|
const rule = ruleDeclarations(adminTokens, ".s-admin-header-user");
|
|
94
94
|
expect(rule, ".s-admin-header-user must be declared").not.toBeNull();
|
|
95
95
|
expect(rule).toContainEqual(["gap", "var(--space-16)"]);
|
|
96
|
-
// Balanced left wing (the centre group stays viewport-centred).
|
|
97
|
-
expect(rule).toContainEqual(["flex", "1 1 0"]);
|
|
98
96
|
});
|
|
99
97
|
|
|
100
|
-
it("
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
it("hangs the shared balanced-header grid contract (the ONE header layout)", () => {
|
|
99
|
+
// User report 2026-10-15: the console's header-centre tab strip sat
|
|
100
|
+
// LEFT of centre on phones (drawer mode hides the title, so the left
|
|
101
|
+
// wing is just the avatar while three icon buttons sit right) while
|
|
102
|
+
// the frontend's strip read centred — two per-band flex dialects had
|
|
103
|
+
// drifted from the same idea. Both faces now hang the ONE grid
|
|
104
|
+
// contract: two EQUAL 1fr side tracks (true centring regardless of
|
|
105
|
+
// wing content) with min-content floors (wings can never be squeezed
|
|
106
|
+
// out of existence) around a shrinkable centre column (the strip
|
|
107
|
+
// slides inside its own box, edge fades + swipe).
|
|
108
|
+
const balanced = ruleDeclarations(adminTokens, ".s-balanced-header");
|
|
109
|
+
expect(balanced, ".s-balanced-header must be declared").not.toBeNull();
|
|
110
|
+
expect(balanced).toContainEqual([
|
|
111
|
+
"grid-template-columns",
|
|
112
|
+
"minmax(min-content, 1fr) minmax(0, auto) minmax(min-content, 1fr)",
|
|
113
|
+
]);
|
|
114
|
+
const start = ruleDeclarations(adminTokens, ".s-header-wing-start");
|
|
115
|
+
expect(start, ".s-header-wing-start must be declared").not.toBeNull();
|
|
116
|
+
// Each wing names its column EXPLICITLY: with auto-placement a
|
|
117
|
+
// centre-less host (the vast majority of admin pages — most never
|
|
118
|
+
// register a centre group) renders only two in-flow children and the
|
|
119
|
+
// actions wing would fall into the MIDDLE track, drifting to the
|
|
120
|
+
// viewport centre instead of hugging the right edge (R2, 2026-10-15).
|
|
121
|
+
expect(start).toContainEqual(["grid-column", "1"]);
|
|
122
|
+
expect(start).toContainEqual(["justify-self", "start"]);
|
|
123
|
+
expect(start).toContainEqual(["overflow", "hidden"]);
|
|
124
|
+
const centre = ruleDeclarations(adminTokens, ".s-header-wing-center");
|
|
125
|
+
expect(centre, ".s-header-wing-center must be declared").not.toBeNull();
|
|
126
|
+
// Default stretch, NOT justify-self: center: a centered item sizes by
|
|
127
|
+
// fit-content, whose min-content floor (the full strip width) punches
|
|
128
|
+
// the item past a shrunken middle track — the strip would paint over
|
|
129
|
+
// the wings instead of scrolling inside its own box.
|
|
130
|
+
expect(centre).toContainEqual(["grid-column", "2"]);
|
|
131
|
+
expect(centre).toContainEqual(["min-width", "0"]);
|
|
132
|
+
expect(centre).not.toContainEqual(["justify-self", "center"]);
|
|
133
|
+
const end = ruleDeclarations(adminTokens, ".s-header-wing-end");
|
|
134
|
+
expect(end, ".s-header-wing-end must be declared").not.toBeNull();
|
|
135
|
+
expect(end).toContainEqual(["grid-column", "3"]);
|
|
136
|
+
expect(end).toContainEqual(["justify-self", "end"]);
|
|
137
|
+
expect(end).toContainEqual(["overflow", "hidden"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps the wings free of their own flex-geometry dialect (the grid owns it)", () => {
|
|
141
|
+
// The flex era's per-wing declarations must stay gone: any return of
|
|
142
|
+
// `flex` on a wing re-introduces a second geometry that drifts from
|
|
143
|
+
// the shared grid (the 2026-10-15 skew).
|
|
144
|
+
expect(ruleDeclarations(adminTokens, ".s-admin-header-user")).not.toContainEqual(["flex", "1 1 0"]);
|
|
145
|
+
expect(ruleDeclarations(adminTokens, ".s-admin-header-actions")).not.toContainEqual(["flex", "1 1 0"]);
|
|
146
|
+
expect(ruleDeclarations(adminTokens, ".s-admin-header-center")).not.toContainEqual(["flex", "0 1 auto"]);
|
|
105
147
|
// The old push would fight the centre wing for the middle.
|
|
106
|
-
expect(actions).not.toContainEqual(["margin-left", "auto"]);
|
|
107
|
-
const centre = ruleDeclarations(adminTokens, ".s-admin-header-center");
|
|
108
|
-
expect(centre, ".s-admin-header-center must be declared").not.toBeNull();
|
|
109
|
-
expect(centre).toContainEqual(["flex", "0 1 auto"]);
|
|
148
|
+
expect(ruleDeclarations(adminTokens, ".s-admin-header-actions")).not.toContainEqual(["margin-left", "auto"]);
|
|
110
149
|
});
|
|
111
150
|
|
|
112
151
|
it("keeps the 28px icon-button step at the theme toggle's own box", () => {
|
|
@@ -120,25 +159,20 @@ describe("admin header chrome geometry (the two user-requested fixes)", () => {
|
|
|
120
159
|
expect(iconButton).toMatch(/PropType<16 \| 24 \| 28 \| 32 \| 36 \| 40>/);
|
|
121
160
|
});
|
|
122
161
|
|
|
123
|
-
it("
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// inside its own box instead of over the avatar.
|
|
162
|
+
it("keeps the phone band free of ad-hoc wing overrides (one geometry at every width)", () => {
|
|
163
|
+
// The 2026-10-12 band pinned the wings and handed the leftover to
|
|
164
|
+
// the centre; it fixed the avatar overlap but skewed the strip off
|
|
165
|
+
// the viewport middle (user report 2026-10-15). The shared grid's
|
|
166
|
+
// min-content floors + shrinkable centre column handle phones
|
|
167
|
+
// intrinsically, so no per-band flex override may come back.
|
|
130
168
|
const userRules = allRuleDeclarations(adminTokens, ".s-admin-header-user");
|
|
131
169
|
const centreRules = allRuleDeclarations(adminTokens, ".s-admin-header-center");
|
|
132
170
|
const actionsRules = allRuleDeclarations(adminTokens, ".s-admin-header-actions");
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
expect(phone(actionsRules)[0]).toContainEqual(["flex", "0 0 auto"]);
|
|
140
|
-
expect(phone(centreRules)[0]).toContainEqual(["flex", "1 1 auto"]);
|
|
141
|
-
expect(phone(centreRules)[0]).toContainEqual(["min-width", "0"]);
|
|
142
|
-
expect(phone(centreRules)[0]).toContainEqual(["overflow", "hidden"]);
|
|
171
|
+
for (const [name, rules] of [["user", userRules], ["centre", centreRules], ["actions", actionsRules]] as const) {
|
|
172
|
+
expect(rules.filter((r) => r.nested), `${name}: no band-nested rules may remain`).toHaveLength(0);
|
|
173
|
+
for (const r of rules) {
|
|
174
|
+
expect(r.decls.map((d) => d[0]), `${name}: no flex geometry may remain`).not.toContain("flex");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
143
177
|
});
|
|
144
178
|
});
|
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";
|
|
@@ -413,6 +413,74 @@
|
|
|
413
413
|
padding: 0 var(--space-16);
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/* ── Balanced header columns (the ONE three-zone header layout) ─────────
|
|
417
|
+
* The shared geometry behind every top bar that owns a header-centre
|
|
418
|
+
* group: the frontend chat header (chest ChatLayout's .s-header-bar-*)
|
|
419
|
+
* and the console header (HkAdminHeader's .s-admin-header-*) both hang
|
|
420
|
+
* these classes next to their own chrome classes, so the two faces can
|
|
421
|
+
* never drift apart again (user report 2026-10-15: the console's
|
|
422
|
+
* header-centre tab strip sat LEFT of centre on phones while the
|
|
423
|
+
* frontend's strip read centred — two per-band flex dialects had grown
|
|
424
|
+
* from the same idea).
|
|
425
|
+
*
|
|
426
|
+
* Grid, not flex: two EQUAL 1fr side tracks pin the middle column to the
|
|
427
|
+
* true viewport middle no matter how lopsided the wings' CONTENT is
|
|
428
|
+
* (avatar-only left vs three icon buttons right — exactly the console's
|
|
429
|
+
* phone case, where the drawer mode hides the title and the left wing is
|
|
430
|
+
* just the 28px trigger). Flex wings grow from flex-basis 0, so equal
|
|
431
|
+
* growth centred the group only while spare space lasted; each band then
|
|
432
|
+
* grew its own ad-hoc override and each override skewed in SOME band.
|
|
433
|
+
* `minmax(min-content, 1fr)` gives each wing a hard content floor, so an
|
|
434
|
+
* overflowing centre column (a wide tab strip) can never squeeze the
|
|
435
|
+
* avatar/action wings out of existence — the 2026-10-12 phone incident
|
|
436
|
+
* class — and the centre column is `minmax(0, auto)`, so IT soaks the
|
|
437
|
+
* squeeze instead: the strip narrows while STAYING centred, and its
|
|
438
|
+
* internal HkTabs scroller takes over (edge fades + swipe, the frontend
|
|
439
|
+
* over-wide-strip behavior) without ever covering a wing. */
|
|
440
|
+
.s-balanced-header {
|
|
441
|
+
display: grid;
|
|
442
|
+
grid-template-columns: minmax(min-content, 1fr) minmax(0, auto) minmax(min-content, 1fr);
|
|
443
|
+
align-items: center;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* Each wing names its column EXPLICITLY: the header's in-flow children are
|
|
447
|
+
* exactly these three zones (every other child teleports out), but a
|
|
448
|
+
* centre-less host renders only two of them — with auto-placement the
|
|
449
|
+
* actions wing would fall into the MIDDLE track and drift to the viewport
|
|
450
|
+
* centre (R2 finding, 2026-10-15), because the trailing 1fr track would
|
|
451
|
+
* sit empty. Naming the columns makes the contract positional by class,
|
|
452
|
+
* not by child order or count: a centre-less header lays out as
|
|
453
|
+
* [user | empty | actions] exactly like the old flex bar did. */
|
|
454
|
+
.s-header-wing-start {
|
|
455
|
+
grid-column: 1;
|
|
456
|
+
justify-self: start;
|
|
457
|
+
min-width: 0;
|
|
458
|
+
/* A wing whose content floors exceed its track must not PAINT past the
|
|
459
|
+
* wing box underneath the centre group (popup surfaces teleport to
|
|
460
|
+
* body, so clipping here never cuts a menu). */
|
|
461
|
+
overflow: hidden;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.s-header-wing-center {
|
|
465
|
+
/* Default stretch, deliberately: the strip must inherit the COLUMN's
|
|
466
|
+
* shrunken width when the middle track soaks a squeeze, so the HTabs
|
|
467
|
+
* scroller inside it takes over (edge fades + swipe). justify-self:
|
|
468
|
+
* center here would size the item by fit-content, whose min-content
|
|
469
|
+
* floor (= the full strip width) punches the item past the shrunken
|
|
470
|
+
* track and the strip paints over the wings instead of scrolling.
|
|
471
|
+
* Centring itself is the tracks' job (equal 1fr sides), not the
|
|
472
|
+
* item's. */
|
|
473
|
+
grid-column: 2;
|
|
474
|
+
min-width: 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.s-header-wing-end {
|
|
478
|
+
grid-column: 3;
|
|
479
|
+
justify-self: end;
|
|
480
|
+
min-width: 0;
|
|
481
|
+
overflow: hidden;
|
|
482
|
+
}
|
|
483
|
+
|
|
416
484
|
/* ── Admin header chrome (HkAdminHeader) ────────────────────────────────
|
|
417
485
|
* The header's own layout. Every declaration below replaces a utility
|
|
418
486
|
* class the markup used to depend on (`w-7 h-7`, `rounded-full`,
|
|
@@ -420,7 +488,10 @@
|
|
|
420
488
|
* atomic layer rendered the header without them, and a host with one
|
|
421
489
|
* (chest/UnoCSS) owned values the design system could not fix. Values are
|
|
422
490
|
* the verified computed ones (audit 2026-09-15 against chest's generated
|
|
423
|
-
* CSS), so no host's rendering changes.
|
|
491
|
+
* CSS), so no host's rendering changes.
|
|
492
|
+
* The three-zone GEOMETRY itself lives in the shared .s-balanced-header
|
|
493
|
+
* contract above — the classes below keep only their wing-INTERNAL
|
|
494
|
+
* layout (row direction, gaps, alignment) and appearance. */
|
|
424
495
|
|
|
425
496
|
.s-admin-header-user {
|
|
426
497
|
display: flex;
|
|
@@ -433,50 +504,15 @@
|
|
|
433
504
|
* The wing gap here carries the whole distance so the title text
|
|
434
505
|
* lands on the same rhythm as the panel's button content. */
|
|
435
506
|
gap: var(--space-16);
|
|
436
|
-
/* Balanced left wing (same grammar as ChatLayout's .s-header-bar-left):
|
|
437
|
-
* grows from the same flex basis as the actions wing so a centre
|
|
438
|
-
* group, when a page renders one, stays pinned to the true middle. */
|
|
439
|
-
flex: 1 1 0;
|
|
440
|
-
min-width: 0;
|
|
441
507
|
}
|
|
442
508
|
|
|
443
509
|
/* The page-owned centre group (filter tab strips): keeps its natural
|
|
444
|
-
* width and hands overflow to the HTabs scroller inside it.
|
|
510
|
+
* width and hands overflow to the HTabs scroller inside it. Column
|
|
511
|
+
* placement/shrinkage is the shared grid contract's job. */
|
|
445
512
|
.s-admin-header-center {
|
|
446
|
-
flex: 0 1 auto;
|
|
447
513
|
display: flex;
|
|
448
514
|
align-items: center;
|
|
449
515
|
justify-content: center;
|
|
450
|
-
min-width: 0;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
/* Phone band (≤767px) — mirrors ChatLayout's own ≤767px header band
|
|
455
|
-
* (the console face of the same balanced-wing header). On phones the
|
|
456
|
-
* desktop balanced wings (`flex: 1 1 0` + `min-width: 0`) let a wide
|
|
457
|
-
* centre tab strip squeeze BOTH side wings below their content floors:
|
|
458
|
-
* the avatar wing shrank under its 28px trigger and the strip painted
|
|
459
|
-
* straight over the avatar (user report 2026-10-12, L2 firewall tabs).
|
|
460
|
-
* The frontend's answer — pin both wings, hand the leftover to the
|
|
461
|
-
* centre and let its HTabs scroller own the overflow — is reproduced
|
|
462
|
-
* here in substance: the strip keeps every pixel the avatar and the action
|
|
463
|
-
* buttons don't need, and slides INSIDE its own box (edge fades + scroll
|
|
464
|
-
* limits intact) without ever covering the avatar.
|
|
465
|
-
* Desktop (>767px) keeps the balanced-wing centring untouched. */
|
|
466
|
-
@media (max-width: 767px) {
|
|
467
|
-
.s-admin-header-user {
|
|
468
|
-
flex: 0 0 auto;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
.s-admin-header-actions {
|
|
472
|
-
flex: 0 0 auto;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
.s-admin-header-center {
|
|
476
|
-
flex: 1 1 auto;
|
|
477
|
-
min-width: 0;
|
|
478
|
-
overflow: hidden;
|
|
479
|
-
}
|
|
480
516
|
}
|
|
481
517
|
|
|
482
518
|
.s-admin-header-avatar {
|
|
@@ -527,13 +563,10 @@
|
|
|
527
563
|
display: flex;
|
|
528
564
|
align-items: center;
|
|
529
565
|
gap: var(--space-6);
|
|
530
|
-
/*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
* automatic min-content floor keeps the icon buttons from being
|
|
535
|
-
* squeezed below their own width on narrow viewports. */
|
|
536
|
-
flex: 1 1 0;
|
|
566
|
+
/* The actions hug the right edge of their grid column (the old
|
|
567
|
+
* margin-left:auto push fought the centre wing for the middle; the
|
|
568
|
+
* flex-balanced `1 1 0` era before that skewed whenever the wings'
|
|
569
|
+
* contents disagreed — the shared grid contract ends both). */
|
|
537
570
|
justify-content: flex-end;
|
|
538
571
|
}
|
|
539
572
|
|