@celestia-island/hikari 0.55.175 → 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.175",
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",
@@ -0,0 +1,67 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h } from "vue";
3
+
4
+ import HkAlert from "./HkAlert";
5
+
6
+ /**
7
+ * Component-level guard for the shared container-padding prop
8
+ * (runtime/containerPad) on HkAlert: the `padding` prop lands the
9
+ * `--hk-pad` custom property on the root element (inline — so it wins
10
+ * the cascade over the base and per-size padding rules without any
11
+ * specificity contest), and the unset prop lands nothing, leaving the
12
+ * size's own padding in force.
13
+ *
14
+ * (Repo test convention: raw createApp + document queries, no
15
+ * @vue/test-utils dependency.)
16
+ */
17
+
18
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
19
+
20
+ function mountAlert(props: Record<string, unknown> = {}): HTMLElement {
21
+ const container = document.createElement("div");
22
+ document.body.appendChild(container);
23
+ const Wrapper = defineComponent({
24
+ setup() {
25
+ return () => h(HkAlert, { message: "mind the gap", ...props });
26
+ },
27
+ });
28
+ const app = createApp(Wrapper);
29
+ app.mount(container);
30
+ mounts.push({ app, container });
31
+ const el = container.querySelector<HTMLElement>(".hk-alert");
32
+ expect(el, ".hk-alert renders").toBeTruthy();
33
+ return el!;
34
+ }
35
+
36
+ afterEach(() => {
37
+ for (const { app, container } of mounts) {
38
+ app.unmount();
39
+ container.remove();
40
+ }
41
+ mounts.length = 0;
42
+ });
43
+
44
+ describe("HkAlert padding prop", () => {
45
+ it("unset prop lands no token — the size keeps its own padding", () => {
46
+ const el = mountAlert({ size: "sm" });
47
+ expect(el.style.getPropertyValue("--hk-pad")).toBe("");
48
+ });
49
+
50
+ it("a named step lands the token inline", () => {
51
+ const el = mountAlert({ padding: "sm" });
52
+ expect(el.style.getPropertyValue("--hk-pad")).toBe("var(--space-8, 0.5rem)");
53
+ });
54
+
55
+ it("none zeroes the padding explicitly", () => {
56
+ const el = mountAlert({ padding: "none" });
57
+ expect(el.style.getPropertyValue("--hk-pad")).toBe("0");
58
+ });
59
+
60
+ it("the token rides the ROOT, so the per-size rules all consume it", () => {
61
+ // sm/lg carry their own padding rules; the inline token must sit
62
+ // above them (root element), not on a nested node.
63
+ const sm = mountAlert({ size: "sm", padding: "lg" });
64
+ expect(sm.classList.contains("hk-alert-sm")).toBe(true);
65
+ expect(sm.style.getPropertyValue("--hk-pad")).toBe("var(--space-16, 1rem)");
66
+ });
67
+ });
@@ -2,7 +2,9 @@
2
2
  display: flex;
3
3
  align-items: flex-start;
4
4
  gap: var(--space-12, 12px);
5
- padding: var(--space-12, 12px) var(--space-16, 16px);
5
+ /* --hk-pad: the shared container-padding token (runtime/containerPad) —
6
+ unset falls back to the historical values, set wins per instance. */
7
+ padding: var(--hk-pad, var(--space-12, 12px) var(--space-16, 16px));
6
8
  border-radius: var(--radius-md, 8px);
7
9
  border: 1px solid transparent;
8
10
  border-inline-start-width: 4px;
@@ -20,7 +22,7 @@
20
22
  /* ── Sizes ─────────────────────────────────────── */
21
23
 
22
24
  .hk-alert-sm {
23
- padding: var(--space-8, 8px) var(--space-12, 12px);
25
+ padding: var(--hk-pad, var(--space-8, 8px) var(--space-12, 12px));
24
26
  font-size: var(--text-xs, 0.75rem);
25
27
  }
26
28
 
@@ -30,7 +32,7 @@
30
32
  }
31
33
 
32
34
  .hk-alert-lg {
33
- padding: var(--space-16, 16px) var(--space-24, 24px);
35
+ padding: var(--hk-pad, var(--space-16, 16px) var(--space-24, 24px));
34
36
  font-size: var(--text-base, 0.875rem);
35
37
  }
36
38
 
