@celestia-island/hikari 0.55.303 → 0.55.304

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.303",
3
+ "version": "0.55.304",
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",
@@ -181,6 +181,13 @@
181
181
  }
182
182
 
183
183
  .hk-affix-tag-x {
184
+ /* Wave B2 hybrid decision: the × keeps its own onmousedown
185
+ prevent-default (tapping must not steal focus or start a selection) and
186
+ its 1.5rem field density, so it deliberately stays a BARE button — the
187
+ contract is pinned Rust-side
188
+ (`affix_tag_close_stays_a_bare_button_by_contract`). What standardizes
189
+ is the INK: it consumes the icon-button channel vars (current values as
190
+ first fallbacks = zero visual change). */
184
191
  flex: none;
185
192
  display: inline-flex;
186
193
  align-items: center;
@@ -191,15 +198,21 @@
191
198
  border: 0;
192
199
  border-radius: var(--radius-full, 999px);
193
200
  background: transparent;
194
- color: rgb(var(--color-muted));
201
+ color: var(--hi-icon-button-icon-color, rgb(var(--color-muted)));
195
202
  cursor: pointer;
196
203
  appearance: none;
197
204
  transition: color 0.12s ease, background 0.12s ease;
198
205
 
199
206
  &:hover,
200
207
  &:focus-visible {
201
- color: rgb(var(--color-error, 247 118 142));
202
- background: rgb(var(--color-error, 247 118 142) / 12%);
208
+ color: var(
209
+ --hi-icon-button-icon-color-active,
210
+ rgb(var(--color-error, 247 118 142))
211
+ );
212
+ background: var(
213
+ --hi-icon-button-bg-hover,
214
+ rgb(var(--color-error, 247 118 142) / 12%)
215
+ );
203
216
  }
204
217
  }
205
218
 
@@ -95,6 +95,11 @@
95
95
  }
96
96
 
97
97
  .hk-dp-clear {
98
+ /* Wave B1 hybrid decision (see HkSearchInput's twin): tabindex=-1 + own
99
+ keydown stop-propagation = stays a BARE button, pinned by
100
+ `date_clear_stays_a_bare_button_by_contract`. The reveal-on-hover
101
+ mechanism is the field's own contract and stays. What standardizes is
102
+ the INK via the icon-button channel vars (zero visual change). */
98
103
  appearance: none;
99
104
  display: inline-flex;
100
105
  align-items: center;
@@ -104,7 +109,7 @@
104
109
  border: 0;
105
110
  background: transparent;
106
111
  border-radius: var(--radius-sm, 4px);
107
- color: var(--hi-color-muted);
112
+ color: var(--hi-icon-button-icon-color, var(--hi-color-muted));
108
113
  cursor: pointer;
109
114
  opacity: 0;
110
115
  transition: opacity var(--duration-fast, 0.15s) ease, color var(--duration-fast, 0.15s) ease;
@@ -116,7 +121,7 @@
116
121
  }
117
122
 
118
123
  &:hover {
119
- color: var(--hi-color-text-primary);
124
+ color: var(--hi-icon-button-icon-color-active, var(--hi-color-text-primary));
120
125
  }
121
126
  }
122
127
 
@@ -98,28 +98,10 @@
98
98
  }
99
99
 
100
100
  .hk-file-clear {
101
+ /* The clear affordance is the STANDARD icon button (wave B1): S24 exactly
102
+ replaces the old 1.5rem box; box, ink, hover and focus come from the
103
+ component. The seat keeps only its flex slot in the trigger row. */
101
104
  flex-shrink: 0;
102
- display: inline-flex;
103
- align-items: center;
104
- justify-content: center;
105
- width: 1.5rem;
106
- height: 1.5rem;
107
- padding: 0;
108
- background: transparent;
109
- border: 0;
110
- border-radius: var(--radius-xs, 2px);
111
- color: rgb(var(--color-text) / 55%);
112
- cursor: pointer;
113
-
114
- &:hover {
115
- background: rgb(var(--color-text) / 8%);
116
- color: rgb(var(--color-text));
117
- }
118
-
119
- &:focus-visible {
120
- outline: 2px solid var(--hi-color-primary, #7aa2f7);
121
- outline-offset: 1px;
122
- }
123
105
  }
124
106
 
125
107
  /* The native input stays in the DOM for the trigger to click, but is
@@ -1,3 +1,7 @@
1
+ import { X } from "lucide-vue-next";
2
+
3
+ import HkIconButton from "./HkIconButton";
4
+
1
5
  import { computed, defineComponent, ref, useId, watch } from "vue";
2
6
 
3
7
  import { useI18n } from "../i18n/context";
@@ -170,26 +174,15 @@ export default defineComponent({
170
174
  </span>
171
175
  </button>
172
176
  {files.length > 0 && !props.disabled && (
173
- <button
174
- type="button"
177
+ <HkIconButton
175
178
  class="hk-file-clear"
179
+ size={24}
180
+ variant="ghost"
176
181
  aria-label={t("hikari::filePicker.fieldClear", "Clear selection")}
177
182
  onClick={clear}
178
183
  >
179
- <svg
180
- viewBox="0 0 24 24"
181
- fill="none"
182
- stroke="currentColor"
183
- stroke-width="2"
184
- stroke-linecap="round"
185
- width="14"
186
- height="14"
187
- aria-hidden="true"
188
- >
189
- <line x1="18" y1="6" x2="6" y2="18" />
190
- <line x1="6" y1="6" x2="18" y2="18" />
191
- </svg>
192
- </button>
184
+ {{ icon: () => <X aria-hidden="true" /> }}
185
+ </HkIconButton>
193
186
  )}
194
187
  {props.mode === "local" && (
195
188
  <input
@@ -83,33 +83,23 @@
83
83
  }
84
84
 
85
85
  .hk-filter-panel-op {
86
+ /* Wave B2: the operator toggles are STANDARD icon buttons at the S28 rung
87
+ (the old 1.75rem box). The pressed operator's persistent primary tint
88
+ rides the seat's channel overrides, scoped to [aria-pressed="true"];
89
+ font-weight stays a seat concern (the component carries no text). */
86
90
  flex: 0 0 auto;
87
91
  scroll-snap-align: center;
88
- min-width: 1.75rem;
89
- height: 1.75rem;
90
- padding: 0 var(--space-6, 0.375rem);
91
- display: inline-flex;
92
- align-items: center;
93
- justify-content: center;
94
- border: none;
95
- border-radius: var(--radius-sm, 6px);
96
- background: transparent;
97
- color: rgb(var(--color-muted, var(--color-text-secondary)));
98
- font-size: var(--text-sm, 0.875rem);
99
- line-height: 1;
100
- cursor: pointer;
101
- transition: background 0.15s ease, color 0.15s ease;
102
- }
103
92
 
