@celestia-island/hikari 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -168,7 +168,7 @@ export const HkAdminHeader = defineComponent({
168
168
  class="s-popup-menu-item"
169
169
  onClick={() => props.onForceSignOut?.()}
170
170
  >
171
- <LogOut size={14} />
171
+ <span class="hk-menu-item-icon"><LogOut size={14} /></span>
172
172
  {props.forceSignOutLabel ?? t("hikari::adminHeader.forceSignOut", "Sign out")}
173
173
  </button>
174
174
  </div>
@@ -202,7 +202,7 @@ export const HkAdminHeader = defineComponent({
202
202
  avatarModalOpen.value = true;
203
203
  }}
204
204
  >
205
- <Camera size={14} />
205
+ <span class="hk-menu-item-icon"><Camera size={14} /></span>
206
206
  {props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar")}
207
207
  </button>
208
208
  {/* The language trigger owns the locale anchor: the ref sits
@@ -216,7 +216,7 @@ export const HkAdminHeader = defineComponent({
216
216
  class="s-popup-menu-item"
217
217
  onClick={() => (localeMenuOpen.value = !localeMenuOpen.value)}
218
218
  >
219
- <Languages size={14} />
219
+ <span class="hk-menu-item-icon"><Languages size={14} /></span>
220
220
  {props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language")}
221
221
  </button>
222
222
  {slots["locale-picker"]?.({
@@ -240,7 +240,7 @@ export const HkAdminHeader = defineComponent({
240
240
  emit("goToFrontend");
241
241
  }}
242
242
  >
243
- <ExternalLink size={14} />
243
+ <span class="hk-menu-item-icon"><ExternalLink size={14} /></span>
244
244
  {props.goToFrontendLabel}
245
245
  </button>
246
246
  ) : null}
@@ -251,7 +251,7 @@ export const HkAdminHeader = defineComponent({
251
251
  emit("logout");
252
252
  }}
253
253
  >
254
- <LogOut size={14} />
254
+ <span class="hk-menu-item-icon"><LogOut size={14} /></span>
255
255
  {props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout")}
256
256
  </button>
257
257
  </div>
@@ -54,15 +54,20 @@ export default defineComponent({
54
54
  const listRef = ref<HTMLDivElement | null>(null);
55
55
 
56
56
  /** Measure the widest label text and publish it as the label-column
57
- * width custom property. `scrollWidth` reports the text width even
58
- * while the fixed column clips it, so this is correct both before
59
- * the variable is first set and after any label change. */
57
+ * width custom property. Each span is momentarily set to
58
+ * `max-content` while reading: a plain `scrollWidth` on the fixed
59
+ * column would report `max(textWidth, columnWidth)` and the column
60
+ * could never shrink below its fallback. All reads happen inside one
61
+ * synchronous task, so no intermediate paint is observable. */
60
62
  function measure() {
61
63
  const root = listRef.value;
62
64
  if (!root) return;
63
65
  let widest = 0;
64
66
  for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
67
+ const previous = label.style.width;
68
+ label.style.width = "max-content";
65
69
  widest = Math.max(widest, label.scrollWidth);
70
+ label.style.width = previous;
66
71
  }
67
72
  if (widest > 0) root.style.setProperty("--auth-methods-label-width", `${widest}px`);
68
73
  }
@@ -1,3 +1,4 @@
1
+ @use "./menu-item" as mi;
1
2
  // Use theme variables with namespace to avoid conflicts
2
3
  @use "./hikari-vars" as vars;
3
4
 
@@ -295,44 +296,28 @@
295
296
  pointer-events: auto;
296
297
  }
297
298
 
298
- // Menu items
299
- .hk-breadcrumb-dropdown-item {
300
- // Layout
301
- display: flex;
302
- align-items: center;
303
- gap: 0.5rem;
304
-
305
- // Spacing
306
- padding: 0.5rem 0.75rem;
307
- margin: 0.125rem 0;
308
-
309
- // Typography
310
- font-size: vars.$hikari-font-size-sm;
311
- color: var(--hi-color-text-primary);
312
- text-decoration: none;
313
- white-space: nowrap;
314
-
315
- // Appearance
316
- border-radius: vars.$hikari-radius-fui-sm;
317
-
318
- // Cursor
319
- cursor: pointer;
299
+ // NOTE: the dropdown ITEM rule lives at TOP LEVEL below, not nested
300
+ // here — @mixin item declares its desktop tokens on the row element,
301
+ // and a deeply nested selector would out-rank the sheet-context token
302
+ // re-declaration in _menu-item.scss (see the comment there).
303
+ }
304
+ }
320
305
 
321
- // Transitions
322
- // DISABLED - migrated to JS animation
306
+ // Menu items — unified menu-item spec (./_menu-item.scss): same
307
+ // metrics/states as every other row family. The old hover (full
308
+ // primary fill + double glow) was the library's only remaining
309
+ // bespoke row hover and is retired with the unification. Kept at
310
+ // (0,1,0) so the sheet binding can win inside sheet contexts.
311
+ .hk-breadcrumb-dropdown-item {
312
+ @include mi.item;
323
313
 
324
- // transition: all vars.$hikari-duration-fast vars.$hikari-ease-smooth;
314
+ // Spacing
315
+ margin: 0.125rem 0;
325
316
 
326
- // Hover state
327
- &:hover {
328
- background: var(--hi-primary);
329
- color: var(--hi-color-text-primary);
330
- box-shadow:
331
- 0 0 12px var(--hi-primary),
332
- inset 0 0 8px var(--hi-primary);
333
- }
334
- }
335
- }
317
+ // Typography
318
+ color: var(--hi-color-text-primary);
319
+ text-decoration: none;
320
+ white-space: nowrap;
336
321
  }
337
322
 
338
323
  // ------
