@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.169",
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",
@@ -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
- expect(kids.indexOf("s-admin-header-user")).toBeLessThan(kids.indexOf("s-admin-header-center"));
262
- expect(kids.indexOf("s-admin-header-center")).toBeLessThan(kids.indexOf("s-admin-header-actions"));
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
- {props.showHamburger && (
173
- <HButton
174
- variant="ghost"
175
- size="sm"
176
- onClick={() => emit("hamburger")}
177
- >
178
- <Menu size={20} class="s-admin-header-hamburger-icon" />
179
- </HButton>
180
- )}
181
-
182
- <div ref={userTriggerRef} class="s-admin-header-user">
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-wing
288
- * header the chat frontend's top bar already uses: the left
289
- * and right wings grow from the same flex basis so this group
290
- * stays pinned to the true viewport middle. Rendered only when
291
- * the host provides it, so headers without a centre keep the
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 { 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", () => {
@@ -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("keeps the actions wing balanced (no margin-left:auto push) and the centre wing shrinkable", () => {
101
- const actions = ruleDeclarations(adminTokens, ".s-admin-header-actions");
102
- expect(actions, ".s-admin-header-actions must be declared").not.toBeNull();
103
- expect(actions).toContainEqual(["flex", "1 1 0"]);
104
- expect(actions).toContainEqual(["justify-content", "flex-end"]);
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("pins the phone band: wings keep their floors, the centre strip owns the overflow", () => {
124
- // User report 2026-10-12: on phones the balanced wings (flex 1 1 0 +
125
- // min-width 0) were squeezed BELOW the avatar's 28px floor by a wide
126
- // centre tab strip, and the strip painted straight over the avatar.
127
- // The ≤767px band must pin both wings and hand the leftover to the
128
- // centre (mirroring ChatLayout's own phone band) so the strip slides
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
- // Exactly one NESTED occurrence (the phone band) per prelude.
134
- const phone = (rules: typeof userRules) => rules.filter((r) => r.nested).map((r) => r.decls);
135
- expect(phone(userRules)).toHaveLength(1);
136
- expect(phone(centreRules)).toHaveLength(1);
137
- expect(phone(actionsRules)).toHaveLength(1);
138
- expect(phone(userRules)[0]).toContainEqual(["flex", "0 0 auto"]);
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
- /* Balanced right wing: with the user wing at flex 1 1 0 the pair
531
- * grows symmetrically, so the centre group stays centred and the
532
- * actions still hug the right edge (justify-end replaces the old
533
- * margin-left:auto push, which would fight the centre wing). The
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