104
- .hk-filter-panel-op:hover {
105
- background: rgb(var(--color-primary) / 12%);
106
- color: rgb(var(--color-text));
107
- }
108
-
109
- .hk-filter-panel-op[aria-pressed="true"] {
110
- background: rgb(var(--color-primary) / 18%);
111
- color: rgb(var(--color-primary));
112
- font-weight: 700;
93
+ &[aria-pressed="true"] {
94
+ --hi-icon-button-bg: rgb(var(--color-primary) / 18%);
95
+ --hi-icon-button-icon-color: rgb(var(--color-primary));
96
+ --hi-icon-button-icon-color-active: rgb(var(--color-primary));
97
+ /* Keep the primary tint THROUGH hover (the old file-order cascade
98
+ did): without this the pressed op falls back to the ghost gray-20
99
+ hover wash and reads un-pressed under the pointer. */
100
+ --hi-icon-button-bg-hover: rgb(var(--color-primary) / 18%);
101
+ font-weight: 700;
102
+ }
113
103
  }
114
104
 
115
105
  .hk-filter-panel-op:disabled {
@@ -133,28 +123,16 @@
133
123
  }
134
124
 
135
125
  .hk-filter-panel-remove {
126
+ /* Wave B2: the condition remove is the STANDARD icon button (S24 = the
127
+ old 1.5rem box). The error-tinted hover rides the channel vars — ink
128
+ hover-scoped (the token-input lesson: a rest-scoped ink var reads at
129
+ rest); the standard layer owns disabled presentation. */
136
130
  flex: 0 0 auto;
137
- width: 1.5rem;
138
- height: 1.5rem;
139
- display: inline-flex;
140
- align-items: center;
141
- justify-content: center;
142
- border: none;
143
- border-radius: var(--radius-sm, 6px);
144
- background: transparent;
145
- color: rgb(var(--color-muted, var(--color-text-secondary)));
146
- cursor: pointer;
147
- transition: background 0.15s ease, color 0.15s ease;
148
- }
149
-
150
- .hk-filter-panel-remove:hover {
151
- background: rgb(var(--color-error) / 12%);
152
- color: rgb(var(--color-error));
153
- }
131
+ --hi-icon-button-bg-hover: rgb(var(--color-error) / 12%);
154
132
 
155
- .hk-filter-panel-remove:disabled {
156
- opacity: 0.5;
157
- cursor: not-allowed;
133
+ &:hover {
134
+ --hi-icon-button-icon-color: rgb(var(--color-error));
135
+ }
158
136
  }
159
137
 
160
138
  /* The growth verb: parked in the OPERATOR COLUMN (grid-column 2), so it
@@ -1,3 +1,5 @@
1
+ import HkIconButton from "./HkIconButton";
2
+
1
3
  import { computed, defineComponent, nextTick, ref, watch, type PropType } from "vue";
2
4
  import { Plus, X } from "lucide-vue-next";
3
5
 
@@ -325,18 +327,19 @@ export default defineComponent({
325
327
  {ops.map((op) => {
326
328
  const glyph = glyphOpFor(kind, op);
327
329
  return (
328
- <button
330
+ <HkIconButton
329
331
  key={op}
330
- type="button"
331
332
  class="hk-filter-panel-op"
333
+ size={28}
334
+ variant="ghost"
332
335
  aria-pressed={cond.op === op}
333
336
  aria-label={t(OPERATOR_LABEL[glyph].key, OPERATOR_LABEL[glyph].fallback)}
334
- title={t(OPERATOR_LABEL[glyph].key, OPERATOR_LABEL[glyph].fallback)}
337
+ {...{ title: t(OPERATOR_LABEL[glyph].key, OPERATOR_LABEL[glyph].fallback) }}
335
338
  disabled={props.disabled || !field}
336
339
  onClick={() => emitCondition(index, { op })}
337
340
  >
338
- <HkFilterOperatorIcon op={glyph} />
339
- </button>
341
+ {{ icon: () => <HkFilterOperatorIcon op={glyph} /> }}
342
+ </HkIconButton>
340
343
  );
341
344
  })}
342
345
  </div>
@@ -394,19 +397,20 @@ export default defineComponent({
394
397
  * page refetches without it). Sibling conditions on
395
398
  * the same field survive — index-targeted removal,
396
399
  * never key-targeted. */}
397
- <button
398
- type="button"
400
+ <HkIconButton
399
401
  class="hk-filter-panel-remove"
402
+ size={24}
403
+ variant="ghost"
400
404
  aria-label={t("hikari::filterPanel.removeCondition", "Remove condition")}
