@celestia-island/hikari 0.47.0 → 0.48.0

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.47.0",
3
+ "version": "0.48.0",
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,8 +1,12 @@
1
- /* Alternative sign-in methods as an icon button group (login card). The
2
- block container `.s-auth-methods` and its spacing are owned by
3
- HkAuthCard's slot styles (styles/admin-tokens.scss); this file only
4
- styles the divider and the icon group row the component renders into
5
- that slot. */
1
+ /* Alternative sign-in methods as CENTERED INDEPENDENT framed icon tiles
2
+ * (login card). The block container `.s-auth-methods` and its spacing are
3
+ * owned by HkAuthCard's slot styles (styles/admin-tokens.scss); this file
4
+ * only styles the divider and the tile row the component renders into that
5
+ * slot.
6
+ *
7
+ * Deliberately NOT a button group (2026-09-14 user direction): no shared
8
+ * track, no shared frame — each provider carries its own border so the row
9
+ * stays as wide as its content, centered in the card. */
6
10
 
7
11
  /* "Other ways to sign in" divider row between rules. */
8
12
  .s-auth-methods-divider {
@@ -24,16 +28,91 @@
24
28
  }
25
29
 
26
30
  /* The component's own wrapper is `display: contents` — layout-wise the
27
- divider and the group row remain direct children of HkAuthCard's
31
+ divider and the tile row remain direct children of HkAuthCard's
28
32
  `.s-auth-methods` slot container. */
29
33
  .s-auth-methods-list {
30
34
  display: contents;
31
35
  }
32
36
 
