@celestia-island/hikari 0.55.26 → 0.55.28

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.26",
3
+ "version": "0.55.28",
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",
@@ -14,8 +14,10 @@
14
14
  align-items: center;
15
15
  gap: var(--space-4, 4px);
16
16
 
17
- /* ── page anchor: absolute bottom-center of the nearest positioned
18
- * ancestor, resting --hk-dock-inset above its content floor. */
17
+ /* ── page anchor (SOUTH edge): absolute bottom-center of the nearest
18
+ * positioned ancestor, resting --hk-dock-inset above its content
19
+ * floor. `bottom` below is the compass spelling of the same
20
+ * geometry. */
19
21
  &[data-anchor="page"] {
20
22
  position: absolute;
21
23
  left: 50%;
@@ -31,6 +33,42 @@
31
33
  margin: 0 auto var(--hk-dock-inset, 12px);
32
34
  }
33
35
 
36
+ /* ── horizontal edge anchors: north and the compass spelling of the
37
+ * south edge — absolute, centered along the edge. */
38
+ &[data-anchor="top"],
39
+ &[data-anchor="bottom"] {
40
+ position: absolute;
41
+ left: 50%;
42
+ transform: translateX(-50%);
43
+ z-index: 50;
44
+ }
45
+
46
+ &[data-anchor="top"] {
47
+ top: var(--hk-dock-inset, 12px);
48
+ }
49
+
50
+ &[data-anchor="bottom"] {
51
+ bottom: var(--hk-dock-inset, 12px);
52
+ }
53
+
54
+ /* ── vertical edge anchors (west / east): the dock runs vertically,
55
+ * centered on the edge midpoint. */
56
+ &[data-anchor="left"],
57
+ &[data-anchor="right"] {
58
+ position: absolute;
59
+ top: 50%;
60
+ transform: translateY(-50%);
61
+ z-index: 50;
62
+ }
63
+
64
+ &[data-anchor="left"] {
65
+ left: var(--hk-dock-inset, 12px);
66
+ }
67
+
68
+ &[data-anchor="right"] {
69
+ right: var(--hk-dock-inset, 12px);
70
+ }
71
+
34
72
  /* ── corner anchors: absolute attachment with the same inset, for
35
73
  * pagers, toolbars and HUD panels that live off-center. */
36
74
  &[data-anchor="top-left"],
@@ -73,6 +111,7 @@
73
111
  align-items: stretch;
74
112
  width: var(--dock-width, auto);
75
113
  max-width: var(--dock-max-width, calc(100vw - var(--space-16, 16px)));
114
+ max-height: var(--dock-max-height, none);
76
115
  padding: var(--dock-padding, var(--space-8, 8px) var(--space-12, 12px));
77
116
  background: rgb(var(--color-surface));
78
117
  border: 1px solid var(--border-subtle);
@@ -82,6 +121,14 @@
82
121
  box-shadow: var(--glow-dock);
83
122
  }
84
123
 
124
+ /* Vertical side docks (west / east edges) stack along the viewport's
125
+ * tall axis — cap them viewport-aware by default so a tall dock never
126
+ * runs off-screen; --dock-max-height still overrides. */
127
+ .hk-dock-bar[data-anchor="left"] .hk-dock-bar-surface,
128
+ .hk-dock-bar[data-anchor="right"] .hk-dock-bar-surface {
129
+ max-height: var(--dock-max-height, calc(100dvh - var(--space-16, 16px)));
130
+ }
131
+
85
132
  /* Glass surface (default) — blur the page canvas behind the dock. */
