@celestia-island/hikari 0.55.299 → 0.55.301

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.299",
3
+ "version": "0.55.301",
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",
@@ -122,6 +122,36 @@ describe("HkIconButton", () => {
122
122
  });
123
123
  });
124
124
 
125
+
126
+ describe("HkIconButton button semantics", () => {
127
+ it("renders type=button so a form never treats the chrome as a submitter", () => {
128
+ const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
129
+ const btn = c.querySelector<HTMLButtonElement>("button")!;
130
+ expect(btn.getAttribute("type")).toBe("button");
131
+ });
132
+
133
+ it("never overwrites an explicit type", () => {
134
+ for (const explicit of ["submit", "reset"] as const) {
135
+ const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: explicit } as never));
136
+ expect(c.querySelector<HTMLButtonElement>("button")!.getAttribute("type")).toBe(explicit);
137
+ }
138
+ });
139
+
140
+ it("keeps the default when the attribute is explicitly undefined or empty", () => {
141
+ // The discriminating cases: a pre-fix ordering (default first, attrs
142
+ // after) elides the attribute for `undefined` and passes an empty
143
+ // string through — the first is an implicit submitter, the second an
144
+ // invalid value that ALSO defaults to submit.
145
+ for (const value of [undefined, null, ""] as const) {
146
+ const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: value } as never));
147
+ expect(
148
+ c.querySelector<HTMLButtonElement>("button")!.getAttribute("type"),
149
+ `type=${JSON.stringify(value)} must fall back to button`,
150
+ ).toBe("button");
151
+ }
152
+ });
153
+ });
154
+
125
155
  /**
126
156
  * Static pin for the header-chrome glyph step (user report 2026-09-29:
127
157
  * the search / refresh / filter glyphs in the top-right clusters read
@@ -61,6 +61,14 @@ export default defineComponent({
61
61
  disabled={props.disabled}
62
62
  onClick={(e: MouseEvent) => emit("click", e)}
63
63
  {...attrs}
64
+ // A rendered <button> defaults to type=submit, which silently turns
65
+ // every icon-only control inside a <form> into a submitter. The
66
+ // family is chrome, never a submitter: default it here, once, so
67
+ // call sites do not each spread an attribute the component does not
68
+ // declare. Placed AFTER the attrs spread and guarded with `||` so an
69
+ // explicit `type` still wins while an explicitly `undefined` one
70
+ // cannot silently defeat the default (HkButton's idiom).
71
+ type={((attrs.type as string) || "button") as "button" | "reset" | "submit"}
64
72
  >
65
73
  <span class="hk-icon-button-icon">
66
74
  {slots.icon
@@ -95,6 +95,40 @@
95
95
  background: rgb(var(--color-primary) / 12%);
96
96
  }
97
97
 
98
+ /* Tone channels (2026-11-06): a pill can carry the hue identity of the
99
+ * thing it filters (the trace chips tint with their lane's token so a
100
+ * chip, its ribbon segments and its rows share one identity). A tone
101
+ * re-tints the ACTIVE state only — inactive pills stay muted regardless
102
+ * of tone. The four hue tones share one alpha (16%); the "muted" tone
103
+ * rides 18% because it has to read as a neutral wash rather than a hue.
104
+ * The attribute pair wins on SPECIFICITY ((0,3,0) vs (0,2,0)) — order
105
+ * independent; they are also written after the plain rule so the source
106
+ * order agrees with what specificity already decides. */
107
+ &[data-tone="success"][data-active] {
108
+ color: rgb(var(--color-success, 14 184 64));
109
+ background: rgb(var(--color-success, 14 184 64) / 16%);
110
+ }
111
+
112
+ &[data-tone="secondary"][data-active] {
113
+ color: rgb(var(--color-secondary, 81 154 115));
114
+ background: rgb(var(--color-secondary, 81 154 115) / 16%);
115
+ }
116
+
117
+ &[data-tone="warning"][data-active] {
118
+ color: rgb(var(--color-warning, 238 214 119));
119
+ background: rgb(var(--color-warning, 238 214 119) / 16%);
120
+ }
121
+
122
+ &[data-tone="error"][data-active] {
123
+ color: rgb(var(--color-error, 247 118 142));
124
+ background: rgb(var(--color-error, 247 118 142) / 16%);
125
+ }
126
+
127
+ &[data-tone="muted"][data-active] {
128
+ color: rgb(var(--color-text, 30 30 30));
129
+ background: rgb(var(--color-muted, 108 108 108) / 18%);
130
+ }
131
+
98
132
  &:focus-visible {
99
133
  outline: 2px solid rgb(var(--color-primary));
100
134
  outline-offset: -2px;
@@ -1,5 +1,9 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, h } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { compile } from "sass";
3
7
 
4
8
  import HkPillToggleGroup from "./HkPillToggleGroup";
5
9
 
@@ -175,3 +179,96 @@ describe("HkPillToggleGroup", () => {
175
179
  expect(toggled).toEqual([OPTIONS[1].value]);
176
180
  });
177
181
  });