401
- title={t("hikari::filterPanel.removeCondition", "Remove condition")}
405
+ {...{ title: t("hikari::filterPanel.removeCondition", "Remove condition") }}
402
406
  disabled={props.disabled}
403
407
  onClick={() => {
404
408
  const next = props.modelValue.filter((_, i) => i !== index);
405
409
  emit("update:modelValue", next);
406
410
  }}
407
411
  >
408
- <X size={14} aria-hidden="true" />
409
- </button>
412
+ {{ icon: () => <X aria-hidden="true" /> }}
413
+ </HkIconButton>
410
414
  </div>
411
415
  </div>
412
416
  );
@@ -41,23 +41,10 @@
41
41
  }
42
42
 
43
43
  .hk-kw-search-clear {
44
- appearance: none;
45
- background: transparent;
46
- border: 0;
47
- color: var(--hi-color-text-secondary, #666666);
48
- cursor: pointer;
49
- display: inline-flex;
44
+ /* Wave B2/B3: the clear affordance is the STANDARD icon button — box,
45
+ ink, hover and focus come from the component; the seat keeps only the
46
+ field's inner padding slot. */
50
47
  padding: var(--space-2, 0.125rem);
51
- border-radius: var(--radius-sm, 0.25rem);
52
-
53
- &:hover {
54
- color: var(--hi-color-text-primary, #1e1e1e);
55
- }
56
-
57
- &:focus-visible {
58
- outline: 2px solid color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 55%, transparent);
59
- outline-offset: 2px;
60
- }
61
48
  }
62
49
 
63
50
  /* ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the results list is NOT a
@@ -1,3 +1,5 @@
1
+ import HkIconButton from "./HkIconButton";
2
+
1
3
  import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
2
4
 
3
5
  import { useI18n } from "../i18n/context";
@@ -241,9 +243,10 @@ export default defineComponent({
241
243
  autofocus
242
244
  />
243
245
  {query.value && (
244
- <button
246
+ <HkIconButton
245
247
  class="hk-kw-search-clear"
246
- type="button"
248
+ size={24}
249
+ variant="ghost"
247
250
  aria-label={t("hikari::keywordSearch.clear", "Clear")}
248
251
  onClick={() => {
249
252
  query.value = "";
@@ -251,19 +254,21 @@ export default defineComponent({
251
254
  semanticResults.value = [];
252
255
  }}
253
256
  >
254
- <svg
255
- width="14"
256
- height="14"
257
- viewBox="0 0 24 24"
258
- fill="none"
259
- stroke="currentColor"
260
- stroke-width="2"
261
- stroke-linecap="round"
262
- >
263
- <line x1="18" y1="6" x2="6" y2="18" />
264
- <line x1="6" y1="6" x2="18" y2="18" />
265
- </svg>
266
- </button>
257
+ {{ icon: () => (
258
+ <svg
259
+ width="14"
260
+ height="14"
261
+ viewBox="0 0 24 24"
262
+ fill="none"
263
+ stroke="currentColor"
264
+ stroke-width="2"
265
+ stroke-linecap="round"
266
+ >
267
+ <line x1="18" y1="6" x2="6" y2="18" />
268
+ <line x1="6" y1="6" x2="18" y2="18" />
269
+ </svg>
270
+ ) }}
271
+ </HkIconButton>
267
272
  )}
268
273
  </div>
269
274
 
@@ -9,13 +9,14 @@
9
9
  * region above all) stays horizontally AND vertically centered at every t;
10
10
  * the translate prefix is byte-identical in every state so the ride can
11
11
  * never re-seat the frame. `.hk-modal-body` keeps a ride of its own:
12
- * `scale(1.1, 1) → scale(1, 1)` — the horizontal factor deliberately
13
- * OVERSHOOTS (user direction 2026-11-06: at the extremes the content is
14
- * WIDER than the window, clipped by the frame's overflow), while its
15
- * vertical factor stays 1 because the body's Y already rides the frame's
16
- * scaleY (own 0.9 would compound to 0.81). The retired grammars: the
17
- * 2026-11-04 whole-frame `scale(1, 0.9)` and the 2026-11-05 body-only
18
- * uniform `scale(0.9)`.
12
+ * `scale(0.95, 1) → scale(1, 1)` — the horizontal factor starts slightly
13
+ * NARROW and spreads out to fill the window (user direction 2026-11-06,
14
+ * revising the first cut: a 1.1 overshoot read as uglier than a slight
15
+ * pinch), while its vertical factor stays 1 because the body's Y already
16
+ * rides the frame's scaleY (own 0.9 would compound to 0.81). The retired
17
+ * grammars: the 2026-11-04 whole-frame `scale(1, 0.9)`, the 2026-11-05
18
+ * body-only uniform `scale(0.9)`, and the 2026-11-06 `scale(1.1, 1)`
19
+ * overshoot.
19
20
  *
20
21
  * Why compile: a source-text scanner can be dodged by a later duplicate
21
22
  * rule, a block comment, SCSS interpolation, `@extend` or an `@use`d
@@ -347,7 +348,7 @@ const BODY_ENTER_TRANSITION =
347
348
  const BODY_LEAVE_TRANSITION =
348
349
  "transform var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
349
350
 
350
- describe("HkModal desktop open/close contract (compiled cascade, user direction 2026-11-05)", () => {
351
+ describe("HkModal desktop open/close contract (compiled cascade, user direction 2026-11-06)", () => {
351
352
  it("compiles into a stylesheet with the ride rules present (known-positive)", () => {
352
353
  // Guard against a vacuous suite: the compiled sheet must really contain
353
354
  // the frame rule, the body rule and the reduced-motion flatten.
@@ -436,11 +437,11 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
436
437
  expect(leaveWill).toBe("opacity, transform");
437
438
  });
438
439
 
439
- it("rides the content region X 1.1→1 with Y pinned at 1, about its center", () => {
440
- // 2026-11-06: the horizontal factor OVERSHOOTS (wider-than-window at
441
- // the extremes; the frame's overflow clips the overhang) and the
442
- // vertical factor stays 1 — the body's Y already rides the frame's
443
- // scaleY, so its own 1 keeps the compound at exactly 0.9→1.
440
+ it("rides the content region X 0.95→1 with Y pinned at 1, about its center", () => {
441
+ // 2026-11-06 (revised): the content starts slightly NARROW (95% of the
442
+ // window width) and spreads to 100%; the vertical factor stays 1 — the
443
+ // body's Y already rides the frame's scaleY, so its own 1 keeps the
444
+ // compound at exactly 0.9→1.
444
445
  const expect_ = (state: string, value: string) => {
445
446
  const frame = frameWith(...RIDE.find((r) => r.name === state)!.classes);
446
447
  const body = child(frame, "hk-modal-body");
@@ -453,8 +454,8 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
453
454
  const [x = "50%", y = "50%"] = origin;
454
455
  expect([x, y], state).toEqual(["50%", "50%"]);
455
456
  };
456
- expect_("enter-from", "scale(1.1, 1)");
457
- expect_("leave-to", "scale(1.1, 1)");
457
+ expect_("enter-from", "scale(0.95, 1)");
458
+ expect_("leave-to", "scale(0.95, 1)");
458
459
  expect_("enter-to", "scale(1, 1)");
459
460
  expect_("leave-from", "scale(1, 1)");
460
461
  // …and the same with the opt-in draggable flag on, where an
@@ -463,7 +464,7 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
463
464
  const frame = draggableFrameWith(...state.classes);
464
465
  const body = child(frame, "hk-modal-body");
465
466
  const expected =
466
- state.name === "enter-from" || state.name === "leave-to" ? "scale(1.1, 1)" : "scale(1, 1)";
467
+ state.name === "enter-from" || state.name === "leave-to" ? "scale(0.95, 1)" : "scale(1, 1)";
467
468
  expect(winner(body, "transform", DESKTOP), `draggable ${state.name}`).toBe(expected);
468
469
  }
469
470
  // At rest the body carries no transform at all.
@@ -546,10 +547,19 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
546
547
  expect(winner(frameWith("hk-modal-content-leave-from", "hk-modal-content-leave-active"), "transform", MOBILE)).toBe("none");
547
548
  const resting = frameWith();
548
549
  expect(winner(resting, "transform", MOBILE)).toBe("none");
549
- // No content-region scale in the sheet layout.
550
+ // No content-region scale in the sheet layout — at EITHER end of the
551
+ // ride. The settled states used to go unpinned (a max-width block
552
+ // could scale the body there and no assertion noticed; R3 mutation
553
+ // 2026-11-06), so all four states are spelled out.
550
554
  const body = child(frame, "hk-modal-body");
551
555
  expect(winner(body, "transform", MOBILE)).toBeUndefined();
552
556
  expect(winner(child(leaveEnd, "hk-modal-body"), "transform", MOBILE)).toBeUndefined();
557
+ expect(
558
+ winner(child(frameWith("hk-modal-content-enter-to", "hk-modal-content-enter-active"), "hk-modal-body"), "transform", MOBILE),
559
+ ).toBeUndefined();
560
+ expect(
561
+ winner(child(frameWith("hk-modal-content-leave-from", "hk-modal-content-leave-active"), "hk-modal-body"), "transform", MOBILE),
562
+ ).toBeUndefined();
553
563
  });
554
564
 
555
565
  it("never lets ANY rule put a transform on a chrome surface", () => {
@@ -590,7 +600,7 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
590
600
  // three translate+scaleY states (reduced-motion flatten included);
591
601
  // body: the two two-arg scales.
592
602
  const LEGAL_FRAME = new Set([CENTERING, FRAME_FROM, FRAME_TO]);
593
- const LEGAL_BODY = new Set(["scale(1.1, 1)", "scale(1, 1)"]);
603
+ const LEGAL_BODY = new Set(["scale(0.95, 1)", "scale(1, 1)"]);
594
604
  const offenders: string[] = [];
595
605
  // Media-independent on purpose: a ride selector declared at top level
596
606
  // (no media query) applies at every width, so it must obey the same
@@ -114,14 +114,14 @@
114
114
  // IS the window-height motion the user asked for.
115
115
  //
116
116
  // The content region keeps a ride of its own: `.hk-modal-body` settles
117
- // `scale(1.1, 1) → scale(1, 1)` about its center. The horizontal factor
118
- // deliberately OVERSHOOTS past 1 (user direction 2026-11-06: at the
119
- // open/close extremes the content is WIDER than the window — the frame's
120
- // `overflow: hidden` clips the overhang at the window edges, reading as
121
- // a press-in from both sides). The vertical factor stays exactly 1: the
122
- // body's Y dimension already rides the frame's `scaleY`, so its own 1
123
- // makes the compound 0.9 → 1 — the same vertical ride as before, never
124
- // the doubled 0.81 a second Y factor would produce.
117
+ // `scale(0.95, 1) → scale(1, 1)` about its center. The horizontal factor
118
+ // starts slightly NARROW (user direction 2026-11-06, after reviewing the
119
+ // first cut: a 1.1 overshoot read as uglier than a slight pinch, so the
120
+ // content starts at 95% width and spreads out to fill the window). The
121
+ // vertical factor stays exactly 1: the body's Y dimension already rides
122
+ // the frame's `scaleY`, so its own 1 makes the compound 0.9 → 1 — the
123
+ // same vertical ride as before, never the doubled 0.81 a second Y factor
124
+ // would produce.
125
125
  //
126
126
  // Hosts retune the ride through the `--hk-modal-*` tokens (duration /
127
127
  // easing) or the body padding vars — NOT by setting `transform` on
@@ -131,13 +131,13 @@
131
131
  // single-class transform would be overridden mid-ride and again under
132
132
  // reduced motion.
133
133
  //
134
- // No clip-path and no Y offset ride the open/close: besides the deliberate
135
- // X overshoot above, the visible box IS the frame at every t, so nothing
136
- // is ever sliced off (the 2026-09-14 clip unveil — and the #508/#512/#524
137
- // lineage before it — stays retired). All compositor-level: zero layout
138
- // per frame. The centering translate lives on the base .hk-modal-content
139
- // rule, so the frame never re-seats mid-flight. Scoped to ≥768px; ≤767px
140
- // keeps the sheet's translateY choreography below.
134
+ // No clip-path and no Y offset ride the open/close: the visible box IS the
135
+ // frame at every t, so nothing is ever sliced off (the 2026-09-14 clip
136
+ // unveil — and the #508/#512/#524 lineage before it — stays retired). All
137
+ // compositor-level: zero layout per frame. The centering translate lives
138
+ // on the base .hk-modal-content rule, so the frame never re-seats
139
+ // mid-flight. Scoped to ≥768px; ≤767px keeps the sheet's translateY
140
+ // choreography below.
141
141
  // ------
142
142
 
143
143
  @media (min-width: 768px) {
@@ -153,7 +153,7 @@
153
153
  }
154
154
 
155
155
  // Close: the reveal's TRUE reverse — same chrome fade, while the window
156
- // settles back toward 90% height and the content widens back out.
156
+ // settles back toward 90% height and the content pinches back in.
157
157
  .hk-modal-content-leave-active {
158
158
  transition:
159
159
  opacity var(--hk-modal-duration, 0.32s)
@@ -205,11 +205,11 @@
205
205
  will-change: transform;
206
206
  }
207
207
 
208
- // Both ends two-arg `scale`: X overshoots 1.1 → 1, Y stays 1 (the body's
209
- // vertical ride comes from the frame's scaleY, not from itself).
208
+ // Both ends two-arg `scale`: X starts at 95% and spreads to 1, Y stays 1
209
+ // (the body's vertical ride comes from the frame's scaleY, not itself).
210
210
  .hk-modal-content-enter-from .hk-modal-body,
211
211
  .hk-modal-content-leave-to .hk-modal-body {
212
- transform: scale(1.1, 1);
212
+ transform: scale(0.95, 1);
213
213
  }
214
214
 
215
215
  .hk-modal-content-enter-to .hk-modal-body,
@@ -275,7 +275,11 @@
275
275
  padding: 0.25rem;
276
276
  border: none;
277
277
  background: transparent;
278
- color: var(--hi-color-text-secondary, #666666);
278
+ /* Wave B2/B3 hybrid decision: the eye is a PRESS-AND-HOLD control
279
+ (mousedown/up + keydown/up drive revealing — pinned Rust-side
280
+ `pwd_eye_stays_a_bare_button_by_contract`), so it stays a BARE button.
281
+ The ink consumes the icon-button channel vars (zero visual change). */
282
+ color: var(--hi-icon-button-icon-color, var(--hi-color-text-secondary, #666666));
279
283
  cursor: pointer;
280
284
  touch-action: none;
281
285
  /* iOS long-press must not summon the system callout / magnifier over
@@ -284,7 +288,10 @@
284
288
  -webkit-touch-callout: none;
285
289
 
286
290
  &:hover:not([data-revealing]) {
287
- color: var(--hi-color-text-primary, #1e1e1e);
291
+ color: var(
292
+ --hi-icon-button-icon-color-active,
293
+ var(--hi-color-text-primary, #1e1e1e)
294
+ );
288
295
  }
289
296
 
290
297
  /* While held, the affordance reads as active: full primary tint like
@@ -64,6 +64,13 @@
64
64
  }
65
65
 
66
66
  .hk-search-input-clear {
67
+ /* Wave B1 hybrid decision: this clear is tabindex=-1 (Escape and the
68
+ field's own keyboard path own dismissal) and keeps the field's hit-band
69
+ density, so it deliberately stays a BARE button — pinned by
70
+ `search_clear_stays_a_bare_button_by_contract`. What standardizes is the
71
+ INK: it consumes the same icon-button channel vars the standard
72
+ component reads, so theming moves together (current values kept as the
73
+ first fallback = zero visual change). */
67
74
  display: flex;
68
75
  align-items: center;
69
76
  justify-content: center;
@@ -73,7 +80,7 @@
73
80
  padding: 0;
74
81
  background: transparent;
75
82
  border: none;
76
- color: var(--hi-color-muted);
83
+ color: var(--hi-icon-button-icon-color, var(--hi-color-muted));
77
84
  cursor: pointer;
78
85
  transition:
79
86
  background-color var(--duration-fast, 0.15s) ease,
@@ -85,12 +92,12 @@
85
92
  }
86
93
 
87
94
  &:hover {
88
- background: color-mix(in srgb, var(--hi-color-surface) 60%, transparent);
89
- color: var(--hi-color-text-primary);
95
+ background: var(--hi-icon-button-bg-hover, color-mix(in srgb, var(--hi-color-surface) 60%, transparent));
96
+ color: var(--hi-icon-button-icon-color-active, var(--hi-color-text-primary));
90
97
  }
91
98
 
92
99
  &:active {
93
- background: color-mix(in srgb, var(--hi-color-surface) 80%, transparent);
100
+ background: var(--hi-icon-button-bg-active, color-mix(in srgb, var(--hi-color-surface) 80%, transparent));
94
101
  }
95
102
  }
96
103
 
@@ -264,36 +264,10 @@
264
264
  }