86
133
  .hk-dock-bar[data-surface="glass"] .hk-dock-bar-surface {
87
134
  backdrop-filter: blur(var(--blur-lg));
@@ -1,4 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { createApp, defineComponent, h, nextTick } from "vue";
3
6
 
4
7
  import HkDockBar from "./HkDockBar";
@@ -49,23 +52,34 @@ describe("HkDockBar", () => {
49
52
  expect(root.dataset.surface).toBe("glass");
50
53
  });
51
54
 
52
- it.each(["plane", "top-left", "top-right", "bottom-left", "bottom-right"] as const)(
53
- "accepts the %s anchor",
54
- async (anchor) => {
55
- const root = await mountDock({ anchor });
56
- expect(root.dataset.anchor).toBe(anchor);
57
- },
58
- );
55
+ it.each(
56
+ [
57
+ "plane",
58
+ "top",
59
+ "bottom",
60
+ "left",
61
+ "right",
62
+ "top-left",
63
+ "top-right",
64
+ "bottom-left",
65
+ "bottom-right",
66
+ ] as const,
67
+ )("accepts the %s anchor", async (anchor) => {
68
+ const root = await mountDock({ anchor });
69
+ expect(root.dataset.anchor).toBe(anchor);
70
+ });
59
71
 
60
- it("exposes style hooks for width, max-width and padding", async () => {
72
+ it("exposes style hooks for width, max-width, max-height and padding", async () => {
61
73
  const root = await mountDock({
62
74
  width: "22rem",
63
75
  maxWidth: "30rem",
76
+ maxHeight: "60vh",
64
77
  padding: "4px 6px",
65
78
  });
66
79
  const style = root.style;
67
80
  expect(style.getPropertyValue("--dock-width")).toBe("22rem");
68
81
  expect(style.getPropertyValue("--dock-max-width")).toBe("30rem");
82
+ expect(style.getPropertyValue("--dock-max-height")).toBe("60vh");
69
83
  expect(style.getPropertyValue("--dock-padding")).toBe("4px 6px");
70
84
  });
71
85
 
@@ -74,3 +88,116 @@ describe("HkDockBar", () => {
74
88
  expect(root.dataset.surface).toBe("solid");
75
89
  });
76
90
  });
91
+
92
+ // ── Anchor geometry contract ──────────────────────────────────────────────
93
+ // SCSS source contract (house pattern, cf. HkButton icon-only contract):
94
+ // happy-dom does not lay out, so each compass direction's geometry is
95
+ // pinned textually against HkDockBar.scss. Dropping any one direction's
96
+ // rule — or regressing its mapping (inset side / centering transform) —
97
+ // turns the matching assertion red.
98
+
99
+ const here = dirname(fileURLToPath(import.meta.url));
100
+ const scss = readFileSync(join(here, "HkDockBar.scss"), "utf8");
101
+
102
+ /** Every SCSS rule body whose selector list mentions the anchor. */
103
+ function rulesFor(anchor: string): string {
104
+ const re = new RegExp(`&\\[data-anchor="${anchor}"\\][^{]*\\{[^}]*\\}`, "g");
105
+ return scss.match(re)?.join("\n") ?? "";
106
+ }
107
+
108
+ describe("HkDockBar anchor geometry contract", () => {
109
+ const INSET = "var(--hk-dock-inset, 12px)";
110
+
111
+ it.each(["page", "bottom"] as const)(
112
+ "%s docks to the south edge, centered (the two spellings share geometry)",
113
+ (anchor) => {
114
+ const rules = rulesFor(anchor);
115
+ expect(rules, `${anchor} rules must exist`).toBeTruthy();
116
+ expect(rules).toContain("position: absolute");
117
+ expect(rules).toContain("left: 50%");
118
+ expect(rules).toContain(`bottom: ${INSET}`);
119
+ expect(rules).toContain("transform: translateX(-50%)");
120
+ },
121
+ );
122
+
123
+ it("top docks to the north edge, centered", () => {
124
+ const rules = rulesFor("top");
125
+ expect(rules, "top rules must exist").toBeTruthy();
126
+ expect(rules).toContain("position: absolute");
127
+ expect(rules).toContain("top: " + INSET);
128
+ expect(rules).toContain("left: 50%");
129
+ expect(rules).toContain("transform: translateX(-50%)");
130
+ expect(rules).not.toContain(`bottom: ${INSET}`);
131
+ });
132
+
133
+ it.each(["left", "right"] as const)(
134
+ "%s docks to the vertical edge midpoint, centered by translateY",
135
+ (anchor) => {
136
+ const rules = rulesFor(anchor);
137
+ expect(rules, `${anchor} rules must exist`).toBeTruthy();
138
+ expect(rules).toContain("position: absolute");
139
+ expect(rules).toContain("top: 50%");
140
+ expect(rules).toContain(`${anchor}: ${INSET}`);
141
+ expect(rules).toContain("transform: translateY(-50%)");
142
+ // A side dock must not pick up the horizontal-edge centering.
143
+ expect(rules).not.toContain("transform: translateX(-50%)");
144
+ },
145
+ );
146
+
147
+ it.each(
148
+ [
149
+ ["top-left", "top", "left", "flex-start"],
150
+ ["top-right", "top", "right", "flex-end"],
151
+ ["bottom-left", "bottom", "left", "flex-start"],
152
+ ["bottom-right", "bottom", "right", "flex-end"],
153
+ ] as const,
154
+ )("%s docks into its corner with both insets", (anchor, side1, side2, align) => {
155
+ const rules = rulesFor(anchor);
156
+ expect(rules, `${anchor} rules must exist`).toBeTruthy();
157
+ expect(rules).toContain("position: absolute");
158
+ expect(rules).toContain(`${side1}: ${INSET}`);
159
+ expect(rules).toContain(`${side2}: ${INSET}`);
160
+ expect(rules).toContain(`align-items: ${align}`);
161
+ });
162
+
163
+ it("page and plane stay semantically distinct: page floats, plane flows", () => {
164
+ const page = rulesFor("page");
165
+ const plane = rulesFor("plane");
166
+ expect(page).toContain("position: absolute");
167
+ expect(plane, "plane rule must exist").toBeTruthy();
168
+ expect(plane).toContain("margin: 0 auto");
169
+ expect(plane).not.toContain("position: absolute");
170
+ });
171
+
172
+ it("consumes the size custom properties on every anchor via the base surface rule", () => {
173
+ // The anchor-agnostic base surface rule is the ONLY SCSS consumer of
174
+ // the size props for page/top/bottom/plane/corner docks — only the
175
+ // vertical side anchors get the viewport-aware cap override below.
176
+ // The style-hook tests above pin that the custom properties are set;
177
+ // this pins that the base rule still consumes them, so maxWidth /
178
+ // maxHeight cannot silently stop working off-side.
179
+ const base = scss.match(/\.hk-dock-bar-surface\s*\{[^}]*\}/)?.[0];
180
+ expect(base, "base surface rule must exist").toBeTruthy();
181
+ expect(base).toContain("width: var(--dock-width, auto)");
182
+ expect(base).toContain("max-width: var(--dock-max-width");
183
+ expect(base).toContain("max-height: var(--dock-max-height, none)");
184
+ expect(base).toContain("padding: var(--dock-padding");
185
+ });
186
+
187
+ it("caps vertical side docks viewport-aware by default", () => {
188
+ const sideCap = scss.match(
189
+ /\.hk-dock-bar\[data-anchor="left"\] \.hk-dock-bar-surface[^{]*\{[^}]*\}/,
190
+ )?.[0];
191
+ expect(sideCap, "side-anchor surface cap rule must exist").toBeTruthy();
192
+ expect(sideCap).toContain("max-height: var(--dock-max-height");
193
+ expect(sideCap).toContain("100dvh");
194
+ });
195
+
196
+ it("solid surface drops the backdrop blur in CSS, not just in data", () => {
197
+ const solid = scss.match(
198
+ /\.hk-dock-bar\[data-surface="solid"\] \.hk-dock-bar-surface\s*\{[^}]*\}/,
199
+ )?.[0];
200
+ expect(solid, "solid surface rule must exist").toBeTruthy();
201
+ expect(solid).toContain("backdrop-filter: none");
202
+ });
203
+ });
@@ -5,6 +5,10 @@ import "./HkDockBar.scss";
5
5
  export type HkDockBarAnchor =