182
+
183
+ // ── Tone channel ─────────────────────────────────────────────────────────
184
+ describe("HkPillToggleGroup tone channel", () => {
185
+ const TONED_OPTIONS = [
186
+ { value: "input", label: "Input", tone: "success" },
187
+ { value: "text", label: "Text", tone: "secondary" },
188
+ { value: "tool", label: "Tool", tone: "warning" },
189
+ { value: "plain", label: "Plain" },
190
+ { value: "primary", label: "Primary", tone: "primary" },
191
+ ] as const;
192
+
193
+ it("carries data-tone for non-primary tones and omits it for default/primary", () => {
194
+ const c = mount(
195
+ h(HkPillToggleGroup, { label: "Kinds", options: [...TONED_OPTIONS], selected: new Set<string>() }),
196
+ );
197
+ const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
198
+ expect(pills[0].getAttribute("data-tone")).toBe("success");
199
+ expect(pills[1].getAttribute("data-tone")).toBe("secondary");
200
+ expect(pills[2].getAttribute("data-tone")).toBe("warning");
201
+ expect(pills[3].hasAttribute("data-tone"), "no tone → no attribute").toBe(false);
202
+ expect(pills[4].hasAttribute("data-tone"), "primary is the default → no attribute").toBe(false);
203
+ });
204
+
205
+ it("renders the option label alongside the tone attribute", () => {
206
+ const c = mount(
207
+ h(HkPillToggleGroup, {
208
+ label: "Kinds",
209
+ options: [{ value: "tool", label: "Tool", tone: "warning" }],
210
+ selected: new Set<string>(["tool"]),
211
+ }),
212
+ );
213
+ const pill = c.querySelector<HTMLButtonElement>(".hk-pill-group-pill")!;
214
+ expect(pill.textContent?.trim()).toBe("Tool");
215
+ expect(pill.hasAttribute("data-active")).toBe(true);
216
+ // Without this the whole channel could be reverted and the test stays green.
217
+ expect(pill.getAttribute("data-tone")).toBe("warning");
218
+ });
219
+
220
+ it("stylesheet contract: every tone re-tints the ACTIVE state only, after the plain rule", () => {
221
+ const here = dirname(fileURLToPath(import.meta.url));
222
+ const css = compile(join(here, "HkPillToggleGroup.scss"), { style: "expanded" }).css;
223
+ const tones = ["success", "secondary", "warning", "error", "muted"] as const;
224
+ // Same triplets styles/theme/channels.scss defines (light base).
225
+ const FALLBACKS: Record<(typeof tones)[number], string> = {
226
+ success: "14 184 64",
227
+ secondary: "81 154 115",
228
+ warning: "238 214 119",
229
+ error: "247 118 142",
230
+ muted: "108 108 108",
231
+ };
232
+ const plainActive = css.indexOf(".hk-pill-group-pill[data-active]");
233
+ expect(plainActive, "the plain active rule exists").toBeGreaterThan(-1);
234
+ for (const tone of tones) {
235
+ // Sass drops quotes around attribute values when expanding
236
+ // (`[data-status=pending]` — the HkRangeRibbon sheet contract hits
237
+ // the same formatting), so match the compiled shape.
238
+ const selector = `.hk-pill-group-pill[data-tone=${tone}][data-active]`;
239
+ const at = css.indexOf(selector);
240
+ expect(at, `${selector} compiles`).toBeGreaterThan(-1);
241
+ // Every tone rule must come AFTER the plain rule: the attribute pair
242
+ // outranks it, and the source order keeps that dominance explicit.
243
+ expect(at, `${tone} rule rides after the plain active rule`).toBeGreaterThan(plainActive);
244
+ // Tone must never repaint an INACTIVE pill: every tone selector has to
245
+ // carry the attribute pair. A bare `[data-tone=…]` rule would pass the
246
+ // assertions above while quietly tinting muted pills.
247
+ expect(css, "no tone rule without [data-active]").not.toMatch(
248
+ new RegExp(`\\[data-tone=${tone}\\]\\s*\\{`),
249
+ );
250
+ const body = css.slice(at, css.indexOf("}", at));
251
+ expect(body, `${tone} re-tints the ink`).toContain("color:");
252
+ expect(body, `${tone} re-tints the fill`).toContain("background:");
253
+ expect(body, `${tone} tint uses its theme token`).toContain(`var(--color-${tone}`);
254
+ // Presence alone is not enough: the token appears twice (ink + fill),
255
+ // so swapping one for another hue kept this green (R3 M9/M9b). Pin the
256
+ // ink and the fill to their own channels — muted is the documented
257
+ // exception (text ink, muted wash).
258
+ expect(body, `${tone} INK uses its own channel`).toContain(
259
+ `color: rgb(var(--color-${tone === "muted" ? "text" : tone}`,
260
+ );
261
+ expect(body, `${tone} FILL uses its own channel`).toContain(
262
+ `background: rgb(var(--color-${tone}`,
263
+ );
264
+ // The canonical fallback is what keeps the Rust-side mirror rendering
265
+ // the same hue without the theme layer — pin the exact triplet.
266
+ expect(body, `${tone} carries its canonical fallback`).toContain(FALLBACKS[tone]);
267
+ // Sass's expanded output drops the space before the alpha slash:
268
+ // `rgb(var(--color-x, t t t)/16%)`.
269
+ expect(body, `${tone} keeps the house alpha`).toMatch(
270
+ tone === "muted" ? /\/\s*18%/ : /\/\s*16%/,
271
+ );
272
+ }
273
+ });
274
+ });
@@ -3,6 +3,13 @@ import { defineComponent, h, type PropType, type VNode } from "vue";
3
3
  import { useOptionStrip } from "../composables/useOptionStrip";
4
4
  import "./HkPillToggleGroup.scss";
5
5
 
6
+ /** Semantic tone channel for a pill's ACTIVE tint — the same vocabulary
7
+ * the HkRangeRibbon segment family speaks (a filter bar can keep its
8
+ * lane-hue identity while riding the shared pill chrome). Omit (or
9
+ * "primary") = the default primary tint. A tone recolors the ACTIVE
10
+ * state only; inactive pills stay muted regardless of tone. */
11
+ export type HkPillTone = "primary" | "success" | "secondary" | "warning" | "error" | "muted";
12
+
6
13
  /** One toggleable pill of the group. */
