@celestia-island/hikari 0.55.134 → 0.55.136

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.134",
3
+ "version": "0.55.136",
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",
@@ -71,6 +71,18 @@
71
71
  }
72
72
  }
73
73
 
74
+ /* A list where every row is locked can never reorder, so the handle lane
75
+ * has nothing to offer — collapse it instead of reserving an empty gutter
76
+ * that reads as "missing grips" (user report 2026-10-11: chest's 2FA
77
+ * factors list). Mixed lists are unaffected: they keep the lane on every
78
+ * row (locked ones render it hidden via [data-hidden]) so the grip column
79
+ * stays aligned. */
80
+ .hk-draggable-list[data-all-locked] {
81
+ .hk-draggable-list-handle {
82
+ display: none;
83
+ }
84
+ }
85
+
74
86
  .hk-draggable-list-content {
75
87
  flex: 1;
76
88
  min-width: 0;
@@ -0,0 +1,64 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { compile } from "sass";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Sheet contract for the locked-row handle lane (DOM twin:
9
+ * HkDraggableList.test.tsx).
10
+ *
11
+ * - a locked row HIDES its grip but keeps the 20px lane reserved
12
+ * (`visibility: hidden` under [data-hidden]) — mixed lists keep the
13
+ * grip column aligned;
14
+ * - a list whose every row is locked (root [data-all-locked]) can never
15
+ * reorder, so the lane collapses outright (`display: none`) instead of
16
+ * reserving an empty gutter that reads as "missing grips" (user report
17
+ * 2026-10-11 on chest's 2FA factors list).
18
+ *
19
+ * Assertions run on the COMPILED sheet, not the source text, so a
20
+ * declaration cannot hide behind SCSS nesting, and comments are stripped
21
+ * first so a commented-out guard cannot keep a hollow test green.
22
+ */
23
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
24
+
25
+ function listCss(): string {
26
+ const file = resolve(componentDir, "HkDraggableList.scss");
27
+ return compile(file, { style: "expanded", loadPaths: [componentDir] }).css.replace(
28
+ /\/\*[\s\S]*?\*\//g,
29
+ "",
30
+ );
31
+ }
32
+
33
+ function ruleBody(css: string, selector: string): string | null {
34
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ const m = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
36
+ return m ? m[1]! : null;
37
+ }
38
+
39
+ describe("HkDraggableList handle-lane sheet", () => {
40
+ const css = listCss();
41
+
42
+ it("compiles", () => {
43
+ expect(css.length).toBeGreaterThan(0);
44
+ // Sanity anchors so a renamed file cannot hollow this guard.
45
+ expect(css).toContain(".hk-draggable-list-item");
46
+ expect(css).toContain(".hk-draggable-list-handle");
47
+ });
48
+
49
+ it("keeps a locked row's lane reserved — hidden, not removed", () => {
50
+ const body = ruleBody(css, ".hk-draggable-list-handle[data-hidden]");
51
+ expect(body, "the [data-hidden] rule must exist").not.toBeNull();
52
+ expect(body).toContain("visibility: hidden");
53
+ expect(body).not.toContain("display: none");
54
+ });
55
+
56
+ it("collapses the lane entirely when every row is locked", () => {
57
+ const body = ruleBody(
58
+ css,
59
+ ".hk-draggable-list[data-all-locked] .hk-draggable-list-handle",
60
+ );
61
+ expect(body, "the [data-all-locked] collapse rule must exist").not.toBeNull();
62
+ expect(body).toContain("display: none");
63
+ });
64
+ });
@@ -0,0 +1,182 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick, ref } from "vue";
3
+
4
+ import HDraggableList from "./HkDraggableList";
5
+
6
+ /**
7
+ * DOM contract for the locked-row handle lane.
8
+ *
9
+ * Every row renders a 20px handle lane so the grip column stays aligned
10
+ * across draggable and locked rows; a locked row hides its grip
11
+ * (`data-hidden` → visibility: hidden — reserved, not removed). When
12
+ * EVERY row is locked the list can never reorder at all, and an
13
+ * all-hidden lane reads as "missing grips" rather than "locked" (user
14
+ * report 2026-10-11 on chest's 2FA factors list): the root then carries
15
+ * `data-all-locked` and the sheet collapses the lane entirely (the sheet
16
+ * twin of this contract: HkDraggableList.styles.test.ts). A custom
17
+ * `handle` slot rides the same lane, so it collapses with it.
18
+ *
19
+ * (Repo test convention: raw createApp + document queries, no
20
+ * @vue/test-utils.)
21
+ */
22
+
23
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
24
+
25
+ afterEach(() => {
26
+ while (mounts.length) {
27
+ const { app, container } = mounts.pop()!;
28
+ app.unmount();
29
+ container.remove();
30
+ }
31
+ });
32
+
33
+ function mountList(items: string[], lockedKeys: string[]): HTMLElement {
34
+ const container = document.createElement("div");
35
+ document.body.appendChild(container);
36
+ const app = createApp({
37
+ render: () =>
38
+ h(
39
+ HDraggableList,
40
+ { items: items.map((key) => ({ key })), lockedKeys },
41
+ {
42
+ item: ({ item }: { item: { key: string } }) =>
43
+ h("span", { class: "row-body" }, item.key),
44
+ },
45
+ ),
46
+ });
47
+ app.mount(container);
48
+ mounts.push({ app, container });
49
+ return container.querySelector(".hk-draggable-list") as HTMLElement;
50
+ }
51
+
52
+ function handlesOf(root: HTMLElement): HTMLElement[] {
53
+ return Array.from(root.querySelectorAll(".hk-draggable-list-handle"));
54
+ }
55
+
56
+ describe("HkDraggableList locked-lane contract", () => {
57
+ it("hides a locked row's grip but keeps the lane in mixed lists", () => {
58
+ const root = mountList(["a", "b"], ["b"]);
59
+
60
+ expect(root.hasAttribute("data-all-locked")).toBe(false);
61
+ const handles = handlesOf(root);
62
+ expect(handles).toHaveLength(2);
63
+ expect(handles[0]!.hasAttribute("data-hidden")).toBe(false);
64
+ expect(handles[1]!.hasAttribute("data-hidden")).toBe(true);
65
+ });
66
+
67
+ it("marks an all-locked list so the sheet can collapse the lane", () => {
68
+ const root = mountList(["a", "b"], ["a", "b"]);
69
+
70
+ expect(root.hasAttribute("data-all-locked")).toBe(true);
71
+ // Handles stay in the DOM (consumers count them — chest's 2FA
72
+ // contract test does); the collapse is presentational, carried by
73
+ // the sheet, so the marker is the load-bearing part here.
74
+ const handles = handlesOf(root);
75
+ expect(handles).toHaveLength(2);
76
+ expect(handles.every((handle) => handle.hasAttribute("data-hidden"))).toBe(true);
77
+ });
78
+
79
+ it("flips the marker reactively as the last draggable row locks", async () => {
80
+ const lockedKeys = ref<string[]>([]);
81
+ const container = document.createElement("div");
82
+ document.body.appendChild(container);
83
+ const app = createApp({
84
+ render: () =>
85
+ h(
86
+ HDraggableList,
87
+ { items: [{ key: "a" }, { key: "b" }], lockedKeys: lockedKeys.value },
88
+ {
89
+ item: ({ item }: { item: { key: string } }) =>
90
+ h("span", { class: "row-body" }, item.key),
91
+ },
92
+ ),
93
+ });
94
+ app.mount(container);
95
+ mounts.push({ app, container });
96
+ const root = container.querySelector(".hk-draggable-list") as HTMLElement;
97
+
98
+ expect(root.hasAttribute("data-all-locked")).toBe(false);
99
+
100
+ lockedKeys.value = ["a", "b"];
101
+ await nextTick();
102
+
103
+ expect(root.hasAttribute("data-all-locked")).toBe(true);
104
+ });
105
+
106
+ it("collapses a custom handle slot with the lane in an all-locked list", () => {
107
+ // The handle slot renders INSIDE the lane span, so the collapse rule
108
+ // hides custom grips the same as the default six-dot svg.
109
+ const container = document.createElement("div");
110
+ document.body.appendChild(container);
111
+ const app = createApp({
112
+ render: () =>
113
+ h(
114
+ HDraggableList,
115
+ { items: [{ key: "a" }], lockedKeys: ["a"] },
116
+ {
117
+ item: ({ item }: { item: { key: string } }) =>
118
+ h("span", { class: "row-body" }, item.key),
119
+ handle: () => h("span", { class: "custom-grip" }, "≡"),
120
+ },
121
+ ),
122
+ });
123
+ app.mount(container);
124
+ mounts.push({ app, container });
125
+
126
+ const grip = container.querySelector(".custom-grip") as HTMLElement;
127
+ expect(grip).not.toBeNull();
128
+ // The custom grip rides the lane span that the sheet display:nones.
129
+ expect(grip.closest(".hk-draggable-list-handle")).not.toBeNull();
130
+ expect(grip.closest(".hk-draggable-list")).not.toBeNull();
131
+ expect(
132
+ (grip.closest(".hk-draggable-list") as HTMLElement).hasAttribute("data-all-locked"),
133
+ ).toBe(true);
134
+ });
135
+
136
+ it("carries no marker on an empty list", () => {
137
+ const root = mountList([], []);
138
+
139
+ expect(root.hasAttribute("data-all-locked")).toBe(false);
140
+ expect(handlesOf(root)).toHaveLength(0);
141
+ });
142
+
143
+ it("ignores locked keys that match no row — membership, not counts", () => {
144
+ // Guards against a length-arithmetic refactor
145
+ // (lockedKeys.length >= items.length): TWO phantom keys vs two rows
146
+ // satisfy the count yet lock nothing — the list stays draggable and
147
+ // the lane must stay.
148
+ const phantomOnly = mountList(["a", "b"], ["zzz", "yyy"]);
149
+ expect(phantomOnly.hasAttribute("data-all-locked")).toBe(false);
150
+
151
+ // A superset still marks: every real row IS locked.
152
+ const superset = mountList(["a", "b"], ["zzz", "a", "b"]);
153
+ expect(superset.hasAttribute("data-all-locked")).toBe(true);
154
+ });
155
+
156
+ it("flips the marker reactively as rows are added around a locked one", async () => {
157
+ const items = ref<{ key: string }[]>([{ key: "a" }]);
158
+ const container = document.createElement("div");
159
+ document.body.appendChild(container);
160
+ const app = createApp({
161
+ render: () =>
162
+ h(
163
+ HDraggableList,
164
+ { items: items.value, lockedKeys: ["a"] },
165
+ {
166
+ item: ({ item }: { item: { key: string } }) =>
167
+ h("span", { class: "row-body" }, item.key),
168
+ },
169
+ ),
170
+ });
171
+ app.mount(container);
172
+ mounts.push({ app, container });
173
+ const root = container.querySelector(".hk-draggable-list") as HTMLElement;
174
+
175
+ expect(root.hasAttribute("data-all-locked")).toBe(true);
176
+
177
+ items.value = [{ key: "a" }, { key: "b" }];
178
+ await nextTick();
179
+
180
+ expect(root.hasAttribute("data-all-locked")).toBe(false);
181
+ });
182
+ });
@@ -22,6 +22,10 @@ export default defineComponent({
22
22
  name: "HkDraggableList",
23
23
  props: {
24
24
  items: { type: Array as PropType<DragListItem[]>, required: true },
25
+ /** Row keys that may not be dragged. A locked row hides its grip but
26
+ * keeps the handle lane reserved ([data-hidden]); when EVERY row is
27
+ * locked the root carries data-all-locked and the sheet collapses
28
+ * the lane entirely — the list cannot reorder at all. */
25
29
  lockedKeys: { type: Array as PropType<string[]>, default: () => [] },
26
30
  },
27
31
  emits: {
@@ -198,8 +202,19 @@ export default defineComponent({
198
202
  return () => {
199
203
  void tick.value;
200
204
  const s = drag.value;
205
+ // A list whose every row is locked can never reorder (chest's 2FA
206
+ // factor list is the all-locked case), and an all-hidden lane reads
207
+ // as "missing grips" rather than "locked" (user report 2026-10-11).
208
+ // Mark the root so the sheet can collapse the lane outright; mixed
209
+ // lists keep it on every row for grip-column alignment.
210
+ const allLocked =
211
+ props.items.length > 0 && props.items.every((item) => isLocked(item.key));
201
212
  return (
202
- <div class="hk-draggable-list" data-dragging={s ? "" : undefined}>
213
+ <div
214
+ class="hk-draggable-list"
215
+ data-dragging={s ? "" : undefined}
216
+ data-all-locked={allLocked || undefined}
217
+ >
203
218
  {props.items.map((item, index) => {
204
219
  const locked = isLocked(item.key);
205
220
  const isSource = s != null && s.fromIndex === index;
@@ -157,6 +157,17 @@ export default defineComponent({
157
157
  * length, applied on the `dvh`-capable branch, never sanitized.
158
158
  */
159
159
  maxHeight: { type: String, default: undefined },
160
+ /**
161
+ * Forwarded to the desktop popout panels' `autoFlip` (HkSelectPanel):
162
+ * whether a panel whose placed side cannot host it migrates to the
163
+ * opposite vertical side. Default true — every existing consumer
164
+ * keeps the flip. Hosts that must keep the popup on its placed side
165
+ * (the fill-template field's vocabulary panel anchors DIRECTLY BELOW
166
+ * its field, user direction 2026-10-10) pass `false`; the viewport
167
+ * clamp then absorbs the overflow instead. Mobile sheets have no
168
+ * flip to make — the prop never reaches them.
169
+ */
170
+ autoFlip: { type: Boolean, default: true },
160
171
  /**
161
172
  * Sidebar variant only: every collapsible group renders EXPANDED
162
173
  * until the user collapses it — nav sidebars that read as a table of
@@ -606,6 +617,7 @@ export default defineComponent({
606
617
  offset={cfg.offset}
607
618
  matchAnchorWidth={cfg.match}
608
619
  maxHeight={props.maxHeight}
620
+ autoFlip={props.autoFlip}
609
621
  onUpdate:open={(v: boolean) => {
610
622
  if (!v) onRootCloseRequest();
611
623
  }}
@@ -634,6 +646,7 @@ export default defineComponent({
634
646
  placement="bottom-start"
635
647
  offset={0}
636
648
  maxHeight={props.maxHeight}
649
+ autoFlip={props.autoFlip}
637
650
  onUpdate:open={(v: boolean) => {
638
651
  if (!v) onSubCloseRequest(level);
639
652
  }}
@@ -431,6 +431,38 @@ describe("HkSelectPanel custom invocation", () => {
431
431
  window.innerHeight = prevHeight;
432
432
  }
433
433
  });
434
+
435
+ it("autoFlip: false keeps a starved bottom popout below its anchor (clamped)", async () => {
436
+ // The below-first rule (HkTemplateField's vocabulary panel): an
437
+ // anchor near the viewport bottom with room only ABOVE must not
438
+ // migrate the panel above — with autoFlip off the placed side holds
439
+ // (data-side stays "bottom", so the pop motion still grows the panel
440
+ // out of the anchor's bottom edge) and the clamp slides the panel up
441
+ // only as far as full visibility requires. The default flips the
442
+ // same rig top-side — that direction is pinned by the tall-popout
443
+ // clamp test above.
444
+ const prevHeight = window.innerHeight;
445
+ window.innerHeight = 800;
446
+ try {
447
+ const { container, open } = mountPanel({ placement: "bottom-start", autoFlip: false });
448
+ await nextTick();
449
+
450
+ const anchor = container.querySelector<HTMLButtonElement>("button")!;
451
+ anchor.getBoundingClientRect = () =>
452
+ ({ top: 760, bottom: 780, left: 40, right: 140, width: 100, height: 20 }) as DOMRect;
453
+ open.value = true;
454
+ await nextTick();
455
+
456
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
457
+ expect(host.dataset.side).toBe("bottom");
458
+ // Raw top 780 + offset 4 = 784 overflows on an 800px viewport
459
+ // (happy-dom measures the panel at the 200px fallback): the clamp
460
+ // pins it to 800 - 200 - 16 = 584 instead of flipping.
461
+ expect(Number.parseInt(host.style.top, 10)).toBe(584);
462
+ } finally {
463
+ window.innerHeight = prevHeight;
464
+ }
465
+ });
434
466
  });
435
467
 
436
468
  describe("HkSelectPanel back-guard (window-first back priority)", () => {
@@ -91,6 +91,19 @@ export default defineComponent({
91
91
  type: String as PropType<SelectPanelPlacement>,
92
92
  default: "bottom-start",
93
93
  },
94
+ /**
95
+ * Auto-flip to the opposite vertical side when the placed side cannot
96
+ * host the panel. Default true — plain dropdowns read better flipped
97
+ * than clamped over their trigger. Surfaces whose popup must stay
98
+ * anchored BELOW their field regardless of how the viewport happens
99
+ * to be filled (the fill-template field's vocabulary panel: user
100
+ * direction 2026-10-10 — it opens in the flow under the input, never
101
+ * migrating above it) pass `false`: the panel keeps the placed side
102
+ * and the viewport clamp absorbs the overflow instead (an oversized
103
+ * panel slides up over the anchor only as far as full visibility
104
+ * requires).
105
+ */
106
+ autoFlip: { type: Boolean, default: true },
94
107
  /** Gap between anchor and popout, in px. */
95
108
  offset: { type: Number, default: 4 },
96
109
  /** Popout min-width follows the anchor width (select parity). */
@@ -557,13 +570,17 @@ export default defineComponent({
557
570
  side === "top"
558
571
  ? r.top - props.offset - ph
559
572
  : r.bottom + props.offset;
560
- // Auto-flip when the chosen side cannot host the panel.
561
- if (side === "bottom" && top + ph > frame.y + frame.height - pad) {
562
- side = "top";
563
- top = r.top - props.offset - ph;
564
- } else if (side === "top" && top < frame.y + pad) {
565
- side = "bottom";
566
- top = r.bottom + props.offset;
573
+ // Auto-flip when the chosen side cannot host the panel (opt-out
574
+ // via autoFlip: false keeps the placed side; the clamp below then
575
+ // absorbs an overflow instead of migrating the panel).
576
+ if (props.autoFlip) {
577
+ if (side === "bottom" && top + ph > frame.y + frame.height - pad) {
578
+ side = "top";
579
+ top = r.top - props.offset - ph;
580
+ } else if (side === "top" && top < frame.y + pad) {
581
+ side = "bottom";
582
+ top = r.bottom + props.offset;
583
+ }
567
584
  }
568
585
  const align = props.placement.endsWith("-center")
569
586
  ? "center"
@@ -75,7 +75,19 @@
75
75
  min-height: calc(1.5rem + var(--space-4, 4px));
76
76
  display: flex;
77
77
  flex-wrap: wrap;
78
- align-items: center;
78
+ // Baseline alignment (user direction 2026-10-10): the chip's smaller
79
+ // monospace text must share the literal text's BASELINE, not its box
80
+ // midpoint — `center` centered the chip's box, which floats the chip's
81
+ // baseline a fraction above the surrounding text's (smaller font,
82
+ // same-height line) and read as the chip riding high. Baseline is the
83
+ // flex-native fix: text runs and chips (inline-flex, whose baseline
84
+ // is its content text's) align on one shared baseline with no pixel
85
+ // nudges. `align-content: center` keeps the single-line read
86
+ // vertically centered inside the min-height box — the line sizes to
87
+ // its content and the LINE centers, where plain `baseline` alone
88
+ // would top-align it in the stretched line.
89
+ align-items: baseline;
90
+ align-content: center;
79
91
  gap: 2px 3px;
80
92
  font-size: var(--text-base, 0.875rem);
81
93
  line-height: 1.5;
@@ -17,7 +17,12 @@ import { describe, expect, it } from "vitest";
17
17
  * mobile sheet must keep the panel as the ONE scroll region;
18
18
  * 2. the scroll host is a positioning context (`position: relative`)
19
19
  * for the overlay rails;
20
- * 3. a curated class list checks TSX→CSS (no unstyled element) and a
20
+ * 3. the edit's children align on the shared text BASELINE
21
+ * (`align-items: baseline` + `align-content: center`) and no chip
22
+ * rule carries a manual vertical nudge — the flex alignment IS the
23
+ * fix for the chip-rides-high report, a pixel hack would be a
24
+ * regression;
25
+ * 4. a curated class list checks TSX→CSS (no unstyled element) and a
21
26
  * reverse scan flags any `hk-tpl-*`/`hk-template-*` selector in the
22
27
  * sheet that no source file renders (no dead rule).
23
28
  */
@@ -109,6 +114,41 @@ describe("HkTemplateField stylesheet contract", () => {
109
114
  expect(css).toMatch(/\.hk-tpl-editor-scroll\s*\{[^}]*position:\s*relative/s);
110
115
  });
111
116
 
117
+ it("aligns the edit's children on the shared text baseline, never box centers", () => {
118
+ // The chip's smaller monospace text must share the literal text's
119
+ // BASELINE (flex-native `align-items: baseline`), not the box
120
+ // midpoint — centering the box floats the chip's baseline a fraction
121
+ // above the surrounding text's and reads as the chip riding high
122
+ // (user report 2026-10-10). Restoring `center` must fail here.
123
+ expect(css).toMatch(/\.hk-template-field-edit\s*\{[^}]*align-items:\s*baseline/s);
124
+ expect(css).not.toMatch(/\.hk-template-field-edit\s*\{[^}]*align-items:\s*center/s);
125
+ // The single-line read stays vertically centered in the min-height
126
+ // box via the LINE centering (align-content) — plain baseline alone
127
+ // would top-align it in the stretched line.
128
+ expect(css).toMatch(/\.hk-template-field-edit\s*\{[^}]*align-content:\s*center/s);
129
+ // The chip editor's current-token line (static chip + description)
130
+ // has been baseline-aligned since introduction — it predates the
131
+ // edit-container fix and stays that way (an R1 review round caught
132
+ // a mutation-recovery slip that had silently flipped it).
133
+ expect(css).toMatch(/\.hk-tpl-editor-current\s*\{[^}]*align-items:\s*baseline/s);
134
+ });
135
+
136
+ it("aligns chips by flex baseline, never by pixel nudges", () => {
137
+ // The baseline contract is the alignment layout itself: no manual
138
+ // vertical offsets on the chip inside the edit (a margin/translate
139
+ // hack would re-introduce the drift the alignment fixes, tuned to
140
+ // one font size). The dark-mode and unknown variants ride the same
141
+ // box, so the whole chip block must stay offset-free.
142
+ const chipRules = [...css.matchAll(/([^{}]*\.hk-tpl-chip[^{}]*)\{([^}]*)\}/g)];
143
+ // Extraction sanity (never trust a 0-hit scan): the chip is styled.
144
+ expect(chipRules.length).toBeGreaterThan(0);
145
+ for (const [, selector, body] of chipRules) {
146
+ expect(body, `chip rule ${(selector ?? "").trim()} must not nudge vertically`).not.toMatch(
147
+ /margin-(top|bottom)|translate[XY]|\btop:|\bbottom:|vertical-align/,
148
+ );
149
+ }
150
+ });
151
+
112
152
  it("styles every editor class the TSX renders", () => {
113
153
  const emitted = classTokensFrom(tsx);
114
154
  for (const cls of ["hk-template-field", "hk-template-field-edit", "hk-template-field-box",
@@ -774,3 +774,85 @@ describe("HkTemplateField vocabulary semantics guards", () => {
774
774
  expect(active[0]!.textContent).toContain("username");
775
775
  });
776
776
  });
777
+
778
+ describe("HkTemplateField popup direction (below-first)", () => {
779
+ /** Fixed visual rect (the rootzoom rig's shape). */
780
+ function rect(x: number, y: number, w: number, h: number): DOMRect {
781
+ return {
782
+ x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h,
783
+ toJSON: () => ({}),
784
+ } as DOMRect;
785
+ }
786
+
787
+ async function flushFrames() {
788
+ await nextTick();
789
+ for (let i = 0; i < 3; i++) {
790
+ await new Promise((r) => setTimeout(r, 0));
791
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
792
+ }
793
+ }
794
+
795
+ it("the vocabulary panel stays below a field anchored near the viewport bottom", async () => {
796
+ // Below-first (user direction 2026-10-10): a low field with room only
797
+ // ABOVE must not migrate the panel above the field — the panel keeps
798
+ // the placed bottom side (data-side drives the pop motion's origin)
799
+ // and the viewport clamp absorbs the overflow. The default-flip
800
+ // behavior for plain panels is pinned over in HkSelectPanel's own
801
+ // suite; this pins the field's opt-out through the HkMenu wiring.
802
+ const prevHeight = window.innerHeight;
803
+ window.innerHeight = 800;
804
+ try {
805
+ const el = mount({ modelValue: "" });
806
+ await nextTick();
807
+ const edit = editable(el);
808
+ edit.getBoundingClientRect = () => rect(40, 760, 300, 20);
809
+ await openTrigger(el, "{{ us");
810
+
811
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
812
+ expect(host).toBeTruthy();
813
+ expect(host.dataset.side).toBe("bottom");
814
+ // Raw top 780 + menu offset 6 = 786 overflows the 800px viewport
815
+ // (the panel measures at the 200px happy-dom fallback): clamped to
816
+ // 800 - 200 - 16 = 584 instead of flipping.
817
+ expect(Number.parseInt(host.style.top, 10)).toBe(584);
818
+ } finally {
819
+ window.innerHeight = prevHeight;
820
+ }
821
+ });
822
+
823
+ it("the chip editor popover stays below a chip anchored near the viewport bottom", async () => {
824
+ // Same below-first rule for the chip editor (autoFlip off on the
825
+ // HPopover): without it, a 300px-tall panel beside a chip with 20px
826
+ // of room below flips top-side — exactly the overlap the user
827
+ // reported. The panel's rect is faked at the prototype (the
828
+ // rootzoom rig's approach) so the flip comparison has real numbers.
829
+ const prevHeight = window.innerHeight;
830
+ window.innerHeight = 800;
831
+ const gBCR = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
832
+ function (this: HTMLElement) {
833
+ if (this.classList.contains("hk-tpl-chip")) return rect(40, 760, 120, 20);
834
+ if (this.classList.contains("hk-popover-panel")) return rect(0, 0, 220, 300);
835
+ return rect(0, 0, 0, 0);
836
+ },
837
+ );
838
+ try {
839
+ const el = mount({ modelValue: "https://g.com/{{username}}.png" });
840
+ await nextTick();
841
+ editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
842
+ await flushFrames();
843
+
844
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
845
+ expect(panel).toBeTruthy();
846
+ // No flip: the resolved placement keeps the bottom side.
847
+ expect(panel.className).toContain("hk-popover-bottom-start");
848
+ expect(panel.className).not.toContain("hk-popover-top-start");
849
+ // And the position: raw top 780 + offset 4 = 784, clamped to
850
+ // 800 - 300 - 16 = 484 (full visibility beats the anchor gap).
851
+ const host = panel.parentElement!;
852
+ expect(host.style.top).toBe("484px");
853
+ } finally {
854
+ gBCR.mockRestore();
855
+ window.innerHeight = prevHeight;
856
+ }
857
+ });
858
+ });
@@ -59,12 +59,14 @@ function isSubsequence(query: string, text: string): boolean {
59
59
  * the styled form of the exact template it serializes to — Gutenberg
60
60
  * style, without any block chrome:
61
61
  *
62
- * - typing `{{` opens the vocabulary panel (desktop: anchored under the
63
- * caret's field; phone: the standard bottom sheet) and picking a row
64
- * inserts the chip; typing a full `{{ name }}` by hand converts it
65
- * just the same;
66
- * - clicking a chip opens the chip editor (desktop: a popover beside
67
- * the chip; phone: a bottom sheet) whose form owns the concrete
62
+ * - typing `{{` opens the vocabulary panel (desktop: anchored DIRECTLY
63
+ * BELOW the field — below-first by user direction 2026-10-10, the
64
+ * viewport clamp absorbs a short viewport instead of a flip; phone:
65
+ * the standard bottom sheet) and picking a row inserts the chip;
66
+ * typing a full `{{ name }}` by hand converts it just the same;
67
+ * - clicking a chip opens the chip editor (desktop: a popover directly
68
+ * below the chip, same below-first rule; phone: a bottom sheet) whose
69
+ * form owns the concrete
68
70
  * editing — swap the placeholder for another vocabulary entry, read
69
71
  * what it fills from, or remove it — and FOCUS MOVES INTO the form so
70
72
  * the next keystroke lands there;
@@ -1015,9 +1017,13 @@ export const HkTemplateField = defineComponent({
1015
1017
  {props.hint && !props.error && <p class="hk-template-field-hint">{props.hint}</p>}
1016
1018
  {props.error && <p class="hk-template-field-error">{props.error}</p>}
1017
1019
 
1018
- {/* Vocabulary panel — anchored under the field on desktop, the
1019
- standard bottom sheet on phone. The editable keeps focus;
1020
- rows click through, arrows/Enter ride the editable keydown. */}
1020
+ {/* Vocabulary panel — anchored DIRECTLY BELOW the field on
1021
+ desktop (autoFlip off, user direction 2026-10-10: the panel
1022
+ opens in the flow under the input it completes; when the
1023
+ viewport below is too short the clamp slides it up only as
1024
+ far as full visibility requires), the standard bottom sheet
1025
+ on phone. The editable keeps focus; rows click through,
1026
+ arrows/Enter ride the editable keydown. */}
1021
1027
  <HkMenu
1022
1028
  variant="popup"
1023
1029
  items={[]}
@@ -1029,6 +1035,7 @@ export const HkTemplateField = defineComponent({
1029
1035
  placement="bottom-start"
1030
1036
  matchAnchorWidth
1031
1037
  maxHeight="min(18rem, 45dvh)"
1038
+ autoFlip={false}
1032
1039
  title={t("hikari::templateField.suggestTitle", "Placeholders")}
1033
1040
  >
1034
1041
  {filteredTokens.value.length > 0 ? (
@@ -1053,8 +1060,10 @@ export const HkTemplateField = defineComponent({
1053
1060
  )}
1054
1061
  </HkMenu>
1055
1062
 
1056
- {/* Chip editor — a popover beside the chip on desktop, a bottom
1057
- sheet on phone; the form inside owns the concrete editing. */}
1063
+ {/* Chip editor — a popover directly below the chip on desktop
1064
+ (autoFlip off, same below-first rule as the vocabulary panel:
1065
+ user direction 2026-10-10), a bottom sheet on phone; the form
1066
+ inside owns the concrete editing. */}
1058
1067
  <HPopover
1059
1068
  modelValue={editorOpen.value}
1060
1069
  onUpdate:modelValue={(v: boolean) => {
@@ -1073,6 +1082,7 @@ export const HkTemplateField = defineComponent({
1073
1082
  anchorRef={editorChip.value}
1074
1083
  sheetOnMobile
1075
1084
  placement="bottom-start"
1085
+ autoFlip={false}
1076
1086
  title={t("hikari::templateField.chipEditorTitle", "Edit placeholder")}
1077
1087
  >
1078
1088
  <div class="hk-tpl-editor" onFocusin={() => { editorHoldsFocus.value = true; }}>