33
- /* The icon button group row: fill the card column and center the
34
- provider tiles inside it (descendant match off `.s-auth-methods` —
35
- the wrapper generates no box of its own). */
36
- .s-auth-methods .hk-icon-group.s-auth-methods-group {
37
+ /* The tile row: fill the card column and center the independent tiles
38
+ inside it. The gap between tiles is the INDEPENDENT-action spacing
39
+ (--space-12) — comfortable air, unlike a group's shoulder-to-shoulder
40
+ track gap. */
41
+ .s-auth-methods .s-auth-methods-tiles {
42
+ display: flex;
43
+ flex-wrap: wrap;
44
+ align-items: center;
45
+ justify-content: center;
46
+ gap: var(--space-12, 0.75rem);
37
47
  width: 100%;
48
+ }
49
+
50
+ /* The HkTooltip wrapper span is the flex child between tiles — let it
51
+ shrink to the button so the row gap (not the wrapper) spaces them. */
52
+ .s-auth-methods .s-auth-methods-tiles > .hk-tooltip-wrapper {
53
+ display: inline-flex;
54
+ flex: none;
55
+ }
56
+
57
+ /* One provider tile: a framed 44px square (the login-chooser tile size,
58
+ same as the icon group's md item). Each tile owns its whole frame —
59
+ border, hover wash, focus ring — with no track around the row. */
60
+ .s-auth-methods-tile {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ width: 2.75rem;
65
+ height: 2.75rem;
66
+ padding: 0;
67
+ font: inherit;
68
+ color: rgb(var(--color-text, 25 30 40));
69
+ background: rgb(var(--color-surface, 255 255 255) / 55%);
70
+ border: 1px solid rgb(var(--color-border, 222 224 228) / 55%);
71
+ border-radius: var(--radius-md, 8px);
72
+ cursor: pointer;
73
+ outline: none;
74
+ transition-property: background-color, border-color, color, box-shadow, opacity, transform;
75
+ transition-duration: var(--duration-short, 0.15s);
76
+ transition-timing-function: ease;
77
+
78
+ /* A real <button disabled> still matches :hover — guard the dead ones. */
79
+ &:hover:not(:disabled) {
80
+ color: rgb(var(--color-text, 25 30 40));
81
+ background: var(--c-primary-faint, rgb(var(--color-primary, 90 140 255) / 5%));
82
+ border-color: var(--c-primary-medium, rgb(var(--color-primary, 90 140 255) / 25%));
83
+ }
84
+
85
+ /* Tactile press feedback — the button-family cadence. */
86
+ &:active:not(:disabled) {
87
+ transform: scale(0.96);
88
+ }
89
+
90
+ /* Outset double ring, same grammar as .hk-btn. */
91
+ &:focus-visible {
92
+ box-shadow:
93
+ 0 0 0 2px rgb(var(--color-surface, 255 255 255)),
94
+ 0 0 0 4px rgb(var(--color-primary, 90 140 255));
95
+ }
96
+
97
+ &:disabled {
98
+ opacity: 0.5;
99
+ cursor: not-allowed;
100
+ }
101
+ }
102
+
103
+ .s-auth-methods-tile-icon {
104
+ display: inline-flex;
105
+ align-items: center;
38
106
  justify-content: center;
107
+ line-height: 0;
108
+ /* Brand marks (svg/img vnodes) at their intrinsic size; lucide comps
109
+ sized by the host. The box never grows past the square. */
110
+ max-width: 100%;
111
+ max-height: 100%;
112
+ }
113
+
114
+ .s-auth-methods-tile-initial {
115
+ font-size: var(--text-sm, 0.8125rem);
116
+ font-weight: 600;
117
+ line-height: 1;
39
118
  }
@@ -28,27 +28,39 @@ const methods = [
28
28
  ];
29
29
 
30
30
  describe("HkAuthMethodList", () => {
31
- it("renders the provider row as an icon button group with an optional divider", () => {
31
+ it("renders centered independent framed tiles with an optional divider", () => {
32
32
  // The card's `methods` slot owns the `.s-auth-methods` container —
33
- // reproduce that context here (the divider and the group row are layout
33
+ // reproduce that context here (the divider and the tile row are layout
34
34
  // children of it through the component's display:contents wrapper).
35
35
  const c = mount(
36
36
  h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { divider: "其他方式登录", methods })),
37
37
  );
38
38
  const divider = c.querySelector(".s-auth-methods-divider");
39
39
  expect(divider?.textContent).toContain("其他方式登录");
40
- const group = c.querySelector<HTMLElement>(".s-auth-methods .hk-icon-group");
41
- expect(group).toBeTruthy();
42
- expect(group!.classList.contains("hk-icon-group-buttons")).toBe(true);
43
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
44
- expect(buttons.length).toBe(2);
40
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
41
+ expect(tiles.length).toBe(2);
45
42
  // Icon-only: the label is the accessible name + tooltip, never visible text.
46
- expect(buttons[0]!.getAttribute("aria-label")).toBe("GitHub");
47
- expect(buttons[1]!.getAttribute("aria-label")).toBe("LinuxDo");
48
- expect(buttons[0]!.textContent).not.toContain("GitHub");
43
+ expect(tiles[0]!.getAttribute("aria-label")).toBe("GitHub");
44
+ expect(tiles[1]!.getAttribute("aria-label")).toBe("LinuxDo");
45
+ expect(tiles[0]!.textContent).not.toContain("GitHub");
49
46
  });
50
47
 
51
- it("wraps every item in a tooltip carrying the provider label", () => {
48
+ it("never wraps the providers in a button-group track", () => {
49
+ // 2026-09-14 user direction: a shared group frame (one wide bordered
50
+ // track with the icons floating inside) is NOT this component's
51
+ // grammar — every provider is an independent framed tile. A regression
52
+ // back to HkIconButtonGroup (or any .hk-icon-group markup) must fail
53
+ // here, because the button group is reserved for selectors and tight
54
+ // action strips.
55
+ const c = mount(
56
+ h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
57
+ );
58
+ expect(c.querySelector(".s-auth-methods .hk-icon-group")).toBeNull();
59
+ expect(c.querySelector(".s-auth-methods [role='group']")).toBeNull();
60
+ expect(c.querySelector(".s-auth-methods [role='radiogroup']")).toBeNull();
61
+ });
62
+
63
+ it("wraps every tile in a tooltip carrying the provider label", () => {
52
64
  const c = mount(
53
65
  h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
54
66
  );
@@ -71,12 +83,12 @@ describe("HkAuthMethodList", () => {
71
83
  },
72
84
  })),
73
85
  );
74
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
75
- buttons[1]!.click();
86
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
87
+ tiles[1]!.click();
76
88
  await nextTick();
77
89
  // Both rows are asserted so a constant-literal emit cannot ride an
78
90
  // accidental fixture coincidence.
79
- buttons[0]!.click();
91
+ tiles[0]!.click();
80
92
  await nextTick();
81
93
  expect(picked).toEqual(["linuxdo", "github"]);
82
94
  });
@@ -95,15 +107,15 @@ describe("HkAuthMethodList", () => {
95
107
  },
96
108
  })),