7
14
  export interface HkPillToggleGroupOption {
8
15
  /** Stable identity — the value emitted back through `toggle`. */
@@ -14,6 +21,8 @@ export interface HkPillToggleGroupOption {
14
21
  * (never stringified: a raw function child renders as source text,
15
22
  * which is exactly the landmine this normalization defuses). */
16
23
  icon?: unknown;
24
+ /** Active-tint hue channel (see {@link HkPillTone}). */
25
+ tone?: HkPillTone;
17
26
  }
18
27
 
19
28
  /** Normalize an option icon to a renderable vnode. A component (function
@@ -94,6 +103,7 @@ export default defineComponent({
94
103
  type="button"
95
104
  key={o.value}
96
105
  class="hk-pill-group-pill"
106
+ data-tone={o.tone && o.tone !== "primary" ? o.tone : undefined}
97
107
  data-active={props.selected.has(o.value) || undefined}
98
108
  aria-pressed={props.selected.has(o.value) ? "true" : "false"}
99
109
  onClick={() => emit("toggle", o.value)}
@@ -9,8 +9,10 @@
9
9
  the strip is 32px tall, a handle is an 8px half-overlapping grip
10
10
  (translateX(-50%) so `left: 20%` centers it on the window edge), a
11
11
  clipped segment keeps a 2px minimum so edge slivers stay visible, and
12
- the reset chip clears the edge handle (right: 4px = the handle's
13
- half-width, so a handle at 0% / 100% is never covered).
12
+ the reset button (a standard HkIconButton) clears the edge handle
13
+ (right: 4px = the handle's half-width, so a handle at 0% / 100% is never
14
+ covered). The 24px touch-floor seat costs the strip's top-right brush
15
+ band — the free band below the seat is what remains.
14
16
 
15
17
  Colours and spacing come from hikari theme tokens; a few geometry values
16
18
  (the 32px strip, the 8px grip, the 2px min-width floor) are deliberate
@@ -152,7 +154,12 @@
152
154
  position: absolute;
153
155
  top: 3px;
154
156
  bottom: 3px;
155
- z-index: 2;
157
+ // ABOVE the reset seat (z-index 3): a 24px seat reaches ~10-13% into the
158
+ // strip's right edge, so at z-index 2 a window whose end lands in that
159
+ // band had both grips covered by the reset's box and unreachable by
160
+ // pointer (the seat is opaque and paints later). The grip wins the band;
161
+ // the reset keeps every other pixel of its area.
162
+ z-index: 4;
156
163
  width: 8px;
157
164
  transform: translateX(-50%);
158
165
  border: 1px solid rgb(var(--color-surface));
@@ -174,32 +181,31 @@
174
181
  outline-offset: 2px;
175
182
  }
176
183
 
177
- .hk-range-ribbon-reset {
184
+ // The reset affordance is a STANDARD HkIconButton (size 24, ghost) — the
185
+ // icon-button sheet owns box, glyph size, hover, press and focus chrome.
186
+ // 24 is the touch floor the family uses for dense chrome (the same floor the
187
+ // consumers' icon-size guards enforce); the 32px strip still seats it with a
188
+ // 4px inset top and bottom.
189
+ // This rule only pins the seat on the overview field: right = the edge
190
+ // handle's half-width, so a handle at 0% / 100% is never covered.
191
+ // The seat rides the descendant key on purpose: `.hk-icon-button` declares
192
+ // `position: relative` at the SAME (0,1,0) specificity, so a bare
193
+ // `.hk-range-ribbon-reset` rule would only win while HkIconButton.scss
194
+ // happens to come first in the bundle (the exact failure the lightbox close
195
+ // hit in 2026-09-11 — its seat rule is `.hk-image-lightbox-close.hk-icon-button`
196
+ // for this reason). (0,2,0) makes the seat order-independent.
197
+ // The surface backing rides it for the same reason: the `--hi-icon-button-*`
198
+ // properties also live on `.hk-icon-button` (HkIconButtonVars). The ghost
199
+ // variant is transparent by default — a control floating on the strip's own
200
+ // paint keeps a surface tint to stay legible over segments and scrims.
201
+ .hk-range-ribbon .hk-range-ribbon-reset {
178
202
  position: absolute;
179
203
  top: 4px;
180
204
  right: 4px;
181
205
  z-index: 3;
182
- display: inline-flex;
183
- align-items: center;
184
- justify-content: center;
185
- width: 18px;
186
- height: 18px;
187
- padding: 0;
188
- border: 1px solid rgb(var(--color-border));
189
- border-radius: var(--radius-sm);
190
- background: rgb(var(--color-surface) / 85%);
191
- color: rgb(var(--color-text));
192
- cursor: pointer;
193
- transition: background-color var(--duration-fast, 0.15s) ease;
194
- }
195
-
196
- .hk-range-ribbon-reset:hover {
197
- background: rgb(var(--color-surface));
198
- }
199
-
200
- .hk-range-ribbon-reset:focus-visible {
201
- outline: 2px solid rgb(var(--color-primary));
202
- outline-offset: 1px;
206
+ --hi-icon-button-bg: rgb(var(--color-surface) / 85%);
207
+ --hi-icon-button-bg-hover: rgb(var(--color-surface));
208
+ --hi-icon-button-bg-active: rgb(var(--color-surface));
203
209
  }
204
210
 
205
211
  // Visually hidden, still read by AT: labels the keyboard contract the
@@ -771,6 +771,18 @@ describe("HkRangeRibbon reset", () => {
771
771
  const host = mountHost([0.2, 0.6]);
772
772
  const button = host.root.querySelector<HTMLElement>(".hk-range-ribbon-reset");
773
773
  expect(button, "reset button is shown while a window exists").toBeTruthy();
774
+ // The reset affordance rides the STANDARD icon button (user direction
775
+ // 2026-11-06: no hand-rolled chip chrome), carrying its lucide glyph
776
+ // through the house-normal slot — pin the lucide class the slot must
777
+ // produce so a glyph swap cannot silently vanish.
778
+ expect(button!.className, "reset is an HkIconButton, not a bespoke chip").toContain(
779
+ "hk-icon-button",
780
+ );
781
+ expect(
782
+ button!.querySelector("svg.lucide-rotate-ccw"),
783
+ "reset glyph renders through the icon slot",
784
+ ).not.toBeNull();
785
+ expect(button!.getAttribute("type"), "reset never becomes a submit button").toBe("button");
774
786
  button!.click();
775
787
  await nextTick();
776
788
  expect(host.windowChange).toHaveBeenLastCalledWith(null);
@@ -1405,8 +1417,36 @@ describe("HkRangeRibbon stylesheet contract", () => {
1405
1417
  // A clipped 2px sliver must still be visible.
1406
1418
  expect(declaration(ruleBody(css, ".hk-range-ribbon-segment"), "min-width")).toBe("2px");
1407
1419
  expect(declaration(ruleBody(css, ".hk-range-ribbon-lane-track"), "height")).toBe("20px");
1408
- // The reset chip clears the edge handle (half its 8px width).
1409
- expect(declaration(ruleBody(css, ".hk-range-ribbon-reset"), "right")).toBe("4px");
1420
+ // The reset seat clears the edge handle (half its 8px width). The rule
1421
+ // must sit on the descendant key (0,2,0): `.hk-icon-button` declares
1422
+ // `position: relative` at (0,1,0), so a bare-class seat would depend on
1423
+ // bundle order.
1424
+ const resetSeat = ruleBody(css, ".hk-range-ribbon .hk-range-ribbon-reset");
1425
+ expect(declaration(resetSeat, "right")).toBe("4px");
1426
+ expect(declaration(resetSeat, "position"), "the seat must out-rank .hk-icon-button").toBe(
1427
+ "absolute",
1428
+ );
1429
+ expect(declaration(resetSeat, "top"), "the seat clears the strip edge").toBe("4px");
1430
+ // The grip must paint ABOVE the seat: a right-side window puts its end
1431
+ // handle inside the seat's box, and an opaque seat below it would make
1432
+ // the grip unreachable by pointer.
1433
+ const gripZ = Number(declaration(ruleBody(css, ".hk-range-ribbon-handle"), "z-index"));
1434
+ const seatZ = Number(declaration(resetSeat, "z-index"));
1435
+ expect(gripZ, "the edge grip outranks the reset seat").toBeGreaterThan(seatZ);
1436
+ // The surface backing is what keeps the control legible over the strip's
1437
+ // segments and scrims: ghost carries NO background (HkIconButtonVars),
1438
+ // so without these three the reset degrades to a bare glyph floating on
1439
+ // the paint. Removing them alone left the whole suite green (R3 M5b) —
1440
+ // pin the paint, not just the geometry.
1441
+ expect(declaration(resetSeat, "--hi-icon-button-bg"), "resting surface tint").toContain(
1442
+ "var(--color-surface)",
1443
+ );
1444
+ expect(declaration(resetSeat, "--hi-icon-button-bg-hover"), "hover surface tint").toContain(
1445
+ "var(--color-surface)",
1446
+ );
1447
+ expect(declaration(resetSeat, "--hi-icon-button-bg-active"), "press surface tint").toContain(
1448
+ "var(--color-surface)",
1449
+ );
1410
1450
  expect(ruleBody(css, ".hk-range-ribbon-window")).toContain("var(--color-primary)");
1411
1451
  expect(ruleBody(css, ".hk-range-ribbon-scrim")).toContain("var(--color-background)");
1412
1452
  });
@@ -11,6 +11,7 @@ import {
11
11
  } from "vue";
12
12
 
13
13
  import { useI18n } from "../i18n/context";
14
+ import HIconButton from "./HkIconButton";
14
15
  import "./HkRangeRibbon.scss";
15
16
 
16
17
  /**
@@ -744,15 +745,23 @@ export default defineComponent({
744
745
  onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
745
746
  onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
746
747
  />
747
- <button
748
- type="button"
748
+ {/* The reset affordance is the STANDARD icon button (user
749
+ direction 2026-11-06: no hand-rolled chip chrome) —
750
+ HkIconButton owns box, glyph size, hover, press and
751
+ focus; this sheet only pins the seat (position) and a
752
+ surface backing via its override vars. The glyph slots
753
+ as a lucide component (the house-normal integration:
754
+ every in-tree HkIconButton slots its glyph). */}
755
+ <HIconButton
749
756
  class="hk-range-ribbon-reset"
757
+ size={24}
758
+ variant="ghost"
750
759
  aria-label={t("hikari::ribbon.reset", "Reset range")}
751
- title={t("hikari::ribbon.reset", "Reset range")}
760
+ {...{ title: t("hikari::ribbon.reset", "Reset range") }}
752
761
  onClick={resetWindow}
753
762
  >
754
- <RotateCcw size={12} aria-hidden="true" />
755
- </button>
763
+ {{ icon: () => <RotateCcw aria-hidden="true" /> }}
764
+ </HIconButton>
756
765
  </>
757
766
  )}
758
767
  </div>
@@ -1,8 +1,10 @@
1
1
  /**
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 —
2
+ * Source contract for the scroll-container edge fades (the 2026-11-06
3
+ * progressive revision of the 2026-11-02 composition): each edge owns a
4
+ * CSS var, the viewport mask is built from the gradient LAYERS listed in
5
+ * --hk-scroll-fade-layers, the axis rules swap that list, and every lit
6
+ * band is the fade size scaled by a live strength the component
7
+ * publishes from the hidden-content depth (the progressive fade) —
6
8
  *
7
9
  * - two-axis containers list the vertical AND horizontal gradients and
8
10
  * intersect them, so the fade reaches zero on all four sides and the
@@ -28,6 +30,17 @@
28
30
  * - all four edge vars are declared (top/right/bottom/left, 0px base);
29
31
  * - start/end/both rules exist for BOTH axes;
30
32
  * - RTL swaps the horizontal vars instead of rewriting the mask.
33
+ *
34
+ * And the regression this file now exists to keep dead (2026-11-06,
35
+ * user report "still hard-clipped while swiping", measured in a real
36
+ * engine): #902 (2026-10-29) declared the four zero defaults on the
37
+ * VIEWPORT base rule, and a directly declared custom property beats any
38
+ * inherited value, so the viewport's own 0px shadowed every root state
39
+ * rule's band — no edge ever faded again (resolved mask stops
40
+ * `rgb(0,0,0) 0px … rgb(0,0,0) 100%`), every faded strip hard-clipped,
41
+ * and the dock fades died with it. The defaults therefore live on the
42
+ * ROOT rule that also carries the strength fallbacks, and the viewport
43
+ * declares only the layers/mask pair.
31
44
  */
