@celestia-island/hikari 0.55.270 → 0.55.272

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.270",
3
+ "version": "0.55.272",
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",
@@ -71,7 +71,6 @@
71
71
  "@xterm/addon-image": "^0.10.0-beta.301",
72
72
  "@xterm/xterm": "^6.1.0-beta.304",
73
73
  "dompurify": "^*",
74
- "echarts": "^*",
75
74
  "highlight.js": "^*",
76
75
  "lucide-vue-next": "^*",
77
76
  "marked": "^*"
@@ -45,6 +45,22 @@ describe("HkDonutChart", () => {
45
45
  ]);
46
46
  });
47
47
 
48
+ it("gives the ring svg intrinsic dimensions from the size prop", () => {
49
+ // Without width/height attributes the svg has no intrinsic size and
50
+ // collapses to 0x0 inside the block-level ring-wrap (wowsp measured a
51
+ // 0x0 getBoundingClientRect on the consuming page before this fix).
52
+ const byDefault = mount(<HkDonutChart entries={ENTRIES} />);
53
+ const defaultSvg = byDefault.querySelector("svg.hk-donut__ring");
54
+ expect(defaultSvg?.getAttribute("width")).toBe("150");
55
+ expect(defaultSvg?.getAttribute("height")).toBe("150");
56
+
57
+ const custom = mount(<HkDonutChart entries={ENTRIES} size={96} />);
58
+ const customSvg = custom.querySelector("svg.hk-donut__ring");
59
+ expect(customSvg?.getAttribute("width")).toBe("96");
60
+ expect(customSvg?.getAttribute("height")).toBe("96");
61
+ expect(customSvg?.getAttribute("viewBox")).toBe("0 0 96 96");
62
+ });
63
+
48
64
  it("defaults hints to label · value · percent", () => {
49
65
  const el = mount(<HkDonutChart entries={ENTRIES} />);
50
66
  const first = el.querySelector(".hk-donut__slice");
@@ -46,7 +46,7 @@ export default defineComponent({
46
46
  props: {
47
47
  /** The slices' source rows; pass count-desc for the wowsp look. */
48
48
  entries: { type: Array as PropType<HkDonutEntry[]>, required: true },
49
- /** Square ring size (px). */
49
+ /** Square ring size (px) — the SVG's intrinsic width/height and viewBox. */
50
50
  size: { type: Number, default: 150 },
51
51
  /** Legend rows per column. */
52
52
  legendRows: { type: Number, default: LEGEND_ROWS_PER_COLUMN },
@@ -102,7 +102,12 @@ export default defineComponent({
102
102
  return (
103
103
  <div class="hk-donut" role="img" aria-label={aria.value}>
104
104
  <div class="hk-donut__ring-wrap">
105
- <svg class="hk-donut__ring" viewBox={`0 0 ${props.size} ${props.size}`}>
105
+ <svg
106
+ class="hk-donut__ring"
107
+ width={props.size}
108
+ height={props.size}
109
+ viewBox={`0 0 ${props.size} ${props.size}`}
110
+ >
106
111
  {slices.value.map((s) => (
107
112
  <path
108
113
  key={s.key}
@@ -619,7 +619,14 @@
619
619
  // padding both consume the vars, so one override moves both.
620
620
  --hk-scroll-pad-top: 1rem;
621
621
  --hk-scroll-pad-right: 1rem;
622
- --hk-scroll-pad-bottom: 1rem;
622
+ // The sheet docks flush on the bottom edge (bottom: 0 above), which on
623
+ // a gesture-nav phone is INSIDE the system bar's band — the body's
624
+ // last row would sit under the home pill. The footer already clears
625
+ // safe area below; a footer-less body gets the same guarantee through
626
+ // its own bottom gutter (2026-11-02 wowsp phone report). Hosts that
627
+ // paint edge-to-edge content still opt out per surface with
628
+ // --hk-scroll-pad-bottom, the same escape hatch the footer contract has.
629
+ --hk-scroll-pad-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
623
630
  --hk-scroll-pad-left: 1rem;
624
631
  }
625
632
 
@@ -1,15 +1,30 @@
1
1
  /**
2
- * Source contract for the scroll-container edge fades (the 2026-10
3
- * revision): each edge owns a CSS var and the viewport mask INTERSECTS a
4
- * vertical and a horizontal gradient, so a two-axis container fades on all
5
- * four sides and the corners fall off naturally.
2
+ * Source contract for the scroll-container edge fades (the 2026-11-02
3
+ * revision of the 2026-10 composition): each edge owns a CSS var, the
4
+ * viewport mask is built from the gradient LAYERS listed in
5
+ * --hk-scroll-fade-layers, and the axis rules swap that list —
6
6
  *
7
- * The regression this pins (user report: "the light effect is clipped at
8
- * the two sides"): the old sheet wrote ONE `mask-image` per axis, so the
9
- * last matching rule won, the other axis never faded, and its band ended in
10
- * a hard cut. Vertical fades were missing entirely. Hence:
11
- * - exactly ONE mask declaration pair lives in the sheet (the composed
7
+ * - two-axis containers list the vertical AND horizontal gradients and
8
+ * intersect them, so the fade reaches zero on all four sides and the
9
+ * corners fall off naturally;
10
+ * - single-axis containers list ONE gradient on their scroll axis:
11
+ * a composed mask needs `mask-composite`, and engines without the
12
+ * standard property (Android WebViews below Chromium 120 — Tauri on
13
+ * Android rides the system WebView) fall back to the default ADD
14
+ * composition, whose union of two opaque layers is fully opaque —
15
+ * the fade vanished and the band ended in a hard cut at the screen
16
+ * edge (wowsp phone report 2026-11-02: the settings tab strip sliced
17
+ * its last pill mid-glyph). One layer needs no composite anywhere.
18
+ *
19
+ * The earlier regression this still pins (user report: "the light effect
20
+ * is clipped at the two sides"): the pre-2026-10 sheet wrote ONE
21
+ * `mask-image` per axis in competing cascade, so the last rule won, the
22
+ * other axis never faded. The axis rules now swap a VAR, never the
23
+ * property — the property pair is declared exactly once. Hence:
24
+ * - exactly ONE mask declaration pair lives in the sheet (the base
12
25
  * block on the viewport) — a new per-axis `mask-image` rule is drift;
26
+ * - the single-axis axis rules set --hk-scroll-fade-layers to a single
27
+ * gradient and touch no mask property;
13
28
  * - all four edge vars are declared (top/right/bottom/left, 0px base);
14
29
  * - start/end/both rules exist for BOTH axes;
15
30
  * - RTL swaps the horizontal vars instead of rewriting the mask.
@@ -23,22 +38,52 @@ const here = dirname(fileURLToPath(import.meta.url));
23
38
  const sheet = readFileSync(join(here, "HkScrollContainer.scss"), "utf-8");
24
39
 
25
40
  describe("scroll fade composition contract", () => {
26
- it("declares one composed mask on the viewport (no per-axis overrides)", () => {
41
+ it("declares one mask property pair on the viewport (no per-axis overrides)", () => {
27
42
  // Feature 2026-10-31: a `mask-image` is authored as
28
43
  // `@include mask-image(...)` (styles/polyfill.scss), so the include —
29
44
  // not the raw declaration — is what a sheet may carry. The mixin
30
45
  // itself owns the -webkit-/standard pair.
31
46
  const declarations = sheet.match(/^\s*@include mask-image\(/gm) ?? [];
32
- // Exactly THREE pairs live in the sheet, all accounted for: the
33
- // composed two-axis block on the viewport, and one feather pair per
34
- // selection-hint painter (the 2026-10-30 inline falloff). Anything
35
- // else is per-axis mask drift.
47
+ // Exactly THREE pairs live in the sheet, all accounted for: the base
48
+ // block on the viewport (fed by --hk-scroll-fade-layers), and one
49
+ // feather pair per selection-hint painter (the 2026-10-30 inline
50
+ // falloff). Anything else is per-axis mask drift — the axis rules
51
+ // must swap the VAR, never re-declare the property (the 2026-10
52
+ // competing-cascade regression).
36
53
  expect(declarations.length, "one include per mask pair, no per-axis drift").toBe(3);
37
54
  expect(sheet).not.toMatch(/^\s*-webkit-mask-image:/m);
55
+ // The composed default (two layers, four edges) still goes through
56
+ // mask-composite — that pair belongs to the base rule only.
38
57
  expect(sheet).toContain("mask-composite: intersect");
39
58
  expect(sheet).toContain("-webkit-mask-composite: source-in");
40
59
  });
41
60
 
61
+ it("declares the layer list and swaps it per axis to a single gradient", () => {
62
+ // The base rule lists BOTH gradients (the four-edge composition) in
63
+ // one var the mask property consumes.
64
+ expect(sheet).toMatch(/--hk-scroll-fade-layers:[\s\S]*linear-gradient\([\s\S]*to bottom[\s\S]*linear-gradient\([\s\S]*to right/);
65
+ // Each single-axis rule lists ONE gradient on its scroll axis — the
66
+ // no-composite form old WebViews render instead of an opaque union.
67
+ expect(
68
+ sheet.match(/\[data-axis="horizontal"\][^\n]*\n\s*--hk-scroll-fade-layers: linear-gradient\(\s*to right/),
69
+ "horizontal containers fade with one to-right gradient",
70
+ ).toBeTruthy();
71
+ expect(
72
+ sheet.match(/\[data-axis="vertical"\][^\n]*\n\s*--hk-scroll-fade-layers: linear-gradient\(\s*to bottom/),
73
+ "vertical containers fade with one to-bottom gradient",
74
+ ).toBeTruthy();
75
+ // The axis rules swap the var only — no composite, no mask property.
76
+ const axisBlocks = sheet.match(
77
+ /\.hk-scroll-container\[data-axis="(?:horizontal|vertical)"\]:is\([^)]*\)[^{]*\{[\s\S]*?\n\}/g,
78
+ ) ?? [];
79
+ expect(axisBlocks.length, "both axis swap rules exist").toBe(2);
80
+ for (const block of axisBlocks) {
81
+ expect(block).toContain("--hk-scroll-fade-layers: linear-gradient(");
82
+ expect(block).not.toContain("mask-composite");
83
+ expect(block).not.toContain("mask-image");
84
+ }
85
+ });
86
+
42
87
  it("declares the four edge vars with zero defaults", () => {
43
88
  for (const edge of ["top", "right", "bottom", "left"]) {
44
89
  expect(sheet).toContain(`--hk-scroll-fade-${edge}: 0px;`);
@@ -42,23 +42,37 @@
42
42
  }
43
43
 
44
44
  /* ── Optional edge fades (fade=true / dock fades) ─────────────────
45
- FOUR-EDGE composition (2026-10 revision): each edge owns a CSS var, and
46
- the viewport's mask INTERSECTS a vertical and a horizontal gradient, so
47
- the fade reaches zero on every side that hides content and the corners
48
- fall off naturally in all four directions. The previous per-axis rules
49
- overwrote one `mask-image` property, so a two-axis container only faded
50
- one axis AND the band ended in a hard cut at the other axis's edges
51
- (user report: the light effect is clipped at the two sides). Vertical
52
- fades were missing entirely. Dock fades ride the same vars, which also
53
- removes their old mask-override conflict. */
45
+ Each edge owns a CSS var; the viewport's mask is built from gradient
46
+ LAYERS listed in --hk-scroll-fade-layers. Two-axis containers list the
47
+ vertical and horizontal gradients and INTERSECT them (mask-composite),
48
+ so the fade reaches zero on every side that hides content and the
49
+ corners fall off naturally in all four directions (2026-10 revision;
50
+ the earlier per-axis rules overwrote one `mask-image` property, so a
51
+ two-axis container only faded one axis AND the band ended in a hard
52
+ cut at the other axis's edges — user report: the light effect is
53
+ clipped at the two sides. Vertical fades were missing entirely).
54
+
55
+ SINGLE-axis containers list ONE gradient on their scroll axis instead
56
+ (2026-11-02 revision): a composed mask needs `mask-composite`, which
57
+ older engines without the standard property (Android WebViews below
58
+ Chromium 120 — Tauri on Android rides the system WebView) fall back to
59
+ the default ADD composition on, and the union of two opaque layers is
60
+ fully opaque — the fade vanished and the overflow band ended in a hard
61
+ cut at the screen edge (wowsp phone report 2026-11-02: the settings
62
+ tab strip sliced its last pill mid-glyph). A single layer needs no
63
+ composite on any engine, and a single-axis container's cross-axis
64
+ gradient is the identity anyway (its edge vars stay 0px), so the
65
+ rendering is unchanged on engines that composed correctly. The
66
+ composite pair stays on the base rule: with one layer it is a no-op
67
+ (there is no layer below to composite against), so the axis rules only
68
+ swap the layer list. Dock fades ride the same vars — they feed the
69
+ vertical gradient, so they land on vertical and two-axis containers. */
54
70
  .hk-scroll-container:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
55
71
  --hk-scroll-fade-top: 0px;
56
72
  --hk-scroll-fade-right: 0px;
57
73
  --hk-scroll-fade-bottom: 0px;
58
74
  --hk-scroll-fade-left: 0px;
59
- -webkit-mask-composite: source-in;
60
- -webkit-mask-repeat: no-repeat;
61
- @include mask-image(
75
+ --hk-scroll-fade-layers:
62
76
  linear-gradient(
63
77
  to bottom,
64
78
  transparent 0,
@@ -72,12 +86,38 @@
72
86
  #000 var(--hk-scroll-fade-left),
73
87
  #000 calc(100% - var(--hk-scroll-fade-right)),
74
88
  transparent 100%
75
- )
76
- );
89
+ );
90
+ -webkit-mask-composite: source-in;
91
+ -webkit-mask-repeat: no-repeat;
92
+ @include mask-image(var(--hk-scroll-fade-layers));
77
93
  mask-composite: intersect;
78
94
  mask-repeat: no-repeat;
79
95
  }
80
96
 
97
+ /* Horizontal axis (HTabs pill strips, horizontal rails): one gradient,
98
+ no composite required — readable on any engine that knows
99
+ -webkit-mask-image. */
100
+ .hk-scroll-container[data-axis="horizontal"]:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
101
+ --hk-scroll-fade-layers: linear-gradient(
102
+ to right,
103
+ transparent 0,
104
+ #000 var(--hk-scroll-fade-left),
105
+ #000 calc(100% - var(--hk-scroll-fade-right)),
106
+ transparent 100%
107
+ );
108
+ }
109
+
110
+ /* Vertical axis (the default): same treatment on the scroll axis. */
111
+ .hk-scroll-container[data-axis="vertical"]:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
112
+ --hk-scroll-fade-layers: linear-gradient(
113
+ to bottom,
114
+ transparent 0,
115
+ #000 var(--hk-scroll-fade-top),
116
+ #000 calc(100% - var(--hk-scroll-fade-bottom)),
117
+ transparent 100%
118
+ );
119
+ }
120
+
81
121
  .hk-scroll-container[data-fade="true"] {
82
122
  --hk-scroll-fade-size: var(--space-24);
83
123
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Source contract for the phone settings sheet (2026-11-02): the docked
3
+ * sheet's settings shell must FILL the modal body instead of keeping the
4
+ * desktop band, and the mobile body gutter must clear the gesture-nav
5
+ * safe area.
6
+ *
7
+ * The regression this pins (wowsp phone report 2026-11-02, screenshots of
8
+ * the attributions section): the host rule kept `.hk-settings` at
9
+ * height: var(--hk-settings-height, calc(70vh - 6.5rem)) on phones too,
10
+ * so on a sheet tall enough to dwarf that band the shell ended
11
+ * mid-screen — the pane's viewport sliced the last card mid-row and
12
+ * everything below it was unreachable dead sheet background. And a
13
+ * footer-less sheet body docked flush on the bottom edge put its last
14
+ * row inside the Android gesture-nav band, which only the mobile FOOTER
15
+ * padding handled before.
16
+ *
17
+ * The reads normalize CRLF first: sibling source contracts match
18
+ * raw bytes and choke on autocrlf checkouts (they fail identically on
19
+ * pristine master there, and pass in CI's LF checkout) — this file must
20
+ * stay green on BOTH.
21
+ */
22
+ import { describe, expect, it } from "vitest";
23
+ import { readFileSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const read = (p: string) => readFileSync(join(here, p), "utf-8").replace(/\r\n/g, "\n");
29
+
30
+ describe("phone settings sheet contract", () => {
31
+ it("fills the shell with the modal body under the family breakpoint", () => {
32
+ const sheet = read("HkSettingsDialog.scss");
33
+ // The desktop band stays as the var-driven base…
34
+ expect(sheet).toContain(
35
+ ".hk-settings-host .hk-settings {",
36
+ );
37
+ expect(sheet).toContain("height: var(--hk-settings-height, calc(70vh - 6.5rem));");
38
+ // …and the phone branch overrides it to a full fill. The override must
39
+ // live INSIDE the host rule's media query — a later standalone rule
40
+ // would also work, but the nested form keeps the band and the fill on
41
+ // one cascade line (the same-specificity later-block win).
42
+ expect(sheet).toMatch(
43
+ /\.hk-settings-host \.hk-settings \{[\s\S]*?@media \(max-width: 767px\) \{\s*height: 100%;\s*\}/,
44
+ );
45
+ });
46
+
47
+ it("adds the gesture-nav safe area to the mobile body bottom gutter", () => {
48
+ const sheet = read("HkModal.scss");
49
+ // The mobile block retunes the four gutter vars; only the bottom one
50
+ // spends the safe area (the sheet docks flush on that edge).
51
+ expect(sheet).toContain("--hk-scroll-pad-top: 1rem;");
52
+ expect(sheet).toContain("--hk-scroll-pad-right: 1rem;");
53
+ expect(sheet).toContain("--hk-scroll-pad-left: 1rem;");
54
+ expect(sheet).toContain(
55
+ "--hk-scroll-pad-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));",
56
+ );
57
+ // The desktop gutter defaults stay untouched.
58
+ expect(sheet).toContain(
59
+ "--hk-scroll-pad-bottom: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));",
60
+ );
61
+ });
62
+ });
@@ -167,6 +167,20 @@
167
167
 
168
168
  .hk-settings-host .hk-settings {
169
169
  height: var(--hk-settings-height, calc(70vh - 6.5rem));
170
+
171
+ /* Phone sheet: the docked sheet's body is much taller than the
172
+ desktop's fixed 70vh band, and a fixed height here cannot stretch —
173
+ the shell ended mid-sheet, the pane's viewport sliced the last card
174
+ mid-row, and the whole lower sheet stayed a dead background band
175
+ (wowsp phone report 2026-11-02). On phones the shell fills the body
176
+ region instead (100% of the body inner's content box, which is the
177
+ scroller minus its paddings), so the pane is the only scroller all
178
+ the way down to the sheet's tail. The --hk-settings-height contract
179
+ stays desktop-only: the host's height knob still sizes the desktop
180
+ modal, and a page-embedded shell (no host class) is untouched. */
181
+ @media (max-width: 767px) {
182
+ height: 100%;
183
+ }
170
184
  }
171
185
 
172
186
  /* Cancel the modal body's fixed right padding so the pane's overlay
@@ -86,18 +86,23 @@
86
86
  * half must be able to paint. Content clipping lives in
87
87
  * .hk-sidebar-rail-clip (inside the panel). */
88
88
  overflow: visible;
89
+ /* The sidebar z band (900 — above content, below floating panels),
90
+ * carried PERMANENTLY, not only while a peek is open (2026-11-02 user
91
+ * screenshot, wowsp): the straddling toggle hangs over the content
92
+ * column in every state, and its own z-index cannot save it — any
93
+ * stacking context the PANEL carries in a host (wowsp's glass
94
+ * backdrop-filter, a transform/opacity dress) demotes the toggle's
95
+ * local z into that panel, and the overhanging half then loses to
96
+ * plain positioned content painted later in tree order. The HOST is
97
+ * the elevation boundary: with the band here the whole rail subtree
98
+ * paints atomically above the content column no matter what contexts
99
+ * exist inside it, so a peek overlay, the pinned expand and the idle
100
+ * toggle all share one rule. */
101
+ z-index: var(--hk-z-sidebar, 900);
89
102
  /* The layout animation the PIN window deliberately keeps. */
90
103
  transition: width var(--duration-normal, 0.3s) ease;
91
104
  }
92
105
 
93
- /* A peek is an OVERLAY: the host rises to the sidebar z band (900 — above
94
- * content, below floating panels) so the full-width panel paints over the
95
- * content it temporarily covers. (The host no longer needs to toggle
96
- * `overflow` here: it never clips.) */
97
- .hk-sidebar-rail-host[data-peeking] {
98
- z-index: var(--hk-z-sidebar, 900);
99
- }
100
-
101
106
  .hk-sidebar-rail-panel {
102
107
  position: absolute;
103
108
  /* Logical: the panel docks the INLINE-START edge in either direction —
@@ -191,7 +196,11 @@
191
196
  .hk-sidebar-rail-flight {
192
197
  position: absolute;
193
198
  inset: 0;
194
- z-index: 2;
199
+ /* Local bump (--hk-z-above band): the layer only has to outrank the
200
+ * z-auto dissolve items it substitutes for — during a flip those items
201
+ * hold opacity contexts, so everything inside them (scrollbar chrome
202
+ * included) is trapped below this layer. */
203
+ z-index: var(--hk-z-above, 1);
195
204
  display: block;
196
205
  pointer-events: none;
197
206
  }
@@ -284,7 +293,14 @@
284
293
  inset-inline-end: calc(var(--hk-sidebar-rail-toggle-size, 24px) / -2 - var(--hk-sidebar-rail-border-width, 1px) / 2);
285
294
  inset-block-start: 50%;
286
295
  translate: 0 -50%;
287
- z-index: 2;
296
+ /* The fab band — a floating circular affordance above the panel's local
297
+ * chrome. The ranking threat inside the panel is the overlay-scrollbar
298
+ * TRACK (absolute, z-index: 10 — a stacking context that also contains
299
+ * the thumb's 2 and the markers' 1, attached beside the sidebar body):
300
+ * the old raw z-index: 2 ranked BELOW that track, so a scroll or a
301
+ * marker flash could paint its band over the circle's inner half. The
302
+ * fab band clears both the track and everything inside it. */
303
+ z-index: var(--hk-z-fab, 40);
288
304
  opacity: 0;
289
305
  pointer-events: none;
290
306
  transition: opacity var(--duration-fast, 0.15s) ease;
@@ -34,7 +34,14 @@ import {
34
34
  * 3. The toggle's center rides the border LINE: negative half-size inset
35
35
  * (plus half the border), vertical centering, size token on the host.
36
36
  * 4. The host stays the LAYOUT box (position relative, width transition)
37
- * and [data-peeking] only raises it to the sidebar z band now.
37
+ * and ALWAYS carries the sidebar z band — not only under [data-peeking]
38
+ * (2026-11-02 user screenshot, wowsp): the straddling toggle hangs over
39
+ * the content column in every state, and a host whose panel carries its
40
+ * own stacking context (wowsp's glass backdrop-filter) demotes the
41
+ * toggle's local z below plain positioned content, so the band must
42
+ * live on the host, the elevation boundary.
43
+ * 4b. No raw z-index numbers: the toggle rides the fab band, the flight
44
+ * layer the local-bump band — both through the scale tokens.
38
45
  * 5. Pinning: [data-pinning] kills the panel's and any nested sidebar's
39
46
  * width transitions (the expand must not replay the slide) while the
40
47
  * HOST width transition stays — and the pinned panel keeps the peek
@@ -93,12 +100,19 @@ describe("HkSidebarRail geometry contract", () => {
93
100
  expect(host, "the host width transition IS the intended motion").toContain("transition: width");
94
101
  });
95
102
 
96
- it("a peek only raises the host to the sidebar z band now (no clip flip)", () => {
97
- const peeking = ruleBody(css, ".hk-sidebar-rail-host[data-peeking]");
98
- expect(peeking, "the peeking rule exists").not.toBeNull();
99
- expect(peeking).toContain("z-index: var(--hk-z-sidebar, 900)");
100
- // The host no longer toggles overflow here — it never clips.
101
- expect(peeking).not.toContain("overflow");
103
+ it("the host carries the sidebar z band ALWAYS (the stacking-trap contract)", () => {
104
+ // The band lives on the host rule itself, not under [data-peeking]:
105
+ // the toggle's overhanging half must paint above the content column in
106
+ // every state, and a host whose panel carries its own stacking context
107
+ // (wowsp's glass backdrop-filter) demotes the toggle's local z below
108
+ // plain positioned content painted later in tree order. There is no
109
+ // bare peeking z rule left to tie the elevation to the peek state.
110
+ const host = ruleBody(css, ".hk-sidebar-rail-host {") ?? ruleBody(css, ".hk-sidebar-rail-host");
111
+ expect(host, "the host rule exists").not.toBeNull();
112
+ expect(host).toContain("z-index: var(--hk-z-sidebar, 900)");
113
+ expect(css).not.toContain(".hk-sidebar-rail-host[data-peeking] {");
114
+ // The peek shadow survives on the panel-scoped rules.
115
+ expect(css).toContain(".hk-sidebar-rail-host[data-peeking] .hk-sidebar-rail-panel");
102
116
  });
103
117
 
104
118
  it("the panel overlays from the inline-start edge, unclipped, and animates its width", () => {
@@ -186,8 +200,12 @@ describe("HkSidebarRail geometry contract", () => {
186
200
  // Vertical centering on the edge.
187
201
  expect(toggle).toContain("inset-block-start: 50%");
188
202
  expect(toggle).toContain("translate: 0 -50%");
189
- // Above the panel + content.
190
- expect(toggle).toContain("z-index: 2");
203
+ // Above the panel's local chrome — through the FAB band token, never a
204
+ // raw number: the overlay-scrollbar thumb rides a raw 2 through
205
+ // uncontexted ancestors right up to this panel, so the toggle must
206
+ // strictly outrank it (a tie would be DOM-order roulette).
207
+ expect(toggle).toContain("z-index: var(--hk-z-fab, 40)");
208
+ expect(toggle).not.toMatch(/z-index:\s*\d/);
191
209
  });
192
210
 
193
211
  it("the peek casts its shadow toward the content side, mirrored in RTL", () => {
@@ -256,7 +274,9 @@ describe("HkSidebarRail geometry contract", () => {
256
274
  expect(layer, "the flight layer rule exists").not.toBeNull();
257
275
  expect(layer).toContain("position: absolute");
258
276
  expect(layer).toContain("inset: 0");
259
- expect(layer).toContain("z-index: 2");
277
+ // The local-bump band token, never a raw number (see 4b).
278
+ expect(layer).toContain("z-index: var(--hk-z-above, 1)");
279
+ expect(layer).not.toMatch(/z-index:\s*\d/);
260
280
  expect(layer).toContain("pointer-events: none");
261
281
 
262
282
  const clone = ruleBody(css, ".hk-sidebar-rail-flight-clone");
@@ -0,0 +1,163 @@
1
+ // Component-side cases for the SVG trend chart (the mirror's render-layer
2
+ // cases, mounted for real through happy-dom). The pure geometry cases live
3
+ // in ./trendGeometry.test.ts, ported from the Rust mirror's cfg(test).
4
+ //
5
+ // AI-generated (glm-5.3-flash via DeepSeek Harness), 2026-11-03 — SySL-1.0 §2.3.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import { createApp, h, nextTick, ref } from "vue";
12
+ import { afterEach, describe, expect, it } from "vitest";
13
+
14
+ import HkTrendChart from "./HkTrendChart";
15
+
16
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
17
+
18
+ function mount(props: Record<string, unknown>) {
19
+ const container = document.createElement("div");
20
+ document.body.appendChild(container);
21
+ const app = createApp({ render: () => h(HkTrendChart, props) });
22
+ app.mount(container);
23
+ mounts.push({ app, container });
24
+ return container;
25
+ }
26
+
27
+ afterEach(() => {
28
+ for (const { app, container } of mounts.splice(0)) {
29
+ app.unmount();
30
+ container.remove();
31
+ }
32
+ });
33
+
34
+ const CPU = { label: "cpu", data: [{ time: 0, value: 1 }, { time: 5000, value: 4 }] };
35
+
36
+ describe("HkTrendChart", () => {
37
+ it("renders the mirror's DOM contract on its own contents host", () => {
38
+ const el = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
39
+ // The root div: class hook + the old echarts wrapper's inline box.
40
+ const root = el.querySelector("div.hk-trend-chart") as HTMLElement;
41
+ expect(root).not.toBeNull();
42
+ // Vue serializes the inline style normalized; the box itself is the
43
+ // old wrapper's "width:100%;height:240px".
44
+ expect(root.style.width).toBe("100%");
45
+ expect(root.style.height).toBe("240px");
46
+ // The SVG mounts on its own display:contents host, exactly as the Rust
47
+ // mirror documents.
48
+ const host = root.firstElementChild as HTMLElement;
49
+ expect(host.style.display).toBe("contents");
50
+ const svg = host.querySelector("svg.hk-trend-chart__svg")!;
51
+ expect(svg).not.toBeNull();
52
+ expect(svg.getAttribute("viewBox")).toBe("0 0 640 240");
53
+ expect(svg.getAttribute("preserveAspectRatio")).toBe("none");
54
+ // One area + one line per pen, drawn with the token ladder.
55
+ expect(el.querySelectorAll("path.hk-trend-chart__area")).toHaveLength(1);
56
+ expect(el.querySelectorAll("polyline.hk-trend-chart__line")).toHaveLength(1);
57
+ const line = el.querySelector("polyline.hk-trend-chart__line")!;
58
+ expect(line.getAttribute("points")).toBe("50,200 620,30");
59
+ expect(line.getAttribute("stroke")).toBe("rgb(var(--color-primary))");
60
+ expect(line.getAttribute("stroke-width")).toBe("2");
61
+ const area = el.querySelector("path.hk-trend-chart__area")!;
62
+ expect(area.getAttribute("d")).toBe("M 50 200 L 620 30 L 620 200 L 50 200 Z");
63
+ expect(area.getAttribute("fill-opacity")).toBe("0.12");
64
+ // No chart library anywhere: no canvas, no echarts chrome.
65
+ expect(el.querySelectorAll("canvas")).toHaveLength(0);
66
+ });
67
+
68
+ it("freezes the window at the newest sample when no now prop is given", () => {
69
+ // now defaults to the newest sample (5000), so the visible geometry is
70
+ // identical to the pinned now: 5000 render above — no empty window.
71
+ const el = mount({ pens: [CPU], timeWindowMs: 5000 });
72
+ expect(el.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
73
+ "50,200 620,30",
74
+ );
75
+ });
76
+
77
+ it("renders threshold guides into the markup, and only then", () => {
78
+ const el = mount({
79
+ pens: [{ label: "cpu", data: [{ time: 0, value: 1 }, { time: 1000, value: 9 }], thresholds: { hh: 8 } }],
80
+ now: 1000,
81
+ timeWindowMs: 1000,
82
+ });
83
+ const guide = el.querySelector("line.hk-trend-chart__threshold")!;
84
+ expect(guide).not.toBeNull();
85
+ expect(guide.getAttribute("x1")).toBe("50");
86
+ expect(guide.getAttribute("x2")).toBe("620");
87
+ expect(guide.getAttribute("stroke")).toBe("#FF1744");
88
+ expect(guide.getAttribute("stroke-dasharray")).toBe("6 4");
89
+ expect(guide.getAttribute("stroke-width")).toBe("1");
90
+
91
+ const bare = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
92
+ expect(bare.querySelectorAll("line.hk-trend-chart__threshold")).toHaveLength(0);
93
+ // showThresholds: false wins over present limits.
94
+ const hidden = mount({
95
+ pens: [{ label: "cpu", data: [{ time: 0, value: 1 }], thresholds: { hh: 8 } }],
96
+ now: 0,
97
+ showThresholds: false,
98
+ });
99
+ expect(hidden.querySelectorAll("line.hk-trend-chart__threshold")).toHaveLength(0);
100
+ });
101
+
102
+ it("lifts a px height into the viewBox and keeps non-px lengths out", () => {
103
+ const px = mount({ pens: [CPU], height: "120px" });
104
+ expect((px.querySelector("div.hk-trend-chart") as HTMLElement).style.height).toBe("120px");
105
+ expect(px.querySelector("svg.hk-trend-chart__svg")!.getAttribute("viewBox")).toBe("0 0 640 120");
106
+
107
+ const rem = mount({ pens: [CPU], height: "18rem" });
108
+ expect((rem.querySelector("div.hk-trend-chart") as HTMLElement).style.height).toBe("18rem");
109
+ expect(rem.querySelector("svg.hk-trend-chart__svg")!.getAttribute("viewBox")).toBe(
110
+ "0 0 640 240",
111
+ );
112
+
113
+ // An empty height keeps the viewBox-derived px on the root box.
114
+ const empty = mount({ pens: [CPU], height: "" });
115
+ expect((empty.querySelector("div.hk-trend-chart") as HTMLElement).style.height).toBe("240px");
116
+ });
117
+
118
+ it("is deterministic: same props, same markup (offline pre-render)", () => {
119
+ const a = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
120
+ const b = mount({ pens: [CPU], now: 5000, timeWindowMs: 5000 });
121
+ expect(a.querySelector("div.hk-trend-chart")!.innerHTML).toBe(
122
+ b.querySelector("div.hk-trend-chart")!.innerHTML,
123
+ );
124
+ });
125
+
126
+ it("re-renders streaming updates through reactive pens", async () => {
127
+ const pens = ref([{ label: "cpu", data: [{ time: 0, value: 1 }] }]);
128
+ const container = document.createElement("div");
129
+ document.body.appendChild(container);
130
+ const app = createApp({ render: () => h(HkTrendChart, { pens: pens.value, now: 1000, timeWindowMs: 1000 }) });
131
+ app.mount(container);
132
+ mounts.push({ app, container });
133
+ expect(container.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
134
+ "50,115",
135
+ );
136
+ pens.value = [{ label: "cpu", data: [{ time: 0, value: 1 }, { time: 1000, value: 4 }] }];
137
+ await nextTick();
138
+ expect(container.querySelector("polyline.hk-trend-chart__line")!.getAttribute("points")).toBe(
139
+ "50,200 620,30",
140
+ );
141
+ });
142
+
143
+ it("stays dependency-free (no echarts import, no echarts dep)", () => {
144
+ // The old build lazily imported echarts/core; the rewrite must not let
145
+ // it back in — pinned at the source and manifest level, the same way
146
+ // componentSelfSufficiency reads sources.
147
+ const here = dirname(fileURLToPath(import.meta.url));
148
+ for (const name of ["HkTrendChart.tsx", "trendGeometry.ts"]) {
149
+ const source = readFileSync(resolve(here, name), "utf8").replace(/\r\n/g, "\n");
150
+ expect(
151
+ /(?:from\s*|import\s*\(\s*)["']echarts/.test(source),
152
+ `${name}: chart library import`,
153
+ ).toBe(false);
154
+ // No side-effect import (i.e. no stylesheet — the mirror's styles()
155
+ // is empty by design).
156
+ expect(source.includes('import "'), `${name}: side-effect import`).toBe(false);
157
+ }
158
+ const pkg = JSON.parse(
159
+ readFileSync(resolve(here, "../../package.json"), "utf8"),
160
+ ) as { dependencies?: Record<string, string> };
161
+ expect(Object.keys(pkg.dependencies ?? {})).not.toContain("echarts");
162
+ });
163
+ });