@celestia-island/hikari 0.55.176 → 0.55.177

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.176",
3
+ "version": "0.55.177",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick, ref } from "vue";
3
3
 
4
4
  import HDraggableList from "./HkDraggableList";
@@ -180,3 +180,165 @@ describe("HkDraggableList locked-lane contract", () => {
180
180
  expect(root.hasAttribute("data-all-locked")).toBe(false);
181
181
  });
182
182
  });
183
+
184
+ describe("HkDraggableList list semantics", () => {
185
+ /** A reorderable list is a list for AT users whether or not the host
186
+ * names it: the root always carries role="list" and every row
187
+ * role="listitem" (the vocabulary a11y rulings #736/#737 applied to
188
+ * the picker surfaces — the drag list adopts the same contract). The
189
+ * accessible NAME stays the host's call: aria-label renders only when
190
+ * the `label` prop is passed, so an unnamed list is still structural
191
+ * and never announces an empty string. */
192
+
193
+ it("announces the root and rows as a list regardless of the label", () => {
194
+ const root = mountList(["a", "b"], []);
195
+
196
+ expect(root.getAttribute("role")).toBe("list");
197
+ expect(root.hasAttribute("aria-label")).toBe(false);
198
+ const rows = Array.from(root.querySelectorAll(".hk-draggable-list-item"));
199
+ expect(rows).toHaveLength(2);
200
+ expect(rows.every((row) => row.getAttribute("role") === "listitem")).toBe(true);
201
+ });
202
+
203
+ it("names the list through the label prop", () => {
204
+ const container = document.createElement("div");
205
+ document.body.appendChild(container);
206
+ const app = createApp({
207
+ render: () =>
208
+ h(
209
+ HDraggableList,
210
+ { items: [{ key: "a" }], label: "Sortable accounts" },
211
+ {
212
+ item: ({ item }: { item: { key: string } }) =>
213
+ h("span", { class: "row-body" }, item.key),
214
+ },
215
+ ),
216
+ });
217
+ app.mount(container);
218
+ mounts.push({ app, container });
219
+
220
+ const root = container.querySelector(".hk-draggable-list") as HTMLElement;
221
+ expect(root.getAttribute("role")).toBe("list");
222
+ expect(root.getAttribute("aria-label")).toBe("Sortable accounts");
223
+ });
224
+ });
225
+
226
+ describe("HkDraggableList press ergonomics (clickable rows)", () => {
227
+ /** Two guards make a clickable row safe inside the list (ported from
228
+ * wowsp's SortableList twin, which ran them in production):
229
+ *
230
+ * 1. ARM — a press under 5px of travel never publishes drag state
231
+ * (no ghost flash on a jittered tap) and keeps the tap's native
232
+ * semantics: no preventDefault until the arm.
233
+ * 2. SWALLOW — the release of an ARMED drag lands as a click on the
234
+ * row the press started from; a capture-phase guard on the root
235
+ * eats it, so dropping a row onto a clickable card never also
236
+ * activates that card. Time-boxed at 300ms so it can never eat
237
+ * a later keyboard activation, which has no pointerdown to
238
+ * reset it. */
239
+
240
+ const PointerEventCtor: typeof MouseEvent =
241
+ (globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
242
+
243
+ const pointerInit: MouseEventInit = { bubbles: true, cancelable: true };
244
+
245
+ function fire(el: Element, type: string, x = 0, y = 0) {
246
+ el.dispatchEvent(new PointerEventCtor(type, {
247
+ ...pointerInit,
248
+ button: 0,
249
+ clientX: x,
250
+ clientY: y,
251
+ }));
252
+ }
253
+
254
+ function fireWin(type: string, x = 0, y = 0) {
255
+ window.dispatchEvent(new PointerEventCtor(type, {
256
+ ...pointerInit,
257
+ clientX: x,
258
+ clientY: y,
259
+ }));
260
+ }
261
+
262
+ function mountClickable(): { root: HTMLElement; clicks: () => number } {
263
+ let clicked = 0;
264
+ const container = document.createElement("div");
265
+ document.body.appendChild(container);
266
+ const app = createApp({
267
+ render: () =>
268
+ h(
269
+ HDraggableList,
270
+ { items: [{ key: "a" }, { key: "b" }] },
271
+ {
272
+ item: () =>
273
+ h(
274
+ "div",
275
+ { class: "row-body", onClick: () => { clicked++; } },
276
+ "row",
277
+ ),
278
+ },
279
+ ),
280
+ });
281
+ app.mount(container);
282
+ mounts.push({ app, container });
283
+ const root = container.querySelector(".hk-draggable-list") as HTMLElement;
284
+ return { root, clicks: () => clicked };
285
+ }
286
+
287
+ it("keeps a plain tap a tap: no drag state, and the row's click fires", async () => {
288
+ const { root, clicks } = mountClickable();
289
+ const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
290
+
291
+ fire(row, "pointerdown", 10, 10);
292
+ // A 1px jitter stays under the arm threshold — no drag publishes.
293
+ fireWin("pointermove", 11, 10);
294
+ await nextTick();
295
+ expect(row.hasAttribute("data-dragging")).toBe(false);
296
+
297
+ fireWin("pointerup");
298
+ (row.querySelector(".row-body") as HTMLElement).dispatchEvent(
299
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
300
+ );
301
+ expect(clicks()).toBe(1);
302
+ });
303
+
304
+ it("arms past the threshold, drags, and swallows the release click", async () => {
305
+ const { root, clicks } = mountClickable();
306
+ const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
307
+
308
+ fire(row, "pointerdown", 10, 10);
309
+ fireWin("pointermove", 30, 40);
310
+ await nextTick();
311
+ expect(row.getAttribute("data-dragging")).not.toBeNull();
312
+
313
+ fireWin("pointerup");
314
+ (row.querySelector(".row-body") as HTMLElement).dispatchEvent(
315
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
316
+ );
317
+ expect(clicks()).toBe(0);
318
+ });
319
+
320
+ it("time-boxes the swallow — a click after the window passes through", async () => {
321
+ vi.useFakeTimers();
322
+ try {
323
+ const { root, clicks } = mountClickable();
324
+ const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
325
+
326
+ fire(row, "pointerdown", 10, 10);
327
+ fireWin("pointermove", 30, 40);
328
+ fireWin("pointerup");
329
+
330
+ (row.querySelector(".row-body") as HTMLElement).dispatchEvent(
331
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
332
+ );
333
+ expect(clicks()).toBe(0);
334
+
335
+ vi.advanceTimersByTime(301);
336
+ (row.querySelector(".row-body") as HTMLElement).dispatchEvent(
337
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
338
+ );
339
+ expect(clicks()).toBe(1);
340
+ } finally {
341
+ vi.useRealTimers();
342
+ }
343
+ });
344
+ });
@@ -1,4 +1,4 @@
1
- import { defineComponent, onBeforeUnmount, ref, shallowRef, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUnmount, onMounted, ref, shallowRef, type PropType } from "vue";
2
2
  import { onceFrame } from "../runtime/animationBus";
3
3
  import { useReportedTransition } from "../composables/useReportedTransition";
4
4
  import { ancestorZoom } from "../runtime/cssZoom";
@@ -14,10 +14,23 @@ interface DragState {
14
14
  fromIndex: number;
15
15
  placeholderIndex: number;
16
16
  originX: number;
17
+ originY: number;
17
18
  grabOffsetX: number;
18
19
  grabOffsetY: number;
19
20
  }
20
21
 
22
+ /** Press-to-drag arm distance — a plain click on a row (or its handle)
23
+ * never starts a drag: the ghost only rises once the pointer moves past
24
+ * this many px, so a clickable row keeps its tap action and a jittered
25
+ * tap never flashes a ghost. */
26
+ const ARM_PX = 5;
27
+
28
+ /** How long after a drop the release click is still recognized as the
29
+ * drag's own click. Time-boxed (rather than a sticky flag) so a drag
30
+ * released off-window can never eat a later keyboard activation,
31
+ * which has no pointerdown to reset it. */
32
+ const SWALLOW_CLICK_MS = 300;
33
+
21
34
  export default defineComponent({
22
35
  name: "HkDraggableList",
23
36
  props: {
@@ -27,6 +40,12 @@ export default defineComponent({
27
40
  * locked the root carries data-all-locked and the sheet collapses
28
41
  * the lane entirely — the list cannot reorder at all. */
29
42
  lockedKeys: { type: Array as PropType<string[]>, default: () => [] },
43
+ /** Accessible name for the list (aria-label on the role="list" root).
44
+ * The list semantics themselves are unconditional — a reorderable
45
+ * list is a list for AT users whether or not the host names it —
46
+ * mirroring the menu/vocabulary a11y rulings (#736/#737): chrome
47
+ * announces structure, the host supplies the name. */
48
+ label: { type: String, default: undefined },
30
49
  },
31
50
  emits: {
32
51
  reorder: (_fromIndex: number, _toIndex: number) => true,
@@ -46,6 +65,38 @@ export default defineComponent({
46
65
 
47
66
  let ghostNode: HTMLElement | null = null;
48
67
 
68
+ /** Press bookkeeping — captured at pointerdown, never read during
69
+ * render. `armed` flips only when the pointer crosses ARM_PX; until
70
+ * then the press is a plain click-in-progress (no ghost, no drag
71
+ * state, no preventDefault — the tap keeps its native semantics). */
72
+ let press: {
73
+ rect: DOMRect;
74
+ snapshot: string;
75
+ state: DragState;
76
+ armed: boolean;
77
+ } | null = null;
78
+ /** Timestamp of an armed drag's release — the capture-phase click
79
+ * guard below eats the click a completed drag leaves behind (press
80
+ * + release on the same element), so dropping a row onto a
81
+ * clickable card never also activates that card. */
82
+ let swallowClickAt = 0;
83
+
84
+ const rootEl = ref<HTMLElement | null>(null);
85
+
86
+ /** Capture-phase click guard — runs before any row content's own
87
+ * handlers (Vue's JSX typings have no onClickCapture attribute, so
88
+ * the listener is wired imperatively in onMounted). */
89
+ function onRootClickCapture(e: MouseEvent) {
90
+ if (Date.now() - swallowClickAt > SWALLOW_CLICK_MS) return;
91
+ swallowClickAt = 0;
92
+ e.preventDefault();
93
+ e.stopPropagation();
94
+ }
95
+
96
+ onMounted(() => {
97
+ rootEl.value?.addEventListener("click", onRootClickCapture, true);
98
+ });
99
+
49
100
  const anim = useReportedTransition(250);
50
101
 
51
102
  function bump() { tick.value++; }
@@ -68,28 +119,29 @@ export default defineComponent({
68
119
  const row = rowEls.value[index];
69
120
  if (!row) return;
70
121
 
71
- e.preventDefault();
72
122
  const rect = row.getBoundingClientRect();
73
123
 
74
124
  const snapshot = row.outerHTML
75
125
  .replace(/\sdraggable="[^"]*"/g, "")
76
126
  .replace(/\sdata-dragging="[^"]*"/g, "");
77
127
 
78
- const state: DragState = {
79
- fromIndex: index,
80
- placeholderIndex: index,
81
- originX: e.clientX,
82
- grabOffsetX: e.clientX - rect.left,
83
- grabOffsetY: e.clientY - rect.top,
128
+ // The press captures everything the arm needs. Nothing publishes
129
+ // yet — no preventDefault either: the handle lane carries its own
130
+ // touch-action:none and the rows are user-select:none, so a plain
131
+ // tap keeps its native click/scroll semantics untouched.
132
+ press = {
133
+ rect,
134
+ snapshot,
135
+ state: {
136
+ fromIndex: index,
137
+ placeholderIndex: index,
138
+ originX: e.clientX,
139
+ originY: e.clientY,
140
+ grabOffsetX: e.clientX - rect.left,
141
+ grabOffsetY: e.clientY - rect.top,
142
+ },
143
+ armed: false,
84
144
  };
85
- drag.value = state;
86
- bump();
87
-
88
- onceFrame(() => {
89
- if (!drag.value) return;
90
- attachGhost(rect, snapshot);
91
- anim.run();
92
- });
93
145
 
94
146
  window.addEventListener("pointermove", onPointerMove, { passive: false });
95
147
  window.addEventListener("pointerup", onPointerUp, { once: true });
@@ -170,22 +222,45 @@ export default defineComponent({
170
222
  }
171
223
 
172
224
  function onPointerMove(e: PointerEvent) {
173
- const s = drag.value;
174
- if (!s) return;
225
+ const p = press;
226
+ if (!p) return;
227
+
228
+ if (!p.armed) {
229
+ // Arm past the threshold: only now does the drag publish, the
230
+ // ghost rise, and the gesture claim its defaults.
231
+ if (Math.hypot(e.clientX - p.state.originX, e.clientY - p.state.originY) <= ARM_PX) {
232
+ return;
233
+ }
234
+ p.armed = true;
235
+ drag.value = p.state;
236
+ bump();
237
+ const { rect, snapshot } = p;
238
+ onceFrame(() => {
239
+ if (!drag.value) return;
240
+ attachGhost(rect, snapshot);
241
+ anim.run();
242
+ });
243
+ }
244
+
175
245
  e.preventDefault();
176
246
  moveGhost(e.clientY);
177
247
  recomputePlaceholder(e.clientY);
178
248
  }
179
249
 
180
250
  function onPointerUp() {
181
- const s = drag.value;
182
251
  window.removeEventListener("pointermove", onPointerMove);
183
252
 
253
+ const s = drag.value;
184
254
  if (s) {
255
+ // An armed drag's release lands as a click on the row the press
256
+ // started from — mark it for the capture guard or every drop
257
+ // onto a clickable card would also activate the card.
258
+ swallowClickAt = Date.now();
185
259
  if (s.placeholderIndex !== s.fromIndex) {
186
260
  emit("reorder", s.fromIndex, s.placeholderIndex);
187
261
  }
188
262
  }
263
+ press = null;
189
264
  anim.run();
190
265
  drag.value = null;
191
266
  bump();
@@ -193,6 +268,7 @@ export default defineComponent({
193
268
  }
194
269
 
195
270
  onBeforeUnmount(() => {
271
+ rootEl.value?.removeEventListener("click", onRootClickCapture, true);
196
272
  window.removeEventListener("pointermove", onPointerMove);
197
273
  detachGhost();
198
274
  });
@@ -211,7 +287,10 @@ export default defineComponent({
211
287
  props.items.length > 0 && props.items.every((item) => isLocked(item.key));
212
288
  return (
213
289
  <div
290
+ ref={rootEl}
214
291
  class="hk-draggable-list"
292
+ role="list"
293
+ aria-label={props.label || undefined}
215
294
  data-dragging={s ? "" : undefined}
216
295
  data-all-locked={allLocked || undefined}
217
296
  >
@@ -227,6 +306,7 @@ export default defineComponent({
227
306
  key={item.key}
228
307
  ref={(el) => setRowEl(index, el as Element | null)}
229
308
  class="hk-draggable-list-item"
309
+ role="listitem"
230
310
  data-locked={locked || undefined}
231
311
  data-dragging={isSource || undefined}
232
312
  data-placeholder={placeholderDir || undefined}
@@ -165,13 +165,17 @@
165
165
  /* The sheet's bottom-edge distance itself comes from the shared
166
166
  * --hk-sheet-bottom-gap (inline in HkPopover's sheet style); here the
167
167
  * content keeps the last row clear of the home bar: the safe-area
168
- * inset stacked on a 12px family base, which also restores a bottom
169
- * cushion over the glass dropdown chrome (equal specificity, earlier
168
+ * inset stacked on the shared container-pad token, whose legacy
169
+ * fallback is the 12px family base (which also restores a bottom
170
+ * cushion over the glass dropdown chrome — equal specificity, earlier
170
171
  * in source, whose own bottom padding the sheet rule would otherwise
171
- * zero out). The modal sheet footer goes further (it adds
172
- * --hk-sheet-footer-gap for its action buttons); this sheet has no
173
- * footer, so this is the family baseline. */
174
- padding-bottom: calc(var(--space-12, 0.75rem) + env(safe-area-inset-bottom, 0px));
172
+ * zero out). A host that sets the `padding` prop retunes the sheet's
173
+ * bottom inset with the SAME step its anchored panel uses (#808
174
+ * completion — hosts stop hand-padding the sheet branch); the safe-
175
+ * area term always rides on top. The modal sheet footer goes further
176
+ * (it adds --hk-sheet-footer-gap for its action buttons); this sheet
177
+ * has no footer, so this is the family baseline. */
178
+ padding-bottom: calc(var(--hk-pad, var(--space-12, 0.75rem)) + env(safe-area-inset-bottom, 0px));
175
179
 
176
180
  /* Overlay scrollbar (useOverlayScrollbar, sheet mode only) — the
177
181
  native chrome is hidden, never styled. */
@@ -184,9 +188,14 @@
184
188
 
185
189
  .hk-popover-sheet-grabber {
186
190
  flex-shrink: 0;
191
+ // Chrome, not content: cancel the panel's shared container pad inline
192
+ // (see the header twin above) so the grabber zone spans the full sheet
193
+ // width at any `padding` step — the centered bar never drifts inward.
194
+ // Longhand AFTER the shorthand or `margin: 0 auto` would win the cascade.
195
+ margin: 0 auto;
196
+ margin-inline: calc(-1 * var(--hk-pad, 0px));
187
197
  // Grabber zone: tap target tall enough for a thumb, bar visually small.
188
198
  padding: 14px 0 20px;
189
- margin: 0 auto;
190
199
  width: 100%;
191
200
  cursor: pointer;
192
201
  display: flex;
@@ -212,15 +221,26 @@
212
221
  * The title aligns to the CONTENT edge: the leading inset defaults to
213
222
  * --space-12, matching the padded content blocks popover sheets host
214
223
  * (e.g. the house 0.75rem popover body). Hosts tune it via
215
- * --hk-sheet-title-inset; the trailing inset hugs the close button. */
224
+ * --hk-sheet-title-inset; the trailing inset hugs the close button.
225
+ *
226
+ * Shared container pad (#808 completion): the panel's `padding` prop
227
+ * insets the CONTENT band, so this chrome row cancels it inline
228
+ * (negative margin, the valid-CSS twin of a negative padding) and
229
+ * keeps its own title-inset from the panel frame — the title lands on
230
+ * the content edge whenever the pad step matches title-inset, at any
231
+ * step. The trailing block gap is the CONTENT's block-start air under
232
+ * the header: it reads the same pad step when the prop is set (legacy
233
+ * 8px otherwise), so a padded host gets one uniform inset around its
234
+ * sheet content too — no hand-padded wrapper branch downstream. */
216
235
  .hk-popover-sheet-header {
217
236
  flex-shrink: 0;
218
237
  display: flex;
219
238
  align-items: center;
220
239
  gap: var(--space-8, 0.5rem);
240
+ margin-inline: calc(-1 * var(--hk-pad, 0px));
221
241
  // Logical longhand (was physical `0 x y title-inset`): the title inset is
222
242
  // the inline START edge so RTL mirrors it with the mirrored title/close.
223
- padding-block: 0 var(--space-8, 0.5rem);
243
+ padding-block: 0 var(--hk-pad, var(--space-8, 0.5rem));
224
244
  padding-inline: var(--hk-sheet-title-inset, var(--space-12, 0.75rem))
225
245
  var(--space-8, 0.5rem);
226
246
  }
@@ -28,9 +28,12 @@ describe("HkPopover mobile sheet docking contract", () => {
28
28
 
29
29
  it("keeps sheet content clear of the home bar via the safe-area inset", () => {
30
30
  const block = scss.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)?.[0] ?? "";
31
- // Stacked on the family 12px base so the glass dropdown chrome's
32
- // bottom padding survives on non-notched phones too.
33
- expect(block).toContain("calc(var(--space-12");
31
+ // Stacked on the shared container-pad token whose legacy fallback is
32
+ // the family 12px base, so the glass dropdown chrome's bottom padding
33
+ // survives on non-notched phones too — and a host's `padding` prop
34
+ // retunes the base on the sheet face exactly like the anchored one
35
+ // (#808 completion; see containerPad.styles.test.ts for the pin).
36
+ expect(block).toContain("calc(var(--hk-pad, var(--space-12");
34
37
  expect(block).toContain("env(safe-area-inset-bottom");
35
38
  });
36
39
  });
@@ -169,6 +169,32 @@ describe("HkPopover sheetOnMobile", () => {
169
169
  expect(header.querySelector(".hk-popover-sheet-close")).toBeTruthy();
170
170
  });
171
171
 
172
+ // #808 completion: the sheet branch consumes the same shared container
173
+ // pad its anchored twin does — the inline --hk-pad the `padding` prop
174
+ // writes on the panel reaches the sheet's content band (the scss
175
+ // contract in containerPad.styles.test.ts pins the consuming rules),
176
+ // so a host retunes ONE prop and both faces follow. No more hand-padded
177
+ // sheet-only wrapper branches downstream (chest's HkContextRing used
178
+ // to need one).
179
+ it("carries the padding prop's inline token onto the sheet panel too", async () => {
180
+ setViewport(375);
181
+ mountPopover({ sheetOnMobile: true, title: "Sheet", padding: "md" });
182
+ await nextTick();
183
+
184
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
185
+ expect(panel.classList.contains("hk-is-sheet")).toBe(true);
186
+ expect(panel.style.getPropertyValue("--hk-pad")).toBe("var(--space-12, 0.75rem)");
187
+ });
188
+
189
+ it("keeps the sheet panel token-free when no padding prop is set", async () => {
190
+ setViewport(375);
191
+ mountPopover({ sheetOnMobile: true, title: "Sheet" });
192
+ await nextTick();
193
+
194
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
195
+ expect(panel.style.getPropertyValue("--hk-pad")).toBe("");
196
+ });
197
+
172
198
  it("keeps the heading row out of anchored desktop mode", async () => {
173
199
  setViewport(1200);
174
200
  mountPopover({ sheetOnMobile: true, title: "Context usage" });
@@ -48,6 +48,29 @@ describe("container pad token — wired sheets", () => {
48
48
  );
49
49
  });
50
50
 
51
+ it("HkPopover sheet: the content band reads the token, chrome cancels it", () => {
52
+ // The sheet branch completes the #808 contract: a host that sets the
53
+ // `padding` prop gets the SAME step around its content docked as a
54
+ // bottom sheet (bottom = step + safe-area, block-start air under the
55
+ // header = step) instead of hand-padding a sheet-only wrapper — the
56
+ // gap chest's HkContextRing had to compensate for locally. Grabber
57
+ // and header are chrome: they cancel the panel's inline pad so the
58
+ // tap zone and title keep their own frame-relative insets.
59
+ const css = compileSheet("HkPopover.scss");
60
+ expect(css).toContain(
61
+ "padding-bottom: calc(var(--hk-pad, var(--space-12, 0.75rem)) + env(safe-area-inset-bottom, 0px))",
62
+ );
63
+ expect(ruleFor(css, ".hk-popover-sheet-header {")).toContain(
64
+ "margin-inline: calc(-1 * var(--hk-pad, 0px))",
65
+ );
66
+ expect(ruleFor(css, ".hk-popover-sheet-header {")).toContain(
67
+ "padding-block: 0 var(--hk-pad, var(--space-8, 0.5rem))",
68
+ );
69
+ expect(ruleFor(css, ".hk-popover-sheet-grabber {")).toContain(
70
+ "margin-inline: calc(-1 * var(--hk-pad, 0px))",
71
+ );
72
+ });
73
+
51
74
  it("HkCard: body 1rem, unpadded 0, the scrollable pad wrapper 1rem", () => {
52
75
  const css = compileSheet("HkCard.scss");
53
76
  expect(ruleFor(css, ".hk-card-body {")).toContain(