@celestia-island/hikari 0.55.238 → 0.55.239

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.238",
3
+ "version": "0.55.239",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -238,31 +238,39 @@
238
238
  // solved against the pill's real backdrop —
239
239
  // the 10% channel tint composited over the 3%-primary card on
240
240
  // `--color-surface` — for the WORST entry of the stock AND chest palettes:
241
- // every tone lands ≥4.6:1. `light-dark()` resolves through the same
242
- // color-scheme the theme engine already drives (`[data-mode] body` in
243
- // theme.scss sets it alongside the channel swap), and an engine without
244
- // relative-colour support drops the declaration, falling back to the
245
- // neutral muted ink above — still AA everywhere: ≥4.87:1 even under the
246
- // coloured pills' own tinted backdrop (4.87–7.20 across both palettes and
247
- // modes; 5.98–8.06 on the plain neutral pill) — rather than shipping an
248
- // unreadable tone colour. The border and background keep the raw channel:
249
- // only the glyphs owe contrast, the tint is decorative.
241
+ // every tone lands ≥4.6:1.
242
+ //
243
+ // The two arms are expressed as THEME-MODE rules — the light one in the base
244
+ // rule, the dark one re-inking under `[data-mode="dark"]` (the HkTooltip
245
+ // precedent) — rather than as one `light-dark()` value. Reason, shipped and
246
+ // paid for once on 2026-10-30 (chest #2027): when the consumer's build floor
247
+ // predates `light-dark()`, the CSS minifier LOWERS it into
248
+ // `var(--lightningcss-light, …) var(--lightningcss-dark, …)` WITHOUT emitting
249
+ // the companion custom-property definitions, so the declaration becomes
250
+ // invalid at computed-value time and the glyphs fall back to the INHERITED
251
+ // colour — worse than the muted ink. Mode rules need no such function, so no
252
+ // build floor can rewrite them, and `[data-mode]` is the very attribute the
253
+ // theme engine writes (`applyTheme` sets it on `<html>` in the same pass that
254
+ // injects the channel variables; `theme.scss` derives `color-scheme` from it),
255
+ // so the arms cannot desynchronise from the palette.
256
+ //
257
+ // Degradation is by PARSE-TIME drop: an engine without relative-colour
258
+ // support discards the tone rule's `color` outright, which leaves the neutral
259
+ // muted ink of `.s-about-modal-row-meta` above in force — still AA everywhere
260
+ // (≥4.87:1 even under the coloured pills' own tinted backdrop; 4.87–7.20
261
+ // across both palettes and modes) rather than shipping an unreadable tone
262
+ // colour. The border and background keep the raw channel: only the glyphs owe
263
+ // contrast, the tint is decorative.
250
264
  .s-about-modal-row-meta[data-tone="positive"] {
251
265
  border-color: rgb(var(--color-success) / 32%);
252
266
  background: rgb(var(--color-success) / 10%);
253
- color: light-dark(
254
- oklch(from rgb(var(--color-success)) min(l, 0.5) c h),
255
- oklch(from rgb(var(--color-success)) max(l, 0.72) c h)
256
- );
267
+ color: oklch(from rgb(var(--color-success)) min(l, 0.5) c h);
257
268
  }
258
269
 
259
270
  .s-about-modal-row-meta[data-tone="caution"] {
260
271
  border-color: rgb(var(--color-warning) / 32%);
261
272
  background: rgb(var(--color-warning) / 10%);
262
- color: light-dark(
263
- oklch(from rgb(var(--color-warning)) min(l, 0.5) c h),
264
- oklch(from rgb(var(--color-warning)) max(l, 0.72) c h)
265
- );
273
+ color: oklch(from rgb(var(--color-warning)) min(l, 0.5) c h);
266
274
  }
267
275
 
268
276
  // Red state: the component is not wired up / unreachable. The row then