6
6
  | "page"
7
7
  | "plane"
8
+ | "top"
9
+ | "bottom"
10
+ | "left"
11
+ | "right"
8
12
  | "top-left"
9
13
  | "top-right"
10
14
  | "bottom-left"
@@ -19,14 +23,30 @@ export type HkDockBarSurface = "glass" | "solid";
19
23
  * (surface, blur, border, radius, reverse glow) means no dock surface
20
24
  * ever re-declares its own background or shadow again.
21
25
  *
22
- * Anchors:
23
- * - `page` (default): absolute bottom-center of the nearest positioned
24
- * ancestor, `--hk-dock-inset` above its content floor.
25
- * - `plane`: in-flow — the host provides the plane (e.g. a fixed
26
- * footer band); this container centers the surface horizontally.
26
+ * Anchors — the full eight-way compass (4 edges + 4 corners):
27
+ * - `page` (default) and `bottom`: absolute bottom-center of the
28
+ * nearest positioned ancestor (the SOUTH edge), `--hk-dock-inset`
29
+ * above its content floor. `page` is the 2026-09-02 spelling kept
30
+ * for existing callers; `bottom` is its compass alias — identical
31
+ * geometry, pick either.
32
+ * - `top`: absolute top-center (north edge) with the same inset.
33
+ * - `left` / `right`: absolute middle of the west / east edge — the
34
+ * dock runs vertically and is centered with translateY; its surface
35
+ * gets a viewport-aware default max-height.
27
36
  * - `top-left` / `top-right` / `bottom-left` / `bottom-right`:
