@celestia-island/hikari 0.54.1 → 0.54.3

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.54.1",
3
+ "version": "0.54.3",
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",
package/src/DemoApp.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  import { computed, defineComponent, ref } from "vue";
5
5
  import { ChevronDown, ChevronUp, PackageOpen } from "lucide-vue-next";
6
6
  import { HIKARI_FONT_MONO } from "./theme/fontContext";
7
+ import { utf8ToBase64 } from "./utils/base64";
7
8
  import {
8
9
  HButton, HIconButton, HIconButtonGroup, HTooltip, HBadge, HTag, HIcon, HSpinner,
9
10
  HProgressBar, HProgressRing, HGaugeRing,
@@ -164,7 +165,7 @@ export default defineComponent({
164
165
  const demoAudioSrc = makeSilentWav();
165
166
  const demoImageSrc =
166
167
  "data:image/svg+xml;base64," +
167
- btoa(`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900">
168
+ utf8ToBase64(`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900">
168
169
  <rect width="1600" height="900" fill="#1b2333"/>
169
170
  <g stroke="#2c3a55" stroke-width="1">
170
171
  ${Array.from({ length: 15 }, (_, i) => `<line x1="${i * 110}" y1="0" x2="${i * 110}" y2="900"/>`).join("")}
@@ -103,6 +103,9 @@ registerCssAnimation("s-voice-wave-bounce", { infinite: true });
103
103
  // components/HkPlaceholderMarquee.scss — overflowing placeholder sweep
104
104
  // (loops forever; pure CSS, loop geometry on inline custom properties).
105
105
  registerCssAnimation("hk-placeholder-marquee-scroll", { infinite: true });
106
+ // styles/admin-tokens.scss — admin header emergency-stop chip while
107
+ // engaged (loops forever).
108
+ registerCssAnimation("s-admin-header-emergency-pulse", { infinite: true });
106
109
  // The styles/theme/mixins.scss ambient/glow/entrance keyframes were
107
110
  // retired with the legacy Gen-1 sheet vendoring (hikari #476) — their
108
111
  // registrar entries went with them.
@@ -166,7 +166,7 @@ export const HkAdminHeader = defineComponent({
166
166
  <header
167
167
  class={[
168
168
  "s-glass-header",
169
- props.compact ? "px-4 gap-2" : "px-6 gap-3",
169
+ props.compact ? "s-glass-header--compact" : "",
170
170
  ]}
171
171
  >
172
172
  {props.showHamburger && (
@@ -175,13 +175,13 @@ export const HkAdminHeader = defineComponent({
175
175
  size="sm"
176
176
  onClick={() => emit("hamburger")}
177
177
  >
178
- <Menu size={20} class="w-5 h-5" />
178
+ <Menu size={20} class="s-admin-header-hamburger-icon" />
179
179
  </HButton>
180
180
  )}
181
181
 
182
- <div ref={userTriggerRef} class="flex items-center gap-2 min-w-0">
182
+ <div ref={userTriggerRef} class="s-admin-header-user">
183
183
  <button
184
- class="w-7 h-7 rounded-full overflow-hidden shrink-0 cursor-pointer transition-opacity relative group p-0 border-0"
184
+ class="s-admin-header-avatar"
185
185
  aria-label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
186
186
  aria-haspopup={props.avatarAction === "drawer" ? "dialog" : "menu"}
187
187
  aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
@@ -191,7 +191,7 @@ export const HkAdminHeader = defineComponent({
191
191
  <img
192
192
  src={props.avatarUrl}
193
193
  alt={props.username}
194
- class="w-full h-full object-cover"
194
+ class="s-admin-header-avatar-img"
195
195
  onError={() => { avatarFailed.value = true; }}
196
196
  />
197
197
  ) : (
@@ -205,24 +205,19 @@ export const HkAdminHeader = defineComponent({
205
205
  </span>
206
206
  )}
207
207
  {props.avatarAction === "menu" && (
208
- <div class="absolute inset-0 rounded-full bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
209
- <Camera size={10} class="text-white" />
208
+ <div class="s-admin-header-avatar-veil">
209
+ <Camera size={10} class="s-admin-header-veil-icon" />
210
210
  </div>
211
211
  )}
212
212
  </button>
213
213
  {/* WHERE am I — the open view's title, not the nickname. The
214
- explicit 1.5 line-height keeps descenders (g, y, p) inside
215
- the truncate clip box: the default text-sm box is exactly
216
- the em advance, so zoom/subpixel rounding in a scaled root
217
- shaves the ink off at the bottom (user report 2026-09-12:
218
- the "g" tail of the nickname was visibly cut). */}
214
+ 1.5 line-height (in the class) keeps descenders (g, y, p)
215
+ inside the truncate clip box: the default text-sm box is
216
+ exactly the em advance, so zoom/subpixel rounding in a
217
+ scaled root shaves the ink off at the bottom (user report
218
+ 2026-09-12: the "g" tail of the nickname was visibly cut). */}
219
219
  {props.avatarAction === "menu" && props.title && (
220
- <span
221
- class="text-sm font-semibold text-text truncate max-w-[8rem]"
222
- style={{ lineHeight: "1.5" }}
223
- >
224
- {props.title}
225
- </span>
220
+ <span class="s-admin-header-title">{props.title}</span>
226
221
  )}
227
222
  </div>
228
223
 
@@ -289,15 +284,15 @@ export const HkAdminHeader = defineComponent({
289
284
  }}
290
285
  </HkMenu>
291
286
 
292
- <div class="ml-auto flex items-center gap-1.5 shrink-0">
287
+ <div class="s-admin-header-actions">
293
288
  {props.showEmergencyStop && (
294
289
  <button
295
290
  class={[
296
- "px-3 py-1 rounded-md text-xs font-bold border transition-all",
297
- props.emergencyStopLoading ? "opacity-50 cursor-wait" : "cursor-pointer",
298
- props.emergencyStopActive
299
- ? "bg-red-600 text-white border-red-700 animate-pulse"
300
- : "bg-red-600/10 text-red-500 border-red-500/40 hover:bg-red-600/25",
291
+ "s-admin-header-emergency",
292
+ props.emergencyStopLoading
293
+ ? "is-loading"
294
+ : "",
295
+ props.emergencyStopActive ? "is-active" : "",
301
296
  ]}
302
297
  disabled={props.emergencyStopLoading}
303
298
  title={props.emergencyStopActive
@@ -312,7 +307,7 @@ export const HkAdminHeader = defineComponent({
312
307
  )}
313
308
  {slots["emergency-stop-extra"]?.()}
314
309
  {(props.actions || []).map((vnode, i) => (
315
- <span key={i} class="flex items-center gap-1">
310
+ <span key={i} class="s-admin-header-action">
316
311
  {vnode}
317
312
  </span>
318
313
  ))}
@@ -45,7 +45,7 @@ export const HkAdminShell = defineComponent({
45
45
  };
46
46
 
47
47
  return () => (
48
- <div class="flex flex-col h-full w-full overflow-hidden">
48
+ <div class="s-admin-shell">
49
49
  {slots.header && (
50
50
  <div style={{ flexShrink: 0 }}>
51
51
  {slots.header({
@@ -61,7 +61,7 @@ export const HkAdminShell = defineComponent({
61
61
  </div>
62
62
  )}
63
63
 
64
- <div class="flex flex-1 min-h-0" style={{ paddingBottom: props.footerHeight }}>
64
+ <div class="s-admin-shell-body" style={{ paddingBottom: props.footerHeight }}>
65
65
  {isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
66
66
  <aside
67
67
  style={{
@@ -75,8 +75,8 @@ export const HkAdminShell = defineComponent({
75
75
  {slots.sidebar({ collapsed: false, onNavigate: closeSidebar })}
76
76
  </aside>
77
77
  )}
78
- <main class="flex-1 flex flex-col min-w-0 min-h-0">
79
- <HScrollContainer class="flex-1 min-h-0">
78
+ <main class="s-admin-shell-main">
79
+ <HScrollContainer class="s-admin-shell-scroll">
80
80
  {/* Padding lives INSIDE the scroll viewport (an inner
81
81
  wrapper) so card box-shadows are not clipped at the
82
82
  viewport edges. */}
@@ -71,7 +71,13 @@
71
71
  margin-bottom: var(--space-4);
72
72
  color: rgb(var(--color-text) / var(--hk-field-label-alpha, 72%));
73
73
  white-space: nowrap;
74
- line-height: 1;
74
+ /* The row layout below clips this label with `overflow: hidden` for its
75
+ * ellipsis, and that clip box IS the line box: at `line-height: 1` the 12px
76
+ * box was shorter than the font's ink box (14.05px Roboto, 17.38px once a
77
+ * CJK fallback renders the glyphs), so every g/j/p/q/y in a colour name lost
78
+ * its tail. 1.5 = 18px, exactly the swatch height in the row layout, so the
79
+ * dense grids keep their geometry. */
80
+ line-height: 1.5;
75
81
  transition: color var(--duration-short);
76
82
 
77
83
  .hk-color-picker-swatch-btn:hover & {
@@ -81,7 +81,7 @@ function formatModified(value: string | number | undefined): string {
81
81
  if (value === undefined) return "";
82
82
  if (typeof value === "number") {
83
83
  const date = new Date(value);
84
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
84
+ return Number.isNaN(date.getTime()) ? String(value) : formatDateTime(date);
85
85
  }
86
86
  return value;
87
87
  }
@@ -3,6 +3,44 @@
3
3
  display: inline-flex;
4
4
  }
5
5
 
6
+ /* The centered read-out. The markup used to borrow the HOST's atomic layer
7
+ * for its whole anatomy (`absolute inset-0 flex flex-col items-center
8
+ * justify-center pointer-events-none` + `text-lg font-bold text-text
9
+ * leading-none` / `text-2xs text-muted mt-0.5`): a host without one (the
10
+ * evernight flasher) rendered the value as a static sibling beside the ring,
11
+ * and a host WITH one (chest/UnoCSS) owned values the design system could not
12
+ * fix. Self-contained rules below instead. */
13
+ .hk-gauge-ring-center {
14
+ position: absolute;
15
+ inset: 0;
16
+ display: flex;
17
+ flex-direction: column;
18
+ align-items: center;
19
+ justify-content: center;
20
+ pointer-events: none;
21
+ }
22
+
23
+ /* The 1.45 ratio is load-bearing: `leading-none` pinned line-height: 1, which
24
+ * put the descender of any g/j/p/q/y ~0.95px below the 18px line box — it
25
+ * overlapped the label underneath and was cut outright inside a truncating
26
+ * wrapper. 1.45 covers the CJK fallback metrics (ascent+descent up to 1.45em),
27
+ * so the ring owns the ratio instead of borrowing a host utility. */
28
+ .hk-gauge-ring-value {
29
+ font-size: var(--text-lg);
30
+ font-weight: 700;
31
+ line-height: 1.45;
32
+ color: rgb(var(--color-text));
33
+ }
34
+
35
+ .hk-gauge-ring-label {
36
+ font-size: var(--text-2xs);
37
+ /* Uno's font-size utilities also emit `line-height: 1`; the label is a
38
+ * single clipped-free line, so the host rendering is kept here. */
39
+ line-height: 1;
40
+ color: rgb(var(--color-muted));
41
+ margin-top: 0.125rem;
42
+ }
43
+
6
44
  // The 0.8s dash sweep rides an INLINE transition on the progress circles
7
45
  // (HkGaugeRing.tsx), so the guard needs !important to beat it.
8
46
  @media (prefers-reduced-motion: reduce) {
@@ -115,12 +115,12 @@ export default defineComponent({
115
115
  ))}
116
116
  </svg>
117
117
  {(props.centerValue || props.centerLabel) && (
118
- <div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
118
+ <div class="hk-gauge-ring-center">
119
119
  {props.centerValue && (
120
- <div class="text-lg font-bold text-text leading-none">{props.centerValue}</div>
120
+ <div class="hk-gauge-ring-value">{props.centerValue}</div>
121
121
  )}
122
122
  {props.centerLabel && (
123
- <div class="text-2xs text-muted mt-0.5">{props.centerLabel}</div>
123
+ <div class="hk-gauge-ring-label">{props.centerLabel}</div>
124
124
  )}
125
125
  </div>
126
126
  )}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Source contract for the DESKTOP modal close choreography (user report
3
+ * 2026-09-15): the unveil expands the visible box outward from the
4
+ * center (bottom-10% clip + +5% offset at t=0, full box at t=1), but the
5
+ * close had degenerated into a fade + downward drift — the box read as
6
+ * shrinking straight down instead of folding back into its center.
7
+ *
8
+ * The close now runs the unveil's TRUE reverse, which is only safe
9
+ * because the leave pins an identity clip: interpolating
10
+ * `none → inset()` is DISCRETE (the #508 regression — the footer band
11
+ * vanished at the first frame), while identical `inset → inset` shape
12
+ * functions interpolate continuously.
13
+ *
14
+ * Pinned on the stylesheet SOURCE (computed animation geometry is not
15
+ * observable in happy-dom), mirroring the themeToggleChrome /
16
+ * tokenHygiene guard pattern.
17
+ */
18
+ import { describe, expect, it } from "vitest";
19
+ import { readFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
25
+
26
+ /** The desktop choreography block — the mobile sheet block (≤767px)
27
+ * carries its own enter/leave classes and must not leak in. */
28
+ const desktop = src.slice(
29
+ src.indexOf("@media (min-width: 768px)"),
30
+ src.indexOf("@media (max-width: 767px)"),
31
+ );
32
+
33
+ /** Body of one flat rule (no nested braces in this block). */
34
+ function bodyOf(selector: string): string {
35
+ const escaped = selector.replace(/\./g, "\\.");
36
+ const match = desktop.match(new RegExp(`${escaped}\\s*{([^}]*)}`));
37
+ expect(match, `rule ${selector} must exist in the desktop block`).toBeTruthy();
38
+ return match![1].replace(/\s+/g, " ").trim();
39
+ }
40
+
41
+ function clipOf(selector: string): string {
42
+ const body = bodyOf(selector);
43
+ const match = body.match(/clip-path:\s*([^;]+);?/);
44
+ expect(match, `${selector} must declare a clip-path`).toBeTruthy();
45
+ return match![1].replace(/\s+/g, " ").trim();
46
+ }
47
+
48
+ describe("HkModal desktop close-collapse contract (user report 2026-09-15)", () => {
49
+ it("pins the identity clip in leave-from, identical to enter-to (no discrete clip jump)", () => {
50
+ // The #508 regression guard: leave-from WITHOUT a clip makes
51
+ // none → inset() interpolate discretely and the footer band vanish
52
+ // at the first frame of the close.
53
+ expect(clipOf(".hk-modal-content-leave-from")).toBe(
54
+ clipOf(".hk-modal-content-enter-to"),
55
+ );
56
+ });
57
+
58
+ it("mirrors the unveil geometry: leave-to clips like enter-from", () => {
59
+ // Bottom-10% clip + same rounding on both ends of the mirror — the
60
+ // visible box collapses symmetrically back to 90% centered.
61
+ expect(clipOf(".hk-modal-content-leave-to")).toBe(
62
+ clipOf(".hk-modal-content-enter-from"),
63
+ );
64
+ });
65
+
66
+ it("transitions clip-path on leave (the collapse animates, not snaps)", () => {
67
+ const body = bodyOf(".hk-modal-content-leave-active");
68
+ expect(body).toMatch(/clip-path\s+var\(--hk-modal-duration/);
69
+ expect(body).toMatch(/will-change:\s*[^;]*clip-path/);
70
+ });
71
+
72
+ it("keeps the close a rigid ride: no scaleY squash anywhere in the desktop block", () => {
73
+ // History: #512 collapsed via scaleY (content squashed), #524
74
+ // reverted to a bare drift. The clip choreography moves whole
75
+ // pixels of the visible window — the frame itself never distorts.
76
+ expect(desktop).not.toContain("scaleY");
77
+ expect(desktop).not.toContain("transform-origin");
78
+ });
79
+ });
@@ -123,18 +123,26 @@
123
123
  will-change: transform, opacity, clip-path;
124
124
  }
125
125
 
126
- // Close: the whole frame fades and drifts down as ONE piece — the
127
- // unveil's clip does NOT re-run on close. Re-clipping first made the
128
- // bottom band (usually the footer) vanish at the very first frame of
129
- // the close while the rest was still on screen, which read as broken
130
- // (user report 2026-09-14).
126
+ // Close: the unveil's TRUE reverse — the visible box collapses back
127
+ // toward the center (top edge sweeps down, bottom edge sweeps up,
128
+ // content rides rigidly — never squashed) while the frame fades
129
+ // (user report 2026-09-15: the fade+drift close read as the box
130
+ // shrinking straight down instead of folding back into its center).
131
+ // The earlier re-clip attempt (#508) broke because leave-from had no
132
+ // clip at all: interpolating `none → inset()` is DISCRETE, so the
133
+ // bottom band (the footer) vanished at the first frame. Pinning the
134
+ // identity clip in leave-from — byte-identical to enter-to — makes
135
+ // the same-shape insets interpolate continuously, which structurally
136
+ // rules that jump out while restoring the symmetric collapse.
131
137
  .hk-modal-content-leave-active {
132
138
  transition:
133
139
  opacity var(--hk-modal-duration, 0.32s)
134
140
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
135
141
  transform var(--hk-modal-duration, 0.32s)
142
+ var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
143
+ clip-path var(--hk-modal-duration, 0.32s)
136
144
  var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
137
- will-change: transform, opacity;
145
+ will-change: transform, opacity, clip-path;
138
146
  }
139
147
 
140
148
  .hk-modal-content-enter-from {
@@ -154,11 +162,20 @@
154
162
  .hk-modal-content-leave-from {
155
163
  opacity: 1;
156
164
  transform: translate(-50%, -50%);
165
+ // The pinned identity clip: identical to enter-to so the leave's
166
+ // clip interpolates (never jumps) — see the close note above.
167
+ clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
168
+ var(--hk-modal-radius, var(--radius-lg, 12px)));
157
169
  }
158
170
 
159
171
  .hk-modal-content-leave-to {
160
172
  opacity: 0;
173
+ // Mirror of enter-from: shifted +5% with the bottom 10% clipped, the
174
+ // visible box is 90% and centered — both edges ease toward the
175
+ // center line, exactly reversing the unveil.
161
176
  transform: translate(-50%, -50%) translateY(5%);
177
+ clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
178
+ var(--hk-modal-radius, var(--radius-lg, 12px)));
162
179
  }
163
180
  }
164
181
 
@@ -75,7 +75,7 @@ export const HkNavSidebar = defineComponent({
75
75
  icon: () => (IconComp ? h(IconComp, { size: 16 }) : null),
76
76
  default: () => (
77
77
  <span class="s-nav-item-content">
78
- <span class="truncate">{item.label}</span>
78
+ <span class="s-nav-item-label">{item.label}</span>
79
79
  {item.badge && (
80
80
  <span
81
81
  class="s-nav-item-badge"
@@ -118,15 +118,13 @@ export const HkSignInCard = defineComponent({
118
118
  ) : (
119
119
  <svg
120
120
  xmlns="http://www.w3.org/2000/svg"
121
- width="16"
122
- height="16"
123
121
  viewBox="0 0 24 24"
124
122
  fill="none"
125
123
  stroke="currentColor"
126
124
  stroke-width="2"
127
125
  stroke-linecap="round"
128
126
  stroke-linejoin="round"
129
- class="w-4 h-4"
127
+ class="s-auth-card-field-icon"
130
128
  aria-hidden="true"
131
129
  >
132
130
  <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
@@ -61,7 +61,12 @@
61
61
  .hk-stat-card-value {
62
62
  font-size: var(--text-2xl, 1.5rem);
63
63
  font-weight: 700;
64
- line-height: 1.2;
64
+ /* `overflow: hidden` below hangs the ellipsis on the line box, so the line
65
+ * box has to cover the ink box: 1.2 (28.8px at the 24px token) cleared the
66
+ * shipped Latin stacks by only 0.6-1.1px and clipped 2.9-3.1px per edge as
67
+ * soon as a host swapped --font-sans for a CJK fallback (ascent+descent
68
+ * 1.34-1.45em, see theme/fontContext). 1.35 covers those metrics. */
69
+ line-height: 1.35;
65
70
  color: rgb(var(--color-text));
66
71
  overflow: hidden;
67
72
  text-overflow: ellipsis;
@@ -200,8 +200,8 @@
200
200
  color: rgb(var(--color-on-solid-icon, 255 255 255));
201
201
  }
202
202
 
203
- /* SR-only utility */
204
- .sr-only {
203
+ /* Visually hidden caption (kept in the a11y tree) */
204
+ .hk-table-sr-only {
205
205
  position: absolute;
206
206
  width: 1px;
207
207
  height: 1px;
@@ -131,7 +131,7 @@ export default defineComponent({
131
131
  <div ref={wrapperHostRef} class="hk-table-host">
132
132
  <div ref={wrapperRef} class="hk-table-wrapper">
133
133
  <table class={tableCls.value}>
134
- {props.caption && <caption class="sr-only">{props.caption}</caption>}
134
+ {props.caption && <caption class="hk-table-sr-only">{props.caption}</caption>}
135
135
  <thead>
136
136
  <tr class="hk-table-header-row">
137
137
  {props.selectable && (
@@ -0,0 +1,152 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Component self-sufficiency guard.
9
+ *
10
+ * A hikari component must carry its own styling. Class literals in a
11
+ * component's TSX are allowed to name hikari's own namespaces (`hk-`, the
12
+ * plana-ui shared surface `s-`, state modifiers `is-`, `hii-`, `hljs`) —
13
+ * those are defined in this package and ship with it. Anything else in a
14
+ * class literal is a HOST-DIALECT utility (`flex`, `inset-0`, `w-7 h-7`,
15
+ * `text-2xs`, `animate-pulse`, …): it resolves only if the consuming app
16
+ * happens to run an atomic-CSS layer, and silently does nothing otherwise.
17
+ *
18
+ * That silent degradation is exactly what shipped: four components
19
+ * (HkAdminHeader, HkAdminShell, HkGaugeRing, HkNavSidebar) spoke the
20
+ * utility dialect, so the evernight flasher rendered its gauge ring with
21
+ * the value unstyled beside the circle and its header with no gap or
22
+ * padding at all — while hosts with UnoCSS (chest) owned values the
23
+ * design system could not fix. Those four were rewritten to component
24
+ * scss (2026-09-15) and the utility sheet they had been propped up with
25
+ * was deleted; this test is the fence that keeps the dialect out.
26
+ *
27
+ * Adding a component that needs a utility class means either giving it a
28
+ * semantic class with rules in its own scss (the house style) or adding
29
+ * it here — and the second should feel like the wrong answer.
30
+ */
31
+
32
+ const componentsDir = resolve(
33
+ dirname(fileURLToPath(import.meta.url)),
34
+ "..",
35
+ "components",
36
+ );
37
+
38
+ /** Namespaces that are hikari's own (semantic classes, not host dialect). */
39
+ const OWN_NAMESPACE = /^(?:hk-|s-|is-|hii-|hljs)/;
40
+
41
+ /** `"desc"` inside `sortDirection === "desc"` is a comparison operand, not
42
+ * a class name — the only false-positive source a class-literal scan has. */
43
+ const COMPARISON_BEFORE = /(?:===|!==|==|!=)\s*$/;
44
+
45
+ /**
46
+ * Cut the literal source of every `class=` attribute and say whether it is
47
+ * bare class text (`class="a b"` — a token list already) or an expression
48
+ * (`class={…}` — token lists hide inside its string literals).
49
+ */
50
+ function classAttributeSources(source: string): { text: string; expression: boolean }[] {
51
+ const out: { text: string; expression: boolean }[] = [];
52
+ const attr = "class=";
53
+ for (let i = source.indexOf(attr); i !== -1; i = source.indexOf(attr, i + 1)) {
54
+ const start = i + attr.length;
55
+ const first = source[start];
56
+ if (first === '"' || first === "'") {
57
+ const end = source.indexOf(first, start + 1);
58
+ if (end !== -1) out.push({ text: source.slice(start + 1, end), expression: false });
59
+ continue;
60
+ }
61
+ if (first === "{") {
62
+ let depth = 0;
63
+ for (let j = start; j < source.length; j += 1) {
64
+ if (source[j] === "{") depth += 1;
65
+ else if (source[j] === "}") {
66
+ depth -= 1;
67
+ if (depth === 0) {
68
+ out.push({ text: source.slice(start + 1, j), expression: true });
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** String literals in an expression, with the text right before each one. */
79
+ function literals(expression: string): { value: string; before: string }[] {
80
+ const out: { value: string; before: string }[] = [];
81
+ const pattern = /(["'])((?:\\.|(?!\1)[^\\])*)\1/g;
82
+ for (let m = pattern.exec(expression); m !== null; m = pattern.exec(expression)) {
83
+ out.push({ value: m[2], before: expression.slice(0, m.index) });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** Host-dialect utility tokens found in the component sources, mapped to
89
+ * the files that write them. */
90
+ function utilityUsages(): Map<string, Set<string>> {
91
+ const usage = new Map<string, Set<string>>();
92
+ for (const name of readdirSync(componentsDir).sort()) {
93
+ if (!name.endsWith(".tsx") || name.endsWith(".test.tsx")) continue;
94
+ const source = readFileSync(join(componentsDir, name), "utf8").replace(/\r\n/g, "\n");
95
+ for (const attr of classAttributeSources(source)) {
96
+ const lists = attr.expression
97
+ ? literals(attr.text)
98
+ .filter(({ before }) => !COMPARISON_BEFORE.test(before))
99
+ .map(({ value }) => value)
100
+ : [attr.text];
101
+ for (const list of lists) {
102
+ for (const token of list.split(/\s+/)) {
103
+ if (!token || token.includes("${") || OWN_NAMESPACE.test(token)) continue;
104
+ if (!usage.has(token)) usage.set(token, new Set());
105
+ usage.get(token)!.add(name);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ return usage;
111
+ }
112
+
113
+ describe("component self-sufficiency", () => {
114
+ it("keeps host-dialect utility classes out of component markup", () => {
115
+ const used = utilityUsages();
116
+ const offenders = [...used.keys()].sort();
117
+ expect(
118
+ offenders,
119
+ `components must style themselves (hk-*/s-*/is-* classes + their own scss), ` +
120
+ `not rely on the host's atomic layer: ${offenders
121
+ .map((t) => `${t} (${[...used.get(t)!].join(", ")})`)
122
+ .join("; ")}`,
123
+ ).toEqual([]);
124
+ });
125
+
126
+ it("still sees the class literals it is supposed to guard", () => {
127
+ // An extractor that silently stopped matching would make the assertion
128
+ // above pass on an empty set. Anchor it: the four rewritten components
129
+ // carry semantic classes, and the scan must find them.
130
+ const seen = new Map<string, Set<string>>();
131
+ for (const name of readdirSync(componentsDir).sort()) {
132
+ if (!name.endsWith(".tsx") || name.endsWith(".test.tsx")) continue;
133
+ const source = readFileSync(join(componentsDir, name), "utf8");
134
+ for (const attr of classAttributeSources(source)) {
135
+ const lists = attr.expression
136
+ ? literals(attr.text).map(({ value }) => value)
137
+ : [attr.text];
138
+ for (const list of lists) {
139
+ for (const token of list.split(/\s+/)) {
140
+ if (!token) continue;
141
+ if (!seen.has(token)) seen.set(token, new Set());
142
+ seen.get(token)!.add(name);
143
+ }
144
+ }
145
+ }
146
+ }
147
+ for (const anchor of ["s-glass-header", "hk-gauge-ring-center", "s-admin-shell"]) {
148
+ expect(seen.has(anchor), `scan lost ${anchor}`).toBe(true);
149
+ }
150
+ expect(seen.size).toBeGreaterThan(50);
151
+ });
152
+ });
@@ -0,0 +1,186 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Descender-clipping contract for every hikari stylesheet.
10
+ *
11
+ * `text-overflow: ellipsis` is not a vertical safeguard: the same
12
+ * `overflow: hidden` that truncates the line horizontally clips glyph ink to
13
+ * the padding box, and that box is the LINE BOX. When the line box is shorter
14
+ * than the font's ink box the bottom of every g/j/p/q/y is cut off flat —
15
+ * reported twice by users (the chest header workspace pill, and the 'g' tail
16
+ * of an admin-header nickname). `line-height: 1` is the classic way to build
17
+ * that trap, because it is reached for as a centring fix inside a fixed-height
18
+ * chip.
19
+ *
20
+ * The floor below is `line-height / font-size`. Descender depth is ~0.21em and
21
+ * the tallest ink (accents, Han) needs another ~1.0-1.1em, so Latin stacks need
22
+ * >= 1.10-1.15; the ceiling is set by the CJK fallbacks hikari supports
23
+ * (`theme/fontContext` lets a host swap --font-sans, and YaHei / Noto Sans CJK
24
+ * carry ascent+descent of 1.34-1.45em). 1.30 is the value that keeps a host
25
+ * font swap from turning a clip box into a paper cut, and it still leaves room
26
+ * for the deliberately tight designs (`--hk-menu-item-lh: 1.3`).
27
+ *
28
+ * A rule is only flagged when the clip and the too-small line box sit in the
29
+ * SAME declaration block: that is the combination that actually cuts ink. An
30
+ * inherited line-height from an ancestor is invisible here, which is why the
31
+ * fix for a flagged rule is to declare a safe ratio on the clipping rule
32
+ * itself rather than somewhere up the tree.
33
+ */
34
+
35
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
36
+ const stylesDir = resolve(componentDir, "../styles");
37
+
38
+ /** line-height / font-size below this clips descenders on some supported font. */
39
+ const SAFE_LINE_HEIGHT_RATIO = 1.3;
40
+
41
+ /**
42
+ * Reviewed exemptions. Every entry must say why the ink cannot be text or why
43
+ * the clip is intended — an unexplained entry is a bug, not a pass.
44
+ */
45
+ const ALLOWED: ReadonlyArray<{ file: string; selector: string; why: string }> = [];
46
+
47
+ type Block = { file: string; selector: string; body: string; order: number };
48
+
49
+ /** Walk a compiled stylesheet, flattening at-rules into their inner blocks. */
50
+ function* blocksOf(css: string, file: string, prefix = "", order = { value: 0 }): Generator<Block> {
51
+ let i = 0;
52
+ while (i < css.length) {
53
+ const open = css.indexOf("{", i);
54
+ if (open === -1) return;
55
+ const prelude = css.slice(i, open).trim();
56
+ let depth = 1;
57
+ let j = open + 1;
58
+ while (j < css.length && depth > 0) {
59
+ if (css[j] === "{") depth++;
60
+ else if (css[j] === "}") depth--;
61
+ j++;
62
+ }
63
+ const body = css.slice(open + 1, j - 1);
64
+ if (prelude.startsWith("@")) {
65
+ yield* blocksOf(body, file, prefix, order);
66
+ } else {
67
+ yield { file, selector: prefix ? `${prefix} ${prelude}` : prelude, body, order: order.value++ };
68
+ }
69
+ i = j;
70
+ }
71
+ }
72
+
73
+ function declaration(body: string, property: string): string | null {
74
+ const match = body.match(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]+)`, "i"));
75
+ return match ? match[1]!.trim() : null;
76
+ }
77
+
78
+ function clipsOverflow(body: string): boolean {
79
+ return ["overflow", "overflow-x", "overflow-y"].some((property) => {
80
+ const value = declaration(body, property);
81
+ return value !== null && /\b(hidden|clip)\b/.test(value);
82
+ });
83
+ }
84
+
85
+ function px(value: string | null): number | null {
86
+ if (value === null) return null;
87
+ const match = value.match(/^([\d.]+)px$/);
88
+ return match ? Number(match[1]) : null;
89
+ }
90
+
91
+ function ratioOf(body: string): number | null {
92
+ const lineHeight = declaration(body, "line-height");
93
+ if (lineHeight === null) return null;
94
+ const unitless = lineHeight.match(/^[\d.]+$/);
95
+ if (unitless) return Number(lineHeight);
96
+ const lineHeightPx = px(lineHeight);
97
+ const fontSizePx = px(declaration(body, "font-size"));
98
+ if (lineHeightPx !== null && fontSizePx !== null && fontSizePx > 0) {
99
+ return lineHeightPx / fontSizePx;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function sheets(): string[] {
105
+ const components = readdirSync(componentDir)
106
+ .filter((name) => name.endsWith(".scss"))
107
+ .map((name) => resolve(componentDir, name));
108
+ const styles = readdirSync(stylesDir)
109
+ .filter((name) => name.endsWith(".scss"))
110
+ .map((name) => resolve(stylesDir, name));
111
+ return [...components, ...styles];
112
+ }
113
+
114
+ /** The element a rule actually styles: the last compound of its selector. */
115
+ function subject(selector: string): string {
116
+ const parts = selector.split(/\s+|>/).filter(Boolean);
117
+ return parts[parts.length - 1] ?? selector;
118
+ }
119
+
120
+ /** Rough CSS specificity, enough to pick the rule that wins inside one sheet. */
121
+ function specificity(selector: string): number {
122
+ return (selector.match(/[.#[\]]/g)?.length ?? 0) * 10 + (selector.match(/[a-z]+[a-z0-9-]*/gi)?.length ?? 0);
123
+ }
124
+
125
+ /**
126
+ * Effective `line-height / font-size` for a clipping rule, resolving the
127
+ * cascade *within the sheet*: the clip and the line box very often live in two
128
+ * different blocks for the same element (the row-layout override adds
129
+ * `overflow: hidden`, the base rule declares `line-height: 1`), which is why a
130
+ * per-block scan sees nothing. Rules that declare no line-height are skipped —
131
+ * the inherited value is `normal`, which cannot clip.
132
+ */
133
+ function effectiveRatio(clip: Block, blocks: Block[]): number | null {
134
+ const element = subject(clip.selector);
135
+ const candidates = blocks
136
+ .filter((block) => block.selector === clip.selector || subject(block.selector) === element)
137
+ .map((block) => ({ block, ratio: ratioOf(block.body) }))
138
+ .filter((entry): entry is { block: Block; ratio: number } => entry.ratio !== null);
139
+ if (!candidates.length) return null;
140
+ candidates.sort(
141
+ (a, b) => specificity(b.block.selector) - specificity(a.block.selector) || b.block.order - a.block.order,
142
+ );
143
+ return candidates[0]!.ratio;
144
+ }
145
+
146
+ describe("stylesheet descender-clipping contract", () => {
147
+ // Compiling ~130 stylesheets takes ~1s on a warm cache and several seconds on
148
+ // a cold one; the default 5s budget turns a slow machine into a red suite.
149
+ it("never pairs a clip box with a line box shorter than the ink box", { timeout: 60_000 }, () => {
150
+ const findings: string[] = [];
151
+ const allowed = new Set(ALLOWED.map((entry) => `${entry.file}|${entry.selector}`));
152
+
153
+ for (const file of sheets()) {
154
+ const name = file.slice(file.indexOf("/src/") + 1);
155
+ // sass keeps `/* … */` comments in expanded output, and a comment that
156
+ // *talks* about `overflow: hidden` or `line-height: 1` would otherwise be
157
+ // parsed as a declaration (that bug made this guard pass while the very
158
+ // clip it was written for was live).
159
+ const css = compile(file, { style: "expanded", loadPaths: [componentDir, stylesDir] }).css.replace(
160
+ /\/\*[\s\S]*?\*\//g,
161
+ "",
162
+ );
163
+ const blocks = [...blocksOf(css, name)];
164
+ for (const block of blocks) {
165
+ if (!clipsOverflow(block.body)) continue;
166
+ const ratio = effectiveRatio(block, blocks);
167
+ if (ratio === null || ratio >= SAFE_LINE_HEIGHT_RATIO) continue;
168
+ if (allowed.has(`${block.file}|${block.selector}`)) continue;
169
+ findings.push(
170
+ `${block.file} — ${block.selector} — clips overflow with an effective line-height ratio of ${ratio.toFixed(2)} (< ${SAFE_LINE_HEIGHT_RATIO}): the ink box of g/j/p/q/y is cut`,
171
+ );
172
+ }
173
+ }
174
+
175
+ expect(findings.sort(), "clip boxes whose line box is too short for the font's ink").toEqual([]);
176
+ });
177
+
178
+ it("keeps the review list honest", () => {
179
+ const unmatched = ALLOWED.filter((entry) => {
180
+ const file = resolve(componentDir, "..", entry.file.replace(/^src\//, ""));
181
+ return !readFileSync(file, "utf8").includes(entry.selector.split(" ").pop()!);
182
+ }).map((entry) => `${entry.file} — ${entry.selector} (${entry.why})`);
183
+ // A stale exemption hides a future regression: it must be deleted.
184
+ expect(unmatched).toEqual([]);
185
+ });
186
+ });
package/src/index.ts CHANGED
@@ -339,7 +339,7 @@ export type { MessagePayload, MessageSeverity, MessageTransport, NotifyOptions,
339
339
  export { useResourceListModal } from "./composables/useResourceListModal";
340
340
 
341
341
  export { downloadBlob, downloadTextAsFile } from "./utils/download";
342
- export { bytesToBase64, base64ToBytes, blobToBase64 } from "./utils/base64";
342
+ export { bytesToBase64, utf8ToBase64, base64ToBytes, blobToBase64 } from "./utils/base64";
343
343
 
344
344
  export { THEME_MODE_STORAGE_KEY } from "./theme/useTheme";
345
345
 
@@ -316,14 +316,205 @@
316
316
  z-index: var(--z-header);
317
317
  display: flex;
318
318
  align-items: center;
319
+ /* Horizontal rhythm + gap live in the class (not in host utilities):
320
+ * the markup used to carry `px-6 gap-3` / `px-4 gap-2` utility classes,
321
+ * which a host WITHOUT an atomic layer simply did not have — the header
322
+ * silently fell back to this file's old `padding: 0 var(--space-12)`
323
+ * and no gap at all. */
324
+ gap: var(--space-12);
325
+ padding: 0 var(--space-24);
319
326
  height: var(--s-header-height);
320
- padding: 0 var(--space-12);
321
327
  background: rgb(var(--color-surface) / var(--opacity-half));
322
328
  backdrop-filter: blur(var(--blur-md));
323
329
  border-bottom: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
324
330
  flex-shrink: 0;
325
331
  }
326
332
 
333
+ .s-glass-header--compact {
334
+ gap: var(--space-8);
335
+ padding: 0 var(--space-16);
336
+ }
337
+
338
+ /* ── Admin header chrome (HkAdminHeader) ────────────────────────────────
339
+ * The header's own layout. Every declaration below replaces a utility
340
+ * class the markup used to depend on (`w-7 h-7`, `rounded-full`,
341
+ * `object-cover`, `max-w-[8rem]`, `animate-pulse`, …) — a host without an
342
+ * atomic layer rendered the header without them, and a host with one
343
+ * (chest/UnoCSS) owned values the design system could not fix. Values are
344
+ * the verified computed ones (audit 2026-09-15 against chest's generated
345
+ * CSS), so no host's rendering changes. */
346
+
347
+ .s-admin-header-user {
348
+ display: flex;
349
+ align-items: center;
350
+ gap: var(--space-8);
351
+ min-width: 0;
352
+ }
353
+
354
+ .s-admin-header-avatar {
355
+ position: relative;
356
+ width: var(--space-28);
357
+ height: var(--space-28);
358
+ flex-shrink: 0;
359
+ border-radius: var(--radius-full);
360
+ overflow: hidden;
361
+ cursor: pointer;
362
+ transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
363
+ }
364
+
365
+ /* The hamburger's leading icon (lucide `Menu`), sized in rem so it tracks
366
+ * a scaled root the way the utility it replaces did. */
367
+ .s-admin-header-hamburger-icon {
368
+ width: var(--space-20);
369
+ height: var(--space-20);
370
+ }
371
+
372
+ .s-admin-header-avatar-img {
373
+ width: 100%;
374
+ height: 100%;
375
+ object-fit: cover;
376
+ }
377
+
378
+ /* Hover veil: the "change avatar" affordance over the trigger. The Uno
379
+ * form was `group-hover:opacity-100` scoped by a `group` marker class on
380
+ * the button; this descendant selector expresses the same scope. */
381
+ .s-admin-header-avatar-veil {
382
+ position: absolute;
383
+ inset: 0;
384
+ display: flex;
385
+ align-items: center;
386
+ justify-content: center;
387
+ border-radius: var(--radius-full);
388
+ background: rgb(0 0 0 / 30%);
389
+ opacity: 0;
390
+ transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
391
+ }
392
+
393
+ .s-admin-header-avatar:hover .s-admin-header-avatar-veil {
394
+ opacity: 1;
395
+ }
396
+
397
+ .s-admin-header-veil-icon {
398
+ color: #fff;
399
+ }
400
+
401
+ /* The open view's title. The 1.5 line-height is load-bearing: the default
402
+ * text-sm box is exactly the em advance, so descenders (g, y, p) fall
403
+ * outside the truncate clip box under subpixel rounding (user report
404
+ * 2026-09-12: the "g" tail of the nickname was visibly cut). */
405
+ .s-admin-header-title {
406
+ font-size: var(--text-sm);
407
+ font-weight: 600;
408
+ color: rgb(var(--color-text));
409
+ line-height: 1.5;
410
+ max-width: 8rem;
411
+ overflow: hidden;
412
+ text-overflow: ellipsis;
413
+ white-space: nowrap;
414
+ }
415
+
416
+ .s-admin-header-actions {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: var(--space-6);
420
+ margin-left: auto;
421
+ flex-shrink: 0;
422
+ }
423
+
424
+ .s-admin-header-action {
425
+ display: flex;
426
+ align-items: center;
427
+ gap: var(--space-4);
428
+ }
429
+
430
+ /* Emergency stop chip: quiet destructive by default, solid red + pulse
431
+ * while engaged (`is-active`), dimmed + wait cursor while the request is
432
+ * in flight (`is-loading`). */
433
+ .s-admin-header-emergency {
434
+ padding: var(--space-4) var(--space-12);
435
+ border-width: 1px;
436
+ border-radius: 0.375rem;
437
+ font-size: var(--text-xs);
438
+ /* Uno's font-size utilities also emit `line-height: 1`; the chip
439
+ * rendered that way in chest, so the class keeps it. */
440
+ line-height: 1;
441
+ font-weight: 700;
442
+ cursor: pointer;
443
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
444
+ background: rgb(220 38 38 / 10%);
445
+ color: #ef4444;
446
+ border-color: rgb(239 68 68 / 40%);
447
+ }
448
+
449
+ .s-admin-header-emergency:hover {
450
+ background: rgb(220 38 38 / 25%);
451
+ }
452
+
453
+ .s-admin-header-emergency.is-loading {
454
+ opacity: 0.5;
455
+ cursor: wait;
456
+ }
457
+
458
+ .s-admin-header-emergency.is-active {
459
+ background: #dc2626;
460
+ color: #fff;
461
+ border-color: #b91c1c;
462
+ animation: s-admin-header-emergency-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
463
+ }
464
+
465
+ @keyframes s-admin-header-emergency-pulse {
466
+ 0%,
467
+ 100% {
468
+ opacity: 1;
469
+ }
470
+ 50% {
471
+ opacity: 0.5;
472
+ }
473
+ }
474
+
475
+ @media (prefers-reduced-motion: reduce) {
476
+ .s-admin-header-emergency.is-active {
477
+ animation: none;
478
+ }
479
+ }
480
+
481
+ /* ── Admin shell layout (HkAdminShell) ──────────────────────────────────
482
+ * Replaces the `flex flex-col h-full w-full overflow-hidden` /
483
+ * `flex-1 min-h-0` utility chain the shell used to require. */
484
+ .s-admin-shell {
485
+ display: flex;
486
+ flex-direction: column;
487
+ height: 100%;
488
+ width: 100%;
489
+ overflow: hidden;
490
+ }
491
+
492
+ .s-admin-shell-body {
493
+ display: flex;
494
+ flex: 1 1 0%;
495
+ min-height: 0;
496
+ }
497
+
498
+ .s-admin-shell-main {
499
+ display: flex;
500
+ flex: 1 1 0%;
501
+ flex-direction: column;
502
+ min-width: 0;
503
+ min-height: 0;
504
+ }
505
+
506
+ .s-admin-shell-scroll {
507
+ flex: 1 1 0%;
508
+ min-height: 0;
509
+ }
510
+
511
+ /* ── Nav sidebar labels (HkNavSidebar) ─ */
512
+ .s-nav-item-label {
513
+ overflow: hidden;
514
+ text-overflow: ellipsis;
515
+ white-space: nowrap;
516
+ }
517
+
327
518
  /* ── Popup menu items ─ */
328
519
  /* Unified menu-item spec (packages/vue/src/components/_menu-item.scss):
329
520
  * one row grammar across menus, selects, theme pills, admin header
@@ -558,6 +749,14 @@
558
749
  animation: s-auth-card-in 0.45s var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1)) both;
559
750
  }
560
751
 
752
+ /* Field leading icon (HkSignInCard's inline user glyph) — sized in rem so
753
+ * it tracks a scaled root, the way the `w-4 h-4` utility it replaces did
754
+ * only in hosts that had an atomic layer. */
755
+ .s-auth-card-field-icon {
756
+ width: var(--space-16);
757
+ height: var(--space-16);
758
+ }
759
+
561
760
  @keyframes s-auth-card-in {
562
761
  from { opacity: 0; transform: translateY(-16px) scale(0.92); }
563
762
  to { opacity: 1; transform: translateY(0) scale(1); }
@@ -9,7 +9,10 @@
9
9
  // 2. theme/scale.scss — the L2 scale tokens: spacing, type, radius,
10
10
  // blur, opacity, durations, easings, z bands, fonts (vendored from
11
11
  // src/scale.scss).
12
- // 3. admin-tokens.scss — the plana-ui shared surface: layout tokens,
12
+ // 3. theme/scrollbar.scss — the shared overlay-scrollbar chrome
13
+ // (.hk-scrollbar-track/thumb) that attachOverlayScrollbars() attaches
14
+ // to every scroll region (vendored from packages/theme/styles).
15
+ // 4. admin-tokens.scss — the plana-ui shared surface: layout tokens,
13
16
  // brand-color aliases and the shared admin component classes.
14
17
  //
15
18
  // initTheme() (runtime) writes only deltas against this base into a
@@ -24,4 +27,5 @@
24
27
  @use "./reset.scss";
25
28
  @use "./theme/channels.scss";
26
29
  @use "./theme/scale.scss";
30
+ @use "./theme/scrollbar.scss";
27
31
  @use "./admin-tokens.scss";
@@ -0,0 +1,80 @@
1
+ // Vendored from packages/theme/styles/_scrollbar.scss so npm registry
2
+ // installs can resolve it — the exports map only exposes ./styles/*, and the
3
+ // overlay scrollbar the composable attaches (attachOverlayScrollbars) renders
4
+ // NOTHING without these rules: every host hid its native bar per the
5
+ // composable's contract and got an unstyled, invisible thumb in exchange
6
+ // (audited 2026-09-15: zero .hk-scrollbar-* rules in any consumer bundle).
7
+ // Edit the upstream file, then re-copy; do not hand-edit here.
8
+
9
+ // _scrollbar.scss — the shared overlay scrollbar chrome.
10
+ //
11
+ // ONE scrollbar system for the whole library: the `.hk-scrollbar-track` /
12
+ // `.hk-scrollbar-thumb` DOM is created imperatively by
13
+ // packages/vue/src/composables/useOverlayScrollbar.ts
14
+ // (attachOverlayScrollbars) and styled here. HkScrollContainer keeps its
15
+ // container-specific rules in HkScrollContainer.scss; every other scroll
16
+ // region in the library attaches the same overlay and hides its native
17
+ // bar with the standard two-liner:
18
+ //
19
+ // scrollbar-width: none;
20
+ // &::-webkit-scrollbar { display: none; }
21
+
22
+ .hk-scrollbar-track {
23
+ position: absolute;
24
+ z-index: 10;
25
+ background: transparent;
26
+ /* Click-through while faded: an invisible rail must never steal
27
+ clicks from the content beneath it (the track hugs the region's
28
+ edge, exactly where end-of-line links/buttons live). The rail
29
+ becomes interactive only while it is visible — the flash window
30
+ after a scroll (and while hovered/dragged within that window). */
31
+ pointer-events: none;
32
+ opacity: 0;
33
+ transition: opacity var(--hi-duration-fast, 0.15s) ease;
34
+
35
+ &[data-axis="horizontal"] {
36
+ left: 4px;
37
+ right: 4px;
38
+ bottom: 2px;
39
+ height: 6px;
40
+ }
41
+
42
+ &:not([data-axis="horizontal"]) {
43
+ top: 4px;
44
+ bottom: 4px;
45
+ right: 2px;
46
+ width: 6px;
47
+ }
48
+
49
+ &.is-scrolling,
50
+ &.is-hovering {
51
+ opacity: 1;
52
+ pointer-events: auto;
53
+ }
54
+ }
55
+
56
+ .hk-scrollbar-thumb {
57
+ position: absolute;
58
+ border-radius: 3px;
59
+ background: color-mix(in srgb, var(--hi-color-muted) 35%, transparent);
60
+ transition: background var(--hi-duration-fast, 0.15s) ease;
61
+
62
+ .is-hovering &,
63
+ &.is-dragging {
64
+ background: color-mix(in srgb, var(--hi-color-muted) 55%, transparent);
65
+ }
66
+ }
67
+
68
+ .hk-scrollbar-track[data-axis="horizontal"] .hk-scrollbar-thumb {
69
+ top: 0;
70
+ left: 0;
71
+ height: 100%;
72
+ min-width: 20px;
73
+ }
74
+
75
+ .hk-scrollbar-track:not([data-axis="horizontal"]) .hk-scrollbar-thumb {
76
+ top: 0;
77
+ left: 0;
78
+ width: 100%;
79
+ min-height: 20px;
80
+ }
@@ -48,6 +48,10 @@ function stripProvenanceHeader(source: string): string {
48
48
  const VENDORED_FROM_VUE: Array<[string, string]> = [
49
49
  ["theme/channels.scss", "tokens.scss"],
50
50
  ["theme/scale.scss", "scale.scss"],
51
+ // The overlay-scrollbar chrome lives with the other theme partials
52
+ // (packages/theme/styles) and is vendored here so npm consumers can
53
+ // resolve it — the exports map only exposes ./styles/*.
54
+ ["theme/scrollbar.scss", "../../theme/styles/_scrollbar.scss"],
51
55
  ];
52
56
 
53
57
 
@@ -9,6 +9,18 @@ export function bytesToBase64(bytes: ArrayBuffer | Uint8Array): string {
9
9
  return btoa(binary);
10
10
  }
11
11
 
12
+ /**
13
+ * Encode a JS string as base64, UTF-8 first.
14
+ *
15
+ * `btoa` throws `InvalidCharacterError` on any code point above U+00FF, which
16
+ * silently kills whatever called it — the demo gallery died on an em dash
17
+ * inside an inline SVG (`Hikari — zoom / pan demo`). Encode through
18
+ * `TextEncoder` so every string is encodable.
19
+ */
20
+ export function utf8ToBase64(text: string): string {
21
+ return bytesToBase64(new TextEncoder().encode(text));
22
+ }
23
+
12
24
  /** Decode a base64 string into raw bytes. */
13
25
  export function base64ToBytes(b64: string): Uint8Array {
14
26
  const binary = atob(b64);
@@ -55,12 +55,16 @@ describe("formatRelativeTime", () => {
55
55
  expect(formatRelativeTime(ago(29 * DAY))).toBe("4w ago");
56
56
  });
57
57
 
58
- it("falls to the absolute-date path at >= 30d", () => {
58
+ it("falls to the absolute-date path at >= 30d, in the app locale", async () => {
59
59
  const d = ago(30 * DAY);
60
- // Do not pin a locale-specific rendering; assert the value comes
61
- // from the same Date's toLocaleDateString().
62
- expect(formatRelativeTime(d)).toBe(d.toLocaleDateString());
63
- expect(formatRelativeTime(d).length).toBeGreaterThan(0);
60
+ // The absolute tier must follow the app-selected hikari locale (the
61
+ // R2 sweep found the old assertion compared the BROWSER default,
62
+ // which proves nothing about locale-following).
63
+ const { setLocale } = await import("../i18n/context");
64
+ await setLocale("zh-Hans");
65
+ expect(formatRelativeTime(d)).toBe(d.toLocaleDateString("zh-Hans"));
66
+ await setLocale("en");
67
+ expect(formatRelativeTime(d)).toBe(d.toLocaleDateString("en"));
64
68
  });
65
69
 
66
70
  it("clamps future timestamps into the justNow tier", () => {
@@ -107,10 +111,11 @@ describe("formatRelativeTime", () => {
107
111
  });
108
112
  });
109
113
 
110
- it("keeps absolute dates away from the translator", () => {
114
+ it("keeps absolute dates away from the translator", async () => {
111
115
  const { t, calls } = makeCaptureT();
112
116
  const d = ago(45 * DAY);
113
- expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString());
117
+ const { activeLocale } = await import("../i18n/context");
118
+ expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString(activeLocale()));
114
119
  expect(calls).toHaveLength(0);
115
120
  });
116
121
  });
@@ -56,7 +56,8 @@ export type RelativeTimeT = (
56
56
  * Pass an optional translator for localized variants; defaults to
57
57
  * compact English text. Tiers: <1min justNow, <60min minutes,
58
58
  * <24h hours, <7d days, <30d weeks, otherwise an absolute
59
- * locale-rendered date (browser locale, like formatDateTime).
59
+ * locale-rendered date (the app-selected hikari locale, like
60
+ * formatDateTime).
60
61
  * The translator owns {n} interpolation — hikari's own useI18n().t
61
62
  * does NOT interpolate named params, so wrap it (the canonical key
62
63
  * set lives in the per-locale i18n time bundles). */