@celestia-island/hikari 0.55.222 → 0.55.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.222",
3
+ "version": "0.55.224",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -1,135 +1,37 @@
1
- import { computed, defineComponent, ref, type PropType } from "vue";
2
- import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
1
+ import { defineComponent, h, type PropType } from "vue";
2
+ import { HkFaceShell } from "./HkFaceShell";
3
3
  import { provideActionBar } from "../composables/useActionBar";
4
- import { useI18n } from "../i18n/context";
5
4
 
6
- /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
7
- * padding (`inherit 0 inherit` is rejected by every engine, measured in
8
- * Chromium), so the declaration would be dropped WHOLE and the page
9
- * would lose the vertical clearance too — worse than not bleeding. */
10
- const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
5
+ // The padding-scan utilities moved INTO HkFaceShell with the content
6
+ // host that applies them (2026-10-29 extraction). Re-exported here
7
+ // because HkAdminShell.padding.test.ts and any external importer know
8
+ // this module as their home — the extraction must not move the public
9
+ // surface, only the implementation.
10
+ export {
11
+ scanPaddingSides,
12
+ splitPaddingSides,
13
+ mobileSideGutterFree,
14
+ } from "./HkFaceShell";
15
+ export type { PaddingSidesScan } from "./HkFaceShell";
11
16
 
12
17
  /**
13
- * A scanned padding shorthand: the top-level `sides` plus whether the
14
- * value ENDED with every function closed and every quote terminated.
15
- * `balanced: false` means the scan cannot know where the value ends —
16
- * CSS closes an open function at EOF, so the authored declaration can
17
- * still be valid while a rewrite of it cannot be.
18
- */
19
- export interface PaddingSidesScan {
20
- sides: string[];
21
- balanced: boolean;
22
- }
23
-
24
- /**
25
- * Scan a CSS padding shorthand into its top-level tracks: split on
26
- * TOP-LEVEL whitespace, quote- and function-aware, so whitespace inside
27
- * a function belongs to that value — `calc(1rem + 2px)`,
28
- * `var(--pad, 1rem)` and a quoted argument are ONE track. A plain
29
- * `split(/\s+/)` shreds them (`["calc(1rem", "+", "2px)"]`), and the
30
- * rebuilt shorthand is invalid CSS — a browser drops the whole
31
- * declaration, taking with it the vertical clearance
32
- * `contentBleedOnMobile` promises to keep (rounds 2 and 3 of this change
33
- * hit exactly that; rounds 4 and 5 the escape and EOF variants). The
34
- * depth counter balances parentheses, so nested functions
35
- * (`clamp(1rem, min(2vw, 3px), 4rem)`) survive; a stray `)` never drives
36
- * the depth negative. Tabs and newlines are valid CSS separators and
37
- * split like spaces, and the whitespace around a shorthand yields no
38
- * empty tracks.
18
+ * HkAdminShell — the ADMIN face of the generalized shell
19
+ * (2026-10-29 extraction): HkFaceShell in document mode, plus the
20
+ * legacy action-bar channel. Everything structural — the frame, the
21
+ * optional sidebar and its mobile drawer, the scroll-viewport content
22
+ * host with `contentPadding` / `contentKey` / `contentBleedOnMobile`,
23
+ * the fixed footer band and its height reservation, the overlays slot —
24
+ * lives in HkFaceShell now; this component is a thin, behavior-identical
25
+ * wrapper kept for every existing consumer (props, slots and rendered
26
+ * classes are unchanged).
39
27
  *
40
- * Exported for its own unit test: happy-dom cannot round-trip
41
- * `var()`/`clamp()` values or the hostile inputs below, so the
42
- * component-level suite cannot observe them.
28
+ * The one thing the wrapper ADDS is its own action-bar provide: hosts
29
+ * of the pre-extraction contract read the registered actions through
30
+ * the header slot's `actions` param (the bar this shell provides
31
+ * itself). Faces that own their action bar — or their whole content
32
+ * lifecycle, like a canvas stage — consume HkFaceShell directly and
33
+ * provide whatever they want, closest to their pages.
43
34
  */