@@ -270,10 +278,21 @@
270
278
  .s-about-modal-row-meta[data-tone="danger"] {
271
279
  border-color: rgb(var(--color-error) / 32%);
272
280
  background: rgb(var(--color-error) / 10%);
273
- color: light-dark(
274
- oklch(from rgb(var(--color-error)) min(l, 0.5) c h),
275
- oklch(from rgb(var(--color-error)) max(l, 0.72) c h)
276
- );
281
+ color: oklch(from rgb(var(--color-error)) min(l, 0.5) c h);
282
+ }
283
+
284
+ // Dark arm: floor the lightness instead of capping it (rationale above). Only
285
+ // the glyphs re-ink — the border and tint already ride the live channel.
286
+ [data-mode="dark"] .s-about-modal-row-meta[data-tone="positive"] {
287
+ color: oklch(from rgb(var(--color-success)) max(l, 0.72) c h);
288
+ }
289
+
290
+ [data-mode="dark"] .s-about-modal-row-meta[data-tone="caution"] {
291
+ color: oklch(from rgb(var(--color-warning)) max(l, 0.72) c h);
292
+ }
293
+
294
+ [data-mode="dark"] .s-about-modal-row-meta[data-tone="danger"] {
295
+ color: oklch(from rgb(var(--color-error)) max(l, 0.72) c h);
277
296
  }
278
297
 
279
298
  /* ── Links: licenses, external links, credits names and filings ───────── */
@@ -33,13 +33,24 @@ function classesFrom(source: string): Set<string> {
33
33
  * Body of one compiled rule, by exact selector (Sass drops the quotes around
34
34
  * an attribute value: `[data-face=plain]`).
35
35
  *
36
- * The selector must compile exactly once: a second copy — say inside a media
37
- * query, or a later override — would otherwise stay invisible here while
38
- * still winning the cascade.
36
+ * The selector must compile exactly once: a second copy — a later override, or
37
+ * one hidden inside a media/supports block — would otherwise stay invisible
38
+ * here while still winning the cascade.
39
+ *
40
+ * The match is anchored to a rule boundary — start of sheet, `}` (the previous
41
+ * rule ended) or `{` (an at-rule body opens) — because a descendant rule such
42
+ * as `[data-mode=dark] .s-about-modal-row-meta` CONTAINS the base selector as a
43
+ * suffix; an unanchored search would read the dark arm as a duplicate of the
44
+ * light one. The boundary is a LOOKBEHIND so it is not consumed: with a
45
+ * consuming `}` the match of one rule would swallow the character the next
46
+ * adjacent rule needs as its boundary, which hid exactly the duplicates this
47
+ * guard exists to catch.
39
48
  */
40
49
  function ruleBody(sheet: string, selector: string): string {
41
50
  const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
- const matches = [...sheet.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))];
51
+ const matches = [
52
+ ...sheet.matchAll(new RegExp(`(?:(?<=^)|(?<=[}{]))\\s*${escaped}\\s*\\{([^}]*)\\}`, "g")),
53
+ ];
43
54
  expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
44
55
  return matches[0]?.[1] ?? "";
45
56
  }
