@celestia-island/hikari 0.55.342 → 0.55.344

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.342",
3
+ "version": "0.55.344",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -105,9 +105,11 @@ describe("HkMinimap zoom bar", () => {
105
105
  expect(slider.getAttribute("aria-valuemax")).toBe("150");
106
106
  expect(slider.getAttribute("aria-valuenow")).toBe("100");
107
107
  expect(slider.getAttribute("aria-valuetext")).toBe("100%");
108
- // Off-grid press: ratio 0.185 → 68.5% snaps to the 5% grid → 70.
108
+ // Off-grid press: the press maps into the INSET rail (7px thumb
109
+ // radius each side of the 200px rect → 186px rail); ratio
110
+ // (46-7)/186 ≈ 0.21 → ~71% snaps to the 5% grid → 70.
109
111
  pinRect(slider);
110
- slider.dispatchEvent(pointer("pointerdown", 37));
112
+ slider.dispatchEvent(pointer("pointerdown", 46));
111
113
  await nextTick();
112
114
  expect(seen).toEqual([[70, "slider"]]);
113
115
  expect(slider.getAttribute("aria-valuenow")).toBe("70");
@@ -9,22 +9,28 @@
9
9
  touch-action: none;
10
10
  @include user-select(none);
11
11
  outline: none;
12
+ // The drawable rail is inset by the thumb radius at each end (the value
13
+ // comes inline from the component; this is only the no-inline fallback).
14
+ // A thumb parked on the first/last stop then rests ON the rail instead
15
+ // of hanging off its cap (2026-11-11 user report).
16
+ --hk-slider-rail-inset: 7px;
17
+ }
18
+
19
+ .hk-slider[data-size="sm"] {
20
+ --hk-slider-rail-inset: 6px;
21
+ height: 14px;
12
22
  }
13
23
 
14
24
  .hk-slider::before {
15
25
  content: "";
16
26
  position: absolute;
17
- inset: 50% 0 auto 0;
27
+ inset: 50% var(--hk-slider-rail-inset, 7px) auto;
18
28
  height: 6px;
19
29
  transform: translateY(-50%);
20
30
  border-radius: var(--radius-full, 9999px);
21
31
  background: rgb(var(--color-muted) / 22%);
22
32
  }
23
33
 
24
- .hk-slider[data-size="sm"] {
25
- height: 14px;
26
- }
27
-
28
34
  .hk-slider[data-size="sm"]::before {
29
35
  height: 4px;
30
36
  }
@@ -42,10 +48,15 @@
42
48
  .hk-slider-fill {
43
49
  position: absolute;
44
50
  top: 50%;
45
- left: 0;
51
+ // Anchored at the rail's left edge; the width (inline from the
52
+ // component) is the rail fraction. The LEFT cap stays FLAT — a pill
53
+ // cap on a 6px saturated bar reads as a pointed nib on screen (the
54
+ // same report that inset the rail) — while the right cap stays round
55
+ // and closes the rail off at 100%.
56
+ left: var(--hk-slider-rail-inset, 7px);
46
57
  height: 6px;
47
58
  transform: translateY(-50%);
48
- border-radius: var(--radius-full, 9999px);
59
+ border-radius: 0 var(--radius-full, 9999px) var(--radius-full, 9999px) 0;
49
60
  background: rgb(var(--color-primary));
50
61
  pointer-events: none;
51
62
  }
@@ -66,8 +66,43 @@ describe("HkSlider", () => {
66
66
 
67
67
  it("sizes the fill and thumb from the snapped value percentage", () => {
68
68
  const c = mount(() => h(HkSlider, { modelValue: 200, min: 100, max: 300, step: 25 }));
69
- expect((c.querySelector(".hk-slider-fill") as HTMLElement).style.width).toBe("50%");
70
- expect((c.querySelector(".hk-slider-thumb") as HTMLElement).style.left).toBe("50%");
69
+ // The rail is inset by the thumb radius (md: 7px → 14px span) and the
70
+ // fill/thumb ride that span (2026-11-11 rail-inset geometry); the
71
+ // linear calc form is the cssstyle-parseable expansion.
72
+ expect((c.querySelector(".hk-slider-fill") as HTMLElement).style.width).toBe(
73
+ "calc(50% - 7px)",
74
+ );
75
+ expect((c.querySelector(".hk-slider-thumb") as HTMLElement).style.left).toBe(
76
+ "calc(50% + 0px)",
77
+ );
78
+ expect(
79
+ (c.querySelector(".hk-slider") as HTMLElement).style.getPropertyValue(
80
+ "--hk-slider-rail-inset",
81
+ ),
82
+ ).toBe("7px");
83
+ });
84
+
85
+ it("keeps the endpoint ticks inside the rail caps", () => {
86
+ const c = mount(() => h(HkSlider, {
87
+ modelValue: 100, min: 100, max: 300, step: 25, showTicks: true,
88
+ }));
89
+ const ticks = c.querySelectorAll(".hk-slider-tick");
90
+ const first = (ticks[0] as HTMLElement).style.left;
91
+ const last = (ticks[ticks.length - 1] as HTMLElement).style.left;
92
+ // Dot centers sit one dot-radius (1px) further inside the rail, so the
93
+ // endpoint dots never clear the rail caps.
94
+ expect(first).toBe("calc(0% + 8px)");
95
+ expect(last).toBe("calc(100% - 8px)");
96
+ });
97
+
98
+ it("draws the fill with a flat start cap and a rounded end cap", () => {
99
+ // Source contract: the pill cap on a 6px saturated bar read as a
100
+ // pointed nib at the rail start (2026-11-11 user report).
101
+ const here = dirname(fileURLToPath(import.meta.url));
102
+ const scss = readFileSync(join(here, "HkSlider.scss"), "utf-8");
103
+ const fill = scss.match(/\.hk-slider-fill\s*{[^}]*}/)?.[0] ?? "";
104
+ expect(fill).toContain("border-radius: 0 var(--radius-full, 9999px)");
105
+ expect(fill).toContain("left: var(--hk-slider-rail-inset, 7px)");
71
106
  });
72
107
 
73
108
  it("emits a snapped value when the track is pressed and dragged", () => {
@@ -15,7 +15,25 @@ import "./HkSlider.scss";
15
15
  * track jumps and drags (emitting continuously so hosts can apply live),
16
16
  * with window-level move/up listeners so the drag keeps tracking the
17
17
  * pointer past the element bounds.
18
+ *
19
+ * Rail geometry (2026-11-11 user report): the rail is INSET by the thumb
20
+ * radius at each end — the native range-input shape. A thumb parked on
21
+ * the first/last stop then rests ON the rail instead of hanging off its
22
+ * cap, the endpoint tick dots stay inside the rail, and the fill starts
23
+ * flat at the rail's left edge (a pill cap on a 6px saturated bar reads
24
+ * as a pointed nib on screen). The inset lives in the
25
+ * `--hk-slider-rail-inset` var (set inline here, mirrored by the
26
+ * constants below — the stylesheet consumes the var for the track/fill
27
+ * edges; keep the numbers in sync).
18
28
  */
29
+
30
+ /** Thumb diameter / 2 per size (HkSlider.scss thumb rules). */
31
+ const RAIL_INSET_PX = { md: 7, sm: 6 } as const;
32
+ /** Tick dot diameter (HkSlider.scss `.hk-slider-tick`); dot centers sit
33
+ * one radius further inside the rail so the last dot never clears the
34
+ * rail cap. */
35
+ const TICK_DOT_PX = 2;
36
+
19
37
  export default defineComponent({
20
38
  name: "HkSlider",
21
39
  props: {
@@ -59,6 +77,29 @@ export default defineComponent({
59
77
  return span > 0 ? ((value.value - props.min) / span) * 100 : 0;
60
78
  });
61
79
 
80
+ /** The value fraction mapped into the INSET rail: `f` runs 0..1
81
+ * across the rail's drawable span, so positions derive as
82
+ * `inset + railLength * f`. Shared by thumb/fill/tick positioning
83
+ * and the pointer mapping below. Emitted in the expanded linear
84
+ * calc form (`calc(F% ± Npx)`) — the `(100% - Npx) * f` product
85
+ * shape renders identically but cssstyle (jsdom) rejects it, and
86
+ * the contract tests assert on the live inline style. */
87
+ const railInset = computed(() => RAIL_INSET_PX[props.size] ?? RAIL_INSET_PX.md);
88
+
89
+ function round4(n: number): number {
90
+ return Math.round(n * 1e4) / 1e4;
91
+ }
92
+
93
+ /** A position ON the rail (thumb center / tick center); the extra
94
+ * inset shaves tick centers by the dot radius so the endpoint
95
+ * dots stay inside the rail caps. */
96
+ function railPos(f: number, extraInsetPx = 0): string {
97
+ const inset = railInset.value + extraInsetPx;
98
+ const px = round4(inset * (1 - 2 * f));
99
+ const op = px < 0 ? " - " : " + ";
100
+ return `calc(${round4(f * 100)}%${op}${Math.abs(px)}px)`;
101
+ }
102
+
62
103
  const tickPositions = computed<number[]>(() => {
63
104
  if (!props.showTicks) return [];
64
105
  const count = Math.round((props.max - props.min) / safeStep.value);
@@ -75,8 +116,12 @@ export default defineComponent({
75
116
  const el = trackRef.value;
76
117
  if (!el) return value.value;
77
118
  const rect = el.getBoundingClientRect();
78
- if (rect.width <= 0) return value.value;
79
- const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
119
+ const inset = railInset.value;
120
+ const rail = rect.width - inset * 2;
121
+ // Degenerate rail (hidden / zero-width): keep the current value
122
+ // rather than inventing a position.
123
+ if (rect.width <= 0 || rail <= 0) return value.value;
124
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left - inset) / rail));
80
125
  return snap(props.min + ratio * (props.max - props.min));
81
126
  }
82
127
 
@@ -145,6 +190,7 @@ export default defineComponent({
145
190
  ref={trackRef}
146
191
  class={["hk-slider", props.disabled && "is-disabled"].filter(Boolean).join(" ")}
147
192
  data-size={props.size}
193
+ style={{ "--hk-slider-rail-inset": `${railInset.value}px` }}
148
194
  role="slider"
149
195
  tabindex={props.disabled ? -1 : 0}
150
196
  aria-label={props.ariaLabel}
@@ -161,12 +207,17 @@ export default defineComponent({
161
207
  key={i}
162
208
  class="hk-slider-tick"
163
209
  data-active={left <= pct.value + 0.001 || undefined}
164
- style={{ left: `${left}%` }}
210
+ // Tick centers ride the rail span, one dot-radius further
211
+ // inset, so the endpoint dots stay inside the rail caps.
212
+ style={{ left: railPos(left / 100, TICK_DOT_PX / 2) }}
165
213
  aria-hidden="true"
166
214
  />
167
215
  ))}
168
- <div class="hk-slider-fill" style={{ width: `${pct.value}%` }} />
169
- <div class="hk-slider-thumb" style={{ left: `${pct.value}%` }} />
216
+ <div
217
+ class="hk-slider-fill"
218
+ style={{ width: `calc(${round4(pct.value)}% - ${round4(railInset.value * 2 * (pct.value / 100))}px)` }}
219
+ />
220
+ <div class="hk-slider-thumb" style={{ left: railPos(pct.value / 100) }} />
170
221
  </div>
171
222
  );
172
223
  },
@@ -285,7 +285,7 @@ describe("HkTokenInput", () => {
285
285
  expect(a.emitted.filter((v) => v === null)).toEqual([]);
286
286
  });
287
287
 
288
- it("clearing unsets; unit flips re-express, never rescale", async () => {
288
+ it("clearing via empty text unsets; unit flips re-express, never rescale", async () => {
289
289
  const a = mountInput({ modelValue: 1_500_000 });
290
290
  (a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
291
291
  await nextTick();
@@ -296,12 +296,16 @@ describe("HkTokenInput", () => {
296
296
  await pickOption(editorSelect, "K");
297
297
  expect(input.value).toBe("1500");
298
298
  expect(a.emitted).toEqual([]);
299
- (document.querySelector(".hk-token-input-clear") as HTMLButtonElement).click();
299
+ // There is deliberately no separate clear button in the editor body
300
+ // (the window close ✕ plus a second ✕ read as duplicates): emptying
301
+ // the text IS the unset gesture, and the editor stays open so the
302
+ // unset value segment ("—") is visible before the host closes it.
303
+ expect(document.querySelector(".hk-token-input-clear")).toBeNull();
304
+ input.value = "";
305
+ input.dispatchEvent(new Event("input", { bubbles: true }));
300
306
  await nextTick();
301
307
  expect(a.emitted.at(-1)).toBe(null);
302
- await vi.waitFor(() => {
303
- expect(document.querySelector(".hk-modal-content")).toBeNull();
304
- }, { timeout: 1500 });
308
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
305
309
  });
306
310
 
307
311
  it("a unit flip with unparseable text clears rather than skewing the scale", async () => {
@@ -1,7 +1,8 @@
1
1
  import HkIconButton from "./HkIconButton";
2
+ import HkAddTile from "./HkAddTile";
2
3
 
3
4
  import { computed, defineComponent, onUnmounted, ref, watch, type PropType } from "vue";
4
- import { Monitor, MoonStar, Palette, Sun, Trash as Trash2 } from "lucide-vue-next";
5
+ import { Monitor, MoonStar, Sun, Trash as Trash2 } from "lucide-vue-next";
5
6
  import {
6
7
  HDivider,
7
8
  solarAltitude,
@@ -309,9 +310,18 @@ export const HkThemePanelContent = defineComponent({
309
310
  );
310
311
  })}
311
312
 
312
- <button
313
- type="button"
314
- class="s-theme-item-btn s-theme-item-customize"
313
+ {/* The customize entry is the standard add tile (2026-11-11
314
+ family migration): the compact dashed form riding the END of
315
+ the theme list — the same "add one more" gesture semantics
316
+ as every other list-end affordance, so hosts stop
317
+ hand-painting it (chest's centered dashed hand-roll retires
318
+ with this). The s-theme-item-customize class stays as the
319
+ host hook (host scopes and tests target it); the default
320
+ Plus glyph replaces the palette mark — creating a scheme is
321
+ an add gesture. */}
322
+ <HkAddTile
323
+ compact
324
+ class="s-theme-item-customize"
315
325
  onClick={() => {
316
326
  if (props.externalCustomize) {
317
327
  emit("request-close");
@@ -321,9 +331,8 @@ export const HkThemePanelContent = defineComponent({
321
331
  }
322
332
  }}
323
333
  >
324
- <span class="hk-menu-item-icon"><Palette size={14} /></span>
325
- <span class="s-theme-item-name">{t("hikari::theme.customize")}</span>
326
- </button>
334
+ {t("hikari::theme.customize")}
335
+ </HkAddTile>
327
336
  </div>
328
337
  {(() => {
329
338
  const extra = slots["menu-extra"]?.() ?? [];
@@ -9,13 +9,15 @@
9
9
  *
10
10
  * The widened cell must stay scoped (2026-09-12 field report): shipped
11
11
  * first as a bare `.s-theme-item-btn .hk-menu-item-icon` rule, it also
12
- * matched the 自定义 row and every host row reusing s-theme-item-btn —
13
- * and mi.icon normalizes those cells' svg to the cell, so their 14px
14
- * glyphs rendered 28px wide. The widening therefore targets named scopes
15
- * (lead slot, customize row) plus the toggle-owned mode-extra strip, the
16
- * last one widening the CELL while pinning the glyph back to the
17
- * standard box so host strip rows (chest's DPI entry) align their labels
18
- * with the theme rows' names without rescaling their icons.
12
+ * matched every host row reusing s-theme-item-btn — and mi.icon
13
+ * normalizes those cells' svg to the cell, so their 14px glyphs rendered
14
+ * 28px wide. The widening therefore targets named scopes (lead slot)
15
+ * plus the toggle-owned mode-extra strip, the last one widening the CELL
16
+ * while pinning the glyph back to the standard box so host strip rows
17
+ * (chest's DPI entry) align their labels with the theme rows' names
18
+ * without rescaling their icons. (The customize row left this contract
19
+ * 2026-11-11: the entry is the standard HkAddTile compact form now — a
20
+ * self-contained dashed tile, not a lead-column row.)
19
21
  */
20
22
  import { describe, expect, it } from "vitest";
21
23
  import { readFileSync } from "node:fs";
@@ -105,30 +107,27 @@ describe("HkThemeToggle row lead-cell contract", () => {
105
107
 
106
108
  it("does not widen icon cells with a bare unscoped theme-row rule", () => {
107
109
  const css = read("HkThemeToggle.scss");
108
- // A bare `.s-theme-item-btn .hk-menu-item-icon {` rule also hits the
109
- // customize row and host mode-extra rows; the widened cell may only
110
- // target named scopes (lead slot, customize row, mode-extra strip).
110
+ // A bare `.s-theme-item-btn .hk-menu-item-icon {` rule also hits
111
+ // host mode-extra rows; the widened cell may only target named
112
+ // scopes (lead slot, mode-extra strip).
111
113
  expect(css).not.toMatch(/^\.s-theme-item-btn \.hk-menu-item-icon\s*\{/m);
112
114
  });
113
115
 
114
- it("aligns the customize row to the lead column without rescaling its glyph", () => {
116
+ it("renders the customize entry as the standard HkAddTile compact form", () => {
117
+ // 2026-11-11 family migration: the entry rides the standard dashed
118
+ // add tile below the theme list (same "add one more" semantics as
119
+ // every other list-end affordance) instead of a lead-aligned menu
120
+ // row — the old .hk-menu-item-icon lead-column alignment rules are
121
+ // retired with the markup that needed them.
122
+ const panel = read("HkThemePanelContent.tsx");
123
+ const entry = panel.match(/<HkAddTile[\s\S]*?s-theme-item-customize[\s\S]*?>[\s\S]*?<\/HkAddTile>/);
124
+ expect(entry).not.toBeNull();
125
+ expect(entry![0]).toContain("compact");
126
+ expect(entry![0]).not.toContain("hk-menu-item-icon");
127
+ // The toggle's own sheet carries no customize-specific icon cell
128
+ // rules anymore (the alignment contract retired with the row).
115
129
  const css = read("HkThemeToggle.scss");
116
- // The customize affordance joins the 28px lead column (2026-09-11
117
- // field report: its palette sat a full cell left of the rows' bitmap
118
- // marks), but the glyph must keep the standard menu icon size —
119
- // mi.icon normalizes svg to the cell, so the box is pinned back.
120
- const block = css.match(
121
- /\.s-theme-item-customize \.hk-menu-item-icon\s*\{[^}]*\}/,
122
- );
123
- expect(block).not.toBeNull();
124
- expect(block![0]).toContain("width: 28px");
125
- expect(block![0]).toContain("height: 28px");
126
-
127
- const svgBlock = css.match(
128
- /\.s-theme-item-customize \.hk-menu-item-icon > svg\s*\{[^}]*\}/,
129
- );
130
- expect(svgBlock).not.toBeNull();
131
- expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
130
+ expect(css).not.toMatch(/\.s-theme-item-customize \.hk-menu-item-icon/);
132
131
  });
133
132
 
134
133
  it("aligns mode-extra host rows to the lead column without rescaling their glyph", () => {
@@ -288,24 +288,13 @@
288
288
  height: 28px;
289
289
  }
290
290
 
291
- /* The customize affordance joins the theme rows' 28px lead column so the
292
- * palette glyph and the row marks sit on one line and every name starts
293
- * at the same x (2026-09-11 field report: with the lead cell at 28px and
294
- * this row at the generic box, the selected theme's bitmap mark read a
295
- * full cell to the right of the customize palette). The cell widens but
296
- * the GLYPH keeps the standard menu icon size — mi.icon normalizes svg
297
- * to the cell, so the box is pinned back explicitly; widening a cell
298
- * must never re-scale its glyph (the 0.42.2 regression class). */
299
- .s-theme-item-customize .hk-menu-item-icon {
300
- @include mi.icon;
301
-
302
- width: 28px;
303
- height: 28px;
304
- }
291
+ /* (The customize row's lead-column alignment rules retired 2026-11-11:
292
+ * the entry itself is the standard HkAddTile compact form now — a
293
+ * self-contained dashed tile below the list, not a row in the lead
294
+ * column — so it carries no .hk-menu-item-icon cell to widen.) */
305
295
 
306
- .s-theme-item-customize .hk-menu-item-icon > svg {
307
- width: var(--hk-menu-item-icon-box);
308
- height: var(--hk-menu-item-icon-box);
296
+ .s-theme-item-customize {
297
+ margin-top: var(--space-8, 0.5rem);
309
298
  }
310
299
 
311
300
  /* Host rows in the mode-extra strip (chest's DPI entry row reuses
@@ -314,9 +303,9 @@
314
303
  * (2026-09-12 field report: with the strip row at the generic 16px cell
315
304
  * its label sat a full cell-width left of the theme rows' names). The
316
305
  * strip container is the toggle's own, so the scope needs no host
317
- * class-name contract. As with the customize row, the cell widens but
318
- * the GLYPH keeps the standard menu icon size — widening a cell must
319
- * never re-scale its glyph (the 0.42.2 regression class). */
306
+ * class-name contract. The cell widens but the GLYPH keeps the standard
307
+ * menu icon size — widening a cell must never re-scale its glyph (the
308
+ * 0.42.2 regression class). */
320
309
  .s-theme-mode-extra .s-theme-item-btn .hk-menu-item-icon {
321
310
  @include mi.icon;
322
311
 
@@ -75,8 +75,11 @@ function triggerButton(container: HTMLElement): HTMLButtonElement {
75
75
  }
76
76
 
77
77
  function paletteButton(): HTMLButtonElement {
78
- const btn = [...document.body.querySelectorAll<HTMLButtonElement>(".s-theme-menu .s-theme-item-btn")].find(
79
- (b) => b.textContent?.includes("Customize"),
78
+ // The entry rides the standard HkAddTile (2026-11-11 family migration)
79
+ // and no longer carries s-theme-item-btn — the stable host hook class
80
+ // is the query surface.
81
+ const btn = document.body.querySelector<HTMLButtonElement>(
82
+ ".s-theme-menu .s-theme-item-customize",
80
83
  );
81
84
  return btn!;
82
85
  }
@@ -67,27 +67,3 @@
67
67
  width: 5rem;
68
68
  }
69
69
 
70
- .hk-token-input-clear {
71
- /* The editor's clear affordance is the STANDARD icon button (wave B1):
72
- S24 replaces the old 26px box. The OLD contract is preserved exactly:
73
- muted ink at rest, error ink + wash on hover (an R3 review round caught
74
- a first draft that set the ink var at rest — red-at-rest was never the
75
- design). The hover-scoped override keeps ghost's rest ink while the
76
- error channels apply on hover/press only. */
77
- flex: 0 0 auto;
78
- margin-top: var(--space-4, 4px);
79
- --hi-icon-button-bg-hover: rgb(var(--color-error) / 10%);
80
- --hi-icon-button-bg-active: rgb(var(--color-error) / 16%);
81
-
82
- &:hover {
83
- --hi-icon-button-icon-color: rgb(var(--color-error));
84
- }
85
-
86
- &:active {
87
- /* Press ink reads the -active channel (HkIconButton.scss
88
- `&:active .hk-icon-button-icon`), so the base var alone is a no-op
89
- here — set both. */
90
- --hi-icon-button-icon-color: rgb(var(--color-error));
91
- --hi-icon-button-icon-color-active: rgb(var(--color-error));
92
- }
93
- }
@@ -3,9 +3,6 @@ import { computed, defineComponent, ref, watch, type PropType } from "vue";
3
3
  import HkAdaptiveDialog from "./HkAdaptiveDialog";
4
4
  import HkInput from "./HkInput";
5
5
  import HkSelect from "./HkSelect";
6
- import { X } from "lucide-vue-next";
7
-
8
- import HkIconButton from "./HkIconButton";
9
6
 
10
7
  import { useI18n } from "../i18n/context";
11
8
  import "./HkTokenInput.scss";
@@ -38,9 +35,13 @@ function trim(n: number): string {
38
35
  * The precision editor is a DRAGGABLE desktop window / slide-up
39
36
  * mobile sheet (HkAdaptiveDialog): a numeric input (major) plus a
40
37
  * unit picker (minor), both live-commit; a unit flip re-expresses the
41
- * same absolute budget (1.5M ⇄ 1500K) instead of rescaling it; a
42
- * clear action unsets. Transiently unparseable intermediates ("1e")
43
- * commit nothing. An `#editor` slot may replace the built-in body.
38
+ * same absolute budget (1.5M ⇄ 1500K) instead of rescaling it;
39
+ * deliberately emptying the input unsets. There is deliberately NO
40
+ * separate clear button in the editor body — the dialog chrome already
41
+ * carries the window's close ✕, and a second ✕ read as a duplicate
42
+ * control (2026-11-11 user report); clearing the text is the unset
43
+ * gesture. Transiently unparseable intermediates ("1e") commit
44
+ * nothing. An `#editor` slot may replace the built-in body.
44
45
  */
45
46
  export default defineComponent({
46
47
  name: "HkTokenInput",
@@ -72,7 +73,6 @@ export default defineComponent({
72
73
  const resolvedEditorTitle = computed(
73
74
  () => props.editorTitle ?? t("hikari::tokenField.editValue", "Edit value"),
74
75
  );
75
- const clearLabel = computed(() => t("hikari::tokenField.clear", "Clear"));
76
76
 
77
77
  function display(): string {
78
78
  if (props.format) return props.format(props.modelValue, props.unit);
@@ -109,11 +109,6 @@ export default defineComponent({
109
109
  emit("update:unit", next as "auto" | TokenUnit);
110
110
  }
111
111
 
112
- function clearValue() {
113
- emit("update:modelValue", null);
114
- editorOpen.value = false;
115
- }
116
-
117
112
  return () => (
118
113
  <div
119
114
  class={["hk-token-input", props.disabled && "is-disabled"].filter(Boolean).join(" ")}
@@ -161,15 +156,6 @@ export default defineComponent({
161
156
  onUpdate:modelValue={(v: number | null) => emit("update:modelValue", v)}
162
157
  onUpdate:unit={(u: TokenUnit) => (editorUnit.value = u)}
163
158
  />
164
- <HkIconButton
165
- class="hk-token-input-clear"
166
- size={24}
167
- variant="ghost"
168
- aria-label={clearLabel.value}
169
- onClick={clearValue}
170
- >
171
- {{ icon: () => <X aria-hidden="true" /> }}
172
- </HkIconButton>
173
159
  </div>
174
160
  ),
175
161
  }}
@@ -114,17 +114,20 @@ describe("close-button family rides the standard icon button", () => {
114
114
  });
115
115
  }
116
116
 
117
- it("sources the wave-B1 field clears from the standard component too", () => {
118
- // The token editor's clear lives inside an HkAdaptiveDialog slot (no
119
- // bare mount path), so it is pinned at SOURCE level like the toast.
117
+ it("the token editor has NO clear button in its editor body", () => {
118
+ // Retired 2026-11-11 (user report): the precision editor's dialog
119
+ // chrome already carries the window close ✕, and a second ✕ next to
120
+ // the unit picker read as a duplicate control — clearing the text is
121
+ // the unset gesture. Pinned NEGATIVELY at source level (the editor
122
+ // lives inside an HkAdaptiveDialog slot, no bare mount path) so the
123
+ // button cannot quietly grow back.
120
124
  const token = readSource("HkTokenInput.tsx");
121
- expect(token, "token clear rides HkIconButton").toMatch(
122
- new RegExp('<HkIconButton[\\s\\S]{0,200}?class="hk-token-input-clear"'),
125
+ expect(token, "no hk-token-input-clear seat class").not.toContain(
126
+ "hk-token-input-clear",
123
127
  );
124
- expect(token, "token clear is not a raw button").not.toMatch(
125
- new RegExp('<button[^>]*class="hk-token-input-clear"'),
128
+ expect(token, "no HkIconButton in the editor body").not.toContain(
129
+ "HkIconButton",
126
130
  );
127
- expect(token, "token clear rides the 24px rung").toContain("size={24}");
128
131
  });
129
132
 
130
133
  it("sources the toast controls from the standard component too", () => {
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "تأكيد",
290
290
  "hikari::userPicker.selectedCount": "تم اختيار {count}",
291
291
  "hikari::tokenField.editValue": "تحرير القيمة",
292
- "hikari::tokenField.clear": "مسح",
293
292
  "hikari::diff.unchangedLines": "{count} أسطر غير معدّلة",
294
293
  "hikari::diff.loadFailed": "فشل التحميل، انقر لإعادة المحاولة",
295
294
  "hikari::search.trigger": "بحث",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "Bestätigen",
290
290
  "hikari::userPicker.selectedCount": "{count} ausgewählt",
291
291
  "hikari::tokenField.editValue": "Wert bearbeiten",
292
- "hikari::tokenField.clear": "Leeren",
293
292
  "hikari::diff.unchangedLines": "{count} unveränderte Zeilen",
294
293
  "hikari::diff.loadFailed": "Laden fehlgeschlagen, zum Wiederholen klicken",
295
294
  "hikari::search.trigger": "Suchen",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "Confirm",
290
290
  "hikari::userPicker.selectedCount": "{count} selected",
291
291
  "hikari::tokenField.editValue": "Edit value",
292
- "hikari::tokenField.clear": "Clear",
293
292
  "hikari::diff.unchangedLines": "{count} unchanged lines",
294
293
  "hikari::diff.loadFailed": "Load failed, click to retry",
295
294
  "hikari::search.trigger": "Search",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "Confirmar",
290
290
  "hikari::userPicker.selectedCount": "{count} seleccionados",
291
291
  "hikari::tokenField.editValue": "Editar el valor",
292
- "hikari::tokenField.clear": "Borrar",
293
292
  "hikari::diff.unchangedLines": "{count} líneas sin cambios",
294
293
  "hikari::diff.loadFailed": "Error al cargar, haz clic para reintentar",
295
294
  "hikari::search.trigger": "Buscar",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "Confirmer",
290
290
  "hikari::userPicker.selectedCount": "{count} sélectionnés",
291
291
  "hikari::tokenField.editValue": "Modifier la valeur",
292
- "hikari::tokenField.clear": "Effacer",
293
292
  "hikari::diff.unchangedLines": "{count} lignes inchangées",
294
293
  "hikari::diff.loadFailed": "Échec du chargement, cliquez pour réessayer",
295
294
  "hikari::search.trigger": "Rechercher",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "確認",
290
290
  "hikari::userPicker.selectedCount": "{count} 件選択中",
291
291
  "hikari::tokenField.editValue": "値を編集",
292
- "hikari::tokenField.clear": "クリア",
293
292
  "hikari::diff.unchangedLines": "未変更の {count} 行",
294
293
  "hikari::diff.loadFailed": "読み込みに失敗しました。クリックで再試行",
295
294
  "hikari::search.trigger": "検索",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "확인",
290
290
  "hikari::userPicker.selectedCount": "{count}명 선택됨",
291
291
  "hikari::tokenField.editValue": "값 편집",
292
- "hikari::tokenField.clear": "지우기",
293
292
  "hikari::diff.unchangedLines": "변경되지 않은 {count}줄",
294
293
  "hikari::diff.loadFailed": "불러오기 실패, 클릭하여 재시도",
295
294
  "hikari::search.trigger": "검색",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "Confirmar",
290
290
  "hikari::userPicker.selectedCount": "{count} selecionados",
291
291
  "hikari::tokenField.editValue": "Editar o valor",
292
- "hikari::tokenField.clear": "Limpar",
293
292
  "hikari::diff.unchangedLines": "{count} linhas inalteradas",
294
293
  "hikari::diff.loadFailed": "Falha ao carregar, clique para tentar novamente",
295
294
  "hikari::search.trigger": "Pesquisar",
@@ -289,7 +289,6 @@
289
289
  "hikari::userPicker.confirm": "Подтвердить",
290
290
  "hikari::userPicker.selectedCount": "Выбрано: {count}",
291
291
  "hikari::tokenField.editValue": "Изменить значение",
292
- "hikari::tokenField.clear": "Очистить",
293
292
  "hikari::diff.unchangedLines": "{count} строк без изменений",
294
293
  "hikari::diff.loadFailed": "Не удалось загрузить, нажмите для повтора",
295
294
  "hikari::search.trigger": "Поиск",
@@ -293,7 +293,6 @@
293
293
  "hikari::userPicker.confirm": "确认",
294
294
  "hikari::userPicker.selectedCount": "已选 {count} 人",
295
295
  "hikari::tokenField.editValue": "编辑数值",
296
- "hikari::tokenField.clear": "清除",
297
296
  "hikari::diff.unchangedLines": "{count} 行未修改",
298
297
  "hikari::diff.loadFailed": "加载失败,点击重试",
299
298
  "hikari::search.trigger": "搜索",
@@ -293,7 +293,6 @@
293
293
  "hikari::userPicker.confirm": "確認",
294
294
  "hikari::userPicker.selectedCount": "已選 {count} 人",
295
295
  "hikari::tokenField.editValue": "編輯數值",
296
- "hikari::tokenField.clear": "清除",
297
296
  "hikari::diff.unchangedLines": "{count} 行未修改",
298
297
  "hikari::diff.loadFailed": "載入失敗,點擊重試",
299
298
  "hikari::search.trigger": "搜尋",