265
265
 
266
266
  .hk-selection-grid-action {
267
+ /* Wave B2: per-item actions are STANDARD icon buttons (S24/ghost) — box,
268
+ ink, hover and focus come from the component; the seat keeps the grid's
269
+ flex slot. */
267
270
  flex: none;
268
- display: inline-flex;
269
- align-items: center;
270
- justify-content: center;
271
- width: 1.5rem;
272
- height: 1.5rem;
273
- padding: 0;
274
- border: 0;
275
- border-radius: var(--radius-sm, 4px);
276
- background: transparent;
277
- color: var(--hi-color-text-tertiary, #8b949e);
278
- cursor: pointer;
279
- transition:
280
- color 200ms ease,
281
- background 200ms ease;
282
-
283
- &:hover:not(:disabled) {
284
- color: rgb(var(--color-text));
285
- background: rgb(var(--color-text) / 8%);
286
- }
287
-
288
- &:focus-visible {
289
- outline: 2px solid rgb(var(--color-primary));
290
- outline-offset: -2px;
291
- }
292
-
293
- &:disabled {
294
- opacity: 0.45;
295
- cursor: not-allowed;
296
- }
297
271
  }
298
272
 
299
273
  /* ── Optional selected edge ──────────────────────────────────────────────
@@ -1,3 +1,5 @@
1
+ import HkIconButton from "./HkIconButton";
2
+
1
3
  import { Check } from "lucide-vue-next";
2
4
  import { defineComponent, type PropType } from "vue";
3
5
 
@@ -156,11 +158,12 @@ export default defineComponent({
156
158
  {item.actions.map((action) => {
157
159
  const ActionIcon = action.icon;
158
160
  return (
159
- <button
161
+ <HkIconButton
160
162
  key={action.id}
161
- type="button"
162
163
  class="hk-selection-grid-action"
163
- title={action.label}
164
+ size={24}
165
+ variant="ghost"
166
+ {...{ title: action.label }}
164
167
  aria-label={action.label}
165
168
  disabled={action.disabled || undefined}
166
169
  onClick={(e: MouseEvent) => {
@@ -171,7 +174,7 @@ export default defineComponent({
171
174
  }}
172
175
  >
173
176
  {ActionIcon && <ActionIcon size={14} />}
174
- </button>
177
+ </HkIconButton>
175
178
  );
176
179
  })}
177
180
  </div>
@@ -31,32 +31,12 @@
31
31
  }
32
32
 
33
33
  .hk-tag-close {
34
- display: inline-flex;
35
- align-items: center;
36
- justify-content: center;
34
+ /* The chip × is the STANDARD icon button at the S16 rung (wave B1): the
35
+ 14px bespoke box, the opacity dance and the focus outline are retired —
36
+ box/ink/hover/focus come from the component. The seat keeps only the
37
+ chip's trailing-edge spacing. 14px → 16px is the declared growth. */
37
38
  flex-shrink: 0;
38
- width: 14px;
39
- height: 14px;
40
- border: none;
41
- background: transparent;
42
- color: inherit;
43
- cursor: pointer;
44
- opacity: 0.5;
45
- padding: 0;
46
- /* Trailing edge of the chip in both directions. */
47
39
  margin-inline-start: var(--space-2, 2px);
48
- border-radius: var(--radius-sm, 4px);
49
- transition: opacity var(--duration-fast, 0.15s) ease;
50
- }
51
-
52
- .hk-tag-close:hover {
53
- opacity: 1;
54
- }
55
-
56
- /* Keyboard focus indicator (house focus-ring pattern). */
57
- .hk-tag-close:focus-visible {
58
- outline: 2px solid rgb(var(--color-primary));
59
- outline-offset: 2px;
60
40
  }