@@ -238,6 +249,7 @@ describe("HkAboutModal stylesheet contract", () => {
238
249
  */
239
250
  describe("row meta tones", () => {
240
251
  const TONE = (tone: string) => `.s-about-modal-row-meta[data-tone=${tone}]`;
252
+ const DARK_TONE = (tone: string) => `[data-mode=dark] ${TONE(tone)}`;
241
253
 
242
254
  // Channel behind each coloured tone, and the WCAG-AA ink derived from it.
243
255
  // The pill text is `--text-2xs` (10px) — small text, 4.5:1 — and the raw
@@ -247,12 +259,18 @@ describe("HkAboutModal stylesheet contract", () => {
247
259
  // lightness moves — capped at 0.5 in light mode, floored at 0.72 in dark
248
260
  // (bounds solved so the worst stock AND chest entry clears 4.5:1 against
249
261
  // the pill's real backdrop: the 10% channel tint over the 3%-primary
250
- // card on `--color-surface`). Reverting any arm to the raw channel — or
251
- // dropping one `light-dark()` side — must turn this red.
252
- const AA_INK = (channel: string) =>
253
- `light-dark(` +
254
- `oklch(from rgb(var(--color-${channel})) min(l, 0.5) c h), ` +
255
- `oklch(from rgb(var(--color-${channel})) max(l, 0.72) c h))`;
262
+ // card on `--color-surface`).
263
+ //
264
+ // The arms are THEME-MODE rules rather than one `light-dark()` value: the
265
+ // CSS minifier lowers `light-dark()` into an undeclared
266
+ // `--lightningcss-*` var pair on any build floor that predates it, which
267
+ // turns the glyph colour into an IACVT fallback to the INHERITED colour
268
+ // (shipped once — chest #2027). Reverting an arm to the raw channel, or
269
+ // dropping either mode arm, must turn this red.
270
+ const AA_INK_LIGHT = (channel: string) =>
271
+ `oklch(from rgb(var(--color-${channel})) min(l, 0.5) c h)`;
272
+ const AA_INK_DARK = (channel: string) =>
273
+ `oklch(from rgb(var(--color-${channel})) max(l, 0.72) c h)`;
256
274
  const TONES: Record<string, string> = {
257
275
  positive: "success",
258
276
  caution: "warning",
@@ -274,9 +292,38 @@ describe("HkAboutModal stylesheet contract", () => {
274
292
  // seed-relative form without spaces around the slash.)
275
293
  borderColor: `rgb(var(--color-${channel})/32%)`,
276
294
  background: `rgb(var(--color-${channel})/10%)`,
277
- color: AA_INK(channel),
295
+ color: AA_INK_LIGHT(channel),
278
296
  });
279
297
  }
280
298
  });
299
+
300
+ it("re-inks only the glyphs in dark mode, with the floored arm", () => {
301
+ for (const [tone, channel] of Object.entries(TONES)) {
302
+ const dark = ruleBody(css, DARK_TONE(tone));
303
+ expect(dark, `the ${tone}-tone dark arm must survive compilation`).not.toBe("");
304
+ // Exactly one declaration: the dark arm owns the glyph colour alone.
305
+ // Border and background stay on the base rule (same live channel), so
306
+ // a second copy here would be a cascade surprise.
307
+ const declarations = dark
308
+ .split(";")
309
+ .map((chunk) => chunk.trim())
310
+ .filter(Boolean);
311
+ expect(declarations, "the dark arm declares the glyph colour only").toHaveLength(1);
312
+ expect(declaration(dark, "color")).toBe(AA_INK_DARK(channel));
313
+ }
314
+ });
315
+
316
+ it("never reintroduces the minifier-mangled light-dark()", () => {
317
+ // The exact shape that shipped broken: lightningcss rewrites the
318
+ // function into an UNDECLARED var pair on older build floors, so the
319
+ // glyphs silently inherit their parent's colour. Mode rules carry the
320
+ // same two arms without any function a minifier can lower.
321
+ //
322
+ // Case-insensitive (CSS function names are), and anchored to a
323
+ // DECLARATION rather than a bare mention: the rationale comments name
324
+ // the function on purpose, and `expanded` output keeps `/* */` blocks.
325
+ expect(css).not.toMatch(/[:]\s*[^;{}]*light-dark\(/i);
326
+ expect(scss).not.toMatch(/:\s*light-dark\(/i);
327
+ });
281
328
  });
282
329
  });
@@ -84,7 +84,13 @@ describe("HkErrorLanding inline contract", () => {
84
84
  // silently disabled the base align/justify centering and glued the
85
85
  // card to the container's top edge.
86
86
  expect(inline).toContain("min-height: 100%");
87
- expect(inline).not.toContain("min-height: 0");
87
+ // The ONLY zero min-height allowed in this variant is the fill
88
+ // seating's own — nested under &.is-fill, flex-context-only, never
89
+ // the default inline (which must keep the percentage fill above).
90
+ const fillAt = inline.indexOf("&.is-fill");
91
+ expect(fillAt, "&.is-fill seating present").toBeGreaterThanOrEqual(0);
92
+ const zeroAt = inline.indexOf("min-height: 0");
93
+ expect(zeroAt, "zero min-height only inside the fill seating").toBeGreaterThan(fillAt);
88
94
  });
89
95
 
90
96
  it("drops the block-axis padding entirely and keeps only the horizontal inset", () => {
@@ -112,6 +118,65 @@ describe("HkErrorLanding inline contract", () => {
112
118
  });
113
119
  });
114
120
 
121
+ describe("HkErrorLanding inline fill-seating contract", () => {
122
+ const css = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
123
+ const inline = ruleBody(css, ".hk-error-landing.is-inline");
124
+ const fill = ruleBody(inline, "&.is-fill");
125
+
126
+ it("grows and shrinks with the host flex pane", () => {
127
+ expect(fill).toContain("flex: 1 1 auto");
128
+ expect(fill).toContain("min-height: 0");
129
+ });
130
+
131
+ it("lays the card out as one capped column so the frame can yield", () => {
132
+ const card = ruleBody(inline, ".hk-error-landing__card");
133
+ expect(card).toContain("display: flex");
134
+ expect(card).toContain("flex-direction: column");
135
+ expect(card).toContain("max-height: 100%");
136
+ expect(card).toContain("margin: auto");
137
+ });
138
+
139
+ it("passes a squeeze through the details region to the frame", () => {
140
+ const details = ruleBody(inline, ".hk-error-landing__details");
141
+ expect(details).toContain("min-height: 0");
142
+ });
143
+
144
+ it("makes the standing frame compressible with a usable floor", () => {
145
+ const pane = ruleBody(inline, ".hk-error-landing__details-pane");
146
+ // Basis = the page-grade standing height (unconstrained cards render
147
+ // exactly the fixed frame), shrink unlocked, floor keeps a usable
148
+ // scroll window.
149
+ expect(pane).toContain("flex-shrink: 1");
150
+ expect(pane).toContain("flex-basis: max(9rem, 20vh)");
151
+ expect(pane).toContain("flex-basis: max(9rem, 20dvh)");
152
+ expect(pane).toContain("min-height: 6rem");
153
+ });
154
+ });
155
+
156
+ describe("HkErrorLanding nested-tree chrome guard contract", () => {
157
+ const css = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
158
+
159
+ it("keeps the pane-owned resets at a :root-dial-beating weight", () => {
160
+ // Hosts restyle the shared tree class as `:root .s-tool-json-tree`
161
+ // ((0,2,0), wowsp's glass dial) — at equal weight the later-bundled
162
+ // dial won the cascade and repainted the nested tree's content band
163
+ // over the pane's uniform fill in both themes. The guard selector
164
+ // carries the extra landing class ((0,3,0)) so no :root-prefixed
165
+ // dial reaches it, whatever the bundle order.
166
+ const guardSel = ".hk-error-landing .hk-error-landing__details-body .s-tool-json-tree {";
167
+ expect(css).toContain(guardSel);
168
+ // And the old two-class form must not survive anywhere.
169
+ const regressed = css
170
+ .split("\n")
171
+ .some((l) => l.trimStart().startsWith(".hk-error-landing__details-body .s-tool-json-tree"));
172
+ expect(regressed).toBe(false);
173
+ const guard = ruleBody(css, ".hk-error-landing .hk-error-landing__details-body .s-tool-json-tree");
174
+ expect(guard).toContain("background: transparent");
175
+ expect(guard).toContain("max-height: none");
176
+ expect(guard).toContain("overflow: visible");
177
+ });
178
+ });
179
+
115
180
  describe("HkErrorLanding multiline headline contract", () => {
116
181
  const css = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
117
182
  const title = ruleBody(css, ".hk-error-landing__title");
@@ -249,8 +249,17 @@
249
249
 
250
250
  // A nested HkJsonTree fills the pane instead of stacking its own frame
251
251
  // and 320px cap — the pane above owns the chrome now (overflow included:
252
- // the tree must never engage its own scroller).
253
- .hk-error-landing__details-body .s-tool-json-tree {
252
+ // the tree must never engage its own scroller). The leading landing class
253
+ // is load-bearing SPECIFICITY, not decoration: hosts blanket-restyle the
254
+ // shared tree class from :root (wowsp's glass dial, `:root
255
+ // .s-tool-json-tree`) at the same (0,2,0) weight the two-class selector
256
+ // below used to carry, and the later-bundled dial won the cascade — the
257
+ // nested tree repainted its content band over the pane's uniform fill,
258
+ // splitting the standing frame into a tinted text block above an untinted
259
+ // void in BOTH themes (2026-10-30 wowsp report). Three classes put the
260
+ // landing's pane chrome out of every :root-prefixed host dial's reach,
261
+ // whatever the bundle order.
262
+ .hk-error-landing .hk-error-landing__details-body .s-tool-json-tree {
254
263
  max-height: none;
255
264
  height: auto;
256
265
  overflow: visible;
@@ -306,13 +315,67 @@
306
315
  align-items: flex-start;
307
316
  background: transparent;
308
317
 
318
+ // Fill seating: the host lays the landing out as the shrinking region of
319
+ // a definite-height flex pane (a settings card whose header sits above
320
+ // and the landing owns the rest). flex takes the remaining space and the
321
+ // lifted min-height lets the CARD compress to it instead of pushing the
322
+ // host pane into a scroll for chrome alone; once even the compressed
323
+ // floor cannot fit, the overflow anchor above still seats the card
324
+ // top-first and reachable. Every declaration is flex-context-only: a
325
+ // non-flex host (or a flex pane with auto height) ignores the fill
326
+ // entirely and keeps the default min-height centering contract, so a
327
+ // wrong host degrades to the flow landing, never to a collapsed one.
328
+ &.is-fill {
329
+ flex: 1 1 auto;
330
+ min-height: 0;
331
+ }
332
+
309
333
  // Safe centering: auto margins center the card on both axes while free
310
334
  // space exists and collapse to 0 when it doesn't (the overflow case
311
335
  // anchored by align-items above). Specificity (0,3,0) also keeps the
312
- // slim padding below outranking the ≤480px media rule (0,1,0).
336
+ // slim padding below outranking the ≤480px media rule (0,1,0). The
337
+ // column layout plus the landing-box cap make the card COMPRESSIBLE:
338
+ // its only flexible part (the details frame below) yields height before
339
+ // the card pushes the host pane into a scroll. Flow rendering is
340
+ // unchanged — every child margin in this card is one-directional, so
341
+ // dropping block-margin collapsing moves no spacing.
313
342
  .hk-error-landing__card {
314
343
  margin: auto;
315
344
  padding: var(--space-16, 1rem) var(--space-28, 1.75rem) var(--space-24, 1.5rem);
345
+ display: flex;
346
+ flex-direction: column;
347
+ max-height: 100%;
348
+ // Never smaller than the card's own content minimum (the details
349
+ // frame's floor included): min-height beats max-height per spec, so a
350
+ // pane tighter than floor + chrome spills the card into the host's
351
+ // scroller (top-anchored, reachable) instead of clipping the actions
352
+ // row inside the card's own overflow: hidden.
353
+ min-height: min-content;
354
+ }
355
+
356
+ // The details region passes a squeeze THROUGH to the frame instead of
357
+ // stopping it at its own content box.
358
+ .hk-error-landing__details {
359
+ display: flex;
360
+ flex-direction: column;
361
+ min-height: 0;
362
+ }
363
+
364
+ // The standing frame yields under a squeeze instead of stretching the
365
+ // card past its pane: the flex-basis IS the page-grade standing height,
366
+ // so an unconstrained card renders exactly the fixed frame, the floor
367
+ // keeps a usable scroll window when the host is tight, and the frame's
368
+ // own overlay scrollbar takes over below the basis. The stable-frame
369
+ // contract is intact — folding tree nodes still never resizes the frame;
370
+ // only a HOST constraint (pane resize, fill seating) moves it, and a
371
+ // resize reflecting the host's real geometry is feedback, not a jump.
372
+ .hk-error-landing__details-pane {
373
+ height: auto;
374
+ flex-grow: 0;
375
+ flex-shrink: 1;
376
+ flex-basis: max(9rem, 20vh);
377
+ flex-basis: max(9rem, 20dvh);
378
+ min-height: 6rem;
316
379
  }
317
380
  }
318
381
 
@@ -5,14 +5,24 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  /**
7
7
  * Source contract: the error landing's raw-details pane is a STANDING
8
- * frame — the larger of 9rem and ~20vh, fixed. The frame must never
9
- * breathe with its content: the 2026-10-01 content-hugging experiment
10
- * (max-height cap + inheriting body) shipped and was reverted the same
11
- * day after mobile review — folding the tree shrank the whole error card
12
- * and the jumping height read as broken. These pins keep BOTH regression
13
- * classes out: the breathing class (max-height instead of height on the
14
- * pane, an inheriting/max-capped body) and the late-re-declaration class
15
- * (a later same-file override re-pinning either box).
8
+ * frame — the larger of 9rem and ~20vh. The frame must never breathe
9
+ * with its content: the 2026-10-01 content-hugging experiment (max-height
10
+ * cap + inheriting body) shipped and was reverted the same day after
11
+ * mobile review — folding the tree shrank the whole error card and the
12
+ * jumping height read as broken. These pins keep BOTH regression classes
13
+ * out: the breathing class (max-height instead of height on the pane, an
14
+ * inheriting/max-capped body) and the late-re-declaration class (a later
15
+ * same-file override re-pinning either box).
16
+ *
17
+ * 2026-10-30 evolution: the base rule keeps the FIXED frame (the page
18
+ * variant owns a viewport; unconstrained hosts render the standing
19
+ * height verbatim), while the INLINE variant re-expresses the SAME
20
+ * standing pair as a flex-basis with a shrink floor — under a real host
21
+ * constraint (fill seating in a definite-height flex pane) the frame
22
+ * yields before the card scrolls the pane. A host-resize squeeze is
23
+ * feedback, not a fold; folding still never moves the frame. The inline
24
+ * side of that contract lives in HkErrorLanding.inline.contract.test.ts;
25
+ * this file pins the base rule and counts the declaration sites.
16
26
  */
17
27
  const here = dirname(fileURLToPath(import.meta.url));
18
28
  const scss = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
@@ -21,17 +31,19 @@ describe("HkErrorLanding standing frame contract", () => {
21
31
  const paneAt = scss.indexOf(".hk-error-landing__details-pane {");
22
32
  const bodyAt = scss.indexOf(".hk-error-landing__details-body {");
23
33
 
24
- it("declares the pane and body exactly once — no late re-declaration", () => {
25
- // Whole-file site count: the slice-scoped sweeps below read only up
26
- // to the nested tree rule, so a LATER `.hk-error-landing__details-
27
- // body { height: ... }` in this file would cascade over the frame
28
- // while every slice pin stays green (the R3-M4 mutation class).
29
- // Pristine file has exactly two sites (pane + body); the descendant
34
+ it("declares the pane and body sites exactly — no late re-declaration", () => {
35
+ // Whole-file site count: the slice-scoped sweeps below read only the
36
+ // base rules, so a LATER `.hk-error-landing__details-body { height:
37
+ // ... }` in this file would cascade over the frame while every slice
38
+ // pin stays green (the R3-M4 mutation class). Exactly three sites are
39
+ // sanctioned: the base pane, the base body, and the inline variant's
40
+ // compressible pane re-expression (flex-basis standing pair + floor;
41
+ // pinned separately by the inline contract). The descendant
30
42
  // `.s-tool-json-tree` rule and the label/toggle selectors don't match.
31
43
  // Keep this pin at strict equality — softening it to >= silently
32
44
  // reopens the late-re-declaration class.
33
45
  const sites = scss.match(/\.hk-error-landing__details-(?:pane|body)\s*[,{]/g) ?? [];
34
- expect(sites.length, `details pane/body selector sites = ${sites.join(" | ")}`).toBe(2);
46
+ expect(sites.length, `details pane/body selector sites = ${sites.join(" | ")}`).toBe(3);
35
47
  });
36
48
 
37
49
  it("fixes the pane at the standing frame, never a content-driven cap", () => {
@@ -53,11 +65,15 @@ describe("HkErrorLanding standing frame contract", () => {
53
65
 
54
66
  it("fills the fixed frame instead of hugging the payload", () => {
55
67
  expect(bodyAt, "body rule present").toBeGreaterThanOrEqual(0);
56
- const nextAt = scss.indexOf(".hk-error-landing__details-body .s-tool-json-tree");
57
- // The slice boundary is load-bearing: the sweeps below must never
58
- // silently extend to EOF (where unrelated rules would trip them).
59
- expect(nextAt, "tree-neutering rule present").toBeGreaterThanOrEqual(0);
60
- const body = scss.slice(bodyAt, nextAt);
68
+ // The boundary is the body rule's OWN closing brace, not the next
69
+ // selector: the sweep must never silently extend into later rules
70
+ // (the nested tree's neutering rule carries a height: auto of its
71
+ // own, and an EOF-reaching slice would trip on unrelated boxes).
72
+ const openAt = scss.indexOf("{", bodyAt);
73
+ const closeAt = scss.indexOf("}", openAt);
74
+ expect(openAt, "body rule opens").toBeGreaterThan(bodyAt);
75
+ expect(closeAt, "body rule closes").toBeGreaterThan(openAt);
76
+ const body = scss.slice(bodyAt, closeAt + 1);
61
77
  // The body fills the fixed frame — its ONLY height declaration is
62
78
  // the 100% fill; any other height (auto/inherit/max cap) is the
63
79
  // reverted content-hugging regression (the R2-M-D mutation class).
@@ -11,6 +11,7 @@ interface MountOptions {
11
11
  status?: number;
12
12
  tone?: "error" | "warning" | "info";
13
13
  variant?: "page" | "inline";
14
+ fill?: boolean;
14
15
  copyText?: string;
15
16
  copyLabel?: string;
16
17
  details?: () => ReturnType<typeof h>;
@@ -30,6 +31,7 @@ function mountLanding(opts: MountOptions = {}) {
30
31
  status: opts.status,
31
32
  tone: opts.tone ?? "error",
32
33
  variant: opts.variant ?? "page",
34
+ fill: opts.fill ?? false,
33
35
  copyText: opts.copyText ?? "",
34
36
  copyLabel: opts.copyLabel ?? "",
35
37
  }, {
@@ -173,6 +175,21 @@ describe("HkErrorLanding", () => {
173
175
  const el = mountLanding({ variant: "inline", title: "Boom" });
174
176
  const root = el.querySelector(".hk-error-landing")!;
175
177
  expect(root.classList.contains("is-inline")).toBe(true);
178
+ expect(root.classList.contains("is-fill")).toBe(false);
179
+ });
180
+
181
+ it("applies the fill seating class in the inline variant", () => {
182
+ const el = mountLanding({ variant: "inline", fill: true, title: "Boom" });
183
+ const root = el.querySelector(".hk-error-landing")!;
184
+ expect(root.classList.contains("is-inline")).toBe(true);
185
+ expect(root.classList.contains("is-fill")).toBe(true);
186
+ });
187
+
188
+ it("never applies the fill seating to the page variant", () => {
189
+ const el = mountLanding({ fill: true, title: "Boom" });
190
+ const root = el.querySelector(".hk-error-landing")!;
191
+ expect(root.classList.contains("is-inline")).toBe(false);
192
+ expect(root.classList.contains("is-fill")).toBe(false);
176
193
  });
177
194
 
178
195
  it("seats the built-in copy action FIRST, left of the host actions", () => {
@@ -28,7 +28,12 @@ export type HErrorTone = "error" | "warning" | "info";
28
28
  * - `inline`: drops the backdrop and fills its host region's height
29
29
  * instead of the viewport, so the same card centers inside whatever
30
30
  * pane captured it (HkErrorBoundary) or whatever content region a host
31
- * view hands it.
31
+ * view hands it. The `fill` prop re-seats it as the shrinking region
32
+ * of a definite-height flex pane: grow to the remaining space and let
33
+ * the card compress its details frame down to its floor so the error
34
+ * card FITS the pane instead of scrolling it (2026-10-30 wowsp report:
35
+ * a settings error card taller than its pane pushed the whole page
36
+ * into a scroll).
32
37
  */
33
38
  export type HErrorLandingVariant = "page" | "inline";
34
39
 
@@ -92,6 +97,12 @@ export const HkErrorLanding = defineComponent({
92
97
  tone: { type: String as PropType<HErrorTone>, default: "error" },
93
98
  /** Layout variant: `page` (viewport backdrop) or `inline` (in-flow card). */
94
99
  variant: { type: String as PropType<HErrorLandingVariant>, default: "page" },
100
+ /** Fill seating (inline variant only): the host lays the landing out as
101
+ * the shrinking region of a definite-height flex pane — grow to the
102
+ * remaining space and let the card compress its details frame down to
103
+ * its floor so an error card fits its pane instead of scrolling it.
104
+ * Default keeps the inline centering fill (min-height contract). */
105
+ fill: { type: Boolean, default: false },
95
106
  },
96
107
  setup(props, { slots }) {
97
108
  const { t } = useI18n();
@@ -173,7 +184,7 @@ export const HkErrorLanding = defineComponent({
173
184
  });
174
185
 
175
186
  return () => (
176
- <div class={`hk-error-landing is-${props.tone}${props.variant === "inline" ? " is-inline" : ""}`}>
187
+ <div class={`hk-error-landing is-${props.tone}${props.variant === "inline" ? " is-inline" : ""}${props.fill && props.variant === "inline" ? " is-fill" : ""}`}>
177
188
  <div class="hk-error-landing__card">
178
189
  {slots.brand?.()}
179
190