@@ -3,49 +3,17 @@
3
3
  * surface): desktop anchored popouts, mobile bottom sheets with
4
4
  * scrim/grabber/title, popup-manager z-stacking, back-guard history.
5
5
  * The panel/sheet geometry lives in HkSelect.scss — this file only
6
- * owns what HkMenu itself brings to that surface: the row grammar,
7
- * the leading check column, and the sidebar variant's inline nav. */
6
+ * owns what HkMenu itself brings to that surface: the row grammar
7
+ * (the unified menu-item spec see ./_menu-item.scss), the leading
8
+ * check column, and the sidebar variant's inline nav. */
8
9
 
9
- /* rows are shared by popout levels and mobile sheet layers */
10
- .hk-menu-row {
11
- display: flex;
12
- align-items: center;
13
- justify-content: flex-start;
14
- gap: 8px;
15
- width: 100%;
16
- padding: 7px 10px;
17
- border: 0;
18
- border-radius: var(--radius-sm, 4px);
19
- background: transparent;
20
- color: rgb(var(--color-text));
21
- font-size: var(--text-sm);
22
- line-height: 1.35;
23
- text-align: left;
24
- cursor: pointer;
25
- appearance: none;
26
- transition: background 0.12s ease;
27
- }
28
-
29
- .hk-menu-row:hover {
30
- background: rgb(var(--color-primary) / 10%);
31
- }
32
-
33
- .hk-menu-row[data-active] {
34
- background: rgb(var(--color-primary) / 14%);
35
- font-weight: 600;
36
- }
10
+ @use "./menu-item" as mi;
37
11
 