61
41
 
62
42
  .hk-tag-default {
@@ -1,4 +1,6 @@
1
1
  import { X } from "lucide-vue-next";
2
+
3
+ import HkIconButton from "./HkIconButton";
2
4
  import { computed, defineComponent, type PropType } from "vue";
3
5
 
4
6
  import "./HkTag.scss";
@@ -38,14 +40,15 @@ export default defineComponent({
38
40
  )}
39
41
  {slots.default?.()}
40
42
  {props.closable && (
41
- <button
42
- type="button"
43
+ <HkIconButton
43
44
  class="hk-tag-close"
45
+ size={16}
46
+ variant="ghost"
44
47
  aria-label={props.closeLabel}
45
48
  onClick={() => emit("close")}
46
49
  >
47
- <X size={12} />
48
- </button>
50
+ {{ icon: () => <X aria-hidden="true" /> }}
51
+ </HkIconButton>
49
52
  )}
50
53
  </span>
51
54
  );
@@ -1,3 +1,5 @@
1
+ import HkIconButton from "./HkIconButton";
2
+
1
3
  import { computed, defineComponent, onUnmounted, ref, watch, type PropType } from "vue";
2
4
  import { Monitor, MoonStar, Palette, Sun, Trash as Trash2 } from "lucide-vue-next";
