@celestia-island/hikari 0.55.238 → 0.55.240
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/HkAboutModal.scss +40 -21
- package/src/components/HkAboutModal.styles.test.ts +58 -11
- package/src/components/HkErrorLanding.inline.contract.test.ts +66 -1
- package/src/components/HkErrorLanding.scss +66 -3
- package/src/components/HkErrorLanding.standingframe.contract.test.ts +36 -20
- package/src/components/HkErrorLanding.test.tsx +17 -0
- package/src/components/HkErrorLanding.tsx +13 -2
- package/src/components/HkMenuPanel.scss +4 -0
- package/src/components/HkMenuSection.test.tsx +191 -0
- package/src/components/HkMenuSection.tsx +95 -0
- package/src/components/HkTemplateField.scss +20 -6
- package/src/components/HkTemplateField.styles.test.ts +50 -2
- package/src/components/_menu-item.contract.test.ts +448 -0
- package/src/components/_menu-item.scss +95 -2
- package/src/index.ts +1 -0
- package/src/styles/admin-tokens.scss +17 -0
package/package.json
CHANGED
|
@@ -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.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
274
|
-
|
|
275
|
-
|
|
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 —
|
|
37
|
-
*
|
|
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 = [
|
|
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`).
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
25
|
-
// Whole-file site count: the slice-scoped sweeps below read only
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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(
|
|
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
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
const
|
|
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
|
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
display: flex;
|
|
14
14
|
flex-direction: column;
|
|
15
15
|
gap: var(--space-4, 4px);
|
|
16
|
+
/* This list's own row rhythm is 4px, so a captioned run moved into a
|
|
17
|
+
group box must match it (the section grammar defaults to the level's
|
|
18
|
+
2px). */
|
|
19
|
+
--hk-menu-section-gap: var(--space-4, 4px);
|
|
16
20
|
/* Inset matches .hk-sidebar-body's inline padding so items share the
|
|
17
21
|
nav text edge; override --hk-menu-panel-inset to re-anchor. */
|
|
18
22
|
padding: var(--space-8, 8px) var(--hk-menu-panel-inset, var(--space-12, 12px));
|