32
45
  import { describe, expect, it } from "vitest";
33
46
  import { readFileSync } from "node:fs";
@@ -90,26 +103,87 @@ describe("scroll fade composition contract", () => {
90
103
  }
91
104
  });
92
105
 
93
- it("fades both axes from the sensed overflow", () => {
94
- for (const axis of ["v", "h"]) {
95
- for (const state of ["start", "end", "both"]) {
96
- expect(sheet).toContain(
97
- `[data-${axis}-overflow="${state}"]`,
98
- );
106
+ it("declares the zero defaults on the ROOT, never on the viewport (the #902 shadowing)", () => {
107
+ // A directly declared custom property beats any inherited value: the
108
+ // moment the viewport base rule re-declares these defaults, every
109
+ // root state rule's band is shadowed back to 0px and the fades die
110
+ // silently (the 2026-10-29..11-06 hard-clip regression, measured in
111
+ // a real engine). The viewport rule declares only the layer list.
112
+ const viewportBase = sheet.match(
113
+ /\.hk-scroll-container:is\(\[data-fade="true"\], \[data-dock-fade\]\) > \.hk-scroll-container-viewport \{[\s\S]*?\n\}/,
114
+ );
115
+ expect(viewportBase, "the viewport base rule exists").toBeTruthy();
116
+ for (const edge of ["top", "right", "bottom", "left"]) {
117
+ expect(viewportBase![0]).not.toContain(`--hk-scroll-fade-${edge}: 0px;`);
118
+ }
119
+ // And the root rule that carries them exists with the strengths.
120
+ const rootDefaults = sheet.match(
121
+ /\.hk-scroll-container:is\(\[data-fade="true"\], \[data-dock-fade\]\) \{[\s\S]*?\n\}/,
122
+ );
123
+ expect(rootDefaults, "the root defaults rule exists").toBeTruthy();
124
+ for (const edge of ["top", "right", "bottom", "left"]) {
125
+ expect(rootDefaults![0]).toContain(`--hk-scroll-fade-${edge}: 0px;`);
126
+ expect(rootDefaults![0]).toContain(`--hk-scroll-fade-strength-${edge}: 1;`);
127
+ }
128
+ // Sheet-wide: the four zero defaults live EXACTLY in the root rule.
129
+ // A second rule re-declaring any of them (the R2 escape: a bare
130
+ // `.hk-scroll-container-viewport { --hk-scroll-fade-top: 0px }`)
131
+ // would re-shadow the bands without this going red. The RTL swap
132
+ // rules also write 0px — the DEAD-side slot, not a default — so
133
+ // they are excluded from the stray scan.
134
+ const rtlBlocks = sheet.match(
135
+ /\.hk-scroll-container\[data-dir="rtl"\]\[data-fade="true"\]\[data-h-overflow="(?:start|end|both)"\] \{[^}]*\}/g,
136
+ ) ?? [];
137
+ const sheetWithoutDefaults = sheet.replace(rootDefaults![0], "").replace(new RegExp(String.raw`${rtlBlocks.map((b) => b.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)).join("|")}`, "g"), "");
138
+ for (const edge of ["top", "right", "bottom", "left"]) {
139
+ expect(
140
+ sheetWithoutDefaults.includes(`--hk-scroll-fade-${edge}: 0px;`),
141
+ `a stray zero default for ${edge} lives outside the root rule`,
142
+ ).toBe(false);
143
+ }
144
+ });
145
+
146
+ it("scales every lit band by the published strength (the progressive fade)", () => {
147
+ // Band = fade size × strength, so the component's hidden-content
148
+ // depth (0..1 over fadeDistance) deepens each edge from a hard cut
149
+ // to the full band. A bare `var(--hk-scroll-fade-size)` band in a
150
+ // state rule is the pre-progressive binary shape — drift.
151
+ const bandRules = sheet.match(
152
+ /^\.hk-scroll-container\[data-fade="true"\]\[data-[hv]-overflow="(?:start|end|both)"\] \{[^}]*\}/gm,
153
+ ) ?? [];
154
+ expect(bandRules.length, "six lit-band state rules (3 per axis)").toBe(6);
155
+ for (const block of bandRules) {
156
+ const declarations = block.match(/--hk-scroll-fade-(?:top|right|bottom|left): [^;]+;/g) ?? [];
157
+ expect(declarations.length, "each state rule lights at least one band").toBeGreaterThan(0);
158
+ for (const declaration of declarations) {
159
+ expect(
160
+ declaration,
161
+ `strength-scaled band: ${declaration}`,
162
+ ).toMatch(/^--hk-scroll-fade-(?:top|right|bottom|left): calc\(var\(--hk-scroll-fade-size\) \* var\(--hk-scroll-fade-strength-(?:top|right|bottom|left), 1\)\);$/);
99
163
  }
100
164
  }
101
- // The vertical half is what the old sheet was missing entirely.
102
- expect(sheet).toContain('--hk-scroll-fade-top: var(--hk-scroll-fade-size)');
103
- expect(sheet).toContain('--hk-scroll-fade-bottom: var(--hk-scroll-fade-size)');
165
+ expect(sheet).not.toMatch(/--hk-scroll-fade-(?:top|right|bottom|left): var\(--hk-scroll-fade-size\);/);
104
166
  });
105
167
 
106
- it("swaps horizontal sides under RTL instead of rewriting the mask", () => {
168
+ it("swaps horizontal sides under the mirrored data-dir instead of rewriting the mask", () => {
169
+ // The slot swap keys on the component's mirrored data-dir (computed
170
+ // direction), NOT a literal [dir] selector: the physical-side
171
+ // strengths resolve direction in JS, and an ancestor attribute the
172
+ // selector misses (dir on the container, dir="auto", a deeper
173
+ // override) would light the wrong edge. See HkScrollContainer.tsx
174
+ // senseOverflow's data-dir mirror.
107
175
  expect(sheet).toContain(
108
- '[dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="start"]',
176
+ '.hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="start"]',
109
177
  );
110
178
  expect(sheet).toContain(
111
- '[dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="end"]',
179
+ '.hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="end"]',
112
180
  );
181
+ // A literal [dir="rtl"] band rule would disagree with the mirrored
182
+ // depths under exactly the configurations the mirror exists for.
183
+ expect(
184
+ sheet.match(/\[dir="rtl"\][^\n]*\{\s*--hk-scroll-fade-(?:left|right)/),
185
+ "no literal [dir]-keyed band rules remain",
186
+ ).toBeNull();
113
187
  });
114
188
 
115
189
  it("routes the dock fades through the same edge vars", () => {
@@ -66,12 +66,46 @@
66
66
  composite pair stays on the base rule: with one layer it is a no-op
67
67
  (there is no layer below to composite against), so the axis rules only
68
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. */
70
- .hk-scroll-container:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
69
+ vertical gradient, so they land on vertical and two-axis containers.
70
+
71
+ PROGRESSIVE DEPTH (2026-11-06 revision) — two halves:
72
+
73
+ 1. The state-driven vars moved back to the ROOT. #902 (2026-10-29)
74
+ added the four zero defaults to the VIEWPORT base rule below, and a
75
+ directly declared custom property beats any inherited value, so the
76
+ viewport's own 0px shadowed every state rule's band — measured in a
77
+ real engine (resolved mask stops `rgb(0,0,0) 0px … rgb(0,0,0)
78
+ 100%`): no edge ever faded, every faded strip hard-clipped, and the
79
+ dock fades died with it. The defaults live on the root here (same
80
+ element the state rules write), where a consumer override on the
81
+ container still wins by the usual specificity order.
82
+
83
+ 2. Each lit band is the fade size scaled by a live strength the
84
+ component publishes while `fade` is on: the hidden-content depth
85
+ past that edge (px) saturates over `fadeDistance` (default 96px)
86
+ into a 0..1 number mirrored as --hk-scroll-fade-strength-<side>.
87
+ Pull the strip and the trailing edge's fade grows from a hard cut
88
+ through a hint to the full band; scroll back and it recedes. Edges
89
+ with nothing hidden read strength 1 (the pre-JS / dock-fade
90
+ fallback), which with a 0px band contributes nothing. Physical-side
91
+ names AND a mirrored data-dir: the component resolves the computed
92
+ direction once and publishes both the depths and the direction
93
+ attribute from the same pass, so the LTR and RTL state rules
94
+ consume the same vars and the slot swap below never disagrees with
95
+ the depths (a literal [dir] selector can miss dir on the
96
+ container, dir="auto", or a deeper override). */
97
+ .hk-scroll-container:is([data-fade="true"], [data-dock-fade]) {
71
98
  --hk-scroll-fade-top: 0px;
72
99
  --hk-scroll-fade-right: 0px;
73
100
  --hk-scroll-fade-bottom: 0px;
74
101
  --hk-scroll-fade-left: 0px;
102
+ --hk-scroll-fade-strength-top: 1;
103
+ --hk-scroll-fade-strength-right: 1;
104
+ --hk-scroll-fade-strength-bottom: 1;
105
+ --hk-scroll-fade-strength-left: 1;
106
+ }
107
+
108
+ .hk-scroll-container:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
75
109
  --hk-scroll-fade-layers:
76
110
  linear-gradient(
77
111
  to bottom,
@@ -122,49 +156,64 @@
122
156
  --hk-scroll-fade-size: var(--space-24);
123
157
  }
124
158
 
159
+ /* ── Lit bands: fade size scaled by the published strength ─────────
160
+ `calc(length * number)` keeps the band arithmetic in CSS (consumer
161
+ --hk-scroll-fade-size overrides keep working; the JS side publishes
162
+ only the unitless strength). An edge with nothing hidden keeps the
163
+ default strength 1 AND a 0px band — no fade either way. */
164
+
125
165
  /* Vertical axis — the half the old implementation never had. */
126
166
  .hk-scroll-container[data-fade="true"][data-v-overflow="start"] {
127
- --hk-scroll-fade-top: var(--hk-scroll-fade-size);
167
+ --hk-scroll-fade-top: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-top, 1));
128
168
  }
129
169
 
130
170
  .hk-scroll-container[data-fade="true"][data-v-overflow="end"] {
131
- --hk-scroll-fade-bottom: var(--hk-scroll-fade-size);
171
+ --hk-scroll-fade-bottom: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-bottom, 1));
132
172
  }
133
173
 
134
174
  .hk-scroll-container[data-fade="true"][data-v-overflow="both"] {
135
- --hk-scroll-fade-top: var(--hk-scroll-fade-size);
136
- --hk-scroll-fade-bottom: var(--hk-scroll-fade-size);
175
+ --hk-scroll-fade-top: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-top, 1));
176
+ --hk-scroll-fade-bottom: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-bottom, 1));
137
177
  }