3
5
  import {
@@ -292,14 +294,16 @@ export const HkThemePanelContent = defineComponent({
292
294
  {trailingSlot ? (
293
295
  <span class="s-theme-item-trailing">{trailingSlot(scope)}</span>
294
296
  ) : th.isCustom ? (
295
- <button
296
- type="button"
297
+ <HkIconButton
297
298
  class="s-theme-item-delete"
298
- title={t("hikari::theme.deleteTheme")}
299
+ size={24}
300
+ variant="ghost"
301
+ aria-label={t("hikari::theme.deleteTheme")}
302
+ {...{ title: t("hikari::theme.deleteTheme") }}
299
303
  onClick={() => removeCustomTheme(th.id)}
300
304
  >
301
- <Trash2 size={12} />
302
- </button>
305
+ {{ icon: () => <Trash2 aria-hidden="true" /> }}
306
+ </HkIconButton>
303
307
  ) : null}
304
308
  </div>
305
309
  );
@@ -345,32 +345,20 @@
345
345
  * pill's trailing edge (the row is relative), not a reserved column:
346
346
  * built-in rows show nothing there and the pill runs to the row edge. */
347
347
  .s-theme-item-delete {
348
+ /* Wave B2/B3: the custom-theme delete is the STANDARD icon button at S24
349
+ (= the old 1.5rem desktop box). The seat keeps the row's parked-edge
350
+ positioning (absolute, trailing, vertically centred) — real geometry.
351
+ The error-tinted hover rides the channel vars, ink hover-scoped (token
352
+ lesson); the inset error focus ring yields to the standard ring. */
348
353
  position: absolute;
349
354
  inset-inline-end: var(--space-8, 0.5rem);
350
355
  top: 50%;
351
356
  transform: translateY(-50%);
352
- display: inline-flex;
353
- align-items: center;
354
- justify-content: center;
355
- width: 1.5rem;
356
- height: 1.5rem;
357
- border: none;
358
- border-radius: var(--radius-sm, 6px);
359
- background: transparent;
360
- color: rgb(var(--color-muted));
361
- cursor: pointer;
362
- }
363
-
364
- .s-theme-item-delete:hover {
365
- background: rgb(var(--color-error) / 15%);
366
- color: rgb(var(--color-error));
367
- }
357
+ --hi-icon-button-bg-hover: rgb(var(--color-error) / 15%);
368
358
 
369
- /* Keyboard parity for the destructive overlay (mi.item-style inset ring,
370
- * error-tinted to match its hover). */
371
- .s-theme-item-delete:focus-visible {
372
- outline: 2px solid rgb(var(--color-error));
373
- outline-offset: -2px;
359
+ &:hover {
360
+ --hi-icon-button-icon-color: rgb(var(--color-error));
361
+ }
374
362
  }
375
363
 
376
364
  /* Touch-sized delete target on phone widths, scaled for the 44px sheet
@@ -379,8 +367,10 @@
379
367
  * label never collide on long custom scheme names. */
380
368
  @media (max-width: 767px) {
381
369
  .s-theme-item-delete {
382
- width: 2.25rem;
383
- height: 2.25rem;
370
+ /* Set the COMPONENT's size var (not width/height — the S24 rung's
371
+ min/max clamps would defeat them): the phone delete target keeps its
372
+ 36px sheet-row contract. */
373
+ --hi-icon-button-size: 2.25rem;
384
374
  }
385
375
 
386
376
  .s-theme-item-row[data-custom] .s-theme-item-name {
@@ -68,21 +68,26 @@
68
68
  }
69
69
 
70
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. */
71
77
  flex: 0 0 auto;
72
- display: inline-flex;
73
- align-items: center;
74
- justify-content: center;
75
78
  margin-top: var(--space-4, 4px);
76
- width: 26px;
77
- height: 26px;
78
- border: none;
79
- border-radius: var(--radius-sm);
80
- background: transparent;
81
- color: rgb(var(--color-muted));
82
- cursor: pointer;
83
- }
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
+ }
84
85
 
85
- .hk-token-input-clear:hover {
86
- background: rgb(var(--color-error) / 10%);
87
- color: rgb(var(--color-error));
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
+ }
88
93
  }