@@ -1,5 +1,6 @@
1
1
  import { computed, defineComponent, ref, type PropType } from "vue";
2
2
  import { useI18n } from "../i18n/context";
3
+ import { hkPadStyle, type HkPad } from "../runtime/containerPad";
3
4
  import { AlertTriangle, CheckCircle, Info, X } from "lucide-vue-next";
4
5
 
5
6
  import "./HkAlert.scss";
@@ -23,6 +24,9 @@ export default defineComponent({
23
24
  closable: { type: Boolean, default: false },
24
25
  banner: { type: Boolean, default: false },
25
26
  size: { type: String as PropType<AlertSize>, default: "md" },
27
+ /** Shared container-padding override (see runtime/containerPad) —
28
+ * unset keeps the size's own padding. */
29
+ padding: { type: String as PropType<HkPad>, default: undefined },
26
30
  },
27
31
  emits: {
28
32
  close: () => true,
@@ -50,7 +54,7 @@ export default defineComponent({
50
54
  const IconComp = VARIANT_ICONS[props.variant];
51
55
 
52
56
  return (
53
- <div class={rootCls.value}>
57
+ <div class={rootCls.value} style={hkPadStyle(props.padding)}>
54
58
  <span class="hk-alert-icon">
55
59
  {slots.icon ? (
56
60
  slots.icon()
@@ -0,0 +1,77 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h } from "vue";
3
+
4
+ import HkCard from "./HkCard";
5
+
6
+ /**
7
+ * Component-level guard for the shared container-padding prop
8
+ * (runtime/containerPad) on HkCard: the token lands on the card ROOT
9
+ * (custom properties inherit, so the body, the unpadded override and
10
+ * the scrollable card's inner pad wrapper all resolve it), and the
11
+ * unset prop lands nothing — `padded: false` keeps zeroing the body.
12
+ *
13
+ * (Repo test convention: raw createApp + document queries, no
14
+ * @vue/test-utils dependency.)
15
+ */
16
+
17
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
18
+
19
+ function mountCard(props: Record<string, unknown> = {}): {
20
+ root: HTMLElement;
21
+ body: HTMLElement;
22
+ } {
23
+ const container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ const Wrapper = defineComponent({
26
+ setup() {
27
+ return () =>
28
+ h(HkCard, { title: "T", ...props }, { default: () => h("p", "body") });
29
+ },
30
+ });
31
+ const app = createApp(Wrapper);
32
+ app.mount(container);
33
+ mounts.push({ app, container });
34
+ const root = container.querySelector<HTMLElement>(".hk-card");
35
+ expect(root, ".hk-card renders").toBeTruthy();
36
+ const body = root!.querySelector<HTMLElement>(".hk-card-body");
37
+ expect(body, ".hk-card-body renders").toBeTruthy();
38
+ return { root: root!, body: body! };
39
+ }
40
+
41
+ afterEach(() => {
42
+ for (const { app, container } of mounts) {
43
+ app.unmount();
44
+ container.remove();
45
+ }
46
+ mounts.length = 0;
47
+ });
48
+
49
+ describe("HkCard padding prop", () => {
50
+ it("unset prop lands no token — the body keeps its inset", () => {
51
+ const { root } = mountCard();
52
+ expect(root.style.getPropertyValue("--hk-pad")).toBe("");
53
+ });
54
+
55
+ it("a named step lands the token on the card root", () => {
56
+ const { root } = mountCard({ padding: "md" });
57
+ expect(root.style.getPropertyValue("--hk-pad")).toBe("var(--space-12, 0.75rem)");
58
+ });
59
+
60
+ it("the token inherits into the body (one root write covers every rule)", () => {
61
+ const { root } = mountCard({ padding: "none" });
62
+ // happy-dom computes inherited custom properties off the DOM tree —
63
+ // the body sees the root's token, which is exactly the cascade the
64
+ // scss rules consume.
65
+ expect(root.style.getPropertyValue("--hk-pad")).toBe("0");
66
+ });
67
+
68
+ it("composes with padded:false — an explicit padding still reaches the body", () => {
69
+ const { root } = mountCard({ padded: false, padding: "sm" });
70
+ expect(
71
+ root.querySelector<HTMLElement>(".hk-card-body")!.classList.contains(
72
+ "hk-card-body-unpadded",
73
+ ),
74
+ ).toBe(true);
75
+ expect(root.style.getPropertyValue("--hk-pad")).toBe("var(--space-8, 0.5rem)");
76
+ });
77
+ });
@@ -42,11 +42,13 @@
42
42
  }
43
43
 
44
44
  .hk-card-body {
45
- padding: var(--space-16);
45
+ /* --hk-pad: the shared container-padding token (runtime/containerPad) —
46
+ the card root carries it when the `padding` prop is set. */
47
+ padding: var(--hk-pad, var(--space-16));
46
48
  }
47
49
 
48
50
  .hk-card-body-unpadded {
49
- padding: 0;
51
+ padding: var(--hk-pad, 0);
50
52
  }
51
53
 
52
54
  .hk-card-footer {
@@ -85,6 +87,6 @@
85
87
  }
86
88
 
87
89
  .hk-card-body-pad {
88
- padding: var(--space-16);
90
+ padding: var(--hk-pad, var(--space-16));
89
91
  }
90
92
  }
@@ -1,4 +1,6 @@
1
- import { defineComponent } from "vue";
1
+ import { defineComponent, type PropType } from "vue";
2
+
3
+ import { hkPadStyle, type HkPad } from "../runtime/containerPad";
2
4
 
3
5
  import HkScrollContainer from "./HkScrollContainer";
4
6
  import "./HkCard.scss";
@@ -9,6 +11,12 @@ export default defineComponent({
9
11
  title: { type: String, default: undefined },
10
12
  hoverable: { type: Boolean, default: false },
11
13
  padded: { type: Boolean, default: true },
14
+ /** Shared container-padding override (see runtime/containerPad) for
15
+ * the body (and the scrollable card's inner pad wrapper) — unset
16
+ * keeps the body's historical inset; composes with `padded`
17
+ * (unpadded still drops the inset unless an explicit padding is
18
+ * given). */
19
+ padding: { type: String as PropType<HkPad>, default: undefined },
12
20
  /** Turn the card body into the library's standard scroll region
13
21
  * (HkScrollContainer: overlay scrollbar, overflow sensing) instead
14
22
  * of a plain static block. The card becomes a column flex box —
@@ -34,6 +42,7 @@ export default defineComponent({
34
42
  props.hoverable ? "hk-card-hoverable" : "",
35
43
  props.class,
36
44
  ]}
45
+ style={hkPadStyle(props.padding)}
37
46
  onClick={(e) => emit("click", e)}
38
47
  >
39
48
  {props.title || slots.header ? (
@@ -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}
@@ -162,7 +162,9 @@
162
162
  /* Consumers nesting a self-padded menu (HkMenuPanel) zero this via
163
163
  --hk-drawer-body-padding so nav rows and the footer menu share one
164
164
  left edge. */
165
- padding: var(--hk-drawer-body-padding, var(--space-16, 1rem));
165
+ /* --hk-pad (runtime/containerPad, the `padding` prop) outranks the
166
+ per-component theme token, which outranks the historical default. */
167
+ padding: var(--hk-pad, var(--hk-drawer-body-padding, var(--space-16, 1rem)));
166
168
 
167
169
  /* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
168
170
  hidden, never styled. */
@@ -15,6 +15,7 @@ import "./HkDrawer.scss";
15
15
  import { focusFirst, trapFocus } from "../utils/dom";
16
16
  import { useOverlay } from "../runtime/useOverlay";
17
17
  import { usePopupManager } from "../runtime/usePopupManager";
18
+ import { hkPadStyle, type HkPad } from "../runtime/containerPad";
18
19
  import { createBackGuard } from "../runtime/backStack";
19
20
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
20
21
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
@@ -36,6 +37,10 @@ export default defineComponent({
36
37
  closable: { type: Boolean, default: true },
37
38
  overlay: { type: Boolean, default: true },
38
39
  size: { type: String, default: "320px" },
40
+ /** Shared container-padding override (see runtime/containerPad) for
41
+ * the body — unset keeps --hk-drawer-body-padding / the 1rem
42
+ * default. */
43
+ padding: { type: String as PropType<HkPad>, default: undefined },
39
44
  /** Extra classes for the floating panel — lets consumers scope
40
45
  * body/footer padding overrides (attrs fallthrough cannot reach a
41
46
  * Teleported panel). */
@@ -289,7 +294,7 @@ export default defineComponent({
289
294
  <div
290
295
  ref={panelRef}
291
296
  class={["hk-drawer-panel", `hk-drawer-${props.side}`, props.panelClass, ...machine.classesFor(panelPrefix)]}
292
- style={panelStyle.value}
297
+ style={{ ...panelStyle.value, ...hkPadStyle(props.padding) }}
293
298
  role="dialog"
294
299
  aria-label={props.title}
295
300
  aria-modal={props.overlay}
@@ -296,10 +296,13 @@
296
296
  // parent): custom properties inherit downward, so the covers, the
297
297
  // scroller and the inner all resolve them. The mobile sheet block
298
298
  // retunes the vars only.
299
- --hk-scroll-pad-top: var(--hk-modal-padding-body, 1.5rem);
300
- --hk-scroll-pad-right: var(--hk-modal-padding-body, 1.5rem);
301
- --hk-scroll-pad-bottom: var(--hk-modal-padding-body, 1.5rem);
302
- --hk-scroll-pad-left: var(--hk-modal-padding-body, 1.5rem);
299
+ // The shared container-padding token (--hk-pad, runtime/containerPad;
300
+ // the modal root carries it via the `padding` prop) outranks the
301
+ // per-component theme token, which outranks the historical default.
302
+ --hk-scroll-pad-top: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
303
+ --hk-scroll-pad-right: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
304
+ --hk-scroll-pad-bottom: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
305
+ --hk-scroll-pad-left: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
303
306
 
304
307
  // Gutter covers (scroll-pin contract, 2026-09-14): the body's top and
305
308
  // bottom whitespace stays paintable OUTSIDE the scroll flow, so a
@@ -7,6 +7,7 @@ import { focusFirst, trapFocus } from "../utils/dom";
7
7
  import { useOverlay } from "../runtime/useOverlay";
8
8
  import { topWindowZ, usePopupManager } from "../runtime/usePopupManager";
9
9
  import { createBackGuard } from "../runtime/backStack";
10
+ import { hkPadStyle, type HkPad } from "../runtime/containerPad";
10
11
  import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
11
12
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
12
13
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
@@ -142,6 +143,10 @@ export default defineComponent({
142
143
  modelValue: { type: Boolean, required: true },
143
144
  title: { type: String, default: undefined },
144
145
  closable: { type: Boolean, default: true },
146
+ /** Shared container-padding override (see runtime/containerPad) for
147
+ * the body — unset keeps --hk-modal-padding-body / the 1.5rem
148
+ * default; the header and sub-header keep their own theme tokens. */
149
+ padding: { type: String as PropType<HkPad>, default: undefined },
145
150
  /** Named preset ("xs"…"xl") or any CSS max-width value (see ModalWidth). */
146
151
  width: { type: String as PropType<ModalWidth>, default: "32rem" },
147
152
  /**
@@ -1033,7 +1038,7 @@ export default defineComponent({
1033
1038
  <Teleport to={ensureOverlayRoot()}>
1034
1039
  <div
1035
1040
  class="hk-modal-root"
1036
- style={{ zIndex: overlayZ.value }}
1041
+ style={{ zIndex: overlayZ.value, ...hkPadStyle(props.padding) }}
1037
1042
  onKeydown={onKeydown}
1038
1043
  >
1039
1044
  <div
@@ -9,6 +9,10 @@
9
9
 
10
10
  .hk-popover-panel {
11
11
  min-width: 0;
12
+ /* --hk-pad: the shared container-padding token (runtime/containerPad) —
13
+ 0 without glass (the bare panel never carried an inset), overridden
14
+ per instance via the `padding` prop. */
15
+ padding: var(--hk-pad, 0);
12
16
  max-height: min(70vh, calc(100vh - 2 * var(--viewport-gutter, 16px)));
13
17
  /* Width MUST be position-independent (max-content, viewport-capped).
14
18
  * The anchored panel is fixed-positioned with shrink-to-fit sizing,
@@ -69,7 +73,9 @@
69
73
  border: 1px solid rgb(var(--color-border) / 20%);
70
74
  border-radius: var(--radius-sm);
71
75
  box-shadow: var(--shadow-dropdown);
72
- padding: var(--space-4, 0.25rem);
76
+ /* The glass chrome's historical 4px menu inset — the shared token
77
+ (runtime/containerPad) retunes it per instance (`padding` prop). */
78
+ padding: var(--hk-pad, var(--space-4, 0.25rem));
73
79
  }
74
80
 
75
81
  /* Locale picker menu items — unified menu-item spec (./_menu-item.scss). */
@@ -159,13 +165,17 @@
159
165
  /* The sheet's bottom-edge distance itself comes from the shared
160
166
  * --hk-sheet-bottom-gap (inline in HkPopover's sheet style); here the
161
167
  * content keeps the last row clear of the home bar: the safe-area
162
- * inset stacked on a 12px family base, which also restores a bottom
163
- * 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
164
171
  * in source, whose own bottom padding the sheet rule would otherwise
165
- * zero out). The modal sheet footer goes further (it adds
166
- * --hk-sheet-footer-gap for its action buttons); this sheet has no
167
- * footer, so this is the family baseline. */
168
- 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));
169
179
 
170
180
  /* Overlay scrollbar (useOverlayScrollbar, sheet mode only) — the
171
181
  native chrome is hidden, never styled. */
@@ -178,9 +188,14 @@
178
188
 
179
189
  .hk-popover-sheet-grabber {
180
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));
181
197
  // Grabber zone: tap target tall enough for a thumb, bar visually small.
182
198
  padding: 14px 0 20px;
183
- margin: 0 auto;
184
199
  width: 100%;
185
200
  cursor: pointer;
186
201
  display: flex;
@@ -206,15 +221,26 @@
206
221
  * The title aligns to the CONTENT edge: the leading inset defaults to
207
222
  * --space-12, matching the padded content blocks popover sheets host
208
223
  * (e.g. the house 0.75rem popover body). Hosts tune it via
209
- * --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. */
210
235
  .hk-popover-sheet-header {
211
236
  flex-shrink: 0;
212
237
  display: flex;
213
238
  align-items: center;
214
239
  gap: var(--space-8, 0.5rem);
240
+ margin-inline: calc(-1 * var(--hk-pad, 0px));
215
241
  // Logical longhand (was physical `0 x y title-inset`): the title inset is
216
242
  // the inline START edge so RTL mirrors it with the mirrored title/close.
217
- padding-block: 0 var(--space-8, 0.5rem);
243
+ padding-block: 0 var(--hk-pad, var(--space-8, 0.5rem));
218
244
  padding-inline: var(--hk-sheet-title-inset, var(--space-12, 0.75rem))
219
245
  var(--space-8, 0.5rem);
220
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
  });
@@ -13,6 +13,7 @@ import {
13
13
  } from "vue";
14
14
 
15
15
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
16
+ import { hkPadStyle, type HkPad } from "../runtime/containerPad";
16
17
  import { useBreakpoint } from "../runtime/useBreakpoint";
17
18
  import { ancestorZoom } from "../runtime/cssZoom";
18
19
  import { popupViewportRect } from "../runtime/popupBounds";
@@ -56,6 +57,12 @@ export default defineComponent({
56
57
  closeOnBackdrop: { type: Boolean, default: true },
57
58
  closeOnEscape: { type: Boolean, default: true },
58
59
  glass: { type: Boolean, default: true },
60
+ /** Shared container-padding override (see runtime/containerPad) for
61
+ * the panel's content inset — unset keeps the glass chrome's 4px
62
+ * (menu-voice) inset, or 0 without glass. Content panels (forms,
63
+ * editors, filter groups) set e.g. "md" instead of hand-padding
64
+ * their slot content. */
65
+ padding: { type: String as PropType<HkPad>, default: undefined },
59
66
  anchorRef: { type: Object as PropType<HTMLElement | null>, default: null },
60
67
  /**
61
68
  * Dock the panel as a bottom sheet on mobile-width viewports (<768px,
@@ -699,6 +706,7 @@ export default defineComponent({
699
706
  >
700
707
  <div
701
708
  ref={panelRef}
709
+ style={hkPadStyle(props.padding)}
702
710
  class={[
703
711
  "hk-popover-panel",
704
712
  ...(sheetBranch ? ["hk-is-sheet"] : [`hk-popover-${resolvedPlacement.value}`]),
@@ -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" });
@@ -106,12 +106,15 @@ describe("scroll-pin whitespace contract", () => {
106
106
  // Declared on the BODY — the covers are the body's own pseudo rules
107
107
  // and custom properties inherit downward only, so the scroller (a
108
108
  // child) could never feed them. The FULL literal is pinned (var name
109
- // AND the 1.5rem fallback): a silently changed default would desync
109
+ // AND the fallback chain): a silently changed default would desync
110
110
  // the covers from the padding while every selector still matches.
111
+ // The chain reads the shared container-pad token (--hk-pad, the
112
+ // modal root's `padding` prop) over the per-component theme token
113
+ // over the historical 1.5rem.
111
114
  const body = cssBlock(modalScss, ".hk-modal-body {");
112
115
  for (const side of ["top", "right", "bottom", "left"]) {
113
116
  expect(body, `--hk-scroll-pad-${side}`).toContain(
114
- `--hk-scroll-pad-${side}: var(--hk-modal-padding-body, 1.5rem);`,
117
+ `--hk-scroll-pad-${side}: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));`,
115
118
  );
116
119
  }
117
120
  });
@@ -2,7 +2,8 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  gap: 0.25rem;
5
- padding: var(--space-12, 0.75rem) var(--space-16, 1rem);
5
+ /* --hk-pad: the shared container-padding token (runtime/containerPad). */
6
+ padding: var(--hk-pad, var(--space-12, 0.75rem) var(--space-16, 1rem));
6
7
  border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
7
8
  border-radius: var(--radius-md, 0.5rem);
8
9
  background: rgb(var(--color-surface));
@@ -1,5 +1,7 @@
1
1
  import { defineComponent, h, type Component, type PropType } from "vue";
2
2
 
3
+ import { hkPadStyle, type HkPad } from "../runtime/containerPad";
4
+
3
5
  import HkGaugeRing from "./HkGaugeRing";
4
6
  import HkProgressBar from "./HkProgressBar";
5
7
  import "./HkStatCard.scss";
@@ -78,6 +80,9 @@ export const HkStatCard = defineComponent({
78
80
  name: "HkStatCard",
79
81
  props: {
80
82
  variant: { type: String as PropType<StatVariant>, default: "plain" },
83
+ /** Shared container-padding override (see runtime/containerPad) —
84
+ * unset keeps the card's historical inset. */
85
+ padding: { type: String as PropType<HkPad>, default: undefined },
81
86
  label: { type: String, required: true },
82
87
  value: { type: [String, Number], required: true },
83
88
  tone: { type: String as PropType<StatTone>, default: "muted" },
@@ -95,6 +100,7 @@ export const HkStatCard = defineComponent({
95
100
  setup(props, { emit, slots }) {
96
101
  return () => {
97
102
  const shared = {
103
+ style: hkPadStyle(props.padding),
98
104
  class: [
99
105
  "hk-stat-card",
100
106
  `hk-stat-card-${props.variant}`,
@@ -0,0 +1,122 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Compiled-stylesheet guard for the shared container-padding token
10
+ * (--hk-pad, runtime/containerPad) across every wired container sheet.
11
+ *
12
+ * The whole facility rests on each container's padding rule consuming
13
+ * `var(--hk-pad, <legacy default>)` — the token is SET per instance
14
+ * (inline, via the `padding` prop) and CONSUMED by these rules, so
15
+ * there is never a specificity contest. Vitest stubs CSS, so the
16
+ * component suites can never catch a rule regressed back to a literal:
17
+ * this file compiles the six sheets for real and pins the exact
18
+ * declarations.
19
+ *
20
+ * The legacy fallbacks here ARE the historical visuals: popover glass
21
+ * 4px (menu voice) / bare panel 0, card body 1rem (unpadded 0),
22
+ * stat card 12/16, modal body --hk-modal-padding-body (1.5rem), drawer
23
+ * body --hk-drawer-body-padding (1rem), alert base/sm/lg shorthands.
24
+ */
25
+
26
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
27
+
28
+ function compileSheet(name: string): string {
29
+ return compile(resolve(componentDir, name), { style: "expanded" }).css;
30
+ }
31
+
32
+ function ruleFor(css: string, selector: string): string {
33
+ const start = css.indexOf(selector);
34
+ expect(start, `${selector} present in the compiled sheet`).toBeGreaterThanOrEqual(0);
35
+ const open = css.indexOf("{", start);
36
+ const close = css.indexOf("}", open);
37
+ return css.slice(start, close + 1);
38
+ }
39
+
40
+ describe("container pad token — wired sheets", () => {
41
+ it("HkPopover: bare panel falls back to 0, glass chrome to the 4px menu inset", () => {
42
+ const css = compileSheet("HkPopover.scss");
43
+ expect(ruleFor(css, ".hk-popover-panel {")).toContain(
44
+ "padding: var(--hk-pad, 0)",
45
+ );
46
+ expect(css).toContain(
47
+ "padding: var(--hk-pad, var(--space-4, 0.25rem))",
48
+ );
49
+ });
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
+
74
+ it("HkCard: body 1rem, unpadded 0, the scrollable pad wrapper 1rem", () => {
75
+ const css = compileSheet("HkCard.scss");
76
+ expect(ruleFor(css, ".hk-card-body {")).toContain(
77
+ "padding: var(--hk-pad, var(--space-16))",
78
+ );
79
+ expect(ruleFor(css, ".hk-card-body-unpadded {")).toContain(
80
+ "padding: var(--hk-pad, 0)",
81
+ );
82
+ expect(ruleFor(css, ".hk-card-body-pad {")).toContain(
83
+ "padding: var(--hk-pad, var(--space-16))",
84
+ );
85
+ });
86
+
87
+ it("HkStatCard: the 12/16 surface inset", () => {
88
+ const css = compileSheet("HkStatCard.scss");
89
+ expect(ruleFor(css, ".hk-stat-card {")).toContain(
90
+ "padding: var(--hk-pad, var(--space-12, 0.75rem) var(--space-16, 1rem))",
91
+ );
92
+ });
93
+
94
+ it("HkModal: every scroll-pad track reads the token over the theme value", () => {
95
+ const css = compileSheet("HkModal.scss");
96
+ for (const side of ["top", "right", "bottom", "left"]) {
97
+ expect(css).toContain(
98
+ `--hk-scroll-pad-${side}: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem))`,
99
+ );
100
+ }
101
+ });
102
+
103
+ it("HkDrawer: the body reads the token over the theme value", () => {
104
+ const css = compileSheet("HkDrawer.scss");
105
+ expect(css).toContain(
106
+ "padding: var(--hk-pad, var(--hk-drawer-body-padding, var(--space-16, 1rem)))",
107
+ );
108
+ });
109
+
110
+ it("HkAlert: the base and both sized shorthands read the token", () => {
111
+ const css = compileSheet("HkAlert.scss");
112
+ expect(ruleFor(css, ".hk-alert {")).toContain(
113
+ "padding: var(--hk-pad, var(--space-12, 12px) var(--space-16, 16px))",
114
+ );
115
+ expect(ruleFor(css, ".hk-alert-sm {")).toContain(
116
+ "padding: var(--hk-pad, var(--space-8, 8px) var(--space-12, 12px))",
117
+ );
118
+ expect(ruleFor(css, ".hk-alert-lg {")).toContain(
119
+ "padding: var(--hk-pad, var(--space-16, 16px) var(--space-24, 24px))",
120
+ );
121
+ });
122
+ });
package/src/index.ts CHANGED
@@ -806,3 +806,4 @@ export { type HAboutLinkIcon as HkAboutLinkIcon } from "./components/HkAboutModa
806
806
  export { type HAboutComponentVersion as HkAboutComponentVersion } from "./components/HkAboutModal";
807
807
  export { type HCustomTheme as HkCustomTheme } from "./components/HkColorSchemeDialog";
808
808
  export { OVERLAY_ROOT_SELECTOR, OVERLAY_Z, ensureOverlayRoot } from "./overlay/overlayRoot";
809
+ export { HK_PAD_VAR, hkPadStyle, type HkPad } from "./runtime/containerPad";
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { HK_PAD_VAR, hkPadStyle } from "./containerPad";
4
+
5
+ /**
6
+ * Direct unit tests for the shared container-padding helper behind every
7
+ * container's `padding` prop (runtime/containerPad).
8
+ *
9
+ * The contract, in one line each:
10
+ * - the token is `--hk-pad`, the ONE custom property every wired
11
+ * container's padding rule consumes (each with its own legacy
12
+ * fallback, so an unset token preserves the historical look);
13
+ * - `hkPadStyle` maps the five named steps onto the house spacing scale
14
+ * (--space-4/8/12/16) verbatim, with fallbacks inlined — the values
15
+ * must stay pure `var()` references so themes can re-scale them;
16
+ * - an unset prop renders NO declaration at all (an empty style object),
17
+ * never a default-valued one — the absence IS the legacy default.
18
+ */
19
+ describe("HK_PAD_VAR", () => {
20
+ it("is the one shared custom property", () => {
21
+ expect(HK_PAD_VAR).toBe("--hk-pad");
22
+ });
23
+ });
24
+
25
+ describe("hkPadStyle", () => {
26
+ it("maps each step onto the spacing scale", () => {
27
+ expect(hkPadStyle("none")).toEqual({ [HK_PAD_VAR]: "0" });
28
+ expect(hkPadStyle("xs")).toEqual({ [HK_PAD_VAR]: "var(--space-4, 0.25rem)" });
29
+ expect(hkPadStyle("sm")).toEqual({ [HK_PAD_VAR]: "var(--space-8, 0.5rem)" });
30
+ expect(hkPadStyle("md")).toEqual({ [HK_PAD_VAR]: "var(--space-12, 0.75rem)" });
31
+ expect(hkPadStyle("lg")).toEqual({ [HK_PAD_VAR]: "var(--space-16, 1rem)" });
32
+ });
33
+
34
+ it("renders no declaration when the prop is unset", () => {
35
+ expect(hkPadStyle(undefined)).toEqual({});
36
+ });
37
+ });
@@ -0,0 +1,53 @@
1
+ import type { CSSProperties } from "vue";
2
+
3
+ /**
4
+ * The shared container-padding scale — the ONE mechanism behind every
5
+ * container component's `padding` prop (HkPopover's panel, HkCard,
6
+ * HkStatCard, HkModal, HkDrawer, HkAlert, …).
7
+ *
8
+ * The contract has two halves:
9
+ *
10
+ * 1. TOKEN — every container's padding rule reads
11
+ * `padding: var(--hk-pad, <its legacy default>)`. Setting the
12
+ * custom property on the element (inline, a utility class, or any
13
+ * ancestor — custom properties inherit) retunes that container's
14
+ * content padding without touching its stylesheet; leaving it
15
+ * unset preserves the component's historical look byte-for-byte.
16
+ * Because the property is consumed (not a competing declaration),
17
+ * there is no specificity contest with the component's own rules
18
+ * or the theme layer.
19
+ *
20
+ * 2. PROP — `padding?: HkPad` on the container maps the named step
21
+ * onto the token via {@link hkPadStyle} (one inline custom
22
+ * property), so a call site opts in per instance:
23
+ * `<HkCard padding="none">…</HkCard>`.
24
+ *
25
+ * The steps ride the house spacing scale (--space-* in
26
+ * styles/theme/scale.scss): none=0, xs=4px, sm=8px, md=12px, lg=16px —
27
+ * the same four steps the popover glass chrome (xs), the settings rows
28
+ * (sm) and the card bodies (lg) already speak.
29
+ */
30
+
31
+ /** The named padding steps every container's `padding` prop accepts. */
32
+ export type HkPad = "none" | "xs" | "sm" | "md" | "lg";
33
+
34
+ /** The custom property the container padding rules consume. Hosts and
35
+ * themes may also set it directly (per instance or per subtree). */
36
+ export const HK_PAD_VAR = "--hk-pad";
37
+
38
+ /** Step → the spacing-scale value handed to the token. */
39
+ const HK_PAD_VALUE: Record<HkPad, string> = {
40
+ none: "0",
41
+ xs: "var(--space-4, 0.25rem)",
42
+ sm: "var(--space-8, 0.5rem)",
43
+ md: "var(--space-12, 0.75rem)",
44
+ lg: "var(--space-16, 1rem)",
45
+ };
46
+
47
+ /** The inline style carrying the token for one `padding` prop value —
48
+ * an empty object when the prop is unset (the component keeps its
49
+ * legacy default). Call sites spread it into their existing style
50
+ * object: `style={{ …panelStyle.value, …hkPadStyle(props.padding) }}`. */
51
+ export function hkPadStyle(pad: HkPad | undefined): CSSProperties {
52
+ return pad ? ({ [HK_PAD_VAR]: HK_PAD_VALUE[pad] } as CSSProperties) : {};
53
+ }
@@ -102,3 +102,8 @@ export {
102
102
  type HkRuntimeSnapshot,
103
103
  type HkRuntimeSnapshotEntry,
104
104
  } from "./registry";
105
+ export {
106
+ HK_PAD_VAR,
107
+ hkPadStyle,
108
+ type HkPad,
109
+ } from "./containerPad";