@celestia-island/hikari 0.40.30 → 0.40.31

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.40.30",
3
+ "version": "0.40.31",
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",
@@ -67,7 +67,9 @@
67
67
  max-width: 24rem;
68
68
  }
69
69
 
70
- /* One selected entry: [flag] label (meta) •active-dot ×]
70
+ /* One selected entry: [flag] value|label (meta) •active-dot ×]
71
+ * With `tagValues` the primary text is the key's mapped value (italic
72
+ * muted "Not set" while empty); without it, the autonym label.
71
73
  * The × opens the shared confirm message box — the dialog's Confirm is
72
74
  * what actually erases; the × alone never does. */
73
75
  .hk-affix-tag {
@@ -126,6 +128,13 @@
126
128
  overflow: hidden;
127
129
  text-overflow: ellipsis;
128
130
  white-space: nowrap;
131
+
132
+ /* Value-driven tags (HkLocalizedInput): the language is listed but
133
+ * holds no text yet — the italic muted "Not set" placeholder. */
134
+ &[data-unset] {
135
+ font-style: italic;
136
+ color: rgb(var(--color-muted));
137
+ }
129
138
  }
130
139
 
131
140
  .hk-affix-tag-meta {
@@ -167,12 +176,12 @@
167
176
  }
168
177
 
169
178
  /* ── option rows ────────────────────────────────────────────────────── */
170
- /* Positioned host for the overlay scrollbar tracks: wraps ONLY the
171
- * scrolling list viewport (the menu body also contains the header/
172
- * search band), carries the popup's width constraints so the geometry
173
- * is unchanged by the wrapper. */
179
+ /* Width container for the option list — carries the popup's width
180
+ * constraints so the list block keeps its designed measure inside the
181
+ * hosting window (HkSelectPanel popout / mobile sheet). It is NOT a
182
+ * scroll region: the window owns THE single scrollbar and scrolls the
183
+ * whole popup content (tags + search + rows) as one. */
174
184
  .hk-affix-scroll {
175
- position: relative;
176
185
  display: flex;
177
186
  flex-direction: column;
178
187
  min-width: 13rem;
@@ -189,20 +198,15 @@
189
198
  width: 100%;
190
199
  }
191
200
 
201
+ /* NO max-height / overflow here — ONE SCROLLBAR PER WINDOW (2026-09-08
202
+ * user report: the language sheet showed two nested scrollbars, this
203
+ * list's 17rem cap + the sheet's own). The list grows with its content
204
+ * and the hosting window scrolls it. */
192
205
  .hk-affix-list {
193
206
  display: flex;
194
207
  flex-direction: column;
195
208
  gap: 1px;
196
209
  padding: 4px 6px 8px;
197
- max-height: 17rem;
198
- overflow-y: auto;
199
- /* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
200
- * always hidden, never styled. */
201
- scrollbar-width: none;
202
-
203
- &::-webkit-scrollbar {
204
- display: none;
205
- }
206
210
  }
207
211
 