@@ -5,6 +5,8 @@ import HkInput from "./HkInput";
5
5
  import HkSelect from "./HkSelect";
6
6
  import { X } from "lucide-vue-next";
7
7
 
8
+ import HkIconButton from "./HkIconButton";
9
+
8
10
  import { useI18n } from "../i18n/context";
9
11
  import "./HkTokenInput.scss";
10
12
 
@@ -159,14 +161,15 @@ export default defineComponent({
159
161
  onUpdate:modelValue={(v: number | null) => emit("update:modelValue", v)}
160
162
  onUpdate:unit={(u: TokenUnit) => (editorUnit.value = u)}
161
163
  />
162
- <button
163
- type="button"
164
+ <HkIconButton
164
165
  class="hk-token-input-clear"
166
+ size={24}
167
+ variant="ghost"
165
168
  aria-label={clearLabel.value}
166
169
  onClick={clearValue}
167
170
  >
168
- <X size={13} />
169
- </button>
171
+ {{ icon: () => <X aria-hidden="true" /> }}
172
+ </HkIconButton>
170
173
  </div>
171
174
  ),
172
175
  }}
@@ -75,24 +75,11 @@
75
75
  }
76
76
 
77
77
  .hk-user-picker-tag-x {
78
+ /* Wave B2: the tag close is the STANDARD icon button at S16 — the literal
79
+ multiplication-sign text button (audited as the family's worst
80
+ offender) is retired along with its bespoke box; the seat keeps only
81
+ the chip's flex slot. */
78
82
  flex: none;
79
- display: inline-flex;
80
- align-items: center;
81
- justify-content: center;
82
- width: 1rem;
83
- height: 1rem;
84
- border: none;
85
- border-radius: 999px;
86
- background: transparent;
87
- color: rgb(var(--color-text) / 60%);
88
- font-size: 0.875rem;
89
- line-height: 1;
90
- cursor: pointer;
91
-
92
- &:hover {
93
- background: rgb(var(--color-text) / 10%);
94
- color: rgb(var(--color-text));
95
- }
96
83
  }
97
84
 
