@celestia-island/hikari 0.55.98 → 0.55.100
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/HkErrorLanding.scss +17 -23
- package/src/components/HkErrorLanding.standingframe.contract.test.ts +80 -0
- package/src/components/HkErrorLanding.test.tsx +2 -2
- package/src/components/HkErrorLanding.tsx +12 -13
- package/src/components/HkThemeToggle.contract.test.ts +67 -0
- package/src/components/HkThemeToggle.scss +68 -16
- package/src/components/HkThemeToggle.test.ts +7 -0
- package/src/components/HkThemeToggle.tsx +15 -6
- package/src/components/_menu-item.scss +2 -0
- package/src/components/HkErrorLanding.detailsfold.contract.test.ts +0 -63
package/package.json
CHANGED
|
@@ -208,38 +208,32 @@
|
|
|
208
208
|
user-select: none;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// follow-the-content height rides the same update path.
|
|
211
|
+
// Standing-frame details pane: the raw payload keeps a STABLE height —
|
|
212
|
+
// the larger of a 9rem floor and ~20vh — so the frame pre-elevates a
|
|
213
|
+
// little but NEVER breathes with the content. The 2026-10-01
|
|
214
|
+
// content-hugging frame shipped and was reverted the same day after
|
|
215
|
+
// mobile review: folding the tree shrank the whole error card with it
|
|
216
|
+
// and the jumping height read as broken. Folding only changes what is
|
|
217
|
+
// INSIDE the frame; a long stack trace scrolls inside it instead of
|
|
218
|
+
// stretching the card. The 9rem term keeps short viewports (20vh < 9rem)
|
|
219
|
+
// at nine rem of scroll area. The pane div is the track's positioning
|
|
220
|
+
// context and wraps EXACTLY the scrolling body (useOverlayScrollbar
|
|
221
|
+
// host contract); the family overlay scrollbar's observers keep the
|
|
222
|
+
// thumb geometry fresh on fold/expand.
|
|
224
223
|
.hk-error-landing__details-pane {
|
|
225
224
|
position: relative;
|
|
226
225
|
margin-top: var(--space-6, 0.375rem);
|
|
227
|
-
|
|
228
|
-
|
|
226
|
+
height: max(9rem, 20vh);
|
|
227
|
+
height: max(9rem, 20dvh);
|
|
229
228
|
border: 1px solid rgb(var(--hel-border) / 70%);
|
|
230
229
|
border-radius: var(--radius-md, 10px);
|
|
231
230
|
background: rgb(var(--hel-muted) / 4%);
|
|
232
231
|
}
|
|
233
232
|
|
|
234
233
|
.hk-error-landing__details-body {
|
|
235
|
-
// The
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
// it: small content hugs, tall content clamps at the ceiling and the
|
|
239
|
-
// overlay scrollbar carries the overflow. A max cap (not a fixed
|
|
240
|
-
// height) keeps the shrink direction free; overflow != visible is what
|
|
241
|
-
// lets the block actually clamp.
|
|
242
|
-
max-height: inherit;
|
|
234
|
+
// The frame's height is fixed, so the body simply fills it; a fold or
|
|
235
|
+
// an expand re-renders INSIDE the frame and never moves the card.
|
|
236
|
+
height: 100%;
|
|
243
237
|
overflow: auto;
|
|
244
238
|
border-radius: inherit;
|
|
245
239
|
padding: var(--space-4, 0.25rem);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
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).
|
|
16
|
+
*/
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const scss = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
|
|
19
|
+
|
|
20
|
+
describe("HkErrorLanding standing frame contract", () => {
|
|
21
|
+
const paneAt = scss.indexOf(".hk-error-landing__details-pane {");
|
|
22
|
+
const bodyAt = scss.indexOf(".hk-error-landing__details-body {");
|
|
23
|
+
|
|
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
|
|
30
|
+
// `.s-tool-json-tree` rule and the label/toggle selectors don't match.
|
|
31
|
+
// Keep this pin at strict equality — softening it to >= silently
|
|
32
|
+
// reopens the late-re-declaration class.
|
|
33
|
+
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);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("fixes the pane at the standing frame, never a content-driven cap", () => {
|
|
38
|
+
expect(paneAt, "pane rule present").toBeGreaterThanOrEqual(0);
|
|
39
|
+
const pane = scss.slice(paneAt, bodyAt);
|
|
40
|
+
// Both viewport lines: the vh fallback pairs with the dvh override,
|
|
41
|
+
// matching the family pattern.
|
|
42
|
+
expect(pane).toContain("height: max(9rem, 20vh)");
|
|
43
|
+
expect(pane).toContain("height: max(9rem, 20dvh)");
|
|
44
|
+
// Every height declaration must BE the frame pair — a later `height:
|
|
45
|
+
// auto` (or an extra one) would let the frame breathe again.
|
|
46
|
+
const heights = [...pane.matchAll(/^\s*height:.*$/gm)].map((m) => m[0].trim());
|
|
47
|
+
expect(heights.length, `pane height declarations = ${JSON.stringify(heights)}`).toBe(2);
|
|
48
|
+
for (const value of heights) expect(value).toMatch(/^height: max\(9rem, 20d?vh\);$/);
|
|
49
|
+
// A content-driven cap is EXACTLY the reverted breathing frame.
|
|
50
|
+
expect(pane).not.toContain("max-height:");
|
|
51
|
+
expect(pane).not.toContain("min-height:");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("fills the fixed frame instead of hugging the payload", () => {
|
|
55
|
+
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);
|
|
61
|
+
// The body fills the fixed frame — its ONLY height declaration is
|
|
62
|
+
// the 100% fill; any other height (auto/inherit/max cap) is the
|
|
63
|
+
// reverted content-hugging regression (the R2-M-D mutation class).
|
|
64
|
+
const bodyHeights = [...body.matchAll(/^\s*height:.*$/gm)].map((m) => m[0].trim());
|
|
65
|
+
expect(bodyHeights, `body height declarations = ${JSON.stringify(bodyHeights)}`).toEqual(["height: 100%;"]);
|
|
66
|
+
expect(body).not.toContain("max-height:");
|
|
67
|
+
expect(body).not.toContain("min-height:");
|
|
68
|
+
// Long content still scrolls inside the frame.
|
|
69
|
+
expect(body).toContain("overflow: auto");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("keeps the mobile dvh override after its vh fallback", () => {
|
|
73
|
+
// The pair is order-sensitive by convention (the later dvh line wins
|
|
74
|
+
// where dvh is supported); nothing may reorder or deduplicate it.
|
|
75
|
+
const vhAt = scss.indexOf("height: max(9rem, 20vh)");
|
|
76
|
+
const dvhAt = scss.indexOf("height: max(9rem, 20dvh)");
|
|
77
|
+
expect(vhAt, "vh fallback present").toBeGreaterThanOrEqual(0);
|
|
78
|
+
expect(dvhAt, "dvh override present").toBeGreaterThan(vhAt);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -95,8 +95,8 @@ describe("HkErrorLanding", () => {
|
|
|
95
95
|
const el = mountLanding({
|
|
96
96
|
details: () => h("pre", { class: "s-tool-json-tree" }, "raw"),
|
|
97
97
|
});
|
|
98
|
-
// No collapse affordance — the pane is
|
|
99
|
-
//
|
|
98
|
+
// No collapse affordance — the pane is a standing frame; folding
|
|
99
|
+
// changes only its content (standing-frame contract in scss).
|
|
100
100
|
expect(el.querySelector(".hk-error-landing__details-toggle")).toBeNull();
|
|
101
101
|
expect(el.querySelector(".hk-error-landing__details-label")!.textContent).toContain("Raw error details");
|
|
102
102
|
expect(el.querySelector(".hk-error-landing__details-pane")).not.toBeNull();
|
|
@@ -44,14 +44,14 @@ function normalizeSlotNodes(nodes: VNode | VNode[] | undefined): VNode[] {
|
|
|
44
44
|
* carrying a tone icon, a (pre-translated) title and description, the wire
|
|
45
45
|
* error code / HTTP status as HkBadge chips above the headline, an
|
|
46
46
|
* always-open raw-details pane (the default slot — hosts render HkJsonTree
|
|
47
|
-
* there)
|
|
48
|
-
* family overlay scrollbar
|
|
47
|
+
* there) with a STABLE standing frame — the larger of 9rem and ~20vh,
|
|
48
|
+
* carried by the family overlay scrollbar — and an actions slot.
|
|
49
49
|
*
|
|
50
|
-
* The
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* The pane never breathes with its content: folding every JSON node only
|
|
51
|
+
* changes what renders INSIDE the standing frame, and a long stack trace
|
|
52
|
+
* scrolls inside it instead of stretching the card. (The 2026-10-01
|
|
53
|
+
* content-hugging frame was reverted the same day: on mobile the whole
|
|
54
|
+
* error card visibly jumped between fold states, which read as broken.)
|
|
55
55
|
*
|
|
56
56
|
* The component is presentation-only and route-agnostic: it never touches
|
|
57
57
|
* the router and can be mounted by an SPA overlay, a modal, or a standalone
|
|
@@ -111,12 +111,11 @@ export const HkErrorLanding = defineComponent({
|
|
|
111
111
|
|
|
112
112
|
const detailsBodyRef = ref<HTMLElement | null>(null);
|
|
113
113
|
let detailsScrollbars: OverlayScrollbarHandle | null = null;
|
|
114
|
-
// The
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
// every fold/expand by whichever observer sees it first.
|
|
114
|
+
// The pane's viewport box is fixed, so the composable's own viewport
|
|
115
|
+
// ResizeObserver never fires when the slot content changes size — and
|
|
116
|
+
// folding a JSON node re-renders HkJsonTree internally, so the landing
|
|
117
|
+
// itself does not re-render either. Observe the CONTENT element (the
|
|
118
|
+
// tree root) so every fold/expand re-reads the thumb geometry.
|
|
120
119
|
let contentResizeObserver: ResizeObserver | null = null;
|
|
121
120
|
let observedContent: Element | null = null;
|
|
122
121
|
|
|
@@ -25,6 +25,73 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
const read = (f: string): string => readFileSync(join(here, f), "utf-8");
|
|
27
27
|
|
|
28
|
+
describe("HkThemeToggle theme-row pill contract", () => {
|
|
29
|
+
// 2026-10-01 user report: with the item-trailing slot reserving a real
|
|
30
|
+
// column, the row button's hover wash stopped short of the row edge —
|
|
31
|
+
// theme rows and the standalone 自定义 row hovered at different widths.
|
|
32
|
+
// The pill therefore lives on `.s-theme-item-row` (full row width no
|
|
33
|
+
// matter how wide the trailing column is) and the row's button is
|
|
34
|
+
// chromeless. happy-dom has no layout engine, so the geometry is pinned
|
|
35
|
+
// as scss-text assertions.
|
|
36
|
+
it("puts the unified menu-item pill on the row, not the row button", () => {
|
|
37
|
+
const css = read("HkThemeToggle.scss");
|
|
38
|
+
const rowBlock = css.match(/\.s-theme-item-row\s*\{[^}]*\}/);
|
|
39
|
+
expect(rowBlock).not.toBeNull();
|
|
40
|
+
expect(rowBlock![0]).toContain("@include mi.item;");
|
|
41
|
+
// The overlaid delete affordance pins to the row (positioning context).
|
|
42
|
+
expect(rowBlock![0]).toContain("position: relative");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("strips the row button's own chrome so the wash is not doubled", () => {
|
|
46
|
+
const css = read("HkThemeToggle.scss");
|
|
47
|
+
const block = css.match(/\.s-theme-item-row \.s-theme-item-btn\s*\{[^}]*\}/);
|
|
48
|
+
expect(block).not.toBeNull();
|
|
49
|
+
// The ROW owns the frame: its border 1 + padding 12 put the content at
|
|
50
|
+
// the same 13px the standalone pills measure (border 1 + padding 12), so
|
|
51
|
+
// the button drops its own border entirely.
|
|
52
|
+
expect(block![0]).toContain("border: none;");
|
|
53
|
+
expect(block![0]).toContain("background: transparent");
|
|
54
|
+
expect(block![0]).toContain("min-height: 0");
|
|
55
|
+
// The button stretches over the row's frame (negative block margins
|
|
56
|
+
// mirrored by re-applied padding) so the WHOLE pill is the click/focus
|
|
57
|
+
// target — master's pill WAS the button — while the name's content box
|
|
58
|
+
// keeps the standalone offsets. Losing the stretch drops the button
|
|
59
|
+
// back to a centered auto-height box inside the row (misaligned and
|
|
60
|
+
// undersized); losing the bleed pair desyncs the content offset by the
|
|
61
|
+
// frame width.
|
|
62
|
+
expect(block![0]).toContain("align-self: stretch");
|
|
63
|
+
expect(block![0]).toContain("margin-block: calc(");
|
|
64
|
+
expect(block![0]).toContain("padding-block: calc(");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("suppresses the row button's own hover and active state chrome", () => {
|
|
68
|
+
const css = read("HkThemeToggle.scss");
|
|
69
|
+
// The hover override must repeat mi.item's guard chain to out-specify
|
|
70
|
+
// the mixin's hover rule; the active/checked suppression targets the
|
|
71
|
+
// state attributes directly.
|
|
72
|
+
expect(css).toContain(
|
|
73
|
+
".s-theme-item-row .s-theme-item-btn:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked])",
|
|
74
|
+
);
|
|
75
|
+
expect(css).toMatch(
|
|
76
|
+
/\.s-theme-item-row \.s-theme-item-btn\[data-active\][^{]*\{[^}]*background: transparent/,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("registers the theme row family for the mobile sheet token swap", () => {
|
|
81
|
+
const mi = read("_menu-item.scss");
|
|
82
|
+
// The row carries the pill, so the sheet-context token binding must
|
|
83
|
+
// target it too — otherwise sheets keep the desktop 34px metrics.
|
|
84
|
+
const sheetList = mi.match(
|
|
85
|
+
/\.hk-popover-panel\.hk-is-sheet[^{]*\{[^{]*\{[^}]*\}/,
|
|
86
|
+
);
|
|
87
|
+
expect(sheetList).not.toBeNull();
|
|
88
|
+
expect(sheetList![0]).toContain(".s-theme-item-row");
|
|
89
|
+
const modalList = mi.match(/\.hk-modal-content\s*\{[^{]*\{[^}]*\}/);
|
|
90
|
+
expect(modalList).not.toBeNull();
|
|
91
|
+
expect(modalList![0]).toContain(".s-theme-item-row");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
28
95
|
describe("HkThemeToggle row lead-cell contract", () => {
|
|
29
96
|
it("sizes the theme-row lead cell to fit a 28px swatch mark", () => {
|
|
30
97
|
const css = read("HkThemeToggle.scss");
|
|
@@ -136,32 +136,84 @@
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/* ── Theme list rows ────────────────────────────────────────────────────
|
|
139
|
-
* Every row is ONE full-width pill
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
139
|
+
* Every row is ONE full-width pill, and the pill is THE ROW (2026-10-01
|
|
140
|
+
* user report: with the trailing slot reserving a real column, the row
|
|
141
|
+
* button's wash stopped short of the row edge — theme rows and the
|
|
142
|
+
* standalone 自定义 row hovered at different widths). The unified
|
|
143
|
+
* menu-item chrome therefore lives on `.s-theme-item-row` (which also
|
|
144
|
+
* carries `data-active` from the TSX), and the row's button surrenders
|
|
145
|
+
* its own chrome below, so the hover/active wash, border and radius
|
|
146
|
+
* span the full row no matter how wide the trailing column is. The
|
|
147
|
+
* standalone pills (自定义 row, host mode-extra rows) keep the chrome on
|
|
148
|
+
* the button itself — same spec, same width as a row.
|
|
145
149
|
*
|
|
146
150
|
* Metrics/states come from the unified menu-item spec (./_menu-item.scss)
|
|
147
151
|
* — the same grammar as menu rows and select options; inside the mobile
|
|
148
152
|
* sheet the shared `.hk-popover-panel.hk-is-sheet` token context applies
|
|
149
|
-
* the 44px touch geometry
|
|
150
|
-
*
|
|
153
|
+
* the 44px touch geometry (the row family is registered there), so the
|
|
154
|
+
* old component-local media bump is gone (only the overlaid delete
|
|
155
|
+
* button keeps its own touch sizing below).
|
|
151
156
|
*/
|
|
152
157
|
|
|
153
158
|
.s-theme-item-row {
|
|
159
|
+
@include mi.item;
|
|
160
|
+
|
|
154
161
|
position: relative;
|
|
155
|
-
display: flex;
|
|
156
|
-
align-items: center;
|
|
157
162
|
}
|
|
158
163
|
|
|
159
|
-
/*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
164
|
+
/* The row's button keeps the CLICK and FOCUS semantics (keyboard parity,
|
|
165
|
+
* screen-reader role) but no visual chrome of its own: background goes
|
|
166
|
+
* transparent and the border is dropped entirely — the ROW now owns the
|
|
167
|
+
* frame, and the content x lands on the same offset as the standalone
|
|
168
|
+
* pills anyway (row border 1 + row padding 12 = the standalone border 1 +
|
|
169
|
+
* padding 12 = 13px from the pill edge). Vertically the button bleeds
|
|
170
|
+
* over the row's frame (negative margins) and re-applies the padding
|
|
171
|
+
* inside, so the WHOLE pill is the click/focus target — master's pill
|
|
172
|
+
* WAS the button — while the name's content box keeps the standalone
|
|
173
|
+
* offsets (border 1 + pad-y). Row heights are unchanged: the button's
|
|
174
|
+
* net layout contribution is its content, so the row's own min-height
|
|
175
|
+
* rules exactly as the button's did before. */
|
|
176
|
+
.s-theme-item-row .s-theme-item-btn {
|
|
177
|
+
min-width: 0;
|
|
178
|
+
min-height: 0;
|
|
179
|
+
flex: 1;
|
|
180
|
+
width: auto;
|
|
181
|
+
/* Fill the row's full cross size (overriding the row's align-items:
|
|
182
|
+
* center FOR THIS ITEM only) so the click/focus target is the whole
|
|
183
|
+
* pill, not just the name's line box. */
|
|
184
|
+
align-self: stretch;
|
|
185
|
+
/* Bleed over the row's frame — its vertical padding plus the 1px
|
|
186
|
+
* mi.item border — then re-apply that padding inside: the name's
|
|
187
|
+
* content box keeps the standalone pills' offsets (border 1 + pad)
|
|
188
|
+
* while the hit area covers the pill edge to edge. The 1px constant
|
|
189
|
+
* mirrors mi.item's border width; if the mixin's border ever changes,
|
|
190
|
+
* this pair moves with it. */
|
|
191
|
+
margin-block: calc(-1 * var(--hk-menu-item-pad-y, 6px) - 1px);
|
|
192
|
+
padding-block: calc(var(--hk-menu-item-pad-y, 6px) + 1px);
|
|
193
|
+
padding-inline: 0;
|
|
194
|
+
border: none;
|
|
195
|
+
/* Follow the row's pill radius: the button keeps mi.item's :focus-visible
|
|
196
|
+
* inset outline (keyboard parity), and outlines follow border-radius —
|
|
197
|
+
* a zero radius here would render the ring as a sharp rectangle poking
|
|
198
|
+
* past the row's rounded corners. */
|
|
199
|
+
border-radius: inherit;
|
|
200
|
+
background: transparent;
|
|
201
|
+
font: inherit;
|
|
202
|
+
color: inherit;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* Suppress the button's own state chrome — the row above owns the wash.
|
|
206
|
+
* The hover guard repeats mi.item's guard chain so this override
|
|
207
|
+
* out-specifies the mixin's (0,6,0) hover rule. */
|
|
208
|
+
.s-theme-item-row .s-theme-item-btn:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked]),
|
|
209
|
+
.s-theme-item-row .s-theme-item-btn[data-active],
|
|
210
|
+
.s-theme-item-row .s-theme-item-btn[data-checked] {
|
|
211
|
+
background: transparent;
|
|
212
|
+
border-color: transparent;
|
|
213
|
+
color: inherit;
|
|
214
|
+
font-weight: inherit;
|
|
215
|
+
}
|
|
216
|
+
|
|
165
217
|
.s-theme-item-btn {
|
|
166
218
|
@include mi.item;
|
|
167
219
|
|
|
@@ -283,6 +283,13 @@ describe("HkThemeToggle color-mode group", () => {
|
|
|
283
283
|
// Active check glyph sits inside the pill, leading the name.
|
|
284
284
|
const activeBtn = document.body.querySelector<HTMLButtonElement>(".s-theme-item-btn[data-active]");
|
|
285
285
|
expect(activeBtn?.querySelector(".s-theme-item-check")).toBeTruthy();
|
|
286
|
+
|
|
287
|
+
// The ROW is the pill (2026-10-01): the active state surfaces on the
|
|
288
|
+
// row element too, so the highlight spans the full row width instead
|
|
289
|
+
// of stopping short at a trailing column.
|
|
290
|
+
const activeRows = rows.filter((r) => r.hasAttribute("data-active"));
|
|
291
|
+
expect(activeRows).toHaveLength(1);
|
|
292
|
+
expect(activeRows[0].querySelector(".s-theme-item-btn[data-active]")).toBeTruthy();
|
|
286
293
|
});
|
|
287
294
|
it("renders the mode-extra strip under the mode group when provided", async () => {
|
|
288
295
|
const container = document.createElement("div");
|
|
@@ -181,16 +181,18 @@ export const HkThemeToggle = defineComponent({
|
|
|
181
181
|
* cache icon vnodes across renders, or closing/reopening the popover
|
|
182
182
|
* would re-mount the same vnode instances. In AUTO mode the Light/
|
|
183
183
|
* Dark options collapse into the group's merged cell (the altitude
|
|
184
|
-
* strip) via `mergeKeys
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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.
|
|
187
190
|
*/
|
|
188
191
|
function modeOptions(): TabItem[] {
|
|
189
|
-
const auto = isAutoMode.value;
|
|
190
192
|
return [
|
|
191
193
|
{ key: "system", label: t("hikari::theme.modeAuto"), icon: <Monitor size={16} /> },
|
|
192
|
-
{ key: "light", label: t("hikari::theme.modeLight"), icon: <Sun size={16}
|
|
193
|
-
{ key: "dark", label: t("hikari::theme.modeDark"), icon: <MoonStar 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} /> },
|
|
194
196
|
];
|
|
195
197
|
}
|
|
196
198
|
|
|
@@ -336,6 +338,13 @@ export const HkThemeToggle = defineComponent({
|
|
|
336
338
|
data-custom={th.isCustom || undefined}
|
|
337
339
|
data-trailing={trailingSlot ? "slot" : undefined}
|
|
338
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}
|
|
339
348
|
>
|
|
340
349
|
<button
|
|
341
350
|
type="button"
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
.hk-menu-action-item,
|
|
80
80
|
.hk-select-option,
|
|
81
81
|
.hk-popup-select-item,
|
|
82
|
+
.s-theme-item-row,
|
|
82
83
|
.s-theme-item-btn,
|
|
83
84
|
.s-popup-menu-item,
|
|
84
85
|
.hk-popup-menu-item,
|
|
@@ -98,6 +99,7 @@
|
|
|
98
99
|
.hk-menu-row,
|
|
99
100
|
.hk-menu-action-item,
|
|
100
101
|
.hk-select-option,
|
|
102
|
+
.s-theme-item-row,
|
|
101
103
|
.s-theme-item-btn,
|
|
102
104
|
.s-popup-menu-item,
|
|
103
105
|
.hk-popup-menu-item,
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Source contract: the error landing's raw-details pane follows its
|
|
8
|
-
* content under a hard ceiling (2026-10-01 screenshot round). The frame
|
|
9
|
-
* used to stand at a fixed ~20vh — folding the tree then left a tall
|
|
10
|
-
* empty box behind a single summary row. The pane now hugs the payload
|
|
11
|
-
* (max-height cap + inheriting body) while tall stack traces still clamp
|
|
12
|
-
* and scroll inside the frame. These pins keep the standing-frame
|
|
13
|
-
* regression class (any `height:` declaration here re-pins the box) and
|
|
14
|
-
* the unbounded-growth class (a missing body cap) out.
|
|
15
|
-
*/
|
|
16
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
-
const scss = readFileSync(join(here, "HkErrorLanding.scss"), "utf-8");
|
|
18
|
-
|
|
19
|
-
describe("HkErrorLanding details fold contract", () => {
|
|
20
|
-
const paneAt = scss.indexOf(".hk-error-landing__details-pane {");
|
|
21
|
-
const bodyAt = scss.indexOf(".hk-error-landing__details-body {");
|
|
22
|
-
|
|
23
|
-
it("declares the pane and body exactly once — no late re-declaration", () => {
|
|
24
|
-
// Whole-file site count: the slice-scoped sweeps below read only up
|
|
25
|
-
// to the nested tree rule, so a LATER `.hk-error-landing__details-
|
|
26
|
-
// body { height: ... }` in this file would cascade over the fold
|
|
27
|
-
// while every slice pin stays green (R3's M4 mutation proved it).
|
|
28
|
-
// Pristine file has exactly two sites (pane + body); the descendant
|
|
29
|
-
// `.s-tool-json-tree` rule and the label/toggle selectors don't match.
|
|
30
|
-
const sites = scss.match(/\.hk-error-landing__details-(?:pane|body)\s*[,{]/g) ?? [];
|
|
31
|
-
expect(sites.length, `details pane/body selector sites = ${sites.join(" | ")}`).toBe(2);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("sizes the pane by cap, never by fixed height", () => {
|
|
35
|
-
expect(paneAt, "pane rule present").toBeGreaterThanOrEqual(0);
|
|
36
|
-
const pane = scss.slice(paneAt, bodyAt);
|
|
37
|
-
// Both viewport lines: the vh fallback pairs with the dvh override,
|
|
38
|
-
// matching the family pattern.
|
|
39
|
-
expect(pane).toContain("max-height: max(9rem, 20vh)");
|
|
40
|
-
expect(pane).toContain("max-height: max(9rem, 20dvh)");
|
|
41
|
-
// ANY height/min-height declaration re-pins the standing frame —
|
|
42
|
-
// exactly the regression this fold fixes (a min-height would force
|
|
43
|
-
// the folded pane back up; R2 F3) — so the pane carries none.
|
|
44
|
-
const heights = [...pane.matchAll(/^\s*(?:min-)?height:.*$/gm)].map((m) => m[0].trim());
|
|
45
|
-
expect(heights, `pane height declarations = ${JSON.stringify(heights)}`).toEqual([]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("clamps the scrolling body with the inherited pane cap", () => {
|
|
49
|
-
expect(bodyAt, "body rule present").toBeGreaterThanOrEqual(0);
|
|
50
|
-
const nextAt = scss.indexOf(".hk-error-landing__details-body .s-tool-json-tree");
|
|
51
|
-
const body = scss.slice(bodyAt, nextAt);
|
|
52
|
-
// Percentage height against an auto-height parent is circular; the
|
|
53
|
-
// body must take the pane's cap itself.
|
|
54
|
-
expect(body).toContain("max-height: inherit");
|
|
55
|
-
// A fixed body height re-pins the standing frame through the back
|
|
56
|
-
// door even with the inherit present (R2 F2) — same sweep as the
|
|
57
|
-
// pane: no height/min-height declarations at all.
|
|
58
|
-
const bodyHeights = [...body.matchAll(/^\s*(?:min-)?height:.*$/gm)].map((m) => m[0].trim());
|
|
59
|
-
expect(bodyHeights, `body height declarations = ${JSON.stringify(bodyHeights)}`).toEqual([]);
|
|
60
|
-
// The clamp only bites when the body may actually shrink and scroll.
|
|
61
|
-
expect(body).toContain("overflow: auto");
|
|
62
|
-
});
|
|
63
|
-
});
|