@celestia-island/hikari 0.55.246 → 0.55.247

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.246",
3
+ "version": "0.55.247",
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",
@@ -23,6 +23,25 @@
23
23
  display: block;
24
24
  }
25
25
 
26
+ /* The FOOT band is an action band, not a run of selectable rows — the
27
+ * "add workspace" button under the picker's options, a context menu's
28
+ * custom tail. It reads as its own band only when it is SEPARATED at the
29
+ * divider's DESKTOP grade (6px; the divider itself widens to 10px inside
30
+ * sheets, but this band's separation is a layout rhythm BETWEEN bands,
31
+ * not row geometry, so it stays 6px in both token sets), never at the
32
+ * 2px row gap: consumers used to hand that separation themselves as
33
+ * wrapper padding, which is exactly the drift that padded the wrapper's
34
+ * rows to a WIDER highlight than the selection rows above (2026-10-31
35
+ * field report: the workspace picker's 「添加工作区」 ran ~12px wider
36
+ * than the 「个人空间」 row). The band owns the separation now —
37
+ * consumers must NOT pad the wrapper (see _menu-item.scss, the band
38
+ * width invariant). Head/extra stay bare DOM hooks: identity blocks own
39
+ * their rhythm (the .s-user-header pins), and the theme menu's extra
40
+ * slot rides the plain row rhythm. */
41
+ .hk-menu-slot--foot {
42
+ margin-block-start: var(--hk-menu-foot-gap, var(--space-6, 6px));
43
+ }
44
+
26
45
  .hk-menu-flag {
27
46
  flex-shrink: 0;
28
47
  font-size: var(--text-base);
@@ -607,7 +607,16 @@ export default defineComponent({
607
607
  * and every consumer hand-rolled its own spacing (PlatformSwitcher
608
608
  * shipped a drifted divider that way). The wrapper renders only
609
609
  * when the slot has content: an empty one would cost a phantom
610
- * flex gap in headerless surfaces. */
610
+ * flex gap in headerless surfaces.
611
+ *
612
+ * Band geometry lives in HkMenu.scss, never in consumers: the foot
613
+ * band carries the divider-grade top separation there (`.hk-menu-slot--foot`),
614
+ * so a consumer adds rows, not padding. A custom default-slot row
615
+ * run CARRIES the geometry-neutral `.hk-menu-body` band class in the
616
+ * HOST's own markup (this component does not wrap the slot — the
617
+ * global rule lives in admin-tokens, _menu-item.scss @mixin body —
618
+ * the WIDTH invariant: wrapper padding insets only its own rows and
619
+ * breaks highlight parity with the foot action band). */
611
620
  function slotWrapper(kind: "head" | "foot" | "extra", nodes: VNode[]): VNode | null {
612
621
  return nodes.length ? (
613
622
  <div class={`hk-menu-slot hk-menu-slot--${kind}`}>{nodes}</div>
@@ -14,7 +14,14 @@
14
14
  * max-width block (HkModal docks via media query, not a class);
15
15
  * - HkMenu's header/footer slots and HkThemeToggle's menu-extra ride
16
16
  * the .hk-menu-slot wrapper instead of splicing raw vnodes into the
17
- * level (PlatformSwitcher-class drift lived exactly there).
17
+ * level (PlatformSwitcher-class drift lived exactly there);
18
+ * - BAND wrappers (slots, .hk-menu-body, .hk-menu-section) stay
19
+ * geometry-neutral — the surface's inset is the one horizontal inset
20
+ * a row receives, so every row family's highlight reads the same
21
+ * width in one panel (2026-10-31: the workspace picker's padded body
22
+ * wrapper made 「添加工作区」 run ~12px wider than the selected row);
23
+ * - the foot action band owns its divider-grade top separation
24
+ * (HkMenu.scss), consumers pad nothing.
18
25
  */
19
26
  import { describe, expect, it } from "vitest";
20
27
  import { readFileSync } from "node:fs";
@@ -24,6 +31,7 @@ import { fileURLToPath } from "node:url";
24
31
  const here = dirname(fileURLToPath(import.meta.url));
25
32
  const grammar = readFileSync(join(here, "_menu-item.scss"), "utf-8");
26
33
  const menuTsx = readFileSync(join(here, "HkMenu.tsx"), "utf-8");
34
+ const menuScss = readFileSync(join(here, "HkMenu.scss"), "utf-8");
27
35
  const themeTsx = readFileSync(join(here, "HkThemePanelContent.tsx"), "utf8");
28
36
  const adminTokens = readFileSync(join(here, "../styles/admin-tokens.scss"), "utf-8");
29
37
  const panelScss = readFileSync(join(here, "HkMenuPanel.scss"), "utf-8");
@@ -576,4 +584,131 @@ describe("menu-item grammar contract", () => {
576
584
  ".hk-menu-list must publish its 4px row rhythm for a captioned run",
577
585
  ).toMatch(/\.hk-menu-list\s*\{[^}]*--hk-menu-section-gap:\s*var\(--space-4,\s*4px\)/s);
578
586
  });
587
+
588
+ it("declares the consumer body band as exactly two neutral declarations", () => {
589
+ // The WIDTH invariant (2026-10-31 field report): a padded band insets
590
+ // only ITS rows, so their highlight stops matching the foot action
591
+ // band's — the workspace picker's 「添加工作区」 ran ~12px wider than
592
+ // the 「个人空间」 row. @mixin body is therefore pinned to EXACTLY the
593
+ // two neutral declarations; a padding/margin/border sneaking in fails
594
+ // here even though every value pin elsewhere stays green.
595
+ const body = mixinBody("body");
596
+ const decls = body
597
+ .split(";")
598
+ .map((stmt) => stmt.replace(/\s+/g, " ").trim())
599
+ .filter(Boolean);
600
+ expect(decls, "@mixin body is display:block + min-width:0, nothing else").toEqual([
601
+ "display: block",
602
+ "min-width: 0",
603
+ ]);
604
+ expect(body, "the body band never pads").not.toMatch(/\bpadding/);
605
+ expect(body, "the body band never margins").not.toMatch(/\bmargin/);
606
+ expect(body, "the body band never draws chrome").not.toMatch(/\bborder/);
607
+ });
608
+
609
+ it("publishes the body band globally and only binds it to the grammar", () => {
610
+ // Hosts render .hk-menu-body by hand beside their own class (the
611
+ // workspace picker does) and cannot import the scss partial — same
612
+ // rule shape as the section band above: the global sheet BINDS the
613
+ // class to @mixin body, every rule mentioning the class carries
614
+ // nothing but that binding.
615
+ const blocks = flattenBlocks(parseScss(stripScssComments(adminTokens)));
616
+ const bodyRules = blocks.flatMap((block) =>
617
+ resolvedSelectors(block)
618
+ .filter((selector) => selector.includes("hk-menu-body"))
619
+ .map((selector) => ({ selector, block })),
620
+ );
621
+ expect(
622
+ bodyRules.map((entry) => entry.selector),
623
+ "the one global body adopter",
624
+ ).toContain(".hk-menu-body");
625
+ for (const { selector, block } of bodyRules) {
626
+ // The binding must be TOP-LEVEL: resolvedSelectors drops at-rule
627
+ // preludes, so a media-scoped duplicate would resolve to the same
628
+ // selector string here — walk the parent chain explicitly and fail
629
+ // on any at-rule ancestor (the modal-sheet test sets the precedent).
630
+ for (let cur = block.parent; cur; cur = cur.parent) {
631
+ expect(
632
+ cur.prelude.startsWith("@"),
633
+ `the body binding for "${selector}" must be a top-level rule, found inside at-rule "${cur.prelude}" (a scoped binding is not the global contract)`,
634
+ ).toBe(false);
635
+ }
636
+ const statements = block.statements.map((stmt) => stmt.replace(/\s+/g, " ").trim());
637
+ expect(statements, `body rule "${selector}" binds the grammar only`).toEqual([
638
+ "@include mi.body",
639
+ ]);
640
+ }
641
+ });
642
+
643
+ it("keeps every band wrapper geometry-neutral (rows own the level width)", () => {
644
+ // Every rule in the sheets that style band classes today — the
645
+ // grammar, HkMenu.scss and admin-tokens (a repo-wide grep is the
646
+ // reviewer's periodic check that this list is still the whole world) —
647
+ // must declare NO padding for a BAND class: the slot hooks, the
648
+ // consumer body band, the section wrapper. The surface's inset is the
649
+ // one horizontal inset a row receives; a band that pads itself
650
+ // re-creates the field defect with its own rows reading wider than
651
+ // the neighbours. Comments stripped, block-parsed, resolved selectors
652
+ // (a context-scoped band rule fails here too).
653
+ const blocks = [
654
+ ...flattenBlocks(parseScss(stripScssComments(grammar))),
655
+ ...flattenBlocks(parseScss(stripScssComments(menuScss))),
656
+ ...flattenBlocks(parseScss(stripScssComments(adminTokens))),
657
+ ];
658
+ const isBandSelector = (selector: string) =>
659
+ selector.includes("hk-menu-slot") ||
660
+ selector.includes("hk-menu-body") ||
661
+ (/hk-menu-section(?!-label)/.test(selector));
662
+ const bandRules = blocks.flatMap((block) =>
663
+ resolvedSelectors(block)
664
+ .filter(isBandSelector)
665
+ .map((selector) => ({ selector, block })),
666
+ );
667
+ // Extraction sanity (never trust a 0-hit scan): the shipped bands are
668
+ // all found — slot hooks, the body band, the section wrapper.
669
+ const found = new Set(bandRules.map((entry) => entry.selector));
670
+ expect(found.has(".hk-menu-slot"), ".hk-menu-slot band found").toBe(true);
671
+ expect(found.has(".hk-menu-slot--foot"), ".hk-menu-slot--foot band found").toBe(true);
672
+ expect(found.has(".hk-menu-body"), ".hk-menu-body band found").toBe(true);
673
+ expect(found.has(".hk-menu-section"), ".hk-menu-section band found").toBe(true);
674
+ for (const { selector, block } of bandRules) {
675
+ const padding = block.statements.filter((stmt) =>
676
+ /(^|[\s;])padding/.test(stmt.replace(/\s+/g, " ").trim()),
677
+ );
678
+ expect(padding, `band rule "${selector}" must not pad itself`).toEqual([]);
679
+ }
680
+ });
681
+
682
+ it("separates the foot action band at the divider grade, band-owned", () => {
683
+ // The foot band is an ACTION band (the picker's "add workspace"), not
684
+ // a run of selectable rows — its separation from the list is the
685
+ // divider's 6px grade, owned by the band (consumers pad nothing).
686
+ // Pinned EXACTLY so a later restyle cannot silently retune it; head
687
+ // and extra stay geometry-neutral (no symmetric creep: identity
688
+ // blocks own their rhythm via the .s-user-header pins).
689
+ const blocks = flattenBlocks(parseScss(stripScssComments(menuScss)));
690
+ const foot = blocks.find((block) => block.prelude.trim() === ".hk-menu-slot--foot");
691
+ expect(foot, ".hk-menu-slot--foot rule lives in HkMenu.scss").toBeTruthy();
692
+ const decls = foot!.statements.map((stmt) =>
693
+ stmt.replace(/\s+/g, " ").trim().replace(/;$/, ""),
694
+ );
695
+ expect(decls, "the foot band carries exactly its top separation").toEqual([
696
+ "margin-block-start: var(--hk-menu-foot-gap, var(--space-6, 6px))",
697
+ ]);
698
+ // Head/extra stay BARE DOM hooks — no rhythm rules may exist for them
699
+ // at all (not merely margin-free ones): the identity blocks own their
700
+ // rhythm through the .s-user-header pins, and the theme menu's extra
701
+ // slot rides the plain row rhythm. A rule appearing here at all is
702
+ // the symmetric-creep attempt, so its EXISTENCE fails — a
703
+ // margin-free-but-present rule would still be a forked band.
704
+ for (const kind of ["--head", "--extra"]) {
705
+ const band = blocks.find((block) =>
706
+ resolvedSelectors(block).includes(`.hk-menu-slot${kind}`),
707
+ );
708
+ expect(
709
+ band,
710
+ `the ${kind} band must stay a bare DOM hook with no rule of its own (its rhythm is owned elsewhere by design)`,
711
+ ).toBeUndefined();
712
+ }
713
+ });
579
714
  });
@@ -44,12 +44,34 @@
44
44
  * section title hugging the edge rather than as an indented row. No
45
45
  * text-transform: translators own casing (the HkTemplateField rule).
46
46
  *
47
+ * Band taxonomy and the WIDTH invariant (2026-10-31):
48
+ * A menu level holds two kinds of children. ROWS — selectable items,
49
+ * custom rich rows, action buttons — all speak @mixin item: each spans
50
+ * the level's content box edge to edge, so its hover/active wash is
51
+ * that full box and every highlight in one panel reads the same width.
52
+ * BANDS — the wrappers around runs of rows — must stay GEOMETRY-NEUTRAL:
53
+ * zero padding of their own. The surface's inset (the popout's 8px, the
54
+ * sheet list's 16px, HkMenuPanel's --hk-menu-panel-inset) is the ONE
55
+ * horizontal inset a row ever receives. A band that pads itself insets
56
+ * only ITS rows, and they stop lining up with the rows outside it: the
57
+ * foot action band's highlight then reads wider than the selection rows
58
+ * above it (2026-10-31 field report: the workspace picker's
59
+ * 「添加工作区」 ran ~12px wider than the 「个人空间」 row because the
60
+ * consumer body wrapper carried a 6px hand pad the foot slot did not).
61
+ * The band homes are: .hk-menu-slot--head/--foot/--extra (HkMenu's slot
62
+ * bands), .hk-menu-section (a captioned run), .hk-menu-body (a
63
+ * consumer-rendered run — @mixin body). Vertical rhythm BETWEEN bands
64
+ * is band-owned, never wrapper-padding-owned: the foot band carries its
65
+ * divider-grade top separation in HkMenu.scss, the section grammar owns
66
+ * its caption bands here.
67
+ *
47
68
  * Adoption: `@use "./menu-item" as mi;` then