138
178
 
139
179
  /* Horizontal axis (LTR: inline start = left). */
140
180
  .hk-scroll-container[data-fade="true"][data-h-overflow="start"] {
141
- --hk-scroll-fade-left: var(--hk-scroll-fade-size);
181
+ --hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
142
182
  }
143
183
 
144
184
  .hk-scroll-container[data-fade="true"][data-h-overflow="end"] {
145
- --hk-scroll-fade-right: var(--hk-scroll-fade-size);
185
+ --hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
146
186
  }
147
187
 
148
188
  .hk-scroll-container[data-fade="true"][data-h-overflow="both"] {
149
- --hk-scroll-fade-left: var(--hk-scroll-fade-size);
150
- --hk-scroll-fade-right: var(--hk-scroll-fade-size);
189
+ --hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
190
+ --hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
151
191
  }
152
192
 
153
- /* RTL: the inline start edge is the right side, so the horizontal vars
154
- swap instead of the mask (the mask itself is direction-neutral). */
155
- [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="start"] {
193
+ /* RTL: the inline start edge is the right side, so the horizontal band
194
+ slots swap instead of the mask (the mask itself is direction-neutral).
195
+ The swap keys on the component's MIRRORED data-dir, not on [dir]:
196
+ the strengths are published per PHYSICAL side from the computed
197
+ direction (normalized scroll signs), and a literal ancestor-[dir]
198
+ selector diverges from that resolution exactly when [dir] sits on the
199
+ container itself, on a dir="auto" ancestor, or under an explicit
200
+ deeper override — computed-direction-is-truth keeps slots and depths
201
+ in agreement. The attribute lands in the same sensing pass as the
202
+ overflow attrs (write-only-on-change, removed when fade turns off),
203
+ so slots and lit attrs are never from different frames. */
204
+ .hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="start"] {
156
205
  --hk-scroll-fade-left: 0px;
157
- --hk-scroll-fade-right: var(--hk-scroll-fade-size);
206
+ --hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
158
207
  }
159
208
 
160
- [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="end"] {
209
+ .hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="end"] {
161
210
  --hk-scroll-fade-right: 0px;
162
- --hk-scroll-fade-left: var(--hk-scroll-fade-size);
211
+ --hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
163
212
  }
164
213
 
165
- [dir="rtl"] .hk-scroll-container[data-fade="true"][data-h-overflow="both"] {
166
- --hk-scroll-fade-left: var(--hk-scroll-fade-size);
167
- --hk-scroll-fade-right: var(--hk-scroll-fade-size);
214
+ .hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="both"] {
215
+ --hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
216
+ --hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
168
217
  }
169
218
 
170
219
  /* ── Anchored docks (dockTop / dockBottom slots) ─────────────────
@@ -187,7 +236,14 @@
187
236
  (opacity 100 -> 0 across --hk-scroll-dock-fade) reads as intentional
188
237
  instead of a hard clip against the dock's top edge. Content never
189
238
  sits under the dock, so this fades the viewport's last few pixels,
190
- not the dock itself. */
239
+ not the dock itself. Ownership note (2026-11-06): when `fade` is ALSO
240
+ on, the state band rules below have higher specificity and own that
241
+ edge while it hides content — the dock band applies only when the
242
+ sense reports nothing hidden past it. With the progressive fade the
243
+ shared edge therefore deepens with the pull (≤ --space-24) and snaps
244
+ to the full dock fade only at the scroll end; no shipped consumer
245
+ combines the two, but a future one should treat that as the
246
+ documented shape, not a bug. */
191
247
  .hk-scroll-container[data-dock-fade="bottom"] {
192
248
  --hk-scroll-fade-bottom: var(--hk-scroll-dock-fade, 2rem);
193
249
  }
@@ -180,6 +180,206 @@ describe("HkScrollContainer overflow sensing", () => {
180
180
  });
181
181
  });