97
109
  );
98
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
99
- expect(buttons.length).toBe(3);
100
- // The prebuilt brand vnode renders inside the icon column.
101
- expect(buttons[0]!.querySelector(".brand-gh")).toBeTruthy();
110
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
111
+ expect(tiles.length).toBe(3);
112
+ // The prebuilt brand vnode renders inside the icon box.
113
+ expect(tiles[0]!.querySelector(".brand-gh")).toBeTruthy();
102
114
  // A missing icon falls back to the label initial (never an empty box).
103
- expect(buttons[2]!.querySelector<HTMLElement>(".hk-icon-group-item-initial")?.textContent).toBe("F");
115
+ expect(tiles[2]!.querySelector<HTMLElement>(".s-auth-methods-tile-initial")?.textContent).toBe("F");
104
116
  // Disabled entries render dead and swallow clicks.
105
- expect(buttons[1]!.disabled).toBe(true);
106
- buttons[1]!.click();
117
+ expect(tiles[1]!.disabled).toBe(true);
118
+ tiles[1]!.click();
107
119
  await nextTick();
108
120
  expect(picked).toBe("");
109
121
  });
@@ -1,23 +1,27 @@
1
1
  import { defineComponent } from "vue";
2
- import HkIconButtonGroup from "./HkIconButtonGroup";
2
+ import HkTooltip from "./HkTooltip";
3
3
  import "./HkAuthMethodList.scss";
4
4
 
5
5
  /**
6
6
  * HkAuthMethodList — the auth card's third-party sign-in block (the
7
7
  * "other ways to sign in" row under the credentials form).
8
8
  *
9
- * Since 2026-09-13 (user direction) the block renders as the ICON
10
- * BUTTON GROUP by default: one centered `HkIconButtonGroup` row of
11
- * icon-only provider buttons (mode "buttons", size md) instead of the
12
- * stacked full-width [icon | label] buttons. Provider identity is
13
- * revealed on hover — every item wraps itself in an HkTooltip with the
14
- * provider label — which matches the platform login-chooser pattern
15
- * (Windows/macOS account tiles) and keeps the card compact when a
16
- * deployment offers several providers. The `select` event carries the
17
- * provider `key` exactly as before, so consumers swap rendering
18
- * without touching their OAuth flow.
9
+ * Since 2026-09-14 (user direction) the block renders as CENTERED
10
+ * INDEPENDENT icon tiles: every provider is its own framed 44px icon
11
+ * button with its own tooltip — NOT a button group. A group track (one
12
+ * shared frame stretched across the card with the icons floating in
13
+ * the middle) reads as an oversized empty box; the button-group
14
+ * component is reserved for its two fundamental jobs — a selector
15
+ * (single/multiple) and a tight action strip (see HkIconButtonGroup).
19
16
  *
20
- * The wrapper stays `display: contents`: the divider and the group row
17
+ * Provider identity is revealed on hover: each tile wraps itself in an
18
+ * HkTooltip carrying the provider label, which matches the platform
19
+ * login-chooser pattern (Windows/macOS account tiles) and keeps the
20
+ * card compact when a deployment offers several providers. The
21
+ * `select` event carries the provider `key` exactly as before, so
22
+ * consumers swap rendering without touching their OAuth flow.
23
+ *
24
+ * The wrapper stays `display: contents`: the divider and the tile row
21
25
  * remain layout children of HkAuthCard's `.s-auth-methods` slot
22
26
  * container (it owns the side padding and vertical rhythm).
23
27
  */
