@celestia-island/hikari 0.54.2 → 0.54.4

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.2",
3
+ "version": "0.54.4",
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 & {
@@ -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
  )}
@@ -10,19 +10,40 @@ import { getModelMeta, registerModelCatalog } from "./HkModelCatalog";
10
10
  * keeps unknown-to-nothing lineups legible.
11
11
  */
12
12
  const WORLD_LINEUP = [
13
- "glm-5.3-flash",
14
- "claude-opus-5",
13
+ // integration world_service `agent_models` (world_content.json) — the
14
+ // model ids demo cruise cards actually carry today.
15
+ "gpt-5.6-sol-pro",
16
+ "claude-sonnet-5",
15
17
  "deepseek-v4-pro",
16
- "deepseek-v4-flash",
17
- "gemini-3.1-flash-preview",
18
+ "gemini-3.1-flash-lite",
18
19
  "gpt-5.5-pro",
19
- "gemini-3.1-pro-preview",
20
- "gpt-5.4",
21
- "gpt-5.4-nano",
20
+ "gemini-2.5-pro",
21
+ "gpt-5.6-luna",
22
+ "deepseek-v4-flash",
23
+ "gemini-3.5-flash",
22
24
  "claude-haiku-4-5",
23
- "gemini-3-flash",
24
- "qwen3.7:14b",
25
- "qwen3.8:27b",
25
+ "qwen3.6:35b-a3b",
26
+ "qwen3.6:27b",
27
+ "gpt-5.4-nano",
28
+ ];
29
+
30
+ /** demo db/seed.sql provider lists — every registry spelling, including the
31
+ * dotted Anthropic aliases, must resolve on its own (exact-match lookup). */
32
+ const REGISTRY_LINEUP = [
33
+ "claude-sonnet-5", "claude-fable-5", "claude-opus-4.8-fast", "claude-opus-4.8",
34
+ "claude-opus-4.7", "claude-sonnet-4.6", "claude-haiku-4-5", "claude-haiku-4.5",
35
+ "claude-opus-4.5",
36
+ "gpt-5.6-terra-pro", "gpt-5.6-terra", "gpt-5.6-sol-pro", "gpt-5.6-sol",
37
+ "gpt-5.6-luna-pro", "gpt-5.6-luna", "gpt-5.5-pro", "gpt-5.5", "gpt-5.4",
38
+ "gpt-5.4-mini", "gpt-5.4-nano",
39
+ "gemini-3.5-flash", "gemini-3.1-flash-lite", "gemini-3.1-flash-image",
40
+ "gemini-3.1-flash-lite-image", "gemini-3-pro-image", "gemini-2.5-pro",
41
+ "gemini-2.5-flash", "gemini-2.5-flash-lite",
42
+ "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2",
43
+ "deepseek-v3.1-terminus", "deepseek-chat-v3.1",
44
+ "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-4.7-flashx",
45
+ "glm-4.7-flash", "glm-4.6v",
46
+ "qwen3.6:35b-a3b", "qwen3.6:27b",
26
47
  ];
27
48
 
