@celestia-island/hikari 0.55.271 → 0.55.273

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.271",
3
+ "version": "0.55.273",
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": "^*"
@@ -0,0 +1,77 @@
1
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick } from "vue";
3
+
4
+ import { HkCardList, HkListRow } from "./HkCardList";
5
+
6
+ /**
7
+ * Touch-first clients never get hover tooltips, so the option-control
8
+ * array swaps its inline icon lane for ONE trigger that opens the same
9
+ * actions as a named menu. The media query is stubbed here (this file
10
+ * runs in its own module graph — vitest isolates test files — so the
11
+ * shared useMediaQuery registry sees the touch-first answer).
12
+ */
13
+
14
+ beforeAll(() => {
15
+ vi.spyOn(window, "matchMedia").mockImplementation(
16
+ (query: string): MediaQueryList => ({
17
+ matches: query.includes("hover: none") || query.includes("pointer: coarse"),
18
+ media: query,
19
+ onchange: null,
20
+ addEventListener: () => {},
21
+ removeEventListener: () => {},
22
+ addListener: () => {},
23
+ removeListener: () => {},
24
+ dispatchEvent: () => false,
25
+ }),
26
+ );
27
+ });
28
+
29
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
30
+
31
+ afterEach(() => {
32
+ while (mounts.length) {
33
+ const { app, container } = mounts.pop()!;
34
+ app.unmount();
35
+ container.remove();
36
+ }
37
+ });
38
+
39
+ const icon = { render: () => null };
40
+
41
+ it("swaps the inline lane for a named upward menu trigger", async () => {
42
+ const picks: string[] = [];
43
+ const container = document.createElement("div");
44
+ document.body.appendChild(container);
45
+ const app = createApp({
46
+ render: () =>
47
+ h(HkCardList, null, {
48
+ default: () => [
49
+ h(HkListRow, {
50
+ title: "row",
51
+ actionsLabel: "row actions",
52
+ actionsMenuLabel: "more actions",
53
+ actions: [
54
+ { name: "reinstall", icon, onSelect: () => picks.push("reinstall") },
55
+ { name: "uninstall", danger: true, onSelect: () => picks.push("uninstall") },
56
+ ],
57
+ }),
58
+ ],
59
+ }),
60
+ });
61
+ mounts.push({ app, container });
62
+ app.mount(container);
63
+
64
+ const lane = container.querySelector(".hk-list-row-actions") as HTMLElement;
65
+ expect(lane.classList.contains("hk-list-row-actions--touch")).toBe(true);
66
+ // No inline icon buttons — the names live in the menu on this client.
67
+ expect(lane.querySelector(".hk-list-row-actions__btn")).toBeNull();
68
+ const trigger = lane.querySelector(".hk-list-row-actions__trigger") as HTMLButtonElement;
69
+ expect(trigger).toBeTruthy();
70
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
71
+ expect(trigger.getAttribute("aria-label")).toBe("more actions");
72
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
73
+
74
+ trigger.click();
75
+ await nextTick();
76
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
77
+ });
@@ -0,0 +1,148 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick, type Component } from "vue";
3
+
4
+ import { HkCardList, HkListRow, hkListRowMenuItems, type HkListRowAction } from "./HkCardList";
5
+
6
+ /**
7
+ * HkListRow's option-control array contract (pointer-client half):
8
+ *
9
+ * - persistent mode keeps the lane in the flow, one aria-labelled icon
10
+ * button per action;
11
+ * - overlay mode (the default) parks the lane out of the flow and only
12
+ * the touch reveal flips [data-revealed] (pointer devices reveal via
13
+ * CSS :hover/:focus-within, which happy-dom cannot exercise);
14
+ * - every action's NAME is its accessible identity — the aria-label and
15
+ * the menu model label (desktop tooltip / touch menu row);
16
+ * - the menu model maps the declared actions one-to-one.
17
+ *
18
+ * (Repo test convention: raw createApp + document queries; happy-dom
19
+ * has no layout engine.)
20
+ */
21
+
22
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
23
+
24
+ afterEach(() => {
25
+ while (mounts.length) {
26
+ const { app, container } = mounts.pop()!;
27
+ app.unmount();
28
+ container.remove();
29
+ }
30
+ });
31
+
32
+ function mount(render: () => ReturnType<typeof h>): HTMLElement {
33
+ const container = document.createElement("div");
34
+ document.body.appendChild(container);
35
+ const app = createApp({ render });
36
+ mounts.push({ app, container });
37
+ app.mount(container);
38
+ return container;
39
+ }
40
+
41
+ function actionsOf(selects: Array<() => void>, over: Partial<HkListRowAction> = {}): HkListRowAction[] {
42
+ return selects.map((onSelect, i) => ({ name: "action " + (i + 1), onSelect, ...over }));
43
+ }
44
+
45
+ /** pointerType cannot ride the Event constructor in every engine —
46
+ * inject it on a plain bubbling event. */
47
+ function pointerDown(target: EventTarget, pointerType: string): void {
48
+ const ev = new Event("pointerdown", { bubbles: true, cancelable: true });
49
+ Object.defineProperty(ev, "pointerType", { value: pointerType });
50
+ target.dispatchEvent(ev);
51
+ }
52
+
53
+ function mountRow(props: Record<string, unknown>, trailing?: () => unknown): HTMLElement {
54
+ return mount(() =>
55
+ h(HkCardList, null, {
56
+ default: () => [
57
+ h(HkListRow, { title: "row", ...props }, trailing ? { trailing } : undefined),
58
+ ],
59
+ }),
60
+ );
61
+ }
62
+
63
+ describe("HkListRow actions — persistent mode", () => {
64
+ it("renders one named button per action and reports selection", () => {
65
+ const picks: string[] = [];
66
+ const container = mountRow({
67
+ actions: [
68
+ { name: "reinstall", onSelect: () => picks.push("reinstall"), icon: { render: () => null } as Component },
69
+ { name: "uninstall", onSelect: () => picks.push("uninstall"), danger: true },
70
+ { name: "disabled one", onSelect: () => picks.push("no"), disabled: true },
71
+ ],
72
+ actionsMode: "persistent",
73
+ actionsLabel: "row actions",
74
+ });
75
+ const lane = container.querySelector(".hk-list-row-actions") as HTMLElement;
76
+ expect(lane).toBeTruthy();
77
+ expect(lane.classList.contains("hk-list-row-actions--persistent")).toBe(true);
78
+ expect(lane.getAttribute("role")).toBe("toolbar");
79
+ expect(lane.getAttribute("aria-label")).toBe("row actions");
80
+ const buttons = Array.from(lane.querySelectorAll<HTMLButtonElement>(".hk-list-row-actions__btn"));
81
+ expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
82
+ "reinstall",
83
+ "uninstall",
84
+ "disabled one",
85
+ ]);
86
+ expect(buttons[1].hasAttribute("data-danger")).toBe(true);
87
+ expect(buttons[2].disabled).toBe(true);
88
+ buttons[0].click();
89
+ buttons[1].click();
90
+ buttons[2].click();
91
+ expect(picks).toEqual(["reinstall", "uninstall"]);
92
+ });
93
+ });
94
+
95
+ describe("HkListRow actions — overlay mode", () => {
96
+ it("parks the lane out of the flow until a non-mouse pointer engages it", async () => {
97
+ const container = mountRow({
98
+ actions: actionsOf([() => {}]),
99
+ actionsMode: "overlay",
100
+ });
101
+ const lane = () => container.querySelector(".hk-list-row-actions") as HTMLElement;
102
+ expect(lane().classList.contains("hk-list-row-actions--overlay")).toBe(true);
103
+ expect(lane().getAttribute("data-revealed")).toBeNull();
104
+ const row = container.querySelector(".hk-list-row") as HTMLElement;
105
+
106
+ // Mouse presses never reveal — pointer devices reveal through CSS
107
+ // :hover/:focus-within, so the JS path stays silent for them.
108
+ pointerDown(row, "mouse");
109
+ await nextTick();
110
+ expect(lane().getAttribute("data-revealed")).toBeNull();
111
+
112
+ // A touch tap inside the row reveals the lane…
113
+ pointerDown(row, "touch");
114
+ await nextTick();
115
+ expect(lane().getAttribute("data-revealed")).not.toBeNull();
116
+
117
+ // …and a tap anywhere else dismisses it again.
118
+ pointerDown(document.body, "touch");
119
+ await nextTick();
120
+ expect(lane().getAttribute("data-revealed")).toBeNull();
121
+ });
122
+
123
+ it("keeps the overlay lane focusable for keyboard users", () => {
124
+ // Hiding is opacity/pointer-events only: visibility:hidden would
125
+ // drop the buttons out of the tab order and strand keyboard users.
126
+ const container = mountRow({
127
+ actions: actionsOf([() => {}]),
128
+ actionsMode: "overlay",
129
+ });
130
+ const btn = container.querySelector(".hk-list-row-actions__btn") as HTMLButtonElement;
131
+ expect(btn).toBeTruthy();
132
+ expect(btn.disabled).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("hkListRowMenuItems", () => {
137
+ it("maps every declared action onto its menu row, keyed by name", () => {
138
+ const icon = { render: () => null } as Component;
139
+ const items = hkListRowMenuItems([
140
+ { name: "reinstall", onSelect: () => {}, icon },
141
+ { name: "uninstall", onSelect: () => {}, danger: true, disabled: true },
142
+ ]);
143
+ expect(items).toEqual([
144
+ { key: "reinstall", label: "reinstall", icon, danger: undefined, disabled: undefined },
145
+ { key: "uninstall", label: "uninstall", icon: undefined, danger: true, disabled: true },
146
+ ]);
147
+ });
148
+ });
@@ -66,3 +66,106 @@
66
66
  align-items: center;
67
67
  gap: var(--space-8, 0.5rem);
68
68
  }
69
+
70
+ /* ── Option-control array (HkListRow's `actions`) ───────────────────────
71
+ * The row's last lane: named icon buttons in one of two layouts (see
72
+ * HkListRowActionsMode). "persistent" keeps the lane in the flow — it
73
+ * costs the row its width and stays visible. "overlay" (the default)
74
+ * parks the lane OUT of the flow: pinned to the row's right edge,
75
+ * invisible and inert until engaged — hover / keyboard focus on pointer
76
+ * devices (CSS below), tap on touch (the [data-revealed] state) — and
77
+ * then temporarily covers the trailing content, so a dense list never
78
+ * pays for actions it is not using. Touch-first clients swap the lane
79
+ * for one trigger that opens the same actions as a named, upward menu
80
+ * (tooltips never fire there, so names must live in the menu). */
81
+ .hk-list-row {
82
+ position: relative;
83
+ }
84
+
85
+ .hk-list-row-actions {
86
+ display: inline-flex;
87
+ align-items: center;
88
+ gap: var(--space-4, 0.25rem);
89
+ flex-shrink: 0;
90
+
91
+ /* In the flow (persistent + touch): pin to the right edge when the
92
+ row carries no trailing slot of its own. */
93
+ &--persistent,
94
+ &--touch {
95
+ margin-left: auto;
96
+ }
97
+
98
+ /* Parked overlay: absolute over the row's right content. Kept
99
+ focusable for keyboard users — opacity alone hides it visually
100
+ while :focus-within reveals it (visibility:hidden would drop it
101
+ out of the tab order entirely). */
102
+ &--overlay {
103
+ position: absolute;
104
+ right: var(--space-8, 0.5rem);
105
+ top: 50%;
106
+ transform: translateY(-50%);
107
+ padding: 0.125rem;
108
+ border-radius: var(--radius-sm, 0.375rem);
109
+ background: rgb(var(--color-surface, 255 255 255));
110
+ box-shadow: 0 2px 10px rgb(var(--color-border, 100 100 100) / 22%);
111
+ opacity: 0;
112
+ pointer-events: none;
113
+ transition: opacity var(--duration-fast, 0.15s) ease;
114
+ }
115
+
116
+ &--overlay[data-revealed],
117
+ &--overlay:focus-within {
118
+ opacity: 1;
119
+ pointer-events: auto;
120
+ }
121
+ }
122
+
123
+ /* Pointer devices: hovering the row reveals the parked overlay. */
124
+ @media (hover: hover) {
125
+ .hk-list-row:hover > .hk-list-row-actions--overlay {
126
+ opacity: 1;
127
+ pointer-events: auto;
128
+ }
129
+ }
130
+
131
+ .hk-list-row-actions__btn,
132
+ .hk-list-row-actions__trigger {
133
+ display: inline-flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ width: 1.625rem;
137
+ height: 1.625rem;
138
+ padding: 0;
139
+ border: 1px solid transparent;
140
+ border-radius: var(--radius-xs, 0.25rem);
141
+ background: transparent;
142
+ color: rgb(var(--color-muted, 108 108 108));
143
+ cursor: pointer;
144
+ transition:
145
+ background-color var(--duration-fast, 0.15s) ease,
146
+ color var(--duration-fast, 0.15s) ease;
147
+
148
+ &:hover:not(:disabled) {
149
+ background: rgb(var(--color-text, 30 30 30) / 6%);
150
+ color: rgb(var(--color-text, 30 30 30));
151
+ }
152
+
153
+ &:focus-visible {
154
+ outline: 2px solid rgb(var(--color-primary, 122 162 247));
155
+ outline-offset: -2px;
156
+ }
157
+
158
+ &:disabled {
159
+ opacity: 0.45;
160
+ cursor: default;
161
+ }
162
+
163
+ &[data-danger] {
164
+ color: rgb(var(--color-error, 247 118 142) / 90%);
165
+
166
+ &:hover:not(:disabled) {
167
+ background: rgb(var(--color-error, 247 118 142) / 10%);
168
+ color: rgb(var(--color-error, 247 118 142));
169
+ }
170
+ }
171
+ }
@@ -1,7 +1,12 @@
1
- import { Comment, defineComponent, type Component, type PropType, type VNode } from "vue";
1
+ import { Comment, computed, defineComponent, h, onBeforeUnmount, onMounted, ref, type Component, type PropType, type VNode } from "vue";
2
+ import { MoreHorizontal } from "lucide-vue-next";
2
3
 
3
4
  import HkCard from "./HkCard";
4
5
  import { HkIconChip } from "./HkIconChip";
6
+ import HkMenu from "./HkMenu";
7
+ import type { HkMenuItem } from "./HkMenu";
8
+ import HkTooltip from "./HkTooltip";
9
+ import { useMediaQuery } from "../runtime/useMediaQuery";
5
10
  import type { StatTone } from "./HkStatCard";
6
11
  import "./HkCardList.scss";
7
12
 
@@ -57,6 +62,39 @@ export const HkCardList = defineComponent({
57
62
  },
58
63
  });
59
64
 
65
+ /** One named action of an HkListRow's option-control array (the
66
+ * `actions` prop). `name` is REQUIRED: it is the button's accessible
67
+ * name and its only visible identity — the pointer tooltip's text on
68
+ * hover devices, and the touch/narrow menu's row label where tooltips
69
+ * never fire. */
70
+ export interface HkListRowAction {
71
+ name: string;
72
+ icon?: Component;
73
+ danger?: boolean;
74
+ disabled?: boolean;
75
+ onSelect: () => void;
76
+ }
77
+
78
+ /** The option-control array's layout contract (see `actionsMode`):
79
+ * "overlay" — the array costs the row no width; it stays hidden until
80
+ * engaged (hover / keyboard focus on pointer devices, tap on touch)
81
+ * and then temporarily covers the row's right content. "persistent" —
82
+ * the array takes the last column and stays visible. */
83
+ export type HkListRowActionsMode = "overlay" | "persistent";
84
+
85
+ /** The touch trigger's menu model: one HkMenuItem per named action.
86
+ * Keys are the action NAMES (unique per row by contract), so the
87
+ * menu's select payload maps straight back onto `onSelect`. */
88
+ export function hkListRowMenuItems(actions: readonly HkListRowAction[]): HkMenuItem[] {
89
+ return actions.map((a) => ({
90
+ key: a.name,
91
+ label: a.name,
92
+ icon: a.icon,
93
+ danger: a.danger,
94
+ disabled: a.disabled,
95
+ }));
96
+ }
97
+
60
98
  /**
61
99
  * HkListRow — one entry of an HkCardList: an optional tone-tinted icon
62
100
  * chip, a main column (title / subtitle / free-form default slot), and a
@@ -76,11 +114,48 @@ export const HkListRow = defineComponent({
76
114
  subtitle: { type: String, default: undefined },
77
115
  /** Whole-row click (drill-through). */
78
116
  clickable: { type: Boolean, default: false },
117
+ /** The row's option-control array: named icon actions rendered as
118
+ * the row's last lane. Omit for rows without row-level actions. */
119
+ actions: { type: Array as PropType<HkListRowAction[]>, default: undefined },
120
+ /** See HkListRowActionsMode. Default "overlay": the array is parked
121
+ * out of the flow and only covers the right content while engaged. */
122
+ actionsMode: { type: String as PropType<HkListRowActionsMode>, default: "overlay" },
123
+ /** Accessible name of the action lane (role="toolbar"). */
124
+ actionsLabel: { type: String, default: undefined },
125
+ /** Accessible name / menu title of the touch trigger. Falls back to
126
+ * `actionsLabel`. */
127
+ actionsMenuLabel: { type: String, default: undefined },
79
128
  },
80
129
  emits: { click: (_e: MouseEvent | KeyboardEvent) => true },
81
130
  setup(props, { emit, slots }) {
131
+ // Touch-first clients get the named menu (tooltips never fire there);
132
+ // pointer clients get the inline icon lane whose names ride tooltips.
133
+ const touchFirst = useMediaQuery("(hover: none), (pointer: coarse)");
134
+ /** Touch-driven overlay reveal. Pointer devices reveal through CSS
135
+ * :hover/:focus-within — the common path stays declarative. */
136
+ const revealed = ref(false);
137
+ const menuOpen = ref(false);
138
+ const rowEl = ref<HTMLElement | null>(null);
139
+ const triggerEl = ref<HTMLElement | null>(null);
140
+ const hasActions = () => (props.actions?.length ?? 0) > 0;
141
+ /** A tap inside the row reveals the overlay lane; a tap anywhere
142
+ * else dismisses it (capture-phase document listener). */
143
+ function onRowPointerDown(e: PointerEvent) {
144
+ if (!hasActions() || e.pointerType === "mouse") return;
145
+ revealed.value = true;
146
+ }
147
+ function onDocumentPointerDown(e: PointerEvent) {
148
+ if (!revealed.value) return;
149
+ if (rowEl.value?.contains(e.target as Node)) return;
150
+ revealed.value = false;
151
+ }
152
+ onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown, true));
153
+ onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown, true));
154
+ const menuItems = computed<HkMenuItem[]>(() => hkListRowMenuItems(props.actions ?? []));
82
155
  return () => (
83
156
  <div
157
+ ref={rowEl}
158
+ onPointerdown={onRowPointerDown}
84
159
  class={["hk-list-row", props.clickable && "hk-list-row-clickable"]}
85
160
  role="listitem"
86
161
  tabindex={props.clickable ? 0 : undefined}
@@ -107,6 +182,67 @@ export const HkListRow = defineComponent({
107
182
  {slots.default?.()}
108
183
  </div>
109
184
  {slots.trailing ? <div class="hk-list-row-trailing">{slots.trailing()}</div> : null}
185
+ {hasActions() && (
186
+ <div
187
+ class={[
188
+ "hk-list-row-actions",
189
+ touchFirst.value
190
+ ? "hk-list-row-actions--touch"
191
+ : props.actionsMode === "persistent"
192
+ ? "hk-list-row-actions--persistent"
193
+ : "hk-list-row-actions--overlay",
194
+ ]}
195
+ data-revealed={revealed.value ? "" : undefined}
196
+ role={touchFirst.value ? undefined : "toolbar"}
197
+ aria-label={touchFirst.value ? undefined : props.actionsLabel}
198
+ >
199
+ {touchFirst.value ? (
200
+ <button
201
+ ref={triggerEl}
202
+ type="button"
203
+ class="hk-list-row-actions__trigger"
204
+ aria-haspopup="menu"
205
+ aria-expanded={menuOpen.value}
206
+ aria-label={props.actionsMenuLabel ?? props.actionsLabel}
207
+ onClick={() => (menuOpen.value = !menuOpen.value)}
208
+ >
209
+ <MoreHorizontal size={14} />
210
+ </button>
211
+ ) : (
212
+ (props.actions ?? []).map((a) => (
213
+ <HkTooltip text={a.name} placement="top">
214
+ {() => (
215
+ <button
216
+ type="button"
217
+ class="hk-list-row-actions__btn"
218
+ data-danger={a.danger || undefined}
219
+ disabled={a.disabled}
220
+ aria-label={a.name}
221
+ onClick={() => a.onSelect()}
222
+ >
223
+ {a.icon ? h(a.icon, { size: 14 }) : null}
224
+ </button>
225
+ )}
226
+ </HkTooltip>
227
+ ))
228
+ )}
229
+ </div>
230
+ )}
231
+ {menuOpen.value && (
232
+ <HkMenu
233
+ variant="popup"
234
+ items={menuItems.value}
235
+ open
236
+ anchorRef={triggerEl.value}
237
+ placement="top-end"
238
+ title={props.actionsMenuLabel ?? props.actionsLabel ?? ""}
239
+ onSelect={(key: string) => {
240
+ menuOpen.value = false;
241
+ (props.actions ?? []).find((a) => a.name === key)?.onSelect();
242
+ }}
243
+ onUpdate:open={(v: boolean) => (menuOpen.value = v)}
244
+ />
245
+ )}
110
246
  </div>
111
247
  );
112
248
  },
@@ -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;`);