@@ -35,11 +39,11 @@ export default defineComponent({
35
39
  /** Accessible name + tooltip text (the hover reveal). */
36
40
  label: string;
37
41
  /** Prebuilt icon vnode (brand SVG, <img>, …) rendered inside the
38
- * icon-only button. Typed loose on purpose (same as
39
- * HkIconButtonGroup options): hosts materialize hikari against
40
- * their own vue store, and a hard VNode type breaks typecheck
41
- * whenever the host's vue minor differs. A missing icon falls
42
- * back to the label's initial. */
42
+ * framed tile. Typed loose on purpose (same as HkIconButtonGroup
43
+ * options): hosts materialize hikari against their own vue store,
44
+ * and a hard VNode type breaks typecheck whenever the host's vue
45
+ * minor differs. A missing icon falls back to the label's
46
+ * initial. */
43
47
  icon?: unknown;
44
48
  disabled?: boolean;
45
49
  }>,
@@ -47,7 +51,7 @@ export default defineComponent({
47
51
  },
48
52
  },
49
53
  emits: {
50
- /** A provider button was clicked. */
54
+ /** A provider tile was clicked. */
51
55
  select: (_key: string) => true,
52
56
  },
53
57
  setup(props, { emit }) {
@@ -58,18 +62,42 @@ export default defineComponent({
58
62
  <span>{props.divider}</span>
59
63
  </div>
60
64
  )}
61
- <HkIconButtonGroup
62
- class="s-auth-methods-group"
63
- mode="buttons"
64
- size="md"
65
- options={props.methods.map((method) => ({
66
- key: method.key,
67
- label: method.label,
68
- icon: method.icon,
69
- disabled: method.disabled === true,
70
- }))}
71
- onSelect={(key: string) => emit("select", key)}
72
- />
65
+ {/* A plain row, deliberately NOT role="group": every tile is an
66
+ independent action (its own frame, its own tooltip), and the
67
+ divider above names the section for sighted users. Group
68
+ semantics belong to the selector/tight-strip components. */}
69
+ <div class="s-auth-methods-tiles">
70
+ {props.methods.map((method) => {
71
+ const tile = (
72
+ <button
73
+ key={method.key}
74
+ type="button"
75
+ class="s-auth-methods-tile"
76
+ data-key={method.key}
77
+ aria-label={method.label}
78
+ title={undefined} // the HkTooltip popup owns the hover text
79
+ disabled={method.disabled === true}
80
+ onClick={() => emit("select", method.key)}
81
+ >
82
+ <span class="s-auth-methods-tile-icon" aria-hidden="true">
83
+ {method.icon != null
84
+ ? method.icon
85
+ : (
86
+ <span class="s-auth-methods-tile-initial">
87
+ {method.label.charAt(0).toUpperCase()}
88
+ </span>
89
+ )
90
+ }
91
+ </span>
92
+ </button>
93
+ );
94
+ return (
95
+ <HkTooltip key={method.key} text={method.label} placement="top" delay={300}>
96
+ {tile}
97
+ </HkTooltip>
98
+ );
99
+ })}
100
+ </div>
73
101
  </div>
74
102
  );
75
103
  },