48
69
  * .your-row { @include mi.item; }
49
70
  * .your-row .your-icon { @include mi.icon; } // wrap TSX side!
50
71
  * .your-row .your-label { @include mi.label; }
51
72
  * .your-section { @include mi.section; }
52
73
  * .your-section-label { @include mi.section-label; }
74
+ * .your-body { @include mi.body; } // custom row-run band
53
75
  * Keep the component's own class names — this file owns metrics and
54
76
  * states only, never selectors of the adopting rows.
55
77
  */
@@ -336,3 +358,24 @@
336
358
  margin-block-start: var(--hk-menu-section-stack, var(--space-6, 6px));
337
359
  }
338
360
  }
361
+
362
+ /* ── Consumer body band ──────────────────────────────────────────────
363
+ * The wrapper for a consumer-rendered run of rows inside a menu level —
364
+ * the workspace picker's custom two-line rows, a menu body of rich rows
365
+ * the flat HkMenuItem model cannot express. The WIDTH invariant (see the
366
+ * header's band taxonomy) makes this band GEOMETRY-NEUTRAL: exactly two
367
+ * declarations, no padding, ever. The surface's inset is the one inset
368
+ * the rows inside receive, so they line up edge-for-edge with the foot
369
+ * action band and every other row family in the same panel (the
370
+ * 2026-10-31 field report shipped as a padded consumer wrapper whose
371
+ * rows ran ~12px wider than the foot band's highlight). A host keeps its
372
+ * own class beside .hk-menu-body for tokens and width floors (e.g. the
373
+ * picker's min-width + caption tokens) — those are content decisions;
374
+ * horizontal insets are not.
375
+ *
376
+ * Not a row: it carries no row tokens, so it needs NO entry in the
377
+ * sheet-context lists — the rows INSIDE it register themselves. */
378
+ @mixin body {
379
+ display: block;
380
+ min-width: 0;
381
+ }
@@ -808,6 +808,19 @@
808
808
  @include mi.section-label;
809
809
  }
810
810
 
811
+ /* The consumer body band (hikari _menu-item.scss @mixin body): the
812
+ * geometry-neutral wrapper for a host-rendered run of rows inside a menu
813
+ * level. Same hand-rendered-host reason as the section band above — the
814
+ * workspace picker wraps its custom rows in .hk-menu-body and cannot
815
+ * import the partial. The band is TWO neutral declarations by contract
816
+ * (the WIDTH invariant: the surface's inset is the only horizontal inset
817
+ * a row receives, so rows here line up edge-for-edge with the foot
818
+ * action band); a host's own class beside it carries the content
819
+ * decisions (min-width floors, caption tokens), never padding. */
820
+ .hk-menu-body {
821
+ @include mi.body;
822
+ }
823
+
811
824
  /* ── User identity header (shared) ─ */
812
825
  /* One identity block for every account surface — the admin user
813
826
  dropdown's header, a mobile drawer's user panel footer — so all