38
- .hk-menu-row[data-disabled] {
39
- opacity: 0.45;
40
- cursor: not-allowed;
41
- }
42
-
43
- .hk-menu-row[data-danger] {
44
- color: rgb(var(--color-danger, 240 70 80));
45
- }
46
-
47
- .hk-menu-row[data-danger]:hover {
48
- background: rgb(var(--color-danger, 240 70 80) / 12%);
12
+ /* rows are shared by popout levels and mobile sheet layers; metrics
13
+ * and states come from the unified spec — inside a sheet the shared
14
+ * panel context swaps the tokens to the 44px touch geometry. */
15
+ .hk-menu-row {
16
+ @include mi.item;
49
17
  }
50
18
 
51
19
  .hk-menu-flag {
@@ -55,22 +23,25 @@
55
23
  }
56
24
 
57
25
  .hk-menu-label {
58
- flex: 1;
59
- min-width: 0;
60
- overflow: hidden;
61
- text-overflow: ellipsis;
62
- white-space: nowrap;
26
+ @include mi.label;
27
+ }
28
+
29
+ /* Leading icon cell — every row icon renders inside the spec's fixed
30
+ * square, so mixed lucide sizes read as one column and the icon box
31
+ * (not the raw svg) centers against the label. */
32
+ .hk-menu-item-icon {
33
+ @include mi.icon;
63
34
  }
64
35
 
65
36
  /* Leading check column (selectMode). The cell is reserved on EVERY row
66
37
  * of a level while the column is on, so labels stay aligned; only
67
- * checked === true shows the mark. */
38
+ * checked === true shows the mark — an SVG check inside the same fixed
39
+ * cell grammar as leading icons (the old text "✓" carried its own
40
+ * baseline rhythm and sat off-center against svg icons). */
68
41
  .hk-menu-check {
69
- flex-shrink: 0;
70
- width: 16px;
42
+ @include mi.icon;
43
+
71
44
  color: rgb(var(--color-primary));
72
- font-weight: 700;
73
- text-align: center;
74
45
  }
75
46
 
76
47
  .hk-menu-more {
@@ -88,14 +59,6 @@
88
59
  min-width: 0;
89
60
  }
90
61
 
91
- /* Touch-sized rows inside the shared mobile sheet surface (the same
92
- * grammar HkSelect applies to its own sheet options). */
93
- .hk-select-sheet-panel .hk-menu-level .hk-menu-row {
94
- min-height: 44px;
95
- padding: 12px 10px;
96
- font-size: var(--text-base);
97
- }
98
-
99
62
  @media (prefers-reduced-motion: reduce) {
100
63
  .hk-menu-row {
101
64
  transition: none;
@@ -104,10 +67,10 @@
104
67
 
105
68
  /* ── sidebar variant ────────────────────────────────────────────── */
106
69
  /* Inline vertical nav list — always rendered, no popover machinery.
107
- * Rows share the popup row's interaction grammar (hover wash, active
108
- * tint + weight, disabled/danger) but with nav-appropriate rhythm:
109
- * a consistent 4px gap between rows so rounded hover pills never touch,
110
- * deeper levels indenting under their group. */
70
+ * Rows share the unified menu-item spec's grammar (metrics + states),
71
+ * with nav-appropriate rhythm: a consistent 4px gap between rows so
72
+ * rounded hover pills never touch, deeper levels indenting under
73
+ * their group. */
111
74
 
112
75
  .hk-menu-sidebar {
113
76
  display: flex;
@@ -117,21 +80,7 @@
117
80
  }
118
81
 
119
82
  .hk-menu-sidebar-row {
120
- display: flex;
121
- align-items: center;
122
- gap: var(--space-8, 8px);
123
- width: 100%;
124
- padding: var(--space-8, 8px) var(--space-12, 12px);
125
- border: 0;
126
- border-radius: var(--radius-sm, 4px);
127
- background: transparent;
128
- color: rgb(var(--color-text) / 82%);
129
- font-size: var(--text-sm);
130
- line-height: 1.4;
131
- text-align: left;
132
- cursor: pointer;
133
- appearance: none;
134
- transition: background 0.12s ease, color 0.12s ease;
83
+ @include mi.item;
135
84
 
136
85
  .hk-menu-label {
137
86
  flex: 1;
@@ -142,24 +91,8 @@
142
91
  }
143
92
  }
144
93
 
145
- .hk-menu-sidebar-row:hover:not([data-disabled]):not([data-active]) {
146
- background: rgb(var(--color-primary) / 10%);
147
- color: rgb(var(--color-text));
148
- }
149
-
150
- .hk-menu-sidebar-row[data-active] {
151
- background: rgb(var(--color-primary) / 14%);
152
- color: rgb(var(--color-primary));
153
- font-weight: 600;
154
- }
155
-
156
- .hk-menu-sidebar-row[data-disabled] {
157
- opacity: 0.45;
158
- cursor: not-allowed;
159
- }
160
-
161
- .hk-menu-sidebar-row[data-danger] {
162
- color: rgb(var(--color-danger, 240 70 80));
94
+ .hk-menu-sidebar-row .hk-menu-item-icon {
95
+ @include mi.icon;
163
96
  }
164
97
 
165
98
  .hk-menu-sidebar-group {
@@ -352,9 +352,11 @@ describe("HkMenu check column (selectMode)", () => {
352
352
  mountMenu(ref(true), checkedItems);
353
353
  const cells = checkCells();
354
354
  expect(cells).toHaveLength(3);
355
- expect(cells[0].textContent).toBe("✓");
356
- expect(cells[1].textContent).toBe("");
357
- expect(cells[2].textContent).toBe("");
355
+ // The mark is an SVG check inside the fixed icon cell (same grammar
356
+ // as leading row icons) — not a text glyph.
357
+ expect(cells[0].querySelector("svg")).not.toBeNull();
358
+ expect(cells[1].querySelector("svg")).toBeNull();
359
+ expect(cells[2].querySelector("svg")).toBeNull();
358
360
  });
359
361
 
360
362
  it("auto shows no column for plain action menus", () => {
@@ -10,7 +10,7 @@ import {
10
10
  type VNode,
11
11
  } from "vue";
12
12
 
13
- import { ChevronRight } from "lucide-vue-next";
13
+ import { Check, ChevronRight } from "lucide-vue-next";
14
14
 
15
15
  import { useBreakpoint } from "../runtime/useBreakpoint";
16
16
  import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
@@ -504,11 +504,13 @@ export default defineComponent({
504
504
  >
505
505
  {reserved && (
506
506
  <span class="hk-menu-check" data-on={item.checked || undefined} aria-hidden="true">
507
- {item.checked ? "✓" : ""}
507
+ {item.checked ? <Check size={12} /> : null}
508
508
  </span>
509
509
  )}
510
510
  {item.flag && <span class="hk-menu-flag">{item.flag}</span>}
511
- {item.icon && h(item.icon, { size: 15 })}
511
+ {item.icon && (
512
+ <span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
513
+ )}
512
514
  <span class="hk-menu-label">{item.label}</span>
513
515
  {hasKids && <ChevronRight size={14} class="hk-menu-more" />}
514
516
  </button>
@@ -708,7 +710,9 @@ export default defineComponent({
708
710
  emit("select", item.key, item);
709
711
  }}
710
712
  >
711
- {item.icon && h(item.icon, { size: 16 })}
713
+ {item.icon && (
714
+ <span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
715
+ )}
712
716
  {item.flag && <span class="hk-menu-flag">{item.flag}</span>}
713
717
  <span class="hk-menu-label">{item.label}</span>
714
718
  {item.badge && <span class="hk-menu-sidebar-badge">{item.badge}</span>}
@@ -734,7 +738,9 @@ export default defineComponent({
734
738
  toggleGroup(item.key);
735
739
  }}
736
740
  >
737
- {item.icon && h(item.icon, { size: 16 })}
741
+ {item.icon && (
742
+ <span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
743
+ )}
738
744
  <span class="hk-menu-label">{item.label}</span>
739
745
  {item.badge && <span class="hk-menu-sidebar-badge">{item.badge}</span>}
740
746
  <ChevronRight size={14} class="hk-menu-more" />
@@ -1,58 +1,20 @@
1
1
  /* ── Standard menu action row (HkMenuActionItem) ────────────────
2
- * Shares the nav row grammar (`.hk-menu-sidebar-row`): same padding,
3
- * gap, typography and hover wash, so a menu panel of these rows reads
4
- * as one list with the navigation above it. */
2
+ * Speaks the unified menu-item spec (see ./_menu-item.scss) same
3
+ * metrics, icon cell, states and focus ring as every other row, so a
4
+ * menu panel of these rows reads as one list with HkMenu rows and
5
+ * select options around it. Danger rides the spec's [data-danger]
6
+ * state (the tsx keeps the legacy `--danger` class for compat). */
5
7
 
6
- .hk-menu-action-item {
7
- display: flex;
8
- align-items: center;
9
- gap: var(--space-8, 8px);
10
- width: 100%;
11
- padding: var(--space-8, 8px) var(--space-12, 12px);
12
- border: 0;
13
- border-radius: var(--radius-sm, 4px);
14
- background: transparent;
15
- color: rgb(var(--color-text) / 82%);
16
- font-size: var(--text-sm);
17
- font-weight: 500;
18
- font-family: inherit;
19
- line-height: 1.4;
20
- text-align: left;
21
- cursor: pointer;
22
- appearance: none;
23
- transition: background 0.12s ease, color 0.12s ease;
24
-
25
- &:hover:not(:disabled) {
26
- background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
27
- color: rgb(var(--color-text));
28
- }
8
+ @use "./menu-item" as mi;
29
9
 
30
- &:disabled {
31
- opacity: 0.5;
32
- cursor: not-allowed;
33
- }
10
+ .hk-menu-action-item {
11
+ @include mi.item;
34
12
  }
35
13
 
36
14
  .hk-menu-action-item__icon {
37
- display: inline-flex;
38
- align-items: center;
39
- justify-content: center;
40
- flex-shrink: 0;
15
+ @include mi.icon;
41
16
  }
42
17
 
43
18
  .hk-menu-action-item__label {
44
- flex: 1;
45
- min-width: 0;
46
- overflow: hidden;
47
- text-overflow: ellipsis;
48
- white-space: nowrap;
49
- }
50
-
51
- .hk-menu-action-item--danger {
52
- color: rgb(var(--color-error, 200 50 50) / 90%);
53
-
54
- &:hover:not(:disabled) {
55
- background: rgb(var(--color-error, 200 50 50) / 10%);
56
- color: rgb(var(--color-error, 200 50 50));
57
- }
19
+ @include mi.label;
58
20
  }
@@ -2,18 +2,20 @@ import { defineComponent } from "vue";
2
2
  import "./HkMenuActionItem.scss";
3
3
 
4
4
  /**
5
- * Standard menu row: icon + label button sharing the nav row's exact
6
- * padding/typography constraints (see HkMenuPanel). Pluggable — renders
7
- * as a bare button, so it works inside HkMenuPanel, a popover, or any
8
- * other menu host; attrs (including a template `ref`) fall through to
9
- * the button element.
5
+ * Standard menu row: icon + label button speaking the unified menu-item
6
+ * spec (metrics/icon cell/states see _menu-item.scss). Pluggable —
7
+ * renders as a bare button, so it works inside HkMenuPanel, a popover,
8
+ * or any other menu host; attrs (including a template `ref`) fall
9
+ * through to the button element.
10
10
  */
11
11
  export default defineComponent({
12
12
  name: "HkMenuActionItem",
13
13
  props: {
14
- /** Leading icon node (e.g. `<Camera size={14} />`). Typed loosely
15
- * (plain Object) so consumers building on a different vue copy than
16
- * hikari's own node_modules never hit VNode type identity errors. */
14
+ /** Leading icon node (e.g. `<Camera size={14} />`). Rendered inside
15
+ * the spec's fixed icon cell, so any size centers uniformly. Typed
16
+ * loosely (plain Object) so consumers building on a different vue
17
+ * copy than hikari's own node_modules never hit VNode type identity
18
+ * errors. */
17
19
  icon: { type: Object, default: undefined },
18
20
  label: { type: String, required: true },
19
21
  /** Destructive styling (logout, delete…). */
@@ -28,6 +30,7 @@ export default defineComponent({
28
30
  <button
29
31
  type="button"
30
32
  class={["hk-menu-action-item", props.danger ? "hk-menu-action-item--danger" : ""]}
33
+ data-danger={props.danger || undefined}
31
34
  disabled={props.disabled}
32
35
  role="menuitem"
33
36
  onClick={(e: MouseEvent) => emit("click", e)}
@@ -1,16 +1,28 @@
1
+ /* HkNavItem — nav row. Speaks the unified menu-item spec's METRICS
2
+ * (min-height / padding / gap / font / radius / icon cell — see
3
+ * ./_menu-item.scss) so nav rows, menu rows, select options and theme
4
+ * pills share one box geometry. Colors stay on the --hi-* token family:
5
+ * nav rows are consumed by hosts that re-theme via --hi-*, so the
6
+ * interaction palette deliberately does not switch to --c-*. */
7
+
8
+ @use "./menu-item" as mi;
9
+
1
10
  .hk-nav-item {
11
+ @include mi.item-tokens;
12
+
2
13
  display: flex;
3
14
  align-items: center;
4
- gap: 0.5rem;
5
- padding: 0.5rem 0.75rem;
6
- border-radius: var(--hi-radius-md, 6px);
15
+ gap: var(--hk-menu-item-gap);
16
+ min-height: var(--hk-menu-item-min-h);
17
+ padding: var(--hk-menu-item-pad-y) var(--hk-menu-item-pad-x);
18
+ border-radius: var(--hk-menu-item-radius);
7
19
  color: var(--hi-color-muted);
8
- font-size: 0.875rem;
20
+ font-size: var(--hk-menu-item-font);
9
21
  font-family: inherit;
10
- line-height: 1.4;
22
+ line-height: var(--hk-menu-item-lh);
11
23
  text-decoration: none;
12
24
  background: none;
13
- border: none;
25
+ border: 1px solid transparent;
14
26
  width: 100%;
15
27
  text-align: left;
16
28
  cursor: pointer;
@@ -64,12 +76,7 @@
64
76
  }
65
77
 
66
78
  .hk-nav-item-icon {
67
- display: inline-flex;
68
- align-items: center;
69
- justify-content: center;
70
- flex-shrink: 0;
71
- width: 1rem;
72
- height: 1rem;
79
+ @include mi.icon;
73
80
  }
74
81
 
75
82
  .hk-nav-item-label {
@@ -1,3 +1,5 @@
1
+ @use "./menu-item" as mi;
2
+
1
3
  .hk-popover-backdrop {
2
4
  position: fixed;
3
5
  inset: 0;
@@ -54,35 +56,13 @@
54
56
  padding: var(--space-4, 12px);
55
57
  }
56
58
 
57
- /* Locale picker menu items */
59
+ /* Locale picker menu items — unified menu-item spec (./_menu-item.scss). */
58
60
  .hk-popup-menu-item {
59
- display: flex;
60
- align-items: center;
61
- gap: var(--space-8);
62
- padding: var(--space-8) var(--space-14);
63
- font-size: var(--text-sm);
64
- font-weight: 500;
65
- font-family: inherit;
66
- color: rgb(var(--color-text));
67
- cursor: pointer;
68
- transition: background var(--duration-normal) ease;
69
- border: none;
70
- background: none;
71
- width: 100%;
72
- text-align: left;
73
- outline: none;
74
- border-radius: var(--radius-sm);
75
- }
76
-
77
- /* Hover = background wash only, never on the active item (active >
78
- hover precedence — see HkNavItem.scss for the spec). */
79
- .hk-popup-menu-item:hover:not([data-active]) {
80
- background: var(--c-primary-subtle);
61
+ @include mi.item;
81
62
  }
82
63
 
83
- .hk-popup-menu-item[data-active] {
84
- color: rgb(var(--color-primary));
85
- font-weight: 600;
64
+ .hk-popup-menu-item[data-active]:hover {
65
+ background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
86
66
  }
87
67
 
88
68
  .hk-locale-submenu {
@@ -173,7 +153,7 @@
173
153
  // slightly letterspaced, inset to the content edge. Hosts tune the
174
154
  // leading inset via --hk-sheet-title-inset.
175
155
  padding: 0 var(--space-16, 1rem) var(--space-8, 0.5rem)
176
- var(--hk-sheet-title-inset, var(--space-16, 1rem));
156
+ var(--hk-sheet-title-inset, calc(var(--space-16, 1rem) * 2));
177
157
  font-size: var(--text-xs, 0.75rem);
178
158
  font-weight: 600;
179
159
  letter-spacing: 0.02em;
@@ -1,4 +1,5 @@
1
1
  @use "../tokens";
2
+ @use "./menu-item" as mi;
2
3
 
3
4
  /* HkPopupSelect */
4
5
 
@@ -108,32 +109,18 @@
108
109
  padding: var(--space-2);
109
110
  }
110
111
 
112
+ /* Unified menu-item spec (see ./_menu-item.scss); sheet geometry flows
113
+ * from the shared `.hk-popover-panel.hk-is-sheet` token context. */
111
114
  .hk-popup-select-item {
112
- display: flex;
113
- align-items: center;
114
- gap: var(--space-8);
115
- padding: var(--space-10) var(--space-16);
116
- font-size: var(--text-base);
117
- color: rgb(var(--color-text));
118
- border: 1px solid transparent;
119
- border-radius: var(--radius-md);
120
- cursor: pointer;
121
- outline: none;
122
- transition: background-color, border-color, color, box-shadow, opacity, transform, filter var(--duration-normal) ease;
123
- text-align: left;
115
+ @include mi.item;
124
116
  }
125
117
 
126
- .hk-popup-select-item:hover,
127
- .hk-popup-select-item[data-highlighted] {
128
- background: var(--c-primary-subtle);
129
- border-color: var(--c-primary-medium);
130
- }
131
-
132
- .hk-popup-select-item[data-checked] {
133
- background: var(--c-primary-dim);
134
- border-color: var(--c-primary-strong);
135
- color: rgb(var(--color-primary));
136
- font-weight: 600;
118
+ /* Highlight face yields to checked/active (see HkSelect.scss). */
119
+ .hk-popup-select-item:hover:not([data-checked]):not([data-active]),
120
+ .hk-popup-select-item[data-highlighted]:not([data-checked]):not([data-active]) {
121
+ background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
122
+ border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
123
+ color: rgb(var(--color-text));
137
124
  }
138
125
 
139
126
  .hk-popup-select-error {
@@ -143,14 +130,9 @@
143
130
  }
144
131
 
145
132
  // Mobile sheet form (HPopover sheetOnMobile): full-width option list with
146
- // touch-sized rows.
133
+ // touch-sized rows (row geometry flows from the shared sheet tokens).
147
134
  .hk-popover-panel.hk-is-sheet.hk-popup-select-content {
148
135
  .hk-popup-select-viewport {
149
136
  max-height: min(56vh, 24rem);
150
137
  }
151
-
152
- .hk-popup-select-item {
153
- padding: var(--space-14) var(--space-16);
154
- font-size: var(--text-base);
155
- }
156
138
  }
@@ -1,4 +1,5 @@
1
1
  @use "../tokens";
2
+ @use "./menu-item" as mi;
2
3
 
3
4
  /* HkSelect — popup select component */
4
5
 
@@ -198,37 +199,26 @@
198
199
  }
199
200
  }
200
201
 
202
+ /* Unified menu-item spec (see ./_menu-item.scss) — options, menu rows
203
+ * and theme pills share one grammar; sheet geometry flows from the
204
+ * shared sheet token context, so the old per-component sheet bump is
205
+ * gone. [data-highlighted] (keyboard/pointer highlight) maps to the
206
+ * spec's hover face. */
201
207
  .hk-select-option {
202
- display: flex;
203
- align-items: center;
204
- gap: var(--space-8);
205
- padding: var(--space-10) var(--space-16);
206
- font-size: var(--text-base);
207
- color: rgb(var(--color-text));
208
- border: 1px solid transparent;
209
- border-radius: var(--radius-md);
210
- cursor: pointer;
211
- outline: none;
212
- transition: background-color, border-color, color, box-shadow, opacity, transform, filter var(--duration-normal) ease;
213
- text-align: left;
208
+ @include mi.item;
214
209
  }
215
210
 
216
- .hk-select-option:hover,
217
- .hk-select-option[data-highlighted] {
218
- background: var(--c-primary-subtle);
219
- border-color: var(--c-primary-medium);
211
+ /* The highlight face never overrides the checked/active face — a
212
+ * keyboard-highlighted selected option keeps its dim wash + primary
213
+ * text (active > hover precedence, same as the spec mixin). */
214
+ .hk-select-option:hover:not([data-checked]):not([data-active]),
215
+ .hk-select-option[data-highlighted]:not([data-checked]):not([data-active]) {
216
+ background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
217
+ border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
220
218
  }
221
219
 
222
- .hk-select-option[data-checked] {
223
- background: var(--c-primary-dim);
224
- border-color: var(--c-primary-strong);
225
- color: rgb(var(--color-primary));
226
- font-weight: 600;
227
- }
228
-
229
- .hk-select-option[data-disabled] {
230
- opacity: 0.4;
231
- cursor: not-allowed;
220
+ .hk-select-option[data-highlighted]:not(:hover):not([data-checked]):not([data-active]) {
221
+ color: rgb(var(--color-text));
232
222
  }
233
223
 
234
224
  .hk-select-error {
@@ -353,11 +343,6 @@
353
343
  &::-webkit-scrollbar {
354
344
  display: none;
355
345
  }
356
-
357
- .hk-select-option {
358
- padding: var(--space-14) var(--space-16);
359
- font-size: var(--text-base);
360
- }
361
346
  }
362
347
 
363
348
  /* Leading in-content heading hidden by the sheet duplicate-title
@@ -1,3 +1,5 @@
1
+ @use "./menu-item" as mi;
2
+
1
3
  .s-theme-toggle {
2
4
  display: inline-flex;
3
5
  align-items: center;
@@ -121,6 +123,12 @@
121
123
  * invisible slot left a permanent dead strip right of every row in the
122
124
  * mobile bottom sheet — and every pill and highlight still shares the
123
125
  * exact same width.
126
+ *
127
+ * Metrics/states come from the unified menu-item spec (./_menu-item.scss)
128
+ * — the same grammar as menu rows and select options; inside the mobile
129
+ * sheet the shared `.hk-popover-panel.hk-is-sheet` token context applies
130
+ * the 44px touch geometry, so the old component-local media bump is gone
131
+ * (only the overlaid delete button keeps its own touch sizing below).
124
132
  */
125
133
 
126
134
  .s-theme-item-row {
@@ -136,30 +144,20 @@
136
144
  * bespoke 6×8 tight rows (small text, borderless 12% wash) are what
137
145
  * made the theme sheet read as "not from the standard dropdown". */
138
146
  .s-theme-item-btn {
139
- display: flex;
140
- align-items: center;
141
- gap: var(--space-8, 0.5rem);
147
+ @include mi.item;
148
+
142
149
  min-width: 0;
143
150
  flex: 1;
144
- padding: var(--space-10, 0.625rem) var(--space-16, 1rem);
145
- border: 1px solid transparent;
146
- border-radius: var(--radius-md, 8px);
147
- background: transparent;
148
- color: rgb(var(--color-text));
149
- font-size: var(--text-base, 0.875rem);
150
- line-height: 1.35;
151
- text-align: left;
152
- cursor: pointer;
153
- appearance: none;
154
- transition: background-color 0.12s ease, border-color 0.12s ease;
151
+ width: auto;
155
152
  }
156
153
 
157
154
  .s-theme-item-name {
158
- flex: 1;
159
- min-width: 0;
160
- overflow: hidden;
161
- text-overflow: ellipsis;
162
- white-space: nowrap;
155
+ @include mi.label;
156
+ }
157
+
158
+ /* Leading check / customize glyphs share the spec's fixed icon cell. */
159
+ .s-theme-item-btn .hk-menu-item-icon {
160
+ @include mi.icon;
163
161
  }
164
162
 
165
163
  /* Custom rows keep the name clear of the overlaid delete button. */
@@ -173,14 +171,14 @@
173
171
  }
174
172
 
175
173
  .s-theme-item-btn:hover:not([data-active]) {
176
- background: var(--c-primary-subtle);
177
- border-color: var(--c-primary-medium);
174
+ background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
175
+ border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
178
176
  color: rgb(var(--color-text));
179
177
  }
180
178
 
181
179
  .s-theme-item-btn[data-active] {
182
- background: var(--c-primary-dim);
183
- border-color: var(--c-primary-strong);
180
+ background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
181
+ border-color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
184
182
  color: rgb(var(--color-primary));
185
183
  font-weight: 600;
186
184
  }
@@ -210,17 +208,11 @@
210
208
  color: rgb(var(--color-error));
211
209
  }
212
210
 
213
- /* Touch-sized rows on phone widths the same sheet bump the shared
214
- * surfaces apply (.hk-select-sheet-list .hk-select-option → 14px
215
- * vertical, .hk-select-sheet-panel .hk-menu-row 44px min-height).
216
- * The overlaid delete button grows with them and the name clearance
217
- * follows so the two never collide on long custom scheme names. */
211
+ /* Touch-sized delete target on phone widths, scaled for the 44px sheet
212
+ * rows that the shared spec applies inside sheet contexts (see
213
+ * ./_menu-item.scss). The name clearance follows so the button and the
214
+ * label never collide on long custom scheme names. */
218
215
  @media (max-width: 767px) {
219
- .s-theme-item-btn {
220
- min-height: 44px;
221
- padding: var(--space-14, 0.875rem) var(--space-16, 1rem);
222
- }
223
-
224
216
  .s-theme-item-delete {
225
217
  width: 2.25rem;
226
218
  height: 2.25rem;
@@ -241,7 +241,9 @@ export const HkThemeToggle = defineComponent({
241
241
  data-active={currentTheme.value === th.id || undefined}
242
242
  onClick={() => onSelectTheme(th.id)}
243
243
  >
244
- {currentTheme.value === th.id && <Check size={14} class="s-theme-item-check" />}
244
+ {currentTheme.value === th.id && (
245
+ <span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
246
+ )}
245
247
  <span class="s-theme-item-name">{th.name}</span>
246
248
  </button>
247
249
  {th.isCustom && (
@@ -269,7 +271,7 @@ export const HkThemeToggle = defineComponent({
269
271
  }
270
272
  }}
271
273
  >
272
- <Palette size={14} />
274
+ <span class="hk-menu-item-icon"><Palette size={14} /></span>
273
275
  <span class="s-theme-item-name">{t("hikari::theme.customize")}</span>
274
276
  </button>
275
277
  </div>
@@ -273,8 +273,10 @@
273
273
  }
274
274
 
275
275
  .s-jt-row {
276
+ /* Baseline alignment keeps the small type hints (boolean/number/string)
277
+ on the value's text baseline instead of floating above it. */
276
278
  display: flex;
277
- align-items: flex-start;
279
+ align-items: baseline;
278
280
  min-height: 1.55em;
279
281
  padding: 0 var(--space-6, 0.375rem);
280
282
  border-radius: var(--radius-xs, 2px);
@@ -297,6 +299,10 @@
297
299
  height: var(--jt-toggle);
298
300
  flex-shrink: 0;
299
301
  margin-right: 2px;
302
+ /* The toggle box has no text baseline of its own — pin it to the line
303
+ top instead of letting it join the row's baseline group with a
304
+ synthesized border-box baseline. */
305
+ align-self: flex-start;
300
306
  }
301
307
 
302
308
  .s-jt-toggle[data-parent] {
@@ -368,9 +374,8 @@
368
374
  color: rgb(var(--color-muted) / 0.6);
369
375
  font-size: var(--text-3xs, 0.5rem);
370
376
  font-style: italic;
371
- margin-left: 0.5em;
377
+ margin-left: 0.75em;
372
378
  font-weight: 400;
373
- align-self: center;
374
379
  }
375
380
 
376
381
  .s-jt-badge {
@@ -0,0 +1,180 @@
1
+ /* ── Unified menu-item grammar (the ONE row spec) ────────────────────
2
+ *
3
+ * Every selectable list row the library renders — cascade menu rows,
4
+ * action rows, select options, popup-select items, theme pills, admin
5
+ * header rows, nav rows, breadcrumb dropdown items — shares this exact
6
+ * grammar. Desktop (anchored popouts) and mobile (bottom sheets) are
7
+ * the same spec at two token sets; a sheet context merely swaps the
8
+ * tokens, so every row inside ANY sheet gets the touch geometry
9
+ * without per-component media queries.
10
+ *
11
+ * Metric contract (desktop / sheet):
12
+ * min-height 34px / 44px icon cell 16px / 20px
13
+ * padding 6×12px / 10×16px font 13px / 14px
14
+ * gap 8px · radius --radius-md · weight 500 · line-height 1.3
15
+ *
16
+ * Interaction contract:
17
+ * - hover: subtle primary wash + medium border (guarded — never
18
+ * fights the active/checked state, never changes layout: the 1px
19
+ * border is always reserved).
20
+ * - active/checked: dim primary wash + strong border + primary text
21
+ * + 600 (active > hover precedence).
22
+ * - checked mark: an SVG check inside the reserved cell, normalized
23
+ * to the cell like every other icon (16px / 20px in sheets).
24
+ * - danger rows tint red; disabled rows dim to 45%.
25
+ * - focus-visible: 2px inset primary ring (keyboard parity for every
26
+ * row family — previously only HkNavItem had one).
27
+ * - label carries the optical shift: small CJK rasterization biases
28
+ * glyph ink high against box-centered icons; a half-pixel down
29
+ * nudge re-centers the perceived mass (tunable per host).
30
+ *
31
+ * Adoption: `@use "./menu-item" as mi;` then
32
+ * .your-row { @include mi.item; }
33
+ * .your-row .your-icon { @include mi.icon; } // wrap TSX side!
34
+ * .your-row .your-label { @include mi.label; }
35
+ * Keep the component's own class names — this file owns metrics and
36
+ * states only, never selectors of the adopting rows.
37
+ */
38
+
39
+ /* ── Tokens ─────────────────────────────────────────────────────────── */
40
+
41
+ @mixin item-tokens {
42
+ --hk-menu-item-min-h: 34px;
43
+ --hk-menu-item-pad-x: 12px;
44
+ --hk-menu-item-pad-y: 6px;
45
+ --hk-menu-item-gap: 8px;
46
+ --hk-menu-item-font: var(--text-sm, 0.8125rem);
47
+ --hk-menu-item-weight: 500;
48
+ --hk-menu-item-lh: 1.3;
49
+ --hk-menu-item-radius: var(--radius-md, 8px);
50
+ --hk-menu-item-icon-box: 16px;
51
+ /* Label optical shift — see header comment. */
52
+ --hk-menu-item-optical: 0.5px;
53
+ }
54
+
55
+ /* Mobile bottom-sheet geometry. */
56
+ @mixin item-sheet-tokens {
57
+ --hk-menu-item-min-h: 44px;
58
+ --hk-menu-item-pad-x: 16px;
59
+ --hk-menu-item-pad-y: 10px;
60
+ --hk-menu-item-font: var(--text-base, 0.875rem);
61
+ --hk-menu-item-icon-box: 20px;
62
+ }
63
+
64
+ /* Sheet context. The tokens are re-declared ON each adopting row, not
65
+ * merely on the panel: a custom property declared on an element always
66
+ * beats an inherited value, and @mixin item declares the desktop set at
67
+ * row level — an ancestor-only binding would never win. The (0,2,0)
68
+ * descendant selector here beats the row's own (0,1,0) declaration.
69
+ * Therefore every adopter MUST @include mi.item at a TOP-LEVEL (0,1,0)
70
+ * rule — a nested include (e.g. inside its parent menu selector)
71
+ * compiles to a higher specificity and silently defeats this binding
72
+ * (HkBreadcrumb was caught exactly this way). New row families must
73
+ * also register their class in this list. */
74
+ .hk-select-sheet-panel,
75
+ .hk-popover-panel.hk-is-sheet,
76
+ .hk-drawer-panel {
77
+ .hk-menu-row,
78
+ .hk-menu-sidebar-row,
79
+ .hk-menu-action-item,
80
+ .hk-select-option,
81
+ .hk-popup-select-item,
82
+ .s-theme-item-btn,
83
+ .s-popup-menu-item,
84
+ .hk-popup-menu-item,
85
+ .hk-breadcrumb-dropdown-item {
86
+ @include item-sheet-tokens;
87
+ }
88
+ }
89
+
90
+ /* ── Row grammar ────────────────────────────────────────────────────── */
91
+
92
+ @mixin item {
93
+ @include item-tokens;
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: flex-start;
97
+ gap: var(--hk-menu-item-gap);
98
+ width: 100%;
99
+ min-height: var(--hk-menu-item-min-h);
100
+ padding: var(--hk-menu-item-pad-y) var(--hk-menu-item-pad-x);
101
+ border: 1px solid transparent;
102
+ border-radius: var(--hk-menu-item-radius);
103
+ background: transparent;
104
+ color: rgb(var(--color-text) / 88%);
105
+ font-size: var(--hk-menu-item-font);
106
+ font-weight: var(--hk-menu-item-weight);
107
+ font-family: inherit;
108
+ line-height: var(--hk-menu-item-lh);
109
+ text-align: left;
110
+ cursor: pointer;
111
+ appearance: none;
112
+ user-select: none;
113
+ -webkit-user-select: none;
114
+ -webkit-tap-highlight-color: transparent;
115
+ transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
116
+
117
+ &:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked]) {
118
+ background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
119
+ border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
120
+ color: rgb(var(--color-text));
121
+ }
122
+
123
+ &:focus-visible {
124
+ outline: 2px solid rgb(var(--color-primary) / 70%);
125
+ outline-offset: -2px;
126
+ }
127
+
128
+ &[data-active],
129
+ &[data-checked] {
130
+ background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
131
+ border-color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
132
+ color: rgb(var(--color-primary));
133
+ font-weight: 600;
134
+ }
135
+
136
+ &[data-disabled],
137
+ &:disabled {
138
+ opacity: 0.45;
139
+ cursor: not-allowed;
140
+ }
141
+
142
+ &[data-danger] {
143
+ color: rgb(var(--color-danger, 240 70 80) / 90%);
144
+
145
+ &:hover:not(:disabled):not([data-disabled]):not([data-active]) {
146
+ background: rgb(var(--color-danger, 240 70 80) / 10%);
147
+ border-color: rgb(var(--color-danger, 240 70 80) / 25%);
148
+ color: rgb(var(--color-danger, 240 70 80));
149
+ }
150
+ }
151
+ }
152
+
153
+ /* ── Leading icon cell ────────────────────────────────────────────────
154
+ * Fixed square; any icon size the consumer passes is centered and
155
+ * normalized to the cell, so lucide 13/14/15/16px imports all read as
156
+ * one column. Icon inherits the row color (danger rows tint red). */
157
+ @mixin icon {
158
+ display: inline-flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ flex: none;
162
+ width: var(--hk-menu-item-icon-box);
163
+ height: var(--hk-menu-item-icon-box);
164
+
165
+ > svg {
166
+ width: 100%;
167
+ height: 100%;
168
+ }
169
+ }
170
+
171
+ /* ── Label ──────────────────────────────────────────────────────────── */
172
+ @mixin label {
173
+ flex: 1;
174
+ min-width: 0;
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ position: relative;
179
+ top: var(--hk-menu-item-optical);
180
+ }
@@ -3,6 +3,8 @@
3
3
  Defines layout variables needed by plana-ui + hikari components.
4
4
  Color tokens are provided by hikari's initTheme(). */
5
5
 
6
+ @use "../components/menu-item" as mi;
7
+
6
8
  :root {
7
9
  /* ── Layout tokens ─ */
8
10
  --s-footer-height: 3rem;
@@ -357,41 +359,23 @@
357
359
  }
358
360
 
359
361
  /* ── Popup menu items ─ */
362
+ /* Unified menu-item spec (packages/vue/src/components/_menu-item.scss):
363
+ * one row grammar across menus, selects, theme pills, admin header
364
+ * rows and nav. The tsx wraps leading icons in .hk-menu-item-icon so
365
+ * the column centers against the label. */
360
366
  .s-popup-menu-item {
361
- display: flex;
362
- align-items: center;
363
- gap: var(--space-8);
364
- padding: var(--space-8) var(--space-14);
365
- font-size: var(--text-sm);
366
- font-weight: 500;
367
- font-family: inherit;
368
- color: rgb(var(--color-text));
369
- cursor: pointer;
370
- transition: background var(--duration-normal) ease;
371
- border: none;
372
- background: none;
373
- width: 100%;
374
- text-align: left;
375
- /* Rows are flex containers (icon + label + optional trailing caret).
376
- Without an explicit justification, Chrome's button UA default
377
- (justify-content: center) applies — invisible on shrink-to-fit
378
- popovers, glaring in full-width mobile sheets where icon+label
379
- float to the middle. Pin to flex-start; trailing carets keep
380
- parking at the far edge via their own margin-left: auto. */
381
- justify-content: flex-start;
382
- outline: none;
383
- border-radius: var(--radius-sm);
367
+ @include mi.item;
368
+
369
+ /* Trailing carets keep parking at the far edge via their own
370
+ margin-left: auto. */
384
371
  }
385
372
 
386
- /* Hover = background wash only, never on the active item (active >
387
- hover precedence — hue and font-weight never change on hover). */
388
- .s-popup-menu-item:hover:not([data-active]) {
389
- background: var(--c-primary-subtle);
373
+ .s-popup-menu-item .hk-menu-item-icon {
374
+ @include mi.icon;
390
375
  }
391
376
 
392
- .s-popup-menu-item[data-active] {
393
- color: rgb(var(--color-primary));
394
- font-weight: 600;
377
+ .s-popup-menu-item[data-active]:hover {
378
+ background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
395
379
  }
396
380
 
397
381
  /* ── User identity header (shared) ─ */