@@ -9,7 +9,9 @@
9
9
  .hk-blocking-toast-container {
10
10
  position: fixed;
11
11
  top: 4rem;
12
- inset-inline-end: 1rem;
12
+ /* Same viewport gutter the anchored popups clamp to (16px desktop /
13
+ * 8px mobile) — the toast stack is a floating layer too. */
14
+ inset-inline-end: var(--viewport-gutter, 1rem);
13
15
  z-index: var(--hk-z-toast, 4000);
14
16
  max-width: 24rem;
15
17
  pointer-events: none;
@@ -25,13 +25,21 @@ export interface HkIconButtonGroupOption {
25
25
  * HkIconButtonGroup — the icon-only variant of the button-group family
26
26
  * (the same centered strip grammar as HkTabs, minus the text).
27
27
  *
28
- * Three working modes:
29
- * - "buttons" a plain action group (no selection state) — the auth
30
- * card's OAuth provider row
31
- * - "single" a radiogroup: one active key (v-model), re-clicking the
32
- * active key is a no-op — radio semantics
33
- * - "multiple" a toggle group: v-model is a string[] of active keys,
34
- * every item carries aria-pressed
28
+ * The group has exactly TWO fundamental jobs (2026-09-14 user direction
29
+ * — a group is a semantic component, not a generic box to throw buttons
30
+ * into), carried by three working modes:
31
+ * - a SELECTOR: "single" (radiogroup: one active key via v-model,
32
+ * re-clicking the active key is a no-op — radio semantics) or
33
+ * "multiple" (toggle set: v-model is a string[] of active keys,
34
+ * every item carries aria-pressed)
35
+ * - a TIGHT ACTION STRIP: "buttons" (no selection state) — related
36
+ * actions packed shoulder-to-shoulder in ONE shared track, the
37
+ * toolbar grammar
38
+ *
39
+ * Anything else is NOT a group: independent actions that merely live on
40
+ * the same row (e.g. a login card's third-party provider tiles) render
41
+ * as separate framed buttons with comfortable spacing — see
42
+ * HkAuthMethodList for the reference pattern.
35
43
  *
36
44
  * Every item is icon-only and rides one size taller than a standard
37
45
  * icon button (md = 44px vs the 32px icon button / 40px text button) —
@@ -14,6 +14,7 @@ import { Check, ChevronRight } from "lucide-vue-next";
14
14
 
15
15
  import { useBreakpoint } from "../runtime/useBreakpoint";
16
16
  import { ancestorZoom } from "../runtime/cssZoom";
17
+ import { viewportGutterPx } from "../runtime/viewportGutter";
17
18
  import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
18
19
  import "./HkMenu.scss";
19
20
 
@@ -58,8 +59,6 @@ export interface HkMenuItem {
58
59
  children?: HkMenuItem[];
59
60
  }
60
61
 
61
- /** Viewport padding mirrored from HkSelectPanel's popout geometry. */
62
- const VIEWPORT_PAD = 8;
63
62
  /** Width estimate used ONLY to pick a cascade's flip side; the real
64
63
  * panel box is measured by HkSelectPanel itself. */
65
64
  const CASCADE_PANEL_W = 224;
@@ -415,8 +414,10 @@ export default defineComponent({
415
414
  const r = row.getBoundingClientRect();
416
415
  if (!r.width && !r.height) return pointRect(0, 0); // detached
417
416
  const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
418
- const openRight = r.right + cascadeW <= window.innerWidth - VIEWPORT_PAD;
419
- const left = openRight ? r.right : Math.max(VIEWPORT_PAD, r.left - cascadeW);
417
+ // Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
418
+ const pad = viewportGutterPx();
419
+ const openRight = r.right + cascadeW <= window.innerWidth - pad;
420
+ const left = openRight ? r.right : Math.max(pad, r.left - cascadeW);
420
421
  return pointRect(left, r.top);
421
422
  },
422
423
  contains: (node) => !!rowRefs.value[id]?.contains(node),
@@ -438,11 +439,13 @@ export default defineComponent({
438
439
  if (!r.width && !r.height) return pointRect(0, 0);
439
440
  const gap = props.offset; // visual-space gap, same as the native placements
440
441
  const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
441
- const openRight = r.right + gap + cascadeW <= window.innerWidth - VIEWPORT_PAD;
442
+ // Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
443
+ const pad = viewportGutterPx();
444
+ const openRight = r.right + gap + cascadeW <= window.innerWidth - pad;
442
445
  const left =
443
446
  side === "right" && openRight
444
447
  ? r.right + gap
445
- : Math.max(VIEWPORT_PAD, r.left - cascadeW - gap);
448
+ : Math.max(pad, r.left - cascadeW - gap);
446
449
  return pointRect(left, r.top);
447
450
  },
448
451
  contains: (node) => !!props.anchorRef?.contains(node),
@@ -115,6 +115,20 @@ export function resolveModalWidth(width: string): string {
115
115
  return value;
116
116
  }
117
117
 
118
+ /**
119
+ * Cap the resolved max-width so the centered desktop frame always keeps
120
+ * the shared viewport gutter (--viewport-gutter: 16px desktop / 8px
121
+ * mobile) clear on both sides — a frame wider than the window can spare
122
+ * used to run edge-to-edge. `100%` resolves against the fixed frame's
123
+ * containing block (the initial containing block), so the cap tracks the
124
+ * live window; the ≤767px sheet branch overrides max-width with
125
+ * `100% !important` in the SCSS, so the intentional full-bleed mobile
126
+ * sheet is untouched.
127
+ */
128
+ export function modalFrameMaxWidth(resolved: string): string {
129
+ return `min(${resolved}, calc(100% - 2 * var(--viewport-gutter, 16px)))`;
130
+ }
131
+
118
132
  const FOCUSABLE_SELECTOR =
119
133
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
120
134
 
@@ -194,7 +208,9 @@ export default defineComponent({
194
208
 
195
209
  const overlayZ = computed(() => handle.value?.zIndex ?? 0);
196
210
  const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
197
- const resolvedWidth = computed(() => resolveModalWidth(props.width));
211
+ const resolvedWidth = computed(() =>
212
+ modalFrameMaxWidth(resolveModalWidth(props.width)),
213
+ );
198
214
  // Layer/dialog name: the explicit surface name wins over the header
199
215
  // title (which header-less surfaces never pass).
200
216
  const resolvedSurfaceName = computed(() => props.surfaceTitle ?? props.title);
@@ -104,11 +104,15 @@ describe("HkModal width rendering", () => {
104
104
 
105
105
  it("renders a named preset as the frame's max-width", async () => {
106
106
  const content = await mountModal("sm");
107
- expect(content.style.maxWidth).toBe("32rem");
107
+ expect(content.style.maxWidth).toBe(
108
+ "min(32rem, calc(100% - 2 * var(--viewport-gutter, 16px)))",
109
+ );
108
110
  });
109
111
 
110
112
  it("renders an arbitrary CSS length as the frame's max-width", async () => {
111
113
  const content = await mountModal("560px");
112
- expect(content.style.maxWidth).toBe("560px");
114
+ expect(content.style.maxWidth).toBe(
115
+ "min(560px, calc(100% - 2 * var(--viewport-gutter, 16px)))",
116
+ );
113
117
  });
114
118
  });
@@ -9,7 +9,7 @@
9
9
 
10
10
  .hk-popover-panel {
11
11
  min-width: 0;
12
- max-height: min(70vh, calc(100vh - 2 * 8px));
12
+ max-height: min(70vh, calc(100vh - 2 * var(--viewport-gutter, 16px)));
13
13
  /* Width MUST be position-independent (max-content, viewport-capped).
14
14
  * The anchored panel is fixed-positioned with shrink-to-fit sizing,
15
15
  * and computePosition places it as `left = anchorEnd - panelWidth`
@@ -21,9 +21,10 @@
21
21
  * wider frame by frame (demo.dev field report 2026-09-07). Pinning
22
22
  * the width to max-content breaks the loop: the panel sizes to its
23
23
  * content wherever it sits, and positioning converges in one pass.
24
- * VIEWPORT_PAD is 8 (script side). */
24
+ * The script-side pad reads the same --viewport-gutter token
25
+ * (runtime/viewportGutter). */
25
26
  width: max-content;
26
- max-width: calc(100vw - 2 * 8px);
27
+ max-width: calc(100vw - 2 * var(--viewport-gutter, 16px));
27
28
  }
28
29
 
29
30
  /* Pop motion family tokens (--hk-pop-* in theme.scss) — the reference
@@ -6,8 +6,9 @@
6
6
  * / HkSelectPanel sheets, hence "some have it, some don't").
7
7
  *
8
8
  * Root cause: the base `.hk-popover-panel` rule sets
9
- * `max-width: calc(100vw - 2 * 8px)` (the anchored panel's
10
- * anti-ratchet clamp, #421). The mobile sheet branch overrides
9
+ * `max-width: calc(100vw - 2 * <viewport pad>)` (the anchored panel's
10
+ * anti-ratchet clamp, #421; today the shared `--viewport-gutter` token).
11
+ * The mobile sheet branch overrides
11
12
  * `width: auto` but inherited the max-width, over-constraining the
12
13
  * inline `left: 0; right: 0` docking — the used width resolved to
13
14
  * 100vw - 16px and LTR dropped the `right` constraint. Pinned here so
@@ -27,7 +27,9 @@ describe("HkPopover glass layer surface hooks", () => {
27
27
  it("pins the anchored panel width to max-content and resets the sheet", () => {
28
28
  const base = src.match(/\.hk-popover-panel\s*{[^}]*}/)![0];
29
29
  expect(base).toContain("width: max-content");
30
- expect(base).toContain("max-width: calc(100vw - 2 * 8px)");
30
+ // The viewport caps ride the shared --viewport-gutter token (16px
31
+ // desktop / 8px mobile) — the same value the script-side clamp reads.
32
+ expect(base).toContain("max-width: calc(100vw - 2 * var(--viewport-gutter, 16px))");
31
33
  const sheet = src.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)![0];
32
34
  expect(sheet).toContain("width: auto");
33
35
  });
@@ -15,6 +15,7 @@ import {
15
15
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
16
16
  import { useBreakpoint } from "../runtime/useBreakpoint";
17
17
  import { ancestorZoom } from "../runtime/cssZoom";
18
+ import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
18
19
  import { useI18n } from "../i18n/context";
19
20
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
20
21
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
@@ -39,8 +40,6 @@ function parsePlacement(p: PopupPlacement): { side: BaseSide; align: Align } {
39
40
  return { side, align: align ?? "center" };
40
41
  }
41
42
 
42
- const VIEWPORT_PAD = 8;
43
-
44
43
  export default defineComponent({
45
44
  name: "HkPopover",
46
45
  props: {
@@ -323,6 +322,10 @@ export default defineComponent({
323
322
  }
324
323
  const vw = window.innerWidth;
325
324
  const vh = window.innerHeight;
325
+ // The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
326
+ // desktop) — read per reposition so a viewport crossing the
327
+ // breakpoint re-clamps with the right value on the next pass.
328
+ const pad = viewportGutterPx();
326
329
 
327
330
  let { side } = parsePlacement(props.placement);
328
331
  const { align } = parsePlacement(props.placement);
@@ -331,25 +334,25 @@ export default defineComponent({
331
334
  if (side === "bottom") {
332
335
  const spaceBelow = vh - anchorRect.bottom;
333
336
  const spaceAbove = anchorRect.top;
334
- if (spaceBelow < panelRect.height + VIEWPORT_PAD && spaceAbove > spaceBelow) {
337
+ if (spaceBelow < panelRect.height + pad && spaceAbove > spaceBelow) {
335
338
  side = "top";
336
339
  }
337
340
  } else if (side === "top") {
338
341
  const spaceAbove = anchorRect.top;
339
342
  const spaceBelow = vh - anchorRect.bottom;
340
- if (spaceAbove < panelRect.height + VIEWPORT_PAD && spaceBelow > spaceAbove) {
343
+ if (spaceAbove < panelRect.height + pad && spaceBelow > spaceAbove) {
341
344
  side = "bottom";
342
345
  }
343
346
  } else if (side === "right") {
344
347
  const spaceRight = vw - anchorRect.right;
345
348
  const spaceLeft = anchorRect.left;
346
- if (spaceRight < panelRect.width + VIEWPORT_PAD && spaceLeft > spaceRight) {
349
+ if (spaceRight < panelRect.width + pad && spaceLeft > spaceRight) {
347
350
  side = "left";
348
351
  }
349
352
  } else if (side === "left") {
350
353
  const spaceLeft = anchorRect.left;
351
354
  const spaceRight = vw - anchorRect.right;
352
- if (spaceLeft < panelRect.width + VIEWPORT_PAD && spaceRight > spaceLeft) {
355
+ if (spaceLeft < panelRect.width + pad && spaceRight > spaceLeft) {
353
356
  side = "right";
354
357
  }
355
358
  }
@@ -374,7 +377,7 @@ export default defineComponent({
374
377
  } else {
375
378
  crossPos = anchorStart + (anchorSize - panelSize) / 2;
376
379
  }
377
- crossPos = Math.max(VIEWPORT_PAD, Math.min(crossPos, viewportSize - panelSize - VIEWPORT_PAD));
380
+ crossPos = clampWithGutter(crossPos, panelSize, viewportSize, pad);
378
381
 
379
382
  if (side === "bottom") {
380
383
  c.top = anchorRect.bottom + off;
@@ -391,13 +394,13 @@ export default defineComponent({
391
394
  }
392
395
 
393
396
  if (side === "bottom") {
394
- c.top = Math.max(VIEWPORT_PAD, Math.min(c.top!, vh - panelRect.height - VIEWPORT_PAD));
397
+ c.top = clampWithGutter(c.top!, panelRect.height, vh, pad);
395
398
  } else if (side === "top") {
396
- c.bottom = Math.max(VIEWPORT_PAD, Math.min(c.bottom ?? 0, vh - panelRect.height - VIEWPORT_PAD));
399
+ c.bottom = clampWithGutter(c.bottom ?? 0, panelRect.height, vh, pad);
397
400
  } else if (side === "right") {
398
- c.left = Math.max(VIEWPORT_PAD, Math.min(c.left!, vw - panelRect.width - VIEWPORT_PAD));
401
+ c.left = clampWithGutter(c.left!, panelRect.width, vw, pad);
399
402
  } else {
400
- c.right = Math.max(VIEWPORT_PAD, Math.min(c.right ?? 0, vw - panelRect.width - VIEWPORT_PAD));
403
+ c.right = clampWithGutter(c.right ?? 0, panelRect.width, vw, pad);
401
404
  }
402
405
 
403
406
  coords.value = c;
@@ -119,6 +119,17 @@
119
119
  position: fixed;
120
120
  min-width: 180px;
121
121
 
122
+ /* Width is position-independent (same contract as .hk-popover-panel):
123
+ * max-content sizes the popout to its rows wherever it sits, instead
124
+ * of shrink-to-fitting against `viewport - left` — an anchor near the
125
+ * right screen edge used to squeeze the panel down to the leftover
126
+ * space. The viewport cap (2 × the --viewport-gutter token, 16px
127
+ * desktop / 8px mobile) bounds it on both edges; the inline
128
+ * matchAnchorWidth min-width still wins over both, as documented
129
+ * above. */
130
+ width: max-content;
131
+ max-width: calc(100vw - 2 * var(--viewport-gutter, 16px));
132
+
122
133
  /* Pop motion family — the popout grows out of the anchor edge (or
123
134
  * midpoint, for the -center placements) it sits on (data-side/data-align
124
135
  * are set by HkSelectPanel from the RESOLVED placement, including after