@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
|
@@ -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
|