98
85
  .hk-user-picker-add {
@@ -13,6 +13,10 @@
13
13
  // Semantics (the HkAffixPicker vocabulary): "single" closes on pick and
14
14
  // replaces the selection; "multi" toggles rows in a working copy and
15
15
  // commits on Confirm. The field's tag × removes directly — no dialog.
16
+ import { X } from "lucide-vue-next";
17
+
18
+ import HkIconButton from "./HkIconButton";
19
+
16
20
  import { computed, defineComponent, ref, watch, type PropType } from "vue";
17
21
 
18
22
  import { useI18n } from "../i18n/context";
@@ -186,17 +190,18 @@ export default defineComponent({
186
190
  {user.email && <span class="hk-user-picker-tag-email">{user.email}</span>}
187
191
  </span>
188
192
  {!props.disabled && (
189
- <button
190
- type="button"
193
+ <HkIconButton
191
194
  class="hk-user-picker-tag-x"
195
+ size={16}
196
+ variant="ghost"
192
197
  aria-label={`${t("hikari::userPicker.remove", "Remove")} ${tagTitle(user)}`}
193
198
  onClick={(e: MouseEvent) => {
194
199
  e.stopPropagation();
195
200
  removeTag(user.uid);
196
201
  }}
197
202
  >
198
- ×
199
- </button>
203
+ {{ icon: () => <X aria-hidden="true" /> }}
204
+ </HkIconButton>
200
205
  )}
201
206
  </span>
202
207
  ))}
@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, h, nextTick } from "vue";
3
3
 
4
4
  import HkAlert from "./HkAlert";
5
+ import HkFileField from "./HkFileField";
6
+ import HkTag from "./HkTag";
5
7
  import HkPersistentToast from "./HkPersistentToast";
6
8
 
7
9
  /**
@@ -26,6 +28,24 @@ const seats = [
26
28
  render: () => h(HkAlert, { message: "m", closable: true }),
27
29
  seat: "hk-alert-close",
28
30
  },
31
+ {
32
+ // Wave B1: the file field's clear rides the standard button at S24.
33
+ name: "HkFileField clear",
34
+ render: () =>
35
+ h(HkFileField, {
36
+ modelValue: [{ name: "a.txt" }],
37
+ }),
38
+ seat: "hk-file-clear",
39
+ rung: 24,
40
+ },
41
+ {
42
+ // Wave B1: the chip close rides S16 (the 14px bespoke box is retired;
43
+ // 14 -> 16 is the declared density growth).
44
+ name: "HkTag chip close",
45
+ render: () => h(HkTag, { closable: true, closeLabel: "Remove" }),
46
+ seat: "hk-tag-close",
47
+ rung: 16,
48
+ },
29
49
  {
30
50
  name: "HkPersistentToast dismiss",
31
51
  render: () => h(HkPersistentToast, { label: "l", dismissible: true }),
@@ -48,7 +68,8 @@ afterEach(() => {
48
68
  });
49
69
 
50
70
  describe("close-button family rides the standard icon button", () => {
51
- for (const { name, render, seat } of seats) {
71
+ for (const entry of seats) {
72
+ const { name, render, seat } = entry;
52
73
  it(`${name} renders the standard class list plus its seat`, async () => {
53
74
  const host = mountSeat(render);
54
75
  await nextTick();
@@ -59,20 +80,22 @@ describe("close-button family rides the standard icon button", () => {
59
80
  // wrapper would leave the sheet styling the wrong box.
60
81
  expect(control!.tagName, `${name} must be a button`).toBe("BUTTON");
61
82
 
83
+ const rung = (entry as typeof entry & { rung?: number }).rung ?? 24;
62
84
  const tokens = [...control!.classList];
63
85
  expect(tokens, `${name} carries the base layer`).toContain("hk-icon-button");
64
- expect(tokens, `${name} carries the 24px rung`).toContain("hk-icon-button-24");
86
+ expect(tokens, `${name} carries the ${rung}px rung`).toContain(
87
+ `hk-icon-button-${rung}`,
88
+ );
65
89
  expect(tokens, `${name} carries the ghost variant`).toContain(
66
90
  "hk-icon-button-ghost",
67
91
  );
68
92
  expect(tokens, `${name} carries its seat`).toContain(seat);
69
93
  // Documented order: base, size, variant, seat.
70
- expect(tokens.indexOf("hk-icon-button")).toBeLessThan(
71
- tokens.indexOf("hk-icon-button-24"),
72
- );
73
- expect(tokens.indexOf("hk-icon-button-24")).toBeLessThan(
74
- tokens.indexOf("hk-icon-button-ghost"),
75
- );
94
+ const base = tokens.indexOf("hk-icon-button");
95
+ const rungAt = tokens.indexOf(`hk-icon-button-${rung}`);
96
+ const ghost = tokens.indexOf("hk-icon-button-ghost");
97
+ expect(base).toBeLessThan(rungAt);
98
+ expect(rungAt).toBeLessThan(ghost);
76
99
  expect(tokens.indexOf("hk-icon-button-ghost")).toBeLessThan(
77
100
  tokens.indexOf(seat),
78
101
  );
@@ -91,6 +114,19 @@ describe("close-button family rides the standard icon button", () => {
91
114
  });
92
115
  }
93
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.
120
+ 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"'),
123
+ );
124
+ expect(token, "token clear is not a raw button").not.toMatch(
125
+ new RegExp('<button[^>]*class="hk-token-input-clear"'),
126
+ );
127
+ expect(token, "token clear rides the 24px rung").toContain("size={24}");
128
+ });
129
+
94
130
  it("sources the toast controls from the standard component too", () => {
95
131
  // HkToast is driven by useToast rather than props, so it cannot be
96
132
  // mounted bare here; pin the source contract instead — the two controls