28
49
  describe("HkModelCatalog builtin specs", () => {
@@ -34,9 +55,18 @@ describe("HkModelCatalog builtin specs", () => {
34
55
  }
35
56
  });
36
57
 
58
+ it("resolves every demo registry spelling, dotted aliases included", () => {
59
+ for (const model of REGISTRY_LINEUP) {
60
+ const meta = getModelMeta(model);
61
+ expect(meta, `${model} resolves`).toBeDefined();
62
+ expect(meta?.contextWindow ?? 0, `${model} window`).toBeGreaterThan(0);
63
+ }
64
+ });
65
+
37
66
  it("keeps ollama-style colon tags whole (only # splits)", () => {
67
+ expect(getModelMeta("qwen3.6:27b")?.contextWindow).toBe(262_144);
68
+ expect(getModelMeta("qwen3.6:35b-a3b")?.contextWindow).toBe(262_144);
38
69
  expect(getModelMeta("qwen3.7:14b")?.contextWindow).toBe(262_144);
39
- expect(getModelMeta("qwen3.8:27b")?.contextWindow).toBe(262_144);
40
70
  });
41
71
 
42
72
  it("still strips a #tag before lookup", () => {
@@ -117,6 +117,185 @@ const BUILTIN_CATALOG: HModelCatalog = {
117
117
  contextWindow: 262_144, maxOutput: 32_768,
118
118
  vision: false, reasoning: true, tools: true,
119
119
  },
120
+
121
+ // ── Demo registry generation (integration db/seed.sql provider lists +
122
+ // world_service agent_models). Every id a demo card can carry must
123
+ // resolve here, or its context ring degrades to the empty "–" state.
124
+ // Anthropic — dotted ids are the registry's own spellings, kept as
125
+ // first-class entries so exact-match lookup never misses.
126
+ "claude-sonnet-5": {
127
+ contextWindow: 200_000, maxOutput: 64_000,
128
+ pricing: { in: 3, out: 15, cached: 0.3 },
129
+ vision: true, reasoning: true, tools: true,
130
+ },
131
+ "claude-fable-5": {
132
+ contextWindow: 200_000, maxOutput: 32_000,
133
+ pricing: { in: 3, out: 15 },
134
+ vision: true, reasoning: false, tools: true,
135
+ },
136
+ "claude-opus-4.8": {
137
+ contextWindow: 200_000, maxOutput: 64_000,
138
+ pricing: { in: 5, out: 25, cached: 0.5 },
139
+ vision: true, reasoning: true, tools: true,
140
+ },
141
+ "claude-opus-4.8-fast": {
142
+ contextWindow: 200_000, maxOutput: 32_000,
143
+ pricing: { in: 3, out: 15, cached: 0.5 },
144
+ vision: true, reasoning: true, tools: true,
145
+ },
146
+ "claude-opus-4.7": {
147
+ contextWindow: 200_000, maxOutput: 32_000,
148
+ pricing: { in: 5, out: 25, cached: 0.5 },
149
+ vision: true, reasoning: true, tools: true,
150
+ },
151
+ "claude-opus-4.5": {
152
+ contextWindow: 200_000, maxOutput: 32_000,
153
+ pricing: { in: 5, out: 25 },
154
+ vision: true, reasoning: true, tools: true,
155
+ },
156
+ "claude-sonnet-4.6": {
157
+ contextWindow: 200_000, maxOutput: 32_000,
158
+ pricing: { in: 3, out: 15, cached: 0.3 },
159
+ vision: true, reasoning: false, tools: true,
160
+ },
161
+ "claude-haiku-4.5": {
162
+ contextWindow: 200_000, maxOutput: 32_000,
163
+ pricing: { in: 0.8, out: 4, cached: 0.08 },
164
+ vision: false, reasoning: false, tools: true,
165
+ },
166
+ // OpenAI — the 5.6 terra/sol/luna tiers.
167
+ "gpt-5.6-terra-pro": {
168
+ contextWindow: 400_000, maxOutput: 100_000,
169
+ pricing: { in: 12, out: 60, cached: 1.2 },
170
+ vision: true, reasoning: true, tools: true,
171
+ },
172
+ "gpt-5.6-terra": {
173
+ contextWindow: 400_000, maxOutput: 64_000,
174
+ pricing: { in: 6, out: 30, cached: 0.6 },
175
+ vision: true, reasoning: true, tools: true,
176
+ },
177
+ "gpt-5.6-sol-pro": {
178
+ contextWindow: 400_000, maxOutput: 100_000,
179
+ pricing: { in: 10, out: 50, cached: 1 },
180
+ vision: true, reasoning: true, tools: true,
181
+ },
182
+ "gpt-5.6-sol": {
183
+ contextWindow: 400_000, maxOutput: 64_000,
184
+ pricing: { in: 5, out: 30, cached: 0.5 },
185
+ vision: true, reasoning: true, tools: true,
186
+ },
187
+ "gpt-5.6-luna-pro": {
188
+ contextWindow: 400_000, maxOutput: 64_000,
189
+ pricing: { in: 4, out: 20, cached: 0.4 },
190
+ vision: true, reasoning: true, tools: true,
191
+ },
192
+ "gpt-5.6-luna": {
193
+ contextWindow: 400_000, maxOutput: 32_000,
194
+ pricing: { in: 1.5, out: 10, cached: 0.15 },
195
+ vision: true, reasoning: true, tools: true,
196
+ },
197
+ "gpt-5.4-mini": {
198
+ contextWindow: 400_000, maxOutput: 32_000,
199
+ pricing: { in: 1.1, out: 4.4, cached: 0.11 },
200
+ vision: false, reasoning: false, tools: true,
201
+ },
202
+ // Google — 3.1 image variants and the 2.5 series.
203
+ "gemini-3.1-flash-lite": {
204
+ contextWindow: 1_000_000, maxOutput: 32_000,
205
+ pricing: { in: 0.5, out: 3 },
206
+ vision: true, reasoning: false, tools: true,
207
+ },
208
+ "gemini-3.1-flash-image": {
209
+ contextWindow: 1_000_000, maxOutput: 32_000,
210
+ pricing: { in: 1.5, out: 9 },
211
+ vision: true, reasoning: false, tools: true,
212
+ },
213
+ "gemini-3.1-flash-lite-image": {
214
+ contextWindow: 1_000_000, maxOutput: 32_000,
215
+ pricing: { in: 0.5, out: 3 },
216
+ vision: true, reasoning: false, tools: true,
217
+ },
218
+ "gemini-3-pro-image": {
219
+ contextWindow: 1_000_000, maxOutput: 64_000,
220
+ pricing: { in: 5, out: 30 },
221
+ vision: true, reasoning: true, tools: true,
222
+ },
223
+ "gemini-2.5-pro": {
224
+ contextWindow: 1_000_000, maxOutput: 64_000,
225
+ pricing: { in: 1.25, out: 10 },
226
+ vision: true, reasoning: true, tools: true,
227
+ },
228
+ "gemini-2.5-flash": {
229
+ contextWindow: 1_000_000, maxOutput: 64_000,
230
+ pricing: { in: 0.3, out: 2.5 },
231
+ vision: true, reasoning: false, tools: true,
232
+ },
233
+ "gemini-2.5-flash-lite": {
234
+ contextWindow: 1_000_000, maxOutput: 64_000,
235
+ pricing: { in: 0.1, out: 0.4 },
236
+ vision: true, reasoning: false, tools: true,
237
+ },
238
+ // DeepSeek — the v3 line the registry still serves.
239
+ "deepseek-v3.2": {
240
+ contextWindow: 128_000, maxOutput: 8_000,
241
+ pricing: { in: 0.3, out: 1.2 },
242
+ vision: false, reasoning: true, tools: true,
243
+ },
244
+ "deepseek-v3.1-terminus": {
245
+ contextWindow: 128_000, maxOutput: 8_000,
246
+ pricing: { in: 0.3, out: 1.2 },
247
+ vision: false, reasoning: true, tools: true,
248
+ },
249
+ "deepseek-chat-v3.1": {
250
+ contextWindow: 128_000, maxOutput: 8_000,
251
+ pricing: { in: 0.3, out: 1.2 },
252
+ vision: false, reasoning: false, tools: true,
253
+ },
254
+ // Zhipu GLM — the 5.x / 4.7 line.
255
+ "glm-5.2": {
256
+ contextWindow: 200_000, maxOutput: 32_000,
257
+ pricing: { in: 1, out: 4 },
258
+ vision: false, reasoning: true, tools: true,
259
+ },
260
+ "glm-5.1": {
261
+ contextWindow: 128_000, maxOutput: 32_000,
262
+ pricing: { in: 0.8, out: 3 },
263
+ vision: false, reasoning: true, tools: true,
264
+ },
265
+ "glm-5": {
266
+ contextWindow: 128_000, maxOutput: 32_000,
267
+ pricing: { in: 0.7, out: 2.8 },
268
+ vision: false, reasoning: true, tools: true,
269
+ },
270
+ "glm-5-turbo": {
271
+ contextWindow: 128_000, maxOutput: 16_000,
272
+ pricing: { in: 0.3, out: 1.2 },
273
+ vision: false, reasoning: false, tools: true,
274
+ },
275
+ "glm-4.7-flashx": {
276
+ contextWindow: 128_000, maxOutput: 16_000,
277
+ pricing: { in: 0.2, out: 0.8 },
278
+ vision: false, reasoning: false, tools: true,
279
+ },
280
+ "glm-4.7-flash": {
281
+ contextWindow: 128_000, maxOutput: 8_000,
282
+ pricing: { in: 0.1, out: 0.4 },
283
+ vision: false, reasoning: false, tools: true,
284
+ },
285
+ "glm-4.6v": {
286
+ contextWindow: 128_000, maxOutput: 16_000,
287
+ pricing: { in: 0.6, out: 2.4 },
288
+ vision: true, reasoning: false, tools: true,
289
+ },
290
+ // Ollama local — the qwen3.6 GGUF lineup.
291
+ "qwen3.6:35b-a3b": {
292
+ contextWindow: 262_144, maxOutput: 32_768,
293
+ vision: false, reasoning: true, tools: true,
294
+ },
295
+ "qwen3.6:27b": {
296
+ contextWindow: 262_144, maxOutput: 32_768,
297
+ vision: false, reasoning: true, tools: true,
298
+ },
120
299
  };
121
300
 
122
301
  /**
@@ -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,237 @@
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
+ const files = sheets();
153
+ // The scan above only fails when it actually sees sheets; a moved/renamed
154
+ // styles directory would otherwise make it pass over an empty input set.
155
+ expect(files.length, "stylesheets discovered for the scan").toBeGreaterThan(100);
156
+ let clipRulesSeen = 0;
157
+
158
+ for (const file of files) {
159
+ const name = file.slice(file.indexOf("/src/") + 1);
160
+ // sass keeps `/* … */` comments in expanded output, and a comment that
161
+ // *talks* about `overflow: hidden` or `line-height: 1` would otherwise be
162
+ // parsed as a declaration (that bug made this guard pass while the very
163
+ // clip it was written for was live).
164
+ const css = compile(file, { style: "expanded", loadPaths: [componentDir, stylesDir] }).css.replace(
165
+ /\/\*[\s\S]*?\*\//g,
166
+ "",
167
+ );
168
+ const blocks = [...blocksOf(css, name)];
169
+ for (const block of blocks) {
170
+ if (!clipsOverflow(block.body)) continue;
171
+ clipRulesSeen++;
172
+ const ratio = effectiveRatio(block, blocks);
173
+ if (ratio === null || ratio >= SAFE_LINE_HEIGHT_RATIO) continue;
174
+ if (allowed.has(`${block.file}|${block.selector}`)) continue;
175
+ findings.push(
176
+ `${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`,
177
+ );
178
+ }
179
+ }
180
+
181
+ expect(clipRulesSeen, "clip boxes discovered across the sheets").toBeGreaterThan(20);
182
+ expect(findings.sort(), "clip boxes whose line box is too short for the font's ink").toEqual([]);
183
+ });
184
+
185
+ it("keeps the reviewed rules descender-safe", { timeout: 60_000 }, () => {
186
+ // The generic scan above only inspects rules that declare `overflow`
187
+ // themselves. These rules were fixed for the same class but carry no clip
188
+ // in-tree — `.hk-gauge-ring-value` is centred inside the ring and gets its
189
+ // clip from whatever host wrapper truncates — so a refactor that quietly
190
+ // drops their ratio would pass the scan. Pin them by selector.
191
+ const pinned: ReadonlyArray<{ file: string; selector: string; minRatio: number }> = [
192
+ { file: "HkColorPicker.scss", selector: ".hk-color-picker-label", minRatio: 1.4 },
193
+ { file: "HkStatCard.scss", selector: ".hk-stat-card-value", minRatio: 1.3 },
194
+ { file: "HkGaugeRing.scss", selector: ".hk-gauge-ring-value", minRatio: 1.4 },
195
+ ];
196
+
197
+ const compiled = new Map<string, ReturnType<typeof compile>["css"]>();
198
+ const found: string[] = [];
199
+ for (const pin of pinned) {
200
+ const file = resolve(componentDir, pin.file);
201
+ if (!compiled.has(file)) {
202
+ compiled.set(
203
+ file,
204
+ compile(file, { style: "expanded", loadPaths: [componentDir, stylesDir] }).css.replace(
205
+ /\/\*[\s\S]*?\*\//g,
206
+ "",
207
+ ),
208
+ );
209
+ }
210
+ const css = compiled.get(file)!;
211
+ const block = [...blocksOf(css, pin.file)].find((entry) => entry.selector === pin.selector);
212
+ if (!block) {
213
+ found.push(`${pin.file} — ${pin.selector} — the pinned rule no longer exists`);
214
+ continue;
215
+ }
216
+ const ratio = ratioOf(block.body);
217
+ if (ratio === null) {
218
+ found.push(`${pin.file} — ${pin.selector} — declares no line-height of its own any more`);
219
+ } else if (ratio < pin.minRatio) {
220
+ found.push(
221
+ `${pin.file} — ${pin.selector} — line-height ratio ${ratio.toFixed(2)} fell below the reviewed floor ${pin.minRatio}`,
222
+ );
223
+ }
224
+ }
225
+
226
+ expect(found.sort(), "pinned descender-safe rules regressed").toEqual([]);
227
+ });
228
+
229
+ it("keeps the review list honest", () => {
230
+ const unmatched = ALLOWED.filter((entry) => {
231
+ const file = resolve(componentDir, "..", entry.file.replace(/^src\//, ""));
232
+ return !readFileSync(file, "utf8").includes(entry.selector.split(" ").pop()!);
233
+ }).map((entry) => `${entry.file} — ${entry.selector} (${entry.why})`);
234
+ // A stale exemption hides a future regression: it must be deleted.
235
+ expect(unmatched).toEqual([]);
236
+ });
237
+ });
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);