182
182
 
183
+ // ── Progressive fade strengths (fadeDistance, 2026-11-06): while `fade`
184
+ // is on the container mirrors the hidden-content depth past each edge as
185
+ // a 0..1 band scale on the root's inline style — pull the strip and the
186
+ // trailing fade deepens, saturate at the full band, recede on the way
187
+ // back. CSS consumes the vars (fade.contract.test.ts pins the sheet). ──
188
+ describe("HkScrollContainer progressive fade strengths", () => {
189
+ const varOf = (m: Mounted, side: string): string =>
190
+ m.root.style.getPropertyValue(`--hk-scroll-fade-strength-${side}`);
191
+
192
+ it("publishes the hidden depth per edge, saturating at 1", () => {
193
+ const m = mountScroller({ axis: "horizontal", fade: true });
194
+ // scrollWidth 300 in a 100px viewport: hiddenLeft = scrollLeft,
195
+ // hiddenRight = 200 - scrollLeft. Default fadeDistance 96.
196
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
197
+ m.instance?.refresh();
198
+ // 48/96 = exactly half depth on the start edge; 152 hidden past the
199
+ // end edge saturates the band.
200
+ expect(varOf(m, "left")).toBe("0.5");
201
+ expect(varOf(m, "right")).toBe("1");
202
+ // Vertical edges hide nothing: strength 1 with a 0px band (invisible).
203
+ expect(varOf(m, "top")).toBe("1");
204
+ expect(varOf(m, "bottom")).toBe("1");
205
+ });
206
+
207
+ it("grows the strength with the pull and recedes on the way back", () => {
208
+ const m = mountScroller({ axis: "horizontal", fade: true });
209
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 0 });
210
+ m.instance?.refresh();
211
+ expect(varOf(m, "left")).toBe("1"); // nothing hidden past the start
212
+ expect(varOf(m, "right")).toBe("1"); // 200 hidden: saturated
213
+
214
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 24 });
215
+ m.viewport.dispatchEvent(new Event("scroll"));
216
+ m.instance?.refresh();
217
+ expect(varOf(m, "left")).toBe("0.25"); // 24/96: the pull deepens it
218
+ expect(varOf(m, "right")).toBe("1"); // 176 hidden: still saturated
219
+
220
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 190 });
221
+ m.viewport.dispatchEvent(new Event("scroll"));
222
+ m.instance?.refresh();
223
+ expect(varOf(m, "left")).toBe("1"); // fully pulled: saturated
224
+ expect(varOf(m, "right")).toBe("0.104"); // 10 hidden past the end: receding
225
+ });
226
+
227
+ it("keeps the ≤1px deadband of the overflow sense on the strength side", () => {
228
+ const m = mountScroller({ axis: "horizontal", fade: true });
229
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 199.5 });
230
+ m.instance?.refresh();
231
+ // 0.5px hidden past the end: the sense still reports "start" (the
232
+ // same deadband) and the strength must not read as a visible fade.
233
+ expect(m.root.getAttribute("data-h-overflow")).toBe("start");
234
+ expect(varOf(m, "right")).toBe("1");
235
+ });
236
+
237
+ it("degrades to the binary shape with a zero fadeDistance", () => {
238
+ const m = mountScroller({ axis: "horizontal", fade: true, fadeDistance: 0 });
239
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
240
+ m.instance?.refresh();
241
+ expect(varOf(m, "left")).toBe("1");
242
+ expect(varOf(m, "right")).toBe("1");
243
+ });
244
+
245
+ it("re-derives strengths when only fadeDistance changes at runtime", async () => {
246
+ // The distance is read live per pass: changing it with NO scroll and
247
+ // NO refresh() call must re-derive every strength on the watcher's
248
+ // scheduled pass alone (a scroll-driven delta would pass even with a
249
+ // dead watcher — the scroll event schedules its own pass).
250
+ const container = document.createElement("div");
251
+ document.body.appendChild(container);
252
+ containers.push(container);
253
+ const props = reactive({ fade: true, fadeDistance: 96 });
254
+ const Wrapper = defineComponent({
255
+ setup() {
256
+ return () =>
257
+ h(HkScrollContainer, { axis: "horizontal", fade: props.fade, fadeDistance: props.fadeDistance }, {
258
+ default: () => h("span", "content"),
259
+ });
260
+ },
261
+ });
262
+ const app = createApp(Wrapper);
263
+ mounts.push(app);
264
+ app.mount(container);
265
+ const root = container.querySelector<HTMLElement>(".hk-scroll-container")!;
266
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
267
+ stubGeometry(viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
268
+ // One baseline pass (the mount pass ran before the stub landed);
269
+ // after this, NO further scroll happens — the flip below must
270
+ // re-derive on the watcher's pass alone.
271
+ viewport.dispatchEvent(new Event("scroll"));
272
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
273
+ expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("0.5");
274
+
275
+ props.fadeDistance = 192; // same geometry, doubled distance
276
+ await nextTick();
277
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
278
+ expect(viewport.scrollLeft, "no scroll between the passes").toBe(48);
279
+ expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("0.25");
280
+ app.unmount();
281
+ });
282
+
283
+ it("publishes on a fade prop flip without any scroll (the re-sense watcher)", async () => {
284
+ // Mounting fade-off with content already hidden publishes nothing;
285
+ // flipping the prop on must light the strengths on the watcher's
286
+ // scheduled pass alone — no scroll event, no refresh() call.
287
+ const container = document.createElement("div");
288
+ document.body.appendChild(container);
289
+ containers.push(container);
290
+ const props = reactive({ fade: false });
291
+ const Wrapper = defineComponent({
292
+ setup() {
293
+ return () =>
294
+ h(HkScrollContainer, { axis: "horizontal", fade: props.fade }, {
295
+ default: () => h("span", "content"),
296
+ });
297
+ },
298
+ });
299
+ const app = createApp(Wrapper);
300
+ mounts.push(app);
301
+ app.mount(container);
302
+ const root = container.querySelector<HTMLElement>(".hk-scroll-container")!;
303
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
304
+ stubGeometry(viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
305
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
306
+ expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("");
307
+
308
+ props.fade = true;
309
+ await nextTick();
310
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
311
+ expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("0.5");
312
+ expect(root.style.getPropertyValue("--hk-scroll-fade-strength-right")).toBe("1");
313
+ app.unmount();
314
+ });
315
+
316
+ it("clears the published strengths and mirrored dir when fade flips off", async () => {
317
+ // Stale inline values must not outlive the feature (the
318
+ // stopSelectionSensing symmetry): off removes all four strength
319
+ // vars and data-dir from the root, so a downstream reader of the
320
+ // root's style never sees zombie strengths.
321
+ const container = document.createElement("div");
322
+ document.body.appendChild(container);
323
+ containers.push(container);
324
+ const props = reactive({ fade: true });
325
+ const Wrapper = defineComponent({
326
+ setup() {
327
+ return () =>
328
+ h(HkScrollContainer, { axis: "horizontal", fade: props.fade }, {
329
+ default: () => h("span", "content"),
330
+ });
331
+ },
332
+ });
333
+ const app = createApp(Wrapper);
334
+ mounts.push(app);
335
+ app.mount(container);
336
+ const root = container.querySelector<HTMLElement>(".hk-scroll-container")!;
337
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
338
+ stubGeometry(viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
339
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
340
+ expect(root.getAttribute("data-dir")).toBe("ltr");
341
+ expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).not.toBe("");
342
+
343
+ props.fade = false;
344
+ await nextTick();
345
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
346
+ for (const side of ["left", "right", "top", "bottom"]) {
347
+ expect(root.style.getPropertyValue(`--hk-scroll-fade-strength-${side}`)).toBe("");
348
+ }
349
+ expect(root.hasAttribute("data-dir")).toBe(false);
350
+ app.unmount();
351
+ });
352
+
353
+ it("publishes nothing while the fade prop is off", () => {
354
+ const m = mountScroller({ axis: "horizontal" });
355
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
356
+ m.instance?.refresh();
357
+ expect(varOf(m, "left")).toBe("");
358
+ expect(varOf(m, "right")).toBe("");
359
+ expect(m.root.getAttribute("data-h-overflow")).toBe("both"); // sense still runs
360
+ });
361
+
362
+ it("normalizes RTL scroll signs for both the sense and the strengths", () => {
363
+ const m = mountScroller({ axis: "horizontal", fade: true });
364
+ m.viewport.style.direction = "rtl";
365
+ // Chromium RTL convention: scrollLeft runs 0 → -(max). Raw -48 sits
366
+ // mid-scroll; the pre-normalization sense would have read "end"
367
+ // (scrollPos ≤ 1) for every RTL position.
368
+ stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: -48 });
369
+ m.instance?.refresh();
370
+ expect(m.root.getAttribute("data-h-overflow")).toBe("both");
371
+ // The mirror is the CSS slot swap's only direction truth — a mutant
372
+ // that hardcodes "ltr" (or inverts the ternary) would keep the
373
+ // depths correct and light the WRONG physical edge, so the artifact
374
+ // itself is pinned, not just its consequences.
375
+ expect(m.root.getAttribute("data-dir")).toBe("rtl");
376
+ // Physical start edge (visual RIGHT under RTL) hides 48px…
377
+ expect(varOf(m, "left")).toBe("1");
378
+ expect(varOf(m, "right")).toBe("0.5");
379
+ m.viewport.style.direction = "";
380
+ });
381
+ });
382
+
183
383
  describe("HkScrollContainer scrollbar reactivity", () => {
184
384
  it("builds and tears down the overlay tracks when the scrollbar prop flips", async () => {
185
385
  const container = document.createElement("div");
@@ -84,6 +84,23 @@ export default defineComponent({
84
84
  /** Fade the viewport's inline edges (CSS mask) on the sides where
85
85
  * the sensed overflow (`data-h-overflow`) reports hidden content. */
86
86
  fade: { type: Boolean, default: false },
87
+ /** Hidden-content distance (px) over which a lit edge fade deepens
88
+ * from a hard cut to the full band (the progressive fade): the
89
+ * component senses how much content hides past each edge and
90
+ * mirrors it as a 0..1 strength (--hk-scroll-fade-strength-<side>)
91
+ * that scales the band in CSS, so pulling the strip grows the
92
+ * trailing fade and scrolling back recedes it. Read live (runtime
93
+ * changes need no remount, like approachDistance). 0 or negative
94
+ * degrades to the pre-2026-11 binary shape: any lit edge jumps
95
+ * straight to the full band. Only read while `fade` is on.
96
+ *
97
+ * RTL normalization assumes the Chromium/WebKit convention
98
+ * (scrollLeft runs 0 → -max): every engine hikari deploys on is
99
+ * Chromium-family (the sheet's WebView notes). A legacy
100
+ * positive-descending engine would clamp the normalized offset to
101
+ * 0 — bounded degradation: the sense parks at "end" and one fade
102
+ * stays saturated, never inverted. */
103
+ fadeDistance: { type: Number, default: 96 },
87
104
  /** Additive opt-in: while autoFollow is enabled and the user has
88
105
  * scrolled away from the bottom, float a small jump-back FAB that
89
106
  * snaps to the latest content and re-arms follow. */
@@ -259,6 +276,20 @@ export default defineComponent({
259
276
  let lastHOverflow: OverflowSense | null = null;
260
277
  let lastVOverflow: OverflowSense | null = null;
261
278
 
279
+ // Progressive fade strengths last written to the root (see the
280
+ // fadeDistance prop doc). Same write-only-on-change discipline as
281
+ // the overflow mirror: scroll redraws the mask every frame anyway,
282
+ // but a pass that re-senses identical geometry must not churn
283
+ // inline style for nothing. The mirrored direction rides the same
284
+ // contract — it keys the CSS band-slot swap (see the SCSS RTL
285
+ // rules) and MUST agree with the physical-side depths below, which
286
+ // is why the computed direction is mirrored instead of CSS
287
+ // sniffing [dir] itself: a computed rtl under a missing/mismatched
288
+ // ancestor attribute (dir on the container, dir="auto") would
289
+ // light the wrong physical edge.
290
+ const lastFadeStrengths = new Map<string, string>();
291
+ let lastPublishedDir: string | null = null;
292
+
262
293
  // Sensed selection edge hint (see the selectionHint prop doc),
263
294
  // mirrored onto the root as data-selection-beyond — the same
264
295
  // write-only-on-change discipline as the overflow mirror.
@@ -618,13 +649,29 @@ export default defineComponent({
618
649
  }
619
650
 
620
651
  /** Mirror the live scroll geometry onto the root element as
621
- * data-h-overflow / data-v-overflow. Runs on every scheduled
622
- * update (scroll, resize, content resize via the aligner). */
652
+ * data-h-overflow / data-v-overflow, and while `fade` is on publish
653
+ * the per-edge fade strengths (hidden-content depth saturating over
654
+ * fadeDistance). Runs on every scheduled update (scroll, resize,
655
+ * content resize via the aligner).
656
+ *
657
+ * Scroll offsets are normalized to LTR convention first: Chromium
658
+ * reports negative scrollLeft under RTL (0 at the inline start down
659
+ * to -(max) at the inline end), so both the overflow sense and the
660
+ * strengths would read as "always at start" without it. Strengths
661
+ * are PHYSICAL-side (left/right), so the direction-neutral
662
+ * start/end state rules and their RTL width swap below consume the
663
+ * same vars unchanged. */
623
664
  function senseOverflow(vp: HTMLElement) {
624
665
  const root = vp.parentElement;
625
666
  if (!root) return;
626
- const h = hasH() ? computeOverflow(vp.scrollWidth, vp.clientWidth, vp.scrollLeft) : "none";
627
- const v = hasV() ? computeOverflow(vp.scrollHeight, vp.clientHeight, vp.scrollTop) : "none";
667
+ const rtl = getComputedStyle(vp).direction === "rtl";
668
+ const hMax = Math.max(0, vp.scrollWidth - vp.clientWidth);
669
+ const vMax = Math.max(0, vp.scrollHeight - vp.clientHeight);
670
+ const clampPos = (pos: number, max: number) => Math.min(max, Math.max(0, pos));
671
+ const hPos = clampPos(hasH() ? (rtl ? -vp.scrollLeft : vp.scrollLeft) : 0, hMax);
672
+ const vPos = clampPos(hasV() ? vp.scrollTop : 0, vMax);
673
+ const h = hasH() ? computeOverflow(vp.scrollWidth, vp.clientWidth, hPos) : "none";
674
+ const v = hasV() ? computeOverflow(vp.scrollHeight, vp.clientHeight, vPos) : "none";
628
675
  if (h !== lastHOverflow) {
629
676
  lastHOverflow = h;
630
677
  root.setAttribute("data-h-overflow", h);
@@ -633,6 +680,78 @@ export default defineComponent({
633
680
  lastVOverflow = v;
634
681
  root.setAttribute("data-v-overflow", v);
635
682
  }
683
+ if (props.fade) {
684
+ // Physical-side depths: the normalized hPos counts the inline
685
+ // START, which is the visual RIGHT edge under RTL — swap there
686
+ // so --hk-scroll-fade-strength-left always means the left edge.
687
+ // Axis-gated edges never hide anything this container can fade,
688
+ // so they publish the unlit "1" rather than live geometry.
689
+ publishFadeStrengths(root, {
690
+ left: hasH() ? (rtl ? hMax - hPos : hPos) : 0,
691
+ right: hasH() ? (rtl ? hPos : hMax - hPos) : 0,
692
+ top: hasV() ? vPos : 0,
693
+ bottom: hasV() ? vMax - vPos : 0,
694
+ });
695
+ // Mirror the COMPUTED direction for the CSS band-slot swap (the
696
+ // SCSS RTL rules key on this attribute, not on [dir]): the
697
+ // depths above are physical, so the slot mapping must come from
698
+ // the same resolved direction the normalization used — an
699
+ // ancestor [dir] the selector happens to miss (dir on this
700
+ // element, dir="auto", an explicit deeper override) would
701
+ // otherwise light the wrong edge. Lands the same pass as the
702
+ // overflow attrs, so band slots and lit attrs are never from
703
+ // different frames. Known trade (2026-11-06 review): a pure
704
+ // direction change at rest (locale flip, scrollLeft 0, no
705
+ // layout delta) schedules no sensing pass, so the mirror and
706
+ // the depths sit stale until the next scroll / resize /
707
+ // refresh / prop touch — wrong physical edge in that window,
708
+ // self-healing; a dir MutationObserver was rejected as
709
+ // over-engineering for a narrow trigger.
710
+ const dir = rtl ? "rtl" : "ltr";
711
+ if (lastPublishedDir !== dir) {
712
+ lastPublishedDir = dir;
713
+ root.setAttribute("data-dir", dir);
714
+ }
715
+ } else {
716
+ clearPublishedFade(root);
717
+ }
718
+ }
719
+
720
+ /** Drop every fade state the mirror published (fade prop off):
721
+ * the four strength vars, their cache and the mirrored direction —
722
+ * a stale inline value must not outlive the feature that wrote it
723
+ * (same contract shape as stopSelectionSensing). */
724
+ function clearPublishedFade(root: HTMLElement) {
725
+ if (lastFadeStrengths.size === 0 && lastPublishedDir === null) return;
726
+ for (const side of ["left", "right", "top", "bottom"]) {
727
+ root.style.removeProperty(`--hk-scroll-fade-strength-${side}`);
728
+ }
729
+ lastFadeStrengths.clear();
730
+ if (lastPublishedDir !== null) {
731
+ lastPublishedDir = null;
732
+ root.removeAttribute("data-dir");
733
+ }
734
+ }
735
+
736
+ /** Progressive fade strengths (see the fadeDistance prop doc): the
737
+ * hidden px past an edge saturates linearly over fadeDistance into
738
+ * the band's 0..1 depth, so the fade grows with the pull and caps
739
+ * at the full band. An edge hiding nothing (the same ≤1px deadband
740
+ * computeOverflow uses) publishes 1 — with its 0px band that is
741
+ * invisible, and it keeps dock-fed edges (which share the vars,
742
+ * no fade prop → never published) at their designed full band. */
743
+ function publishFadeStrengths(root: HTMLElement, hidden: { left: number; right: number; top: number; bottom: number }) {
744
+ const dist = Math.max(0, props.fadeDistance);
745
+ for (const [side, depth] of Object.entries(hidden) as Array<[keyof typeof hidden, number]>) {
746
+ const strength =
747
+ depth <= 1 || dist === 0
748
+ ? "1"
749
+ : String(Math.min(1, Math.round((depth / dist) * 1000) / 1000));
750
+ if (lastFadeStrengths.get(side) !== strength) {
751
+ lastFadeStrengths.set(side, strength);
752
+ root.style.setProperty(`--hk-scroll-fade-strength-${side}`, strength);
753
+ }
754
+ }
636
755
  }
637
756
 
638
757
  /** Selection edge hint (see the selectionHint prop doc): does any
@@ -705,6 +824,13 @@ export default defineComponent({
705
824
 
706
825
  watch(() => props.selectionSelector, () => scheduleUpdate());
707
826
 
827
+ // Runtime fade changes re-sense immediately: flipping `fade` on with
828
+ // content already hidden must not wait for a scroll, and a different
829
+ // fadeDistance re-derives every strength without any DOM motion.
830
+ watch(() => [props.fade, props.fadeDistance] as const, () => {
831
+ scheduleUpdate();
832
+ });
833
+
708
834
  function onScroll() {
709
835
  if (!viewportRef.value) return;
710
836
  scheduleUpdate();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Source contract for the range-ribbon reset affordance (user direction
3
+ * 2026-11-06: the waterfall minimap's reset control must be the STANDARD
4
+ * icon button, not a per-component hand-rolled `<button>` chip).
5
+ *
6
+ * HkRangeRibbon renders its reset through HkIconButton (the shared icon
7
+ * button: one box ladder, one glyph channel, one hover/press/focus chrome)
8
+ * carrying the a directly-imported `RotateCcw` glyph in the icon slot — the same "named-icon contract" the unified window-close wave
9
+ * pinned (window-close.contract.test.ts). The positioning class
10
+ * `hk-range-ribbon-reset` survives ONLY as the seat hook the component
11
+ * sheet pins (it must keep clearing the edge handle).
12
+ *
13
+ * Pinned here so a refactor cannot silently re-sprout a bespoke chip —
14
+ * the same "looks unified but drifted" failure class the window-close
15
+ * contract guards against.
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const ribbon = readFileSync(join(here, "HkRangeRibbon.tsx"), "utf-8");
24
+
25
+ describe("range-ribbon reset control contract", () => {
26
+ it("renders the reset affordance through the standard HkIconButton", () => {
27
+ expect(ribbon, "the reset must ride HkIconButton chrome").toContain("HIconButton");
28
+ expect(ribbon, "the seat class survives as the positioning hook").toContain(
29
+ 'class="hk-range-ribbon-reset"',
30
+ );
31
+ expect(
32
+ ribbon,
33
+ "the glyph rides the house-normal lucide slot (every in-tree HkIconButton slots its glyph)",
34
+ ).toContain("<RotateCcw");
35
+ expect(ribbon, "no hand-rolled <button> may carry the reset class").not.toMatch(
36
+ /<button[^>]*hk-range-ribbon-reset/,
37
+ );
38
+ // The three things a partial revert silently drops while still passing
39
+ // the substring checks above.
40
+ expect(ribbon, "the reset keeps its touch-floor size").toMatch(
41
+ /<HIconButton[\s\S]{0,200}?size=\{24\}/,
42
+ );
43
+ // The non-submit guarantee moved upstream into HkIconButton (one
44
+ // implementation, not a per-call-site spread); it is pinned in
45
+ // HkIconButton.test.tsx and asserted on the rendered DOM in
46
+ // HkRangeRibbon.test.tsx.
47
+ expect(ribbon, "the reset passes the standard variant explicitly").toContain('variant="ghost"');
48
+ expect(ribbon, "the glyph rides the slot form, not the icon prop").toMatch(
49
+ /\{\{\s*icon:\s*\(\)\s*=>\s*<RotateCcw/,
50
+ );
51
+ });
52
+ });
package/src/index.ts CHANGED
@@ -978,7 +978,7 @@ export type {
978
978
  } from "./composables/useInfinitePages";
979
979
  export { default as HkFilterChip } from "./components/HkFilterChip";
980
980
  export { default as HkPillToggleGroup } from "./components/HkPillToggleGroup";
981
- export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
981
+ export type { HkPillToggleGroupOption, HkPillTone } from "./components/HkPillToggleGroup";
982
982
  export { HkIconChip as HkIconChip } from "./components/HkIconChip";
983
983
  export { HkIconChip as HIconChip } from "./components/HkIconChip";
984
984
  export { HkCardList, HkListRow } from "./components/HkCardList";