44
- export function scanPaddingSides(value: string): PaddingSidesScan {
45
- const sides: string[] = [];
46
- let current = "";
47
- let depth = 0;
48
- let quote = "";
49
- for (const ch of value) {
50
- if (quote) {
51
- current += ch;
52
- if (ch === quote) quote = "";
53
- continue;
54
- }
55
- if (ch === "\"" || ch === "'") {
56
- quote = ch;
57
- current += ch;
58
- continue;
59
- }
60
- if (ch === "(") depth += 1;
61
- else if (ch === ")") depth = Math.max(0, depth - 1);
62
- if (depth === 0 && /\s/.test(ch)) {
63
- if (current) sides.push(current);
64
- current = "";
65
- continue;
66
- }
67
- current += ch;
68
- }
69
- if (current) sides.push(current);
70
- return { sides, balanced: depth === 0 && quote === "" };
71
- }
72
-
73
- /** The tracks of {@link scanPaddingSides} — for callers that only need
74
- * the split, and for the unit test that pins the splitting rules. */
75
- export function splitPaddingSides(value: string): string[] {
76
- return scanPaddingSides(value).sides;
77
- }
78
-
79
- /**
80
- * The side-gutter-free form of a padding shorthand: the vertical tracks
81
- * survive, the horizontal ones become zero (`"1rem 2rem"` -> `"1rem 0
82
- * 1rem"`). The value is returned VERBATIM — the page keeps its padding
83
- * and simply does not bleed — whenever the shorthand cannot be rewritten
84
- * safely. That fallback IS the contract: emitting a declaration a browser
85
- * drops costs the page ALL FOUR sides, including the vertical clearance
86
- * this prop exists to keep.
87
- *
88
- * Verbatim cases:
89
- *
90
- * - an empty / whitespace-only value has nothing to strip;
91
- * - a LONE CSS-wide keyword cannot be combined per side (`inherit 0
92
- * inherit` is not a padding — every engine rejects it);
93
- * - the value contains a CSS comment, whose text would land in the
94
- * rebuilt declaration as a garbage track (`1rem 0 x`, measured in
95
- * Chromium: dropped, all four sides lost);
96
- * - the value contains a backslash: this scanner does not decode CSS
97
- * escapes, and an escape can hide a quote, a parenthesis or a whole
98
- * keyword (`\69 nherit` IS `inherit` to an engine) — the literal text
99
- * is not the value, so no rewrite of it is trustworthy (round 5);
100
- * - the value ends with a `!important` annotation: the annotation
101
- * belongs to the DECLARATION, so a per-side rewrite would either
102
- * silently lose the priority (`2rem !important`) or rebuild the
103
- * `inherit 0 inherit` every engine rejects (`inherit !important`,
104
- * measured in Chromium, round 5);
105
- * - the scan ends inside an open function or an unterminated quote: CSS
106
- * auto-closes a function at EOF, so `"calc(1rem + 2px"` IS a real
107
- * padding (measured: all four sides) while its rewrite is not; an
108
- * unterminated quote is invalid at both ends, and bailing keeps the
109
- * two paths identical (round 5).
110
- *
111
- * Known residual (documented, not fixable at string level): a `var()`
112
- * whose custom property resolves to MORE than one track cannot keep its
113
- * vertical half — `padding: var(--multi) 0 var(--multi)` is valid at
114
- * parse time and invalid at computed-value time, and all four sides
115
- * collapse. No consumer passes a multi-track custom property today.
116
- */
117
- export function mobileSideGutterFree(value: string): string {
118
- if (value.includes("/*") || value.includes("\\")) return value;
119
- // `!important` is an annotation on the DECLARATION, not a track — it
120
- // cannot ride a per-side rewrite: `2rem !important` would silently lose
121
- // its priority, and `inherit !important` would rebuild into the
122
- // `inherit 0 inherit` every engine drops (measured in Chromium, round
123
- // 5). Bail out and let the authored padding stand.
124
- if (/!\s*important\s*$/i.test(value)) return value;
125
- const { sides, balanced } = scanPaddingSides(value);
126
- if (!balanced) return value;
127
- if (sides.length === 0) return value;
128
- if (sides.length === 1 && CSS_WIDE_KEYWORDS.has(sides[0].toLowerCase())) return value;
129
- const [top, , bottom] = sides;
130
- return `${top} 0 ${bottom ?? top}`;
131
- }
132
-
133
35
  export const HkAdminShell = defineComponent({
134
36
  name: "HkAdminShell",
135
37
  props: {
@@ -145,11 +47,9 @@ export const HkAdminShell = defineComponent({
145
47
  * whether the shell actually renders a status bar — a filled
146
48
  * `footer` slot (the shell's own fixed `.s-status-bar`) reserves
147
49
  * `var(--s-footer-height)` by default, and with NO footer slot
148
- * nothing is reserved (`0px`). The old unconditional default
149
- * reserved the band on every host, leaving a dead 3rem blank under
150
- * consoles that never mount a status bar. Hosts that pin their OWN
151
- * fixed bar outside the shell opt back in by passing an explicit
152
- * value (e.g. `"var(--s-footer-height)"`). */
50
+ * nothing is reserved (`0px`). Hosts that pin their OWN fixed bar
51
+ * outside the shell opt back in by passing an explicit value
52
+ * (e.g. `"var(--s-footer-height)"`). */
153
53
  footerHeight: { type: String, default: undefined },
154
54
  navTitle: { type: String, default: undefined },
155
55
  /** Extra class for the mobile nav drawer's panel — lets consumers zero
@@ -188,134 +88,52 @@ export const HkAdminShell = defineComponent({
188
88
  * of risking a declaration a browser would drop whole.
189
89
  * Default false: the padding reads the same at every width. */
190
90
  contentBleedOnMobile: { type: Boolean, default: false },
91
+ /** The content host mode, forwarded to HkFaceShell. DECLARED on
92
+ * this wrapper on purpose (R2 S2, 2026-10-29): an undeclared input
93
+ * would fall through `$attrs` onto the inner shell, where it IS a
94
+ * declared prop — and fallthrough attrs override explicit props,
95
+ * so a stray `content-mode="stage"` from a template or a
96
+ * `v-bind="$attrs"` relay would silently flip the admin shell into
97
+ * the stage host. Declared here, the input is a real prop of this
98
+ * component and forwards honestly; the default stays `document`,
99
+ * which is what the admin face is. */
100
+ contentMode: { type: String as PropType<"document" | "stage">, default: "document" },
191
101
  },
192
102
  setup(props, { slots }) {
193
- const { t } = useI18n();
194
- const { width: viewportWidth } = useBreakpoint();
195
- const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
196
- const sidebarOpen = ref(false);
197
-
198
- const contentStyle = computed(() => ({
199
- padding: isDesktop.value || !props.contentBleedOnMobile
200
- ? props.contentPadding
201
- : mobileSideGutterFree(props.contentPadding),
202
- }));
203
-
204
103
  const actionBar = provideActionBar();
205
104
 
206
- // The status-bar band exists only when the shell renders one (the
207
- // footer slot) or the host pins its own bar and says so via the prop
208
- // — an unreserved shell carries no dead clearance (2026-10-24: the
209
- // unconditional default used to blank 3rem under every console that
210
- // never mounted a status bar). Computed INSIDE the render (not a
211
- // `computed()`): slot PRESENCE is what it reads, and a cached
212
- // computed is not reliably invalidated by a slot appearing or
213
- // disappearing, while every re-render re-evaluates this line.
214
- function footerPad(): string {
215
- if (props.footerHeight !== undefined) return props.footerHeight;
216
- return slots.footer ? "var(--s-footer-height)" : "0px";
217
- }
218
-
219
- const toggleHamburger = () => {
220
- sidebarOpen.value = !sidebarOpen.value;
221
- };
222
-
223
- const openSidebar = () => {
224
- sidebarOpen.value = true;
225
- };
226
-
227
- const closeSidebar = () => {
228
- sidebarOpen.value = false;
229
- };
230
-
231
- return () => (
232
- <div class="s-admin-shell">
233
- {slots.header && (
234
- <div style={{ flexShrink: 0 }}>
235
- {slots.header({
236
- isDesktop: isDesktop.value,
237
- showHamburger: !isDesktop.value,
238
- compact: !isDesktop.value,
239
- actions: actionBar.actions.value ? actionBar.actions.value() : [],
240
- onHamburger: toggleHamburger,
241
- // Lets a header trigger (e.g. the avatar in "drawer" action
242
- // mode) open the mobile nav drawer directly.
243
- onOpenDrawer: openSidebar,
244
- })}
245
- </div>
246
- )}
247
-
248
- <div class="s-admin-shell-body" style={{ paddingBottom: footerPad() }}>
249
- {isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
250
- <aside
251
- style={{
252
- width: props.sidebarWidth,
253
- flexShrink: 0,
254
- borderRight: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
255
- background: "rgb(var(--color-surface))",
256
- overflow: "hidden",
257
- }}
258
- >
259
- {slots.sidebar({ collapsed: false, onNavigate: closeSidebar })}
260
- </aside>
261
- )}
262
- <main class="s-admin-shell-main">
263
- <HScrollContainer class="s-admin-shell-scroll">
264
- {/* Padding lives INSIDE the scroll viewport (an inner
265
- wrapper) so card box-shadows are not clipped at the
266
- viewport edges. */}
267
- <div style={contentStyle.value}>
268
- {props.contentKey != null ? (
269
- <HkCrossfade swapKey={props.contentKey} class="s-admin-shell-swap">
270
- {slots.content?.()}
271
- </HkCrossfade>
272
- ) : (
273
- slots.content?.()
274
- )}
275
- </div>
276
- </HScrollContainer>
277
- </main>
278
-
279
- {!isDesktop.value && (
280
- <HDrawer
281
- modelValue={sidebarOpen.value}
282
- onUpdate:modelValue={(v: boolean) => (sidebarOpen.value = v)}
283
- side="left"
284
- size="280px"
285
- title={props.navTitle ?? t("hikari::adminShell.navTitle", "Navigation")}
286
- panelClass={props.drawerPanelClass}
287
- >
288
- {/* The drawer body carries the nav; a `userPanel` slot rides
289
- the drawer footer (identity + account actions) so mobile
290
- gets the same content the desktop user menu exposes.
291
- `inDrawer` lets the sidebar slot fill the drawer width.
292
- The userPanel receives `onNavigate` (closes the drawer)
293
- so its action rows — e.g. "go to frontend", which swaps
294
- the whole layout underneath — can dismiss the drawer
295
- instead of leaving it hovering over the new page. */}
296
- {{
297
- default: () =>
298
- slots.sidebar?.({ collapsed: false, onNavigate: closeSidebar, inDrawer: true }),
299
- footer: slots.userPanel
300
- ? () => slots.userPanel!({ onNavigate: closeSidebar })
301
- : undefined,
302
- }}
303
- </HDrawer>
304
- )}
305
- </div>
306
-
307
- {slots.footer && (
308
- <footer class="s-status-bar" style={{ position: "fixed", bottom: 0, left: 0, right: 0, // Footer band (was a bare 40 — the pre-band chrome value).
309
- zIndex: "var(--hk-z-toolbar)" }}>
310
- {slots.footer()}
311
- </footer>
312
- )}
313
-
314
- {/* Scoped slots are functions — rendering the slot itself instead of
315
- calling it would stringify the compiled withCtx source into a text
316
- node (normalizeVNode String()s non-vnode children). */}
317
- {slots.overlays?.()}
318
- </div>
319
- );
105
+ return () =>
106
+ h(HkFaceShell, {
107
+ sidebarCollapsed: props.sidebarCollapsed,
108
+ sidebarWidth: props.sidebarWidth,
109
+ mobileBreakpoint: props.mobileBreakpoint,
110
+ footerHeight: props.footerHeight,
111
+ navTitle: props.navTitle,
112
+ drawerPanelClass: props.drawerPanelClass,
113
+ // The admin face IS the document mode by default — stage faces
114
+ // normally consume HkFaceShell directly; the forward keeps an
115
+ // explicit opt-in honest (and the declared prop above keeps a
116
+ // stray attr from ever reaching the inner shell on its own).
117
+ contentMode: props.contentMode,
118
+ contentPadding: props.contentPadding,
119
+ contentKey: props.contentKey,
120
+ contentBleedOnMobile: props.contentBleedOnMobile,
121
+ }, {
122
+ header: slots.header
123
+ ? (shellParams: Record<string, unknown>) =>
124
+ slots.header!({
125
+ ...shellParams,
126
+ // The legacy channel this wrapper exists to keep: the
127
+ // actions of the bar provided above, handed to the header
128
+ // slot exactly as before the extraction.
129
+ actions: actionBar.actions.value ? actionBar.actions.value() : [],
130
+ })
131
+ : undefined,
132
+ sidebar: slots.sidebar,
133
+ userPanel: slots.userPanel,
134
+ content: slots.content,
135
+ footer: slots.footer,
136
+ overlays: slots.overlays,
137
+ });
320
138
  },
321
139
  });
@@ -93,6 +93,11 @@
93
93
  }
94
94
 
95
95
  .hk-alert-text {
96
+ /* Alert body = the same copy-this-error-detail content class as the
97
+ * toast message and message-box text (L2 selection policy re-enables,
98
+ * 2026-10-29); the title above stays chrome. */
99
+ -webkit-user-select: text;
100
+ user-select: text;
96
101
  margin: 0;
97
102
  line-height: 1.5;
98
103
  color: color-mix(in srgb, var(--hi-color-text-secondary, #666666) 85%, transparent);
@@ -58,10 +58,17 @@
58
58
  }
59
59
 
60
60
  .hk-blocking-toast-title {
61
+ /* Content, not chrome — copy-worthy detail under the L2 selection
62
+ * policy (2026-10-29): the card shell denies, these two text blocks
63
+ * select again. */
64
+ -webkit-user-select: text;
65
+ user-select: text;
61
66
  font-weight: 700;
62
67
  }
63
68
 
64
69
  .hk-blocking-toast-message {
70
+ -webkit-user-select: text;
71
+ user-select: text;
65
72
  margin: 0;
66
73
  display: -webkit-box;
67
74
  -webkit-line-clamp: 5;
@@ -15,6 +15,10 @@
15
15
  }
16
16
 
17
17
  .hk-confirm-dialog-message {
18
+ /* Dialog message = copy-worthy content, same class as the message-box
19
+ * text (L2 selection policy re-enables, 2026-10-29). */
20
+ -webkit-user-select: text;
21
+ user-select: text;
18
22
  margin: 0;
19
23
  color: color-mix(in srgb, var(--hi-color-text-secondary) 85%, transparent);
20
24
  font-size: var(--text-sm, 0.8125rem);
@@ -67,6 +67,10 @@
67
67
  }
68
68
 
69
69
  .hk-diff-viewer__code {
70
+ /* The diff body is content (review text users copy into replies);
71
+ * the line-number gutter above and the sign column stay denied. */
72
+ -webkit-user-select: text;
73
+ user-select: text;
70
74
  padding-inline-end: 1ch;
71
75
  min-width: 0;
72
76
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as barrel from "../index";
3
+
4
+ /**
5
+ * The public barrel guard for HkFaceShell (R3 M6 on PR #900): deleting
6
+ * the export line from index.ts kept the ENTIRE suite green before this
7
+ * pin — every other test reaches the component through the direct
8
+ * "./HkFaceShell" path, so only an external consumer would have noticed.
9
+ * Same shape as theme/themeDecorSurface.test.ts.
10
+ *
11
+ * This file deliberately does NOT mock "@celestia-island/hikari": the
12
+ * package's self-reference resolves to the very same src/index.ts module
13
+ * as "../index", so a vi.mock here would replace the barrel under test
14
+ * with the mock's own namespace (measured: HkFaceShell read as undefined
15
+ * through `...actual` under the re-entrant factory).
16
+ */
17
+ describe("the public barrel", () => {
18
+ it("exports HkFaceShell (deleting the line kept the suite green before this pin)", () => {
19
+ // defineComponent's object syntax yields a plain object, so the
20
+ // identity check is the component's own `name`, not typeof.
21
+ const face = barrel.HkFaceShell as unknown as { name?: string };
22
+ expect(face, "HkFaceShell must be re-exported").toBeTruthy();
23
+ expect(face.name).toBe("HkFaceShell");
24
+ // The admin wrapper stays reachable too — the extraction must not
25
+ // move the public surface.
26
+ const admin = barrel.HkAdminShell as unknown as { name?: string };
27
+ expect(admin, "HkAdminShell must stay exported").toBeTruthy();
28
+ expect(admin.name).toBe("HkAdminShell");
29
+ });
30
+ });