208
212
  .hk-affix-row {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Source contract: ONE SCROLLBAR PER WINDOW (2026-09-08 user report —
3
+ * the localized input's language sheet showed two nested scrollbars:
4
+ * the HkSelectPanel sheet's own AND the affix picker's 17rem-capped row
5
+ * list scrolling inside it). Principle: every window has exactly ONE
6
+ * scrollbar serving the window's own content; a second scroll level
7
+ * belongs in a sub-window, never an inline embed. HkAffixPicker's popup
8
+ * therefore mounts no scroll region of its own — the window surface
9
+ * (desktop popout / mobile sheet) scrolls tags + search + rows as one.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const scss = readFileSync(join(here, "HkAffixPicker.scss"), "utf-8");
18
+ const tsx = readFileSync(join(here, "HkAffixPicker.tsx"), "utf-8");
19
+
20
+ /** Extract ONE balanced `{...}` declaration block following the given
21
+ * selector — a naive `[^}]*` would stop at the first nested `}` and let
22
+ * properties appended after a future nested block evade the pin. */
23
+ function cssBlock(src: string, selector: string): string {
24
+ const at = src.indexOf(selector);
25
+ expect(at, `${selector} block exists`).toBeGreaterThanOrEqual(0);
26
+ const open = src.indexOf("{", at);
27
+ let depth = 0;
28
+ for (let i = open; i < src.length; i++) {
29
+ if (src[i] === "{") depth++;
30
+ else if (src[i] === "}") {
31
+ depth--;
32
+ if (depth === 0) return src.slice(open, i + 1);
33
+ }
34
+ }
35
+ return "";
36
+ }
37
+
38
+ function expectNoScroll(block: string, what: string): void {
39
+ expect(block, `${what} block exists`).toBeTruthy();
40
+ expect(block).not.toMatch(/max-height/);
41
+ expect(block).not.toMatch(/overflow/);
42
+ expect(block).not.toMatch(/scrollbar-width/);
43
+ }
44
+
45
+ describe("HkAffixPicker single-scrollbar-per-window contract", () => {
46
+ it("the row list carries no max-height and no overflow of its own", () => {
47
+ expectNoScroll(cssBlock(scss, ".hk-affix-list"), ".hk-affix-list");
48
+ });
49
+
50
+ it("the width container is neither a scroll region nor a track host", () => {
51
+ const block = cssBlock(scss, ".hk-affix-scroll");
52
+ expectNoScroll(block, ".hk-affix-scroll");
53
+ // The old overlay-rail host role is gone — nothing needs a
54
+ // positioning context anymore.
55
+ expect(block).not.toMatch(/position\s*:/);
56
+ // Defense in depth: the mobile-sheet descendant override (width/
57
+ // centering only) must never grow a scroll region either.
58
+ const sheetBlock = cssBlock(
59
+ scss.slice(scss.indexOf(".hk-select-sheet-panel .hk-affix-scroll")),
60
+ ".hk-affix-scroll",
61
+ );
62
+ expect(sheetBlock, "sheet descendant block exists").toBeTruthy();
63
+ expectNoScroll(sheetBlock, ".hk-select-sheet-panel .hk-affix-scroll");
64
+ });
65
+
66
+ it("the popup mounts no overlay scrollbar machinery", () => {
67
+ expect(tsx).not.toContain("attachOverlayScrollbars");
68
+ expect(tsx).not.toContain("OverlayScrollbarHandle");
69
+ });
70
+ });
@@ -29,6 +29,7 @@ interface MountOptions {
29
29
  closeOnSelect?: boolean;
30
30
  confirmRemove?: boolean;
31
31
  disabled?: boolean;
32
+ tagValues?: Record<string, string>;
32
33
  }
33
34
 
34
35
  function mountPicker(opts: MountOptions = {}) {
@@ -52,6 +53,7 @@ function mountPicker(opts: MountOptions = {}) {
52
53
  closeOnSelect: opts.closeOnSelect,
53
54
  confirmRemove: opts.confirmRemove,
54
55
  disabled: opts.disabled ?? false,
56
+ tagValues: opts.tagValues,
55
57
  onSelect: (key: string) => events.select.push(key),
56
58
  onRemove: (key: string) => events.remove.push(key),
57
59
  onCustom: (q: string) => events.custom.push(q),
@@ -213,12 +215,12 @@ describe("HkAffixPicker", () => {
213
215
  const { container } = mountPicker();
214
216
  await openPopup(container);
215
217
  // A no-match query swaps the default slot to the empty branch —
216
- // the scrolling list (and its overlay-scrollbar host) unmounts.
218
+ // the list block unmounts.
217
219
  await typeQuery("zzz-none");
218
220
  expect(document.querySelector(".hk-affix-empty")).toBeTruthy();
219
221
  expect(document.querySelector(".hk-affix-scroll")).toBeNull();
220
222
  // Clearing the query remounts a FRESH list — the row list must come
221
- // back and the overlay host must be remounted for the scrollbar.
223
+ // back inside its width container.
222
224
  await typeQuery("");
223
225
  expect(document.querySelector(".hk-affix-empty")).toBeNull();
224
226
  expect(rows().map((r) => r.textContent)).toEqual([
@@ -380,4 +382,66 @@ describe("HkAffixPicker", () => {
380
382
  await openPopup(container);
381
383
  expect(rows()).toHaveLength(0);
382
384
  });
385
+
386
+ it("tagValues swaps the tag primary text to each key's value", async () => {
387
+ const { container } = mountPicker({
388
+ mode: "multi",
389
+ selected: ["cn", "jp"],
390
+ tagValues: { cn: "China's stored text", jp: " " },
391
+ });
392
+ await openPopup(container);
393
+ const texts = tags().map(
394
+ (t) => t.querySelector(".hk-affix-tag-text")?.textContent,
395
+ );
396
+ // cn carries its stored value; jp (whitespace-only = unfilled) shows
397
+ // the italic unset placeholder.
398
+ expect(texts).toEqual(["China's stored text", "Not set"]);
399
+ expect(tags()[0].querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(false);
400
+ expect(tags()[1].querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(true);
401
+ // The autonym survives as the tag's identity in the title (and the
402
+ // confirm dialog / aria naming, asserted in the dialog tests).
403
+ expect(
404
+ tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-body")?.title,
405
+ ).toContain("China");
406
+ // The pick rows are untouched — they keep the autonym labels.
407
+ expect(rows().map((r) => r.textContent)).toEqual([
408
+ expect.stringContaining("中华人民共和国"),
409
+ expect.stringContaining("United States"),
410
+ ]);
411
+ });
412
+
413
+ it("a tagValues key missing from the map renders the unset placeholder", async () => {
414
+ const { container } = mountPicker({
415
+ mode: "multi",
416
+ selected: ["us"],
417
+ tagValues: {},
418
+ });
419
+ await openPopup(container);
420
+ const text = tags()[0].querySelector(".hk-affix-tag-text")!;
421
+ expect(text.textContent).toBe("Not set");
422
+ expect(text.hasAttribute("data-unset")).toBe(true);
423
+ });
424
+
425
+ it("without tagValues the tags keep the autonym labels (no unset state)", async () => {
426
+ const { container } = mountPicker({ mode: "multi", selected: ["cn"] });
427
+ await openPopup(container);
428
+ const text = tags()[0].querySelector(".hk-affix-tag-text")!;
429
+ expect(text.textContent).toBe("China");
430
+ expect(text.hasAttribute("data-unset")).toBe(false);
431
+ });
432
+
433
+ it("mounts NO inner scroll region — the window owns the one scrollbar", async () => {
434
+ const { container } = mountPicker({
435
+ mode: "multi",
436
+ selected: ["cn", "jp", "us"],
437
+ });
438
+ await openPopup(container);
439
+ expect(
440
+ document.querySelector<HTMLElement>(".hk-affix-list"),
441
+ "row list renders",
442
+ ).toBeTruthy();
443
+ // No overlay-scroll chrome of the list's own inside the popup — the
444
+ // CSS/text half of the contract is pinned by the contract test.
445
+ expect(document.querySelector(".hk-affix-list .hk-scrollbar-track")).toBeNull();
446
+ });
383
447
  });
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  computed,
3
3
  defineComponent,
4
- nextTick,
5
- onBeforeUnmount,
6
4
  ref,
7
5
  watch,
8
6
  type PropType,
@@ -12,10 +10,6 @@ import {
12
10
  import { ChevronDown, Plus, Search, X } from "lucide-vue-next";
13
11
 
14
12
  import { useI18n } from "../i18n/context";
15
- import {
16
- attachOverlayScrollbars,
17
- type OverlayScrollbarHandle,
18
- } from "../composables/useOverlayScrollbar";
19
13
 
20
14
  import HkInput from "./HkInput";
21
15
  import HkListTransition from "./HkListTransition";
@@ -71,6 +65,23 @@ function isSubsequence(query: string, text: string): boolean {
71
65
  * without leaving the keyboard flow (Enter picks the first row, or
72
66
  * the custom row when nothing matches).
73
67
  *
68
+ * ONE SCROLLBAR PER WINDOW: the popup mounts NO scroll region of its
69
+ * own — the window surface it opens as (the desktop popout or the
70
+ * mobile bottom sheet, both provided by HkSelectPanel) owns THE single
71
+ * scrollbar and scrolls the whole popup content (tags + search + rows)
72
+ * as one. The row list deliberately carries no max-height/overflow; a
73
+ * nested second scrollbar inside the same window is a contract
74
+ * violation (2026-09-08 user report: the language sheet scrolled twice).
75
+ *
76
+ * With `tagValues`, the multi picker's TAG LIST switches to
77
+ * value-driven primary text: a selected key renders its mapped value
78
+ * (trimmed), and a key with no value renders `tagUnsetText` in an
79
+ * italic muted state (`data-unset`) — "the language is listed but
80
+ * nothing typed yet". The autonym label stays in the tag's title /
81
+ * aria naming and the confirm dialog, which identify the LANGUAGE, not
82
+ * the current text. Hosts that don't pass `tagValues` keep the
83
+ * autonym-label tags.
84
+ *
74
85
  * The picker owns ZERO field semantics: selection state lives with the
75
86
  * host (`selected` key(s) in, events out), and the chip visuals come
76
87
  * from the host through the scoped `chip` slot — the same component
@@ -103,6 +114,18 @@ export const HkAffixPicker = defineComponent({
103
114
  /** Gate tag deletion behind a confirm message box (multi mode).
104
115
  * Default true; pass false when the host runs its own guard. */
105
116
  confirmRemove: { type: Boolean, default: true },
117
+ /** Per-key value text replacing the tag list's primary label (multi
118
+ * mode). Provided = value-driven tags: a key mapping to a non-empty
119
+ * string renders that string; an empty/missing key renders
120
+ * `tagUnsetText` italic (`data-unset`). Left undefined = the
121
+ * autonym-label tags. Does not affect the pick rows. */
122
+ tagValues: {
123
+ type: Object as PropType<Record<string, string>>,
124
+ default: undefined,
125
+ },
126
+ /** Text shown (italic) for a tag whose `tagValues` entry is empty;
127
+ * defaulted from the i18n bundle. */
128
+ tagUnsetText: { type: String, default: undefined },
106
129
  /** Override the default close-on-pick (single: true, multi: false).
107
130
  * E.g. a multi picker that should close after each add passes
108
131
  * true; a single picker that should stay open passes false. */
@@ -141,58 +164,17 @@ export const HkAffixPicker = defineComponent({
141
164
  * are outside THIS popup, and the panel's outside-close must not
142
165
  * tear the tag list down mid-decision. */
143
166
  const confirmHeld = ref(false);
144
- /** The scrolling option list and its overlay-scrollbar host (see the
145
- * default slot — the host wraps ONLY the list viewport, not the
146
- * header/search band). */
147
- const listRef = ref<HTMLElement | null>(null);
148
- const scrollHostRef = ref<HTMLElement | null>(null);
149
- /** Live overlay-scrollbar handle for the open popup; null when the
150
- * popup is closed (content not mounted). */
151
- let scrollbar: OverlayScrollbarHandle | null = null;
152
- /** The viewport element `scrollbar` is currently attached to, so a
153
- * remounted list (the empty-state swap) can be told apart from the
154
- * same in-flight list across content-size updates. */
155
- let scrollbarViewport: HTMLElement | null = null;
156
167
 
157
- function detachScrollbar() {
158
- scrollbar?.detach();
159
- scrollbar = null;
160
- scrollbarViewport = null;
161
- }
162
-
163
- function attachScrollbar() {
164
- detachScrollbar();
165
- if (listRef.value && scrollHostRef.value) {
166
- scrollbarViewport = listRef.value;
167
- scrollbar = attachOverlayScrollbars(listRef.value, {
168
- axis: "vertical",
169
- host: scrollHostRef.value,
170
- });
171
- }
172
- }
173
-
174
- // A fresh open starts calm: empty filter.
168
+ // A fresh open starts calm: empty filter. (The popup mounts no
169
+ // scroll machinery of its own — the window surface owns the one
170
+ // scrollbar — so there is nothing to attach/detach on open/close.)
175
171
  watch(open, (v) => {
176
172
  if (!v) {
177
173
  query.value = "";
178
174
  }
179
- if (v) {
180
- // The list mounts on this very render — attach the overlay
181
- // scrollbar once the DOM has landed. A same-tick open→close
182
- // must not arm it on the leaving popup (the close branch
183
- // already detached it).
184
- void nextTick(() => {
185
- if (!open.value) return;
186
- attachScrollbar();
187
- });
188
- } else {
189
- detachScrollbar();
190
- }
191
175
  emit("update:open", v);
192
176
  });
193
177
 
194
- onBeforeUnmount(detachScrollbar);
195
-
196
178
  const selectedKeys = computed<readonly string[]>(() =>
197
179
  Array.isArray(props.selected) ? props.selected : props.selected ? [props.selected] : [],
198
180
  );
@@ -232,29 +214,6 @@ export const HkAffixPicker = defineComponent({
232
214
  });
233
215
  });
234
216
 
235
- // Content-size changes from the search filter change the thumb
236
- // geometry without resizing the viewport — keep it in sync on the
237
- // live scrollbar (no-op while the popup is closed). Post-flush so
238
- // the DOM (esp. a remounted list after the empty-state swap) has
239
- // landed and the template refs point at the live nodes before we
240
- // decide whether to attach, re-attach or update.
241
- watch(
242
- [filteredRows, query],
243
- () => {
244
- if (!open.value) return;
245
- if (!listRef.value) {
246
- detachScrollbar();
247
- return;
248
- }
249
- if (listRef.value !== scrollbarViewport) {
250
- attachScrollbar();
251
- return;
252
- }
253
- scrollbar?.update();
254
- },
255
- { flush: "post" },
256
- );
257
-
258
217
  /** Exact label match suppresses the custom row while the user is
259
218
  * simply re-typing an existing entry. */
260
219
  const exactMatch = computed(
@@ -351,6 +310,14 @@ export const HkAffixPicker = defineComponent({
351
310
  props.searchPlaceholder ?? t("hikari::affixPicker.search", "Search");
352
311
  const emptyText = props.emptyText ?? t("hikari::affixPicker.empty", "No matches");
353
312
  const removeLabel = t("hikari::affixPicker.remove", "Remove");
313
+ // Value-driven tags (tagValues provided): each tag's primary text
314
+ // is its mapped value, or the italic unset text when empty. The
315
+ // autonym stays the tag's identity in title/aria and the dialog.
316
+ const valueDriven = props.tagValues !== undefined;
317
+ const unsetText =
318
+ props.tagUnsetText ?? t("hikari::affixPicker.unset", "Not set");
319
+ const tagValue = (key: string): string =>
320
+ (props.tagValues?.[key] ?? "").trim();
354
321
  const placement = props.side === "suffix" ? "bottom-end" : "bottom-start";
355
322
  return (
356
323
  <>
@@ -420,6 +387,7 @@ export const HkAffixPicker = defineComponent({
420
387
  class="hk-affix-tag-list"
421
388
  >
422
389
  {tags.map((tag) => {
390
+ const value = tagValue(tag.key);
423
391
  return (
424
392
  <div
425
393
  key={tag.key}
@@ -429,7 +397,9 @@ export const HkAffixPicker = defineComponent({
429
397
  <button
430
398
  type="button"
431
399
  class="hk-affix-tag-body"
432
- title={`${tag.label}${tag.meta ? ` (${tag.meta})` : ""}`}
400
+ title={`${tag.label}${tag.meta ? ` (${tag.meta})` : ""}${
401
+ valueDriven ? `: ${value || unsetText}` : ""
402
+ }`}
433
403
  aria-label={`${t("hikari::affixPicker.switchTo", "Switch to")} ${tag.label}`}
434
404
  onClick={(e: MouseEvent) => {
435
405
  e.stopPropagation();
@@ -441,7 +411,12 @@ export const HkAffixPicker = defineComponent({
441
411
  {tag.flag}
442
412
  </span>
443
413
  )}
444
- <span class="hk-affix-tag-text">{tag.label}</span>
414
+ <span
415
+ class="hk-affix-tag-text"
416
+ data-unset={valueDriven && !value ? "" : undefined}
417
+ >
418
+ {valueDriven ? value || unsetText : tag.label}
419
+ </span>
445
420
  {tag.meta && (
446
421
  <span class="hk-affix-tag-meta">{tag.meta}</span>
447
422
  )}
@@ -499,8 +474,12 @@ export const HkAffixPicker = defineComponent({
499
474
  ),
500
475
  default: () =>
501
476
  rows.length > 0 || customVisible.value ? (
502
- <div class="hk-affix-scroll" ref={scrollHostRef}>
503
- <div class="hk-affix-list" ref={listRef}>
477
+ /* Width container only — the window surface (HkSelectPanel
478
+ * popout / sheet) owns THE single scrollbar and scrolls this
479
+ * list with the rest of the popup content; no inner
480
+ * max-height/overflow here, ever. */
481
+ <div class="hk-affix-scroll">
482
+ <div class="hk-affix-list">
504
483
  {rows.map((option) => {
505
484
  const active =
506
485
  props.mode === "single"
@@ -326,15 +326,16 @@
326
326
  // Entries area — the list/grid body (state carries on data-layout)
327
327
  // ------
328
328
 
329
+ /* ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the entries area is NOT a
330
+ * scroll region — its old 24rem cap + native bar was a second scrollbar
331
+ * nested inside the modal window. The listing grows with its content and
332
+ * the HkModal body scroller (the window's ONE scrollbar) scrolls it. */
329
333
  .hk-file-browser-entries {
330
334
  display: flex;
331
335
  flex-direction: column;
332
336
  min-height: 12rem;
333
- max-height: 24rem;
334
- overflow-y: auto;
335
337
  border: 1px solid var(--border-subtle);
336
338
  border-radius: var(--radius-md);
337
- overscroll-behavior: contain;
338
339
  }
339
340
 
340
341
  .hk-file-browser-loading {
@@ -52,27 +52,15 @@
52
52
  }
53
53
  }
54
54
 
55
- /* Track host for the results list's overlay scrollbar — hugs the LIST
56
- band only (the modal body also holds the search-input row). */
57
- .hk-kw-search-results-wrap {
58
- position: relative;
59
- }
60
-
55
+ /* ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the results list is NOT a
56
+ scroll region — its old 22rem/50vh cap + overlay rail was a second
57
+ scrollbar nested inside the modal window (the HkModal body scroller).
58
+ The list grows with its content; the modal window scrolls it. */
61
59
  .hk-kw-search-results {
62
60
  display: flex;
63
61
  flex-direction: column;
64
62
  gap: var(--hi-space-4, 0.25rem);
65
- max-height: min(22rem, 50vh);
66
- overflow-y: auto;
67
- overflow-x: hidden;
68
63
  padding: 0 var(--hi-space-2, 0.125rem);
69
- /* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
70
- hidden, never styled. */
71
- scrollbar-width: none;
72
-
73
- &::-webkit-scrollbar {
74
- display: none;
75
- }
76
64
  }
77
65
 
78
66
  .hk-kw-search-result {
@@ -1,11 +1,10 @@
1
- import { computed, defineComponent, nextTick, onBeforeUnmount, ref, watch, type PropType } from "vue";
1
+ import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
 
5
5
  import "./HkKeywordSearchModal.scss";
6
6
  import HModal from "./HkModal";
7
7
  import { scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
8
- import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
9
8
 
10
9
  interface FuzzyMatch {
11
10
  matched: boolean;
@@ -161,21 +160,21 @@ export default defineComponent({
161
160
  }
162
161
 
163
162
  const resultsRef = ref<HTMLDivElement | null>(null);
164
- // Positioned wrapper around ONLY the results list — the rail host.
165
- const resultsWrapRef = ref<HTMLDivElement | null>(null);
166
- let resultsScrollbar: OverlayScrollbarHandle | null = null;
167
-
168
- onBeforeUnmount(() => {
169
- resultsScrollbar?.detach();
170
- resultsScrollbar = null;
171
- debounceTimer?.disconnect();
172
- });
163
+ /** ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the results list no
164
+ * longer scrolls itself (its old 22rem/50vh cap + overlay rail was a
165
+ * second scrollbar nested inside the modal window). The HkModal
166
+ * body scroller is THE scrollbar; new results reset ITS position. */
167
+ function resetResultsScroll(): void {
168
+ resultsRef.value
169
+ ?.closest<HTMLElement>(".hk-modal-body-scroll")
170
+ ?.scrollTo({ top: 0 });
171
+ }
173
172
 
174
- watch(debouncedQuery, (q) => {
175
- if (semanticActive.value) void runSemantic(q);
173
+ watch(debouncedQuery, () => {
174
+ if (semanticActive.value) void runSemantic(debouncedQuery.value);
176
175
  // New results replace the list (fuzzy + semantic paths both
177
- // recompute from debouncedQuery): return the viewport to the top.
178
- resultsRef.value?.scrollTo({ top: 0 });
176
+ // recompute from debouncedQuery): return the window to the top.
177
+ resetResultsScroll();
179
178
  });
180
179
 
181
180
  const results = computed(() => {
@@ -189,33 +188,22 @@ export default defineComponent({
189
188
 
190
189
  watch(
191
190
  () => props.modelValue,
192
- (open) => {
193
- if (open) {
191
+ () => {
192
+ if (props.modelValue) {
194
193
  query.value = "";
195
194
  debouncedQuery.value = "";
196
195
  semanticResults.value = [];
197
196
  semanticLoading.value = false;
198
- // The results list mounts with the modal body — attach the
199
- // overlay scrollbar (shared chrome) once the DOM has landed;
200
- // detach on close so nothing leaks in the modal portal.
201
- void nextTick(() => {
202
- if (!props.modelValue || !resultsRef.value) return;
203
- resultsScrollbar?.detach();
204
- // Exact host = the results wrapper (see the render): the
205
- // broader modal body also holds the search-input row, and a
206
- // rail spanning that would light up in the wrong place.
207
- resultsScrollbar = attachOverlayScrollbars(resultsRef.value, {
208
- axis: "vertical",
209
- host: resultsWrapRef.value ?? undefined,
210
- });
211
- });
212
197
  } else {
213
- resultsScrollbar?.detach();
214
- resultsScrollbar = null;
198
+ semanticLoading.value = false;
215
199
  }
216
200
  },
217
201
  );
218
202
 
203
+ onBeforeUnmount(() => {
204
+ debounceTimer?.disconnect();
205
+ });
206
+
219
207
  function pick(rec: unknown) {
220
208
  emit("select", rec);
221
209
  emit("update:modelValue", false);
@@ -278,8 +266,9 @@ export default defineComponent({
278
266
  )}
279
267
  </div>
280
268
 
281
- <div class="hk-kw-search-results-wrap" ref={resultsWrapRef}>
282
- <div class="hk-kw-search-results" ref={resultsRef}>
269
+ {/* No inner scroll region — the modal window is THE scroller
270
+ (ONE SCROLLBAR PER WINDOW, see resetResultsScroll). */}
271
+ <div class="hk-kw-search-results" ref={resultsRef}>
283
272
  {semanticActive.value ? (
284
273
  semanticLoading.value && semanticResults.value.length === 0 ? (
285
274
  <div class="hk-kw-search-empty">
@@ -336,7 +325,6 @@ export default defineComponent({
336
325
  </button>
337
326
  ))
338
327
  )}
339
- </div>
340
328
  </div>
341
329
  </div>
342
330
  </HModal>
@@ -133,27 +133,30 @@ function pickerTags(): HTMLElement[] {
133
133
  return [...document.querySelectorAll<HTMLElement>(".hk-affix-tag")];
134
134
  }
135
135
 
136
- function tagLabels(): string[] {
136
+ /** Primary texts of the tags — each language's stored value, or the
137
+ * italic "Not set" placeholder for the edited-but-unfilled one. */
138
+ function tagTexts(): string[] {
137
139
  return pickerTags().map(
138
140
  (t) => t.querySelector(".hk-affix-tag-text")?.textContent ?? "",
139
141
  );
140
142
  }
141
143
 
142
- /** The tag whose label is `label`. */
143
- function tag(label: string): HTMLElement | undefined {
144
+ /** The tag whose locale-code suffix is `code` (the code suffix is the
145
+ * stable identity — the primary text is the language's VALUE). */
146
+ function tagByCode(code: string): HTMLElement | undefined {
144
147
  return pickerTags().find(
145
- (t) => (t.querySelector(".hk-affix-tag-text")?.textContent ?? "") === label,
148
+ (t) => t.querySelector(".hk-affix-tag-meta")?.textContent === code,
146
149
  );
147
150
  }
148
151
 
149
152
  /** The tag body (switch target) of the language tag. */
150
- function tagBody(label: string): HTMLButtonElement | undefined {
151
- return tag(label)?.querySelector<HTMLButtonElement>(".hk-affix-tag-body") ?? undefined;
153
+ function tagBody(code: string): HTMLButtonElement | undefined {
154
+ return tagByCode(code)?.querySelector<HTMLButtonElement>(".hk-affix-tag-body") ?? undefined;
152
155
  }
153
156
 
154
157
  /** The × of the language tag (opens the confirm dialog). */
155
- function tagX(label: string): HTMLButtonElement | undefined {
156
- return tag(label)?.querySelector<HTMLButtonElement>(".hk-affix-tag-x") ?? undefined;
158
+ function tagX(code: string): HTMLButtonElement | undefined {
159
+ return tagByCode(code)?.querySelector<HTMLButtonElement>(".hk-affix-tag-x") ?? undefined;
157
160
  }
158
161
 
159
162
  /** The message box's confirm/cancel buttons (mounted at body level). */
@@ -164,10 +167,6 @@ function boxButton(confirm: boolean): HTMLButtonElement {
164
167
  return btn!;
165
168
  }
166
169
 
167
- /** Full erase flow: the × opens the shared confirm dialog naming the
168
- * entry; the dialog's Confirm erases. The leaving tag lingers through
169
- * its transition window (jsdom has no CSS engine, so the ghost clears
170
- * on the next-frame fallback) — poll until the tag is really gone. */
171
170
  /** Poll until the condition turns truthy (box leave animations and
172
171
  * tag transitions lag a few frames behind the click). */
173
172
  async function until(condition: () => boolean, what: string): Promise<void> {
@@ -201,8 +200,13 @@ async function untilBoxOpen(label: string): Promise<void> {
201
200
  ).toContain(label);
202
201
  }
203
202
 
204
- async function eraseViaConfirm(label: string, tagGone = true) {
205
- tagX(label)!.click();
203
+ /** Full erase flow: the × opens the shared confirm dialog naming the
204
+ * entry (by its language label); the dialog's Confirm erases. The
205
+ * leaving tag lingers through its transition window (jsdom has no CSS
206
+ * engine, so the ghost clears on the next-frame fallback) — poll until
207
+ * the tag is really gone. */
208
+ async function eraseViaConfirm(code: string, label: string, tagGone = true) {
209
+ tagX(code)!.click();
206
210
  await untilBoxOpen(label);
207
211
  boxButton(true).click();
208
212
  await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on confirm");
@@ -210,7 +214,7 @@ async function eraseViaConfirm(label: string, tagGone = true) {
210
214
  // no other language remains (it stays as the active tag), so the
211
215
  // absence wait only applies to genuinely-removed entries.
212
216
  if (tagGone) {
213
- await until(() => !tag(label), `tag "${label}" erased`);
217
+ await until(() => !tagByCode(code), `tag "${code}" erased`);
214
218
  } else {
215
219
  await nextTick();
216
220
  await nextTick();
@@ -282,9 +286,11 @@ describe("HkLocalizedInput", () => {
282
286
  // The chip stays code-free while the popup is closed.
283
287
  expect(queryChip(container).textContent).not.toContain("(zh-Hans)");
284
288
  await openPicker(container);
285
- // Tags show the bare label plus a muted code suffix — no parens form.
286
- const zhTag = tag("简体中文");
289
+ // Tags show the language's VALUE plus a muted code suffix — no
290
+ // parens form, and never the autonym as the primary text.
291
+ const zhTag = tagByCode("zh-Hans");
287
292
  expect(zhTag, "tag for the filled translation renders").toBeTruthy();
293
+ expect(zhTag!.querySelector(".hk-affix-tag-text")?.textContent).toBe("工厂总览");
288
294
  expect(zhTag!.querySelector(".hk-affix-tag-meta")?.textContent).toBe("zh-Hans");
289
295
  expect(zhTag!.textContent).not.toContain("(zh-Hans)");
290
296
  // Addable rows carry their code as the muted meta too.
@@ -324,10 +330,11 @@ describe("HkLocalizedInput", () => {
324
330
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
325
331
  });
326
332
  await openPicker(container);
327
- // Every language in the map is a tag — the edited one marked active.
328
- expect(tagLabels()).toEqual(expect.arrayContaining(["English", "简体中文"]));
333
+ // Every language in the map is a tag showing its VALUE — the edited
334
+ // one marked active.
335
+ expect(tagTexts()).toEqual(expect.arrayContaining(["Plant overview", "工厂总览"]));
329
336
  const activeTag = pickerTags().find((t) => t.hasAttribute("data-active"));
330
- expect(activeTag?.querySelector(".hk-affix-tag-text")?.textContent).toBe("English");
337
+ expect(activeTag?.querySelector(".hk-affix-tag-text")?.textContent).toBe("Plant overview");
331
338
  // One body + one arm/confirm × per tag.
332
339
  for (const t of pickerTags()) {
333
340
  expect(t.querySelector(".hk-affix-tag-body"), "body on every tag").toBeTruthy();
@@ -340,9 +347,14 @@ describe("HkLocalizedInput", () => {
340
347
  it("lists the edited language even while it holds no translation", async () => {
341
348
  const { container } = mountInput({ modelValue: "Plant overview" });
342
349
  await openPicker(container);
343
- // The field edits English with nothing stored yet — still listed, active.
344
- expect(tagLabels()).toEqual(["English"]);
345
- expect(tag("English")?.hasAttribute("data-active")).toBe(true);
350
+ // The field edits English with nothing stored yet — still listed,
351
+ // active, and showing the italic "Not set" placeholder.
352
+ expect(tagTexts()).toEqual(["Not set"]);
353
+ const enTag = tagByCode("en")!;
354
+ expect(enTag.hasAttribute("data-active")).toBe(true);
355
+ expect(enTag.querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(true);
356
+ // The autonym survives as the language's identity (title naming).
357
+ expect(tagBody("en")?.title).toContain("English");
346
358
  });
347
359
 
348
360
  it("switches to an existing language via its tag body: commits text, loads its value, closes the popup", async () => {
@@ -351,7 +363,7 @@ describe("HkLocalizedInput", () => {
351
363
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
352
364
  });
353
365
  await openPicker(container);
354
- const body = tagBody("简体中文");
366
+ const body = tagBody("zh-Hans");
355
367
  expect(body).toBeTruthy();
356
368
  body!.click();
357
369
  await nextTick();
@@ -440,8 +452,8 @@ describe("HkLocalizedInput", () => {
440
452
  mounts.push({ app, container });
441
453
  expect(queryChip(container).disabled).toBe(false);
442
454
  await openPicker(container);
443
- // Only the current language's tag remains in the list.
444
- expect(tagLabels()).toEqual(["English"]);
455
+ // Only the current language's tag remains in the list (its value).
456
+ expect(tagTexts()).toEqual(["Plant overview"]);
445
457
  });
446
458
 
447
459
  it("keeps edits on the switched-from language when the text is blank", async () => {
@@ -450,7 +462,7 @@ describe("HkLocalizedInput", () => {
450
462
  translations: { "zh-Hans": "工厂总览" },
451
463
  });
452
464
  await openPicker(container);
453
- const body = tagBody("简体中文");
465
+ const body = tagBody("zh-Hans");
454
466
  body!.click();
455
467
  await nextTick();
456
468
  await nextTick();
@@ -465,7 +477,7 @@ describe("HkLocalizedInput", () => {
465
477
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
466
478
  });
467
479
  await openPicker(container);
468
- const body = tagBody("简体中文");
480
+ const body = tagBody("zh-Hans");
469
481
  body!.click();
470
482
  await nextTick();
471
483
  await nextTick();
@@ -520,7 +532,7 @@ describe("HkLocalizedInput", () => {
520
532
  app.mount(container);
521
533
  mounts.push({ app, container });
522
534
  await openPicker(container);
523
- expect(tag("简体中文")?.querySelector(".hk-affix-tag-flag")?.textContent).toBe(flagOf("cn"));
535
+ expect(tagByCode("zh-Hans")?.querySelector(".hk-affix-tag-flag")?.textContent).toBe(flagOf("cn"));
524
536
  });
525
537
 
526
538
  it("follows a sourceLang change without stealing focus", async () => {
@@ -566,14 +578,14 @@ describe("HkLocalizedInput", () => {
566
578
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
567
579
  });
568
580
  await openPicker(container);
569
- tagX("简体中文")!.click();
581
+ tagX("zh-Hans")!.click();
570
582
  await untilBoxOpen("简体中文");
571
583
  // The dialog names the entry and carries a danger-toned confirm.
572
584
  expect(boxButton(true).className).toContain("hk-btn-danger");
573
585
  // Cancel → nothing is erased, the dialog closes.
574
586
  boxButton(false).click();
575
587
  await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on cancel");
576
- await until(() => !!tag("简体中文"), "tag stays after cancel");
588
+ await until(() => !!tagByCode("zh-Hans"), "tag stays after cancel");
577
589
  });
578
590
 
579
591
  it("names the tag body by its switch action and the × by its remove intent", async () => {
@@ -582,8 +594,8 @@ describe("HkLocalizedInput", () => {
582
594
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
583
595
  });
584
596
  await openPicker(container);
585
- expect(tagBody("简体中文")?.getAttribute("aria-label")).toBe("Switch to 简体中文");
586
- expect(tagX("简体中文")?.getAttribute("aria-label")).toBe("Remove — 简体中文");
597
+ expect(tagBody("zh-Hans")?.getAttribute("aria-label")).toBe("Switch to 简体中文");
598
+ expect(tagX("zh-Hans")?.getAttribute("aria-label")).toBe("Remove — 简体中文");
587
599
  });
588
600
 
589
601
  it("keeps the picker usable after a dismissed delete dialog", async () => {
@@ -592,13 +604,13 @@ describe("HkLocalizedInput", () => {
592
604
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
593
605
  });
594
606
  await openPicker(container);
595
- tagX("简体中文")!.click();
607
+ tagX("zh-Hans")!.click();
596
608
  await untilBoxOpen("简体中文");
597
609
  boxButton(false).click();
598
610
  await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on cancel");
599
611
  // The dialog never leaves the picker half-broken: the tag can still
600
612
  // switch the edited language right after a dismissal.
601
- tagBody("简体中文")!.click();
613
+ tagBody("zh-Hans")!.click();
602
614
  await nextTick();
603
615
  await nextTick();
604
616
  expect(document.activeElement).toBe(queryField(container));
@@ -610,7 +622,7 @@ describe("HkLocalizedInput", () => {
610
622
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
611
623
  });
612
624
  await openPicker(container);
613
- await eraseViaConfirm("简体中文");
625
+ await eraseViaConfirm("zh-Hans", "简体中文");
614
626
  // The map loses only the erased language; no edit-state events fire.
615
627
  expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
616
628
  expect(events.modelValue).toEqual([]);
@@ -618,7 +630,7 @@ describe("HkLocalizedInput", () => {
618
630
  expect(queryField(container).value).toBe("Plant overview");
619
631
  expect(queryChip(container).textContent).toContain("English");
620
632
  // The popup STAYS open and the list updates live.
621
- expect(tagLabels()).toEqual(["English"]);
633
+ expect(tagTexts()).toEqual(["Plant overview"]);
622
634
  expect(pickerRows().some((r) => (r.textContent ?? "").includes("日本語"))).toBe(true);
623
635
  });
624
636
 
@@ -628,10 +640,10 @@ describe("HkLocalizedInput", () => {
628
640
  translations: { en: "Plant overview", "zh-Hans": "工厂总览", ja: "プラント概覧" },
629
641
  });
630
642
  await openPicker(container);
631
- await eraseViaConfirm("简体中文");
632
- await eraseViaConfirm("日本語");
643
+ await eraseViaConfirm("zh-Hans", "简体中文");
644
+ await eraseViaConfirm("ja", "日本語");
633
645
  expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
634
- expect(tagLabels()).toEqual(["English"]);
646
+ expect(tagTexts()).toEqual(["Plant overview"]);
635
647
  });
636
648
 
637
649
  it("falls back to the source language when the edited language is erased", async () => {
@@ -643,20 +655,20 @@ describe("HkLocalizedInput", () => {
643
655
  // Switch to zh-Hans first so erasing it means erasing the edited
644
656
  // language while the source still holds a translation.
645
657
  await openPicker(container);
646
- tagBody("简体中文")!.click();
658
+ tagBody("zh-Hans")!.click();
647
659
  await nextTick();
648
660
  await nextTick();
649
661
  expect(queryChip(container).textContent).toContain("简体中文");
650
662
  expect(queryChip(container).textContent).not.toContain("(zh-Hans)");
651
663
  await openPicker(container);
652
- await eraseViaConfirm("简体中文");
664
+ await eraseViaConfirm("zh-Hans", "简体中文");
653
665
  expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
654
666
  expect(events.modelValue.at(-1)).toBe("Plant overview");
655
667
  expect(events.languagechange.at(-1)).toBe("en");
656
668
  expect(queryChip(container).textContent).toContain("English");
657
669
  expect(queryChip(container).textContent).not.toContain("(en)");
658
670
  // The popup stays open after an erase.
659
- expect(tagLabels()).toEqual(["English"]);
671
+ expect(tagTexts()).toEqual(["Plant overview"]);
660
672
  });
661
673
 
662
674
  it("falls back to the first remaining translation when the source language is erased", async () => {
@@ -666,7 +678,7 @@ describe("HkLocalizedInput", () => {
666
678
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
667
679
  });
668
680
  await openPicker(container);
669
- await eraseViaConfirm("English");
681
+ await eraseViaConfirm("en", "English");
670
682
  expect(events.translations.at(-1)).toEqual({ "zh-Hans": "工厂总览" });
671
683
  expect(events.modelValue.at(-1)).toBe("工厂总览");
672
684
  expect(events.languagechange.at(-1)).toBe("zh-Hans");
@@ -680,12 +692,13 @@ describe("HkLocalizedInput", () => {
680
692
  translations: { en: "Plant overview" },
681
693
  });
682
694
  await openPicker(container);
683
- await eraseViaConfirm("English", false);
695
+ await eraseViaConfirm("en", "English", false);
684
696
  expect(events.translations.at(-1)).toEqual({});
685
697
  expect(events.modelValue.at(-1)).toBe("");
686
- // The edited language tag stays (active); the popup stays open.
687
- expect(tagLabels()).toEqual(["English"]);
688
- expect(tag("English")?.hasAttribute("data-active")).toBe(true);
698
+ // The edited language tag stays (active, italic Not set); the popup
699
+ // stays open.
700
+ expect(tagTexts()).toEqual(["Not set"]);
701
+ expect(tagByCode("en")?.hasAttribute("data-active")).toBe(true);
689
702
  expect(pickerRows().some((r) => (r.textContent ?? "").includes("日本語"))).toBe(true);
690
703
  });
691
704
 
@@ -695,7 +708,7 @@ describe("HkLocalizedInput", () => {
695
708
  translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
696
709
  });
697
710
  await openPicker(container);
698
- tagBody("English")!.click();
711
+ tagBody("en")!.click();
699
712
  await nextTick();
700
713
  await nextTick();
701
714
  await untilPickerSettled();
@@ -723,4 +736,34 @@ describe("HkLocalizedInput", () => {
723
736
  expect(events.modelValue.at(-1)).toBe("Line one\nLine two");
724
737
  expect(events.translations.at(-1)).toEqual({ en: "Line one\nLine two" });
725
738
  });
739
+
740
+ it("tags show stored values; an unfilled language vanishes once left", async () => {
741
+ const { events, container } = mountInput({
742
+ modelValue: "",
743
+ translations: { "zh-Hans": "工厂总览" },
744
+ });
745
+ await openPicker(container);
746
+ // Filled language → its value as the primary text; the edited-but-
747
+ // unfilled source language → the italic Not set placeholder (the
748
+ // only tag that can ever be empty).
749
+ expect(tagTexts()).toEqual(["工厂总览", "Not set"]);
750
+ expect(tagByCode("en")!.querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(true);
751
+ expect(tagByCode("zh-Hans")!.querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(false);
752
+ // The autonym + code still identify the language beside the value.
753
+ expect(tagBody("zh-Hans")!.title).toContain("简体中文");
754
+ expect(tagByCode("zh-Hans")!.querySelector(".hk-affix-tag-meta")?.textContent).toBe("zh-Hans");
755
+ // Switching away from the unfilled language (adding ja moves the
756
+ // field there): "en" never entered translations, so it leaves the
757
+ // list — the Not set state only ever marks the CURRENT edit.
758
+ const jaRow = pickerRows().find((r) => (r.textContent ?? "").includes("日本語"));
759
+ jaRow!.click();
760
+ await nextTick();
761
+ await nextTick();
762
+ expect(events.languagechange.at(-1)).toBe("ja");
763
+ await untilPickerSettled();
764
+ await openPicker(container);
765
+ expect(tagByCode("en")).toBeUndefined();
766
+ expect(tagTexts()).toEqual(["工厂总览", "Not set"]);
767
+ expect(tagByCode("ja")).toBeTruthy();
768
+ });
726
769
  });
@@ -14,9 +14,11 @@ export interface HkLocaleOption {
14
14
  code: string;
15
15
  /** Display label — apps pass the SAME text their global language
16
16
  * switcher shows (e.g. the autonym "简体中文" / "English"). The chip
17
- * renders the bare label; the menu's language tags render the label
18
- * plus a small code suffix, so the code lives only in the opened
19
- * menu. */
17
+ * renders the bare label; the menu's language tags render the
18
+ * language's stored translation (or the italic "Not set") plus a
19
+ * small code suffix, and the label survives as the tag's title /
20
+ * aria / confirm-dialog naming; the add-language rows render the
21
+ * label plus the code. */
20
22
  label: string;
21
23
  /** Optional flag glyph rendered in the menu tags, matching the app's
22
24
  * language switcher rows when they carry one. */
@@ -36,11 +38,19 @@ export interface HkLocaleOption {
36
38
  * - Click the chip → the shared HkAffixPicker (multi-select, right
37
39
  * anchored, closes on pick so the field is immediately editable):
38
40
  * · a TAG LIST of every language currently present — the one being
39
- * edited carries the active dot. The × on a tag arms the delete
40
- * (danger tint) and a second tap erases; the popup stays open
41
- * after removals so several translations can be wiped in one
42
- * pass, with squeeze-in / squeeze-out list transitions
43
- * (HkListTransition, animation-context aware);
41
+ * edited carries the active dot. A tag's primary text is that
42
+ * language's CURRENT TRANSLATION (value-driven `tagValues`), not
43
+ * its autonym; the language being edited but not yet filled
44
+ * renders an italic muted "Not set" placeholder (the only tag
45
+ * that can be empty — an unfilled language vanishes from the
46
+ * list the moment the field switches away, since empty values
47
+ * never enter `translations`). The autonym and locale code stay
48
+ * on the tag as title/aria naming and the muted code suffix.
49
+ * The × on a tag arms the delete (danger tint) and a second tap
50
+ * erases; the popup stays open after removals so several
51
+ * translations can be wiped in one pass, with squeeze-in /
52
+ * squeeze-out list transitions (HkListTransition,
53
+ * animation-context aware);
44
54
  * · a SEARCHABLE list of the languages NOT yet present — typing
45
55
  * filters, picking adds the language and drops the field
46
56
  * straight into edit state for it. This replaces the old
@@ -56,6 +66,8 @@ export interface HkLocaleOption {
56
66
  * (as a muted suffix on each tag) so the code never burns space inside
57
67
  * the field. The popup participates in the shared modal/dropdown stacking
58
68
  * contexts via HkMenu's popup-manager integration — safe inside modals.
69
+ * The popup mounts no scroll region of its own: the window surface it
70
+ * opens as owns the ONE scrollbar (HkAffixPicker contract).
59
71
  *
60
72
  * Set `multiline` to edit long-form translations: the field becomes an
61
73
  * auto-growing textarea (`rows` seeds the height, `autoGrow` lets it
@@ -305,6 +317,7 @@ export const HkLocalizedInput = defineComponent({
305
317
  "Add language",
306
318
  )}
307
319
  emptyText={t("hikari::localizedInput.noMatches", "No matching language")}
320
+ tagValues={props.translations}
308
321
  onSelect={(code: string) => switchLanguage(code)}
309
322
  onRemove={(code: string) => eraseLanguage(code)}
310
323
  >
@@ -131,8 +131,9 @@
131
131
 
132
132
  // Mobile sheet form (HPopover sheetOnMobile): full-width option list with
133
133
  // touch-sized rows (row geometry flows from the shared sheet tokens).
134
- .hk-popover-panel.hk-is-sheet.hk-popup-select-content {
135
- .hk-popup-select-viewport {
136
- max-height: min(56vh, 24rem);
137
- }
138
- }
134
+ // ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the sheet PANEL is the
135
+ // window scroller (.hk-popover-panel.hk-is-sheet carries overflow-y:
136
+ // auto), so the viewport must NOT cap itself here — an inner max-height
137
+ // lit a second nested scrollbar inside the same window. The desktop
138
+ // popout keeps its 240px content cap + viewport scroll, where the
139
+ // viewport bar is the popup's only scrollbar.
@@ -1,27 +1,13 @@
1
- // HkProgressDialog.scss — the log pane's chrome. Inline styles cannot
2
- // carry the `::-webkit-scrollbar` half of the native-bar hide, so the
3
- // pane's visual block lives here like every other overlay-scroll
4
- // consumer (see packages/theme/styles/_scrollbar.scss).
1
+ // HkProgressDialog.scss — the log pane's chrome.
2
+ // ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the pane is NOT a scroll
3
+ // region — its old 10rem cap + overlay rail was a second scrollbar nested
4
+ // inside the modal window. Logs grow unbounded; the HkModal body scroller
5
+ // (the window's ONE scrollbar) scrolls them, and the tailing watcher in
6
+ // HkProgressDialog.tsx keeps the latest line visible.
5
7
  // Use theme variables with namespace to avoid conflicts
6
8
  @use "./hikari-vars" as vars;
7
- /* Track host: wraps ONLY the log pane so the rail hugs the log band,
8
- not the spinner/progress bands above it. */
9
- .s-progress-dialog-log-wrap {
10
- position: relative;
11
- }
12
9
 
13
10
  .s-progress-dialog-log {
14
- max-height: 10rem;
15
- overflow: hidden auto;
16
-
17
- /* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
18
- always hidden, never styled. */
19
- scrollbar-width: none;
20
-
21
- &::-webkit-scrollbar {
22
- display: none;
23
- }
24
-
25
11
  border-radius: 0.375rem;
26
12
  background: rgba(0, 0, 0, 0.06);
27
13
  padding: 0.5rem;
@@ -1,7 +1,6 @@
1
- import { defineComponent, nextTick, onBeforeUnmount, ref, watch } from "vue";
1
+ import { defineComponent, nextTick, ref, watch } from "vue";
2
2
 
3
3
  import { useProgressDialog } from "../composables/useProgressDialog";
4
- import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
5
4
  import HModal from "./HkModal";
6
5
  import HProgressBar from "./HkProgressBar";
7
6
  import HSpinner from "./HkSpinner";
@@ -12,43 +11,22 @@ export default defineComponent({
12
11
  setup() {
13
12
  const state = useProgressDialog();
14
13
  const logRef = ref<HTMLElement>();
15
- // Positioned wrapper around the conditional log pane — the rail host
16
- // (the modal body also holds the spinner/progress bands).
17
- const logWrapRef = ref<HTMLElement>();
18
- // Overlay scrollbar (shared chrome) on the conditional log pane —
19
- // attached when it mounts, detached when it unmounts.
20
- let logScrollbar: OverlayScrollbarHandle | null = null;
21
-
22
- function syncLogScrollbar() {
23
- void nextTick(() => {
24
- if (logRef.value) {
25
- logScrollbar ??= attachOverlayScrollbars(logRef.value, {
26
- axis: "vertical",
27
- host: logWrapRef.value,
28
- });
29
- } else {
30
- logScrollbar?.detach();
31
- logScrollbar = null;
32
- }
33
- });
14
+ // ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the log pane no longer
15
+ // scrolls itself — its old 10rem cap + overlay rail was a second
16
+ // scrollbar nested inside the modal window (the HkModal body
17
+ // scroller). Tailing now scrolls THAT window to the bottom.
18
+ function scrollWindowToBottom() {
19
+ const win = logRef.value?.closest<HTMLElement>(".hk-modal-body-scroll");
20
+ if (win) win.scrollTop = win.scrollHeight;
34
21
  }
35
22
 
36
23
  watch(
37
24
  () => state.logs.length,
38
25
  () => {
39
- syncLogScrollbar();
40
- nextTick(() => {
41
- const el = logRef.value;
42
- if (el) el.scrollTop = el.scrollHeight;
43
- });
26
+ void nextTick(scrollWindowToBottom);
44
27
  },
45
28
  );
46
29
 
47
- onBeforeUnmount(() => {
48
- logScrollbar?.detach();
49
- logScrollbar = null;
50
- });
51
-
52
30
  return () => (
53
31
  <HModal
54
32
  modelValue={state.open}
@@ -68,14 +46,12 @@ export default defineComponent({
68
46
  </div>
69
47
  )}
70
48
  {state.logs.length > 0 ? (
71
- <div ref={logWrapRef} class="s-progress-dialog-log-wrap">
72
- <div ref={logRef} class="s-progress-dialog-log">
73
- <ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
74
- {state.logs.map((line, i) => (
75
- <li key={i}>{line}</li>
76
- ))}
77
- </ul>
78
- </div>
49
+ <div ref={logRef} class="s-progress-dialog-log">
50
+ <ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
51
+ {state.logs.map((line, i) => (
52
+ <li key={i}>{line}</li>
53
+ ))}
54
+ </ul>
79
55
  </div>
80
56
  ) : null}
81
57
  </div>
@@ -197,6 +197,7 @@
197
197
  "hikari::messageBox.ok": "OK",
198
198
  "hikari::affixPicker.removeConfirmTitle": "Remove entry",
199
199
  "hikari::affixPicker.removeConfirm": "Remove \"{label}\"? This cannot be undone.",
200
+ "hikari::affixPicker.unset": "Not set",
200
201
  "hikari::messageBox.alertTitle": "Notice",
201
202
  "hikari::messageBox.confirmTitle": "Please confirm",
202
203
  "hikari::messageBox.promptTitle": "Input"
@@ -201,6 +201,7 @@
201
201
  "hikari::messageBox.ok": "好",
202
202
  "hikari::affixPicker.removeConfirmTitle": "移除条目",
203
203
  "hikari::affixPicker.removeConfirm": "移除「{label}」?此操作不可撤销。",
204
+ "hikari::affixPicker.unset": "未设置",
204
205
  "hikari::messageBox.alertTitle": "提示",
205
206
  "hikari::messageBox.confirmTitle": "请确认",
206
207
  "hikari::messageBox.promptTitle": "输入"
@@ -201,6 +201,7 @@
201
201
  "hikari::messageBox.ok": "好",
202
202
  "hikari::affixPicker.removeConfirmTitle": "移除項目",
203
203
  "hikari::affixPicker.removeConfirm": "移除「{label}」?此操作無法復原。",
204
+ "hikari::affixPicker.unset": "未設定",
204
205
  "hikari::messageBox.alertTitle": "提示",
205
206
  "hikari::messageBox.confirmTitle": "請確認",
206
207
  "hikari::messageBox.promptTitle": "輸入"