28
37
  * absolute corner attachment with the same inset, for pagers,
29
38
  * toolbars and HUD panels that live off-center.
39
+ * - `plane`: NOT a compass direction — in-flow; the host provides
40
+ * the plane (e.g. a fixed footer band) and this container centers
41
+ * the surface horizontally. Use it when the host owns positioning.
42
+ *
43
+ * Relationship to HkScrollContainer: that component's dockTop /
44
+ * dockBottom named slots are the contract for docks that live INSIDE a
45
+ * scrolling region — pinned to the scroll viewport, excluded from the
46
+ * scroll flow. HkDockBar is the carrier for docks that FLOAT over a
47
+ * canvas or page instead. Pick by host: scrolling region →
48
+ * HkScrollContainer dock slots; canvas/page overlay → HkDockBar. Do
49
+ * not nest an HkDockBar inside a scroll viewport expecting pinning.
30
50
  *
31
51
  * Surfaces:
32
52
  * - `glass` (default): translucent surface + backdrop blur.
@@ -39,8 +59,9 @@ export type HkDockBarSurface = "glass" | "solid";
39
59
  const HkDockBar = defineComponent({
40
60
  name: "HkDockBar",
41
61
  props: {
42
- /** Which plane carries the dock: page (bottom-center), a host plane
43
- * (in-flow), or one of the four corners. */
62
+ /** Which plane carries the dock: a compass anchor (4 edges + 4
63
+ * corners, `page` being the south edge), or a host plane
64
+ * (in-flow). */
44
65
  anchor: { type: String as PropType<HkDockBarAnchor>, default: "page" },
45
66
  /** Surface finish: glass (blur) or solid (moving canvas). */
46
67
  surface: { type: String as PropType<HkDockBarSurface>, default: "glass" },
@@ -48,6 +69,9 @@ const HkDockBar = defineComponent({
48
69
  width: { type: String, default: undefined },
49
70
  /** CSS max-width of the surface (content caps, e.g. minimap clearance). */
50
71
  maxWidth: { type: String, default: undefined },
72
+ /** CSS max-height of the surface; defaults to a viewport-aware cap
73
+ * on the vertical side anchors (left / right), none elsewhere. */
74
+ maxHeight: { type: String, default: undefined },
51
75
  /** CSS padding override; defaults to the shared chrome rhythm. */
52
76
  padding: { type: String, default: undefined },
53
77
  },
@@ -56,6 +80,7 @@ const HkDockBar = defineComponent({
56
80
  const s: Record<string, string> = {};
57
81
  if (props.width) s["--dock-width"] = props.width;
58
82
  if (props.maxWidth) s["--dock-max-width"] = props.maxWidth;
83
+ if (props.maxHeight) s["--dock-max-height"] = props.maxHeight;
59
84
  if (props.padding) s["--dock-padding"] = props.padding;
60
85
  return s;
61
86
  });
@@ -166,4 +166,57 @@ describe("HkSignInCard", () => {
166
166
  expect(c.querySelector(".s-auth-methods")).toBeTruthy();
167
167
  expect(c.querySelector(".methods-probe")).toBeTruthy();
168
168
  });
169
+
170
+ // ── Account-first mode (passwordField: false) ─────────────────────────
171
+
172
+ it("omits the password field entirely in account-first mode", () => {
173
+ const c = mount(h(HkSignInCard, { title: "T", passwordField: false }));
174
+ expect(usernameField(c)).toBeTruthy();
175
+ expect(passwordField(c)).toBeNull();
176
+ // No hidden input may survive either: a password-type field on a form
177
+ // that never carries a password would still trip credential-manager
178
+ // save prompts.
179
+ expect(c.querySelector('input[type="password"]')).toBeNull();
180
+ });
181
+
182
+ it("submits with an empty password once only the account name is typed", async () => {
183
+ const onSubmit = vi.fn();
184
+ const c = mount(h(HkSignInCard, { title: "T", passwordField: false, onSubmit }));
185
+ const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
186
+ // Empty username still gates the submit.
187
+ expect(btn.getAttribute("disabled")).not.toBeNull();
188
+ typeInto(usernameField(c), "demiurge");
189
+ await Promise.resolve();
190
+ expect(btn.getAttribute("disabled")).toBeNull(); // no password expected
191
+ btn.click();
192
+ await Promise.resolve();
193
+ await Promise.resolve();
194
+ expect(onSubmit).toHaveBeenCalledWith("demiurge", "");
195
+ });
196
+
197
+ it("does not submit on Enter with an empty username in account-first mode", async () => {
198
+ const onSubmit = vi.fn();
199
+ const c = mount(h(HkSignInCard, { title: "T", passwordField: false, onSubmit }));
200
+ usernameField(c).dispatchEvent(
201
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
202
+ );
203
+ await Promise.resolve();
204
+ expect(onSubmit).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("seeds the username field from initialUsername", () => {
208
+ const c = mount(
209
+ h(HkSignInCard, { title: "T", passwordField: false, initialUsername: " demiurge " }),
210
+ );
211
+ expect((usernameField(c) as HTMLInputElement).value).toBe(" demiurge ");
212
+ // And the seeded name alone satisfies the submit guard.
213
+ const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
214
+ expect(btn.getAttribute("disabled")).toBeNull();
215
+ });
216
+
217
+ it("keeps the password guard when passwordField is unset (default true)", () => {
218
+ const c = mount(h(HkSignInCard, { title: "T", initialUsername: "demiurge" }));
219
+ const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
220
+ expect(btn.getAttribute("disabled")).not.toBeNull(); // password still empty
221
+ });
169
222
  });
@@ -28,6 +28,12 @@ import HkAuthSubmitButton from "./HkAuthSubmitButton";
28
28
  * - `usernamePlaceholder` / `usernameType` — override the username field
29
29
  * (e.g. email-identifier logins); the placeholder falls back to the
30
30
  * `hikari::signIn.usernamePlaceholder` locale when unset.
31
+ * - `passwordField: false` — account-first sign-in: the card collects ONLY
32
+ * the account name and `submit` fires with an empty password. The
33
+ * password itself is proven in the consumer's NEXT step (chest's
34
+ * verify-account panel), where it reappears as one factor among several.
35
+ * Pair with `initialUsername` so the typed name survives the step-flow
36
+ * round-trip (the card remounts when the user navigates back).
31
37
  * - `footer` slot — content below the submit button (remember-me,
32
38
  * protocol links, …).
33
39
  * - `methods` slot — forwarded to HkAuthCard's full-width methods block
@@ -67,21 +73,31 @@ export const HkSignInCard = defineComponent({
67
73
  usernameType: { type: String, default: "text" },
68
74
  /** Username placeholder; defaults to the hikari::signIn locale. */
69
75
  usernamePlaceholder: { type: String, default: undefined },
76
+ /** Account-first flows: hide the password field entirely — the card
77
+ * collects only the account name and `submit` fires with an empty
78
+ * password (the consumer's next step owns the actual proof). Default
79
+ * keeps the classic two-field card. */
80
+ passwordField: { type: Boolean, default: true },
81
+ /** Seed for the username field. Step flows remount this card when the
82
+ * user navigates back from a later step, so the typed account name
83
+ * must travel through a prop rather than module state. */
84
+ initialUsername: { type: String, default: "" },
70
85
  },
71
86
  emits: {
72
- /** Fired on explicit click or Enter; never with empty fields or while busy. */
87
+ /** Fired on explicit click or Enter; never with an empty username (or
88
+ * an empty password while `passwordField` is on) or while busy. */
73
89
  submit: (_username: string, _password: string) => true,
74
90
  },
75
91
  setup(props, { emit, slots }) {
76
92
  const { t } = useI18n();
77
- const username = ref("");
93
+ const username = ref(props.initialUsername);
78
94
  const password = ref("");
79
95
 
80
96
  function attemptSubmit() {
81
97
  if (props.loading || props.disabled) return;
82
98
  const u = username.value.trim();
83
- if (!u || !password.value) return;
84
- emit("submit", u, password.value);
99
+ if (!u || (props.passwordField && !password.value)) return;
100
+ emit("submit", u, props.passwordField ? password.value : "");
85
101
  }
86
102
 
87
103
  return () => (
@@ -132,21 +148,27 @@ export const HkSignInCard = defineComponent({
132
148
  ),
133
149
  }}
134
150
  </HkInput>
135
- <HkInput
136
- variant="password"
137
- modelValue={password.value}
138
- onUpdate:modelValue={(v: string) => (password.value = v)}
139
- name="signin-password"
140
- autocomplete={props.passwordAutocomplete ?? credentialAutocomplete("password", "current-password")}
141
- placeholder={t("hikari::signIn.passwordPlaceholder", "Password")}
142
- disabled={props.loading || props.disabled}
143
- submitOnEnter={attemptSubmit}
144
- />
151
+ {/* Account-first mode omits the field entirely — not hides
152
+ it with CSS: a hidden password input would still plant a
153
+ credential-manager save prompt and an autocomplete target
154
+ on a form that never carries a password. */}
155
+ {props.passwordField && (
156
+ <HkInput
157
+ variant="password"
158
+ modelValue={password.value}
159
+ onUpdate:modelValue={(v: string) => (password.value = v)}
160
+ name="signin-password"
161
+ autocomplete={props.passwordAutocomplete ?? credentialAutocomplete("password", "current-password")}
162
+ placeholder={t("hikari::signIn.passwordPlaceholder", "Password")}
163
+ disabled={props.loading || props.disabled}
164
+ submitOnEnter={attemptSubmit}
165
+ />
166
+ )}
145
167
  <HkAuthSubmitButton
146
168
  label={props.submitLabel ?? t("hikari::signIn.submit", "Sign in")}
147
169
  block
148
170
  loading={props.loading}
149
- disabled={props.disabled || !username.value.trim() || !password.value}
171
+ disabled={props.disabled || !username.value.trim() || (props.passwordField && !password.value)}
150
172
  doSubmit={() => Promise.resolve(attemptSubmit())}
151
173
  />
152
174
  </form>
@@ -114,13 +114,15 @@ describe("useTheme lean cssvar injection", () => {
114
114
  });
115
115
  });
116
116
 
117
- it("writes theme vars into a managed :root style block, never inline", () => {
117
+ it("writes theme vars into a managed html:root style block, never inline", () => {
118
118
  theme.initTheme();
119
119
  const styleEl = document.head.querySelector("style[data-hikari-theme-vars]");
120
120
  expect(styleEl).not.toBeNull();
121
121
  // Real browsers compute the static defaults, so the block holds only the
122
- // true deltas; either way it is a :root block, not an inline attribute.
123
- expect(styleEl!.textContent).toMatch(/^:root\{/);
122
+ // true deltas; either way it is an html:root block (specificity 0,1,1 —
123
+ // it must outrank every :root-level static seed regardless of document
124
+ // order), not an inline attribute.
125
+ expect(styleEl!.textContent).toMatch(/^html:root\{/);
124
126
  expect(styleEl!.textContent).toContain("--color-primary");
125
127
  // The html inline style attribute stays clean — no token vars on it.
126
128
  expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("");
@@ -133,9 +135,39 @@ describe("useTheme lean cssvar injection", () => {
133
135
  theme.useTheme().setTheme("nord");
134
136
  const blocks = document.head.querySelectorAll("style[data-hikari-theme-vars]");
135
137
  expect(blocks).toHaveLength(1);
136
- expect(blocks[0].textContent).toMatch(/^:root\{/);
138
+ expect(blocks[0].textContent).toMatch(/^html:root\{/);
137
139
  expect(blocks[0].textContent).not.toBe(before);
138
140
  });
141
+
142
+ it("a :root seed injected AFTER the managed block cannot un-theme the page", () => {
143
+ // The 2026-09-18 dev.cw incident: every hikari component sheet @uses
144
+ // tokens.scss, so a lazily-loaded route chunk re-emits the static seed
145
+ // as a `:root` rule appended to <head> after this block — same
146
+ // specificity, later in the cascade, and the brand palette lost to it
147
+ // on the login page. The managed block therefore carries html:root
148
+ // (0,1,1), which outranks any :root-level emission (0,1,0) regardless
149
+ // of document order.
150
+ theme.initTheme();
151
+ const styleEl = document.head.querySelector("style[data-hikari-theme-vars]")!;
152
+ expect(styleEl.textContent).toContain("--color-primary");
153
+
154
+ const computed = () =>
155
+ getComputedStyle(document.documentElement).getPropertyValue("--color-primary").trim();
156
+ const themed = computed();
157
+ expect(themed).not.toBe("");
158
+
159
+ // Simulate the route-chunk seed: a :root rule with the static default,
160
+ // appended LAST (exactly what a lazy chunk's <link> does).
161
+ const seed = document.createElement("style");
162
+ seed.textContent = ":root{--color-primary:122 162 247;--color-background:214 236 240;--color-surface:240 244 248;}";
163
+ document.head.appendChild(seed);
164
+ expect(computed()).toBe(themed);
165
+
166
+ // Re-applying the theme must keep winning, too (no one-shot luck).
167
+ theme.useTheme().setTheme("nord");
168
+ expect(computed()).not.toBe("122 162 247");
169
+ seed.remove();
170
+ });
139
171
  });
140
172
 
141
173
  describe("useTheme preset/custom shadowing", () => {
@@ -149,7 +149,7 @@ const THEME_TRANSITION_DURATION = 300;
149
149
  let transitionTimer: CronHandle | null = null;
150
150
 
151
151
  /**
152
- * Theme vars live in ONE managed stylesheet block (`:root` overrides)
152
+ * Theme vars live in ONE managed stylesheet block (`html:root` overrides)
153
153
  * instead of a hundred inline declarations on `<html style="...">`.
154
154
  * - Inline style attributes bloat the DOM, show up as giant devtools
155
155
  * noise on the root element, defeat caching of the token set and make
@@ -162,8 +162,20 @@ let transitionTimer: CronHandle | null = null;
162
162
  * so a token whose value already resolves identically needs no
163
163
  * override at all — a default-ish theme injects a handful of vars
164
164
  * instead of ~80.
165
+ *
166
+ * The selector is `html:root`, not plain `:root`, on purpose: the static
167
+ * seed is `:root`-level, and lazy route chunks re-emit it (every component
168
+ * sheet `@use`s tokens.scss) into `<link>`s appended to `<head>` AFTER
169
+ * this block — a same-specificity `:root` there would silently beat the
170
+ * runtime palette and un-theme every late-loaded route (2026-09-18,
171
+ * dev.cw login: the HkOtpInput chunk flattened the brand palette back to
172
+ * the static seed). `html:root` (0,1,1) outranks every `:root`-level
173
+ * declaration (0,1,0) regardless of document order, while staying below
174
+ * inline styles and equal to `html[data-theme]` decor rules (order
175
+ * decides between those two, so a later decor can still override).
165
176
  */
166
177
  const THEME_VARS_STYLE_ATTR = "data-hikari-theme-vars";
178
+ const THEME_VARS_SELECTOR = "html:root";
167
179
 
168
180
  function normalizeVarValue(value: string): string {
169
181
  return value.replace(/\s+/g, " ").trim();
@@ -214,7 +226,7 @@ function injectThemeVars(el: HTMLElement, vars: Record<string, string>): void {
214
226
 
215
227
  const deltas = pickThemeVarDeltas(vars, baseline);
216
228
  styleEl.textContent = Object.keys(deltas).length > 0
217
- ? `:root{${Object.entries(deltas).map(([key, value]) => `${key}:${value}`).join(";")}}`
229
+ ? `${THEME_VARS_SELECTOR}{${Object.entries(deltas).map(([key, value]) => `${key}:${value}`).join(";")}}`
218
230
  : "";
219
231
  }
220
232