@celestia-island/hikari 0.55.239 → 0.55.240

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.239",
3
+ "version": "0.55.240",
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",
@@ -13,6 +13,10 @@
13
13
  display: flex;
14
14
  flex-direction: column;
15
15
  gap: var(--space-4, 4px);
16
+ /* This list's own row rhythm is 4px, so a captioned run moved into a
17
+ group box must match it (the section grammar defaults to the level's
18
+ 2px). */
19
+ --hk-menu-section-gap: var(--space-4, 4px);
16
20
  /* Inset matches .hk-sidebar-body's inline padding so items share the
17
21
  nav text edge; override --hk-menu-panel-inset to re-anchor. */
18
22
  padding: var(--space-8, 8px) var(--hk-menu-panel-inset, var(--space-12, 12px));
@@ -0,0 +1,191 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ Fragment,
4
+ createApp,
5
+ createCommentVNode,
6
+ createTextVNode,
7
+ h,
8
+ nextTick,
9
+ ref,
10
+ } from "vue";
11
+
12
+ import HkMenuSection from "./HkMenuSection";
13
+
14
+ /**
15
+ * HkMenuSection contract tests:
16
+ *
17
+ * - GROUP: the wrapper is `role="group"` named by the `label` prop (the
18
+ * HkTemplateField a11y precedent), so a captioned row run is announced
19
+ * as one named group inside a menu surface.
20
+ * - CAPTION: the visible band carries the label text, is `aria-hidden`
21
+ * (the wrapper already announced the name) and uses the grammar's
22
+ * class names — the global adopter rules in styles/admin-tokens style
23
+ * them, the component imports no scss.
24
+ * - SLOT OVERRIDE: the `label` slot replaces the visible text while the
25
+ * accessible name stays the `label` prop; an EMPTY slot result (an empty
26
+ * array, a comment vnode from a compiled `v-if`, blank strings / TEXT
27
+ * vnodes) falls back to the prop text (never an empty padded band).
28
+ * - ROWS: the default slot renders inside the group, after the caption.
29
+ */
30
+
31
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
32
+
33
+ function mount(render: () => unknown): HTMLElement {
34
+ const container = document.createElement("div");
35
+ document.body.appendChild(container);
36
+ const app = createApp({ render });
37
+ app.mount(container);
38
+ mounts.push({ app, container });
39
+ return container;
40
+ }
41
+
42
+ afterEach(() => {
43
+ for (const { app, container } of mounts) {
44
+ app.unmount();
45
+ container.remove();
46
+ }
47
+ mounts.length = 0;
48
+ });
49
+
50
+ describe("HkMenuSection", () => {
51
+ it("renders a role=group named by label, with a visible aria-hidden caption", () => {
52
+ const c = mount(() => h(HkMenuSection, { label: "Temp spaces" }));
53
+
54
+ const group = c.querySelector<HTMLElement>(".hk-menu-section");
55
+ expect(group, ".hk-menu-section renders").toBeTruthy();
56
+ expect(group!.getAttribute("role")).toBe("group");
57
+ expect(group!.getAttribute("aria-label")).toBe("Temp spaces");
58
+
59
+ const caption = group!.querySelector<HTMLElement>(".hk-menu-section-label");
60
+ expect(caption, "the visible caption band renders").toBeTruthy();
61
+ expect(caption!.textContent).toBe("Temp spaces");
62
+ // The band repeats the wrapper's accessible name visually only.
63
+ expect(caption!.getAttribute("aria-hidden")).toBe("true");
64
+ // It is the group's first child: caption above the rows.
65
+ expect(group!.firstElementChild).toBe(caption);
66
+ });
67
+
68
+ it("keeps the accessible name on the label prop when the label slot overrides the band", () => {
69
+ const c = mount(() =>
70
+ h(
71
+ HkMenuSection,
72
+ { label: "Temp spaces" },
73
+ {
74
+ label: () => [h("span", { class: "section-count" }, "3")],
75
+ default: () => [
76
+ h("button", { type: "button", class: "hk-menu-action-item" }, "Scratch"),
77
+ ],
78
+ },
79
+ ),
80
+ );
81
+
82
+ const group = c.querySelector<HTMLElement>(".hk-menu-section")!;
83
+ // Rich visible content never changes the announced group name.
84
+ expect(group.getAttribute("aria-label")).toBe("Temp spaces");
85
+ const caption = group.querySelector<HTMLElement>(".hk-menu-section-label")!;
86
+ expect(caption.textContent).toBe("3");
87
+ expect(caption.querySelector(".section-count")).toBeTruthy();
88
+ expect(caption.getAttribute("aria-hidden")).toBe("true");
89
+ });
90
+
91
+ it("falls back to the label prop when the label slot renders nothing", () => {
92
+ // `[]` is not nullish: a naive `slots.label?.() ?? props.label` keeps
93
+ // the empty band (padded dead space above the rows).
94
+ const c = mount(() =>
95
+ h(HkMenuSection, { label: "Temp spaces" }, { label: () => [] }),
96
+ );
97
+
98
+ const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
99
+ expect(caption.textContent).toBe("Temp spaces");
100
+ expect(caption.getAttribute("aria-hidden")).toBe("true");
101
+ });
102
+
103
+ it("falls back to the label prop when the label slot renders only a comment vnode", () => {
104
+ // A compiled `v-if` slot root is a COMMENT vnode (length 1, not nullish):
105
+ // it must count as no caption or the aria-hidden band stays padded and
106
+ // empty.
107
+ const c = mount(() =>
108
+ h(HkMenuSection, { label: "Temp spaces" }, {
109
+ label: () => [createCommentVNode("v-if", true)],
110
+ }),
111
+ );
112
+
113
+ const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
114
+ expect(caption.textContent).toBe("Temp spaces");
115
+ expect(caption.getAttribute("aria-hidden")).toBe("true");
116
+ });
117
+
118
+ it("looks through a fragment that wraps only comments and blank strings", () => {
119
+ const c = mount(() =>
120
+ h(HkMenuSection, { label: "Temp spaces" }, {
121
+ label: () => [
122
+ h(Fragment, null, [createCommentVNode("v-if", true), " "]),
123
+ ],
124
+ }),
125
+ );
126
+
127
+ const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
128
+ expect(caption.textContent).toBe("Temp spaces");
129
+ });
130
+
131
+ it("falls back for top-level blank and empty slot strings", () => {
132
+ for (const blank of [" ", ""]) {
133
+ const c = mount(() =>
134
+ h(HkMenuSection, { label: "Temp spaces" }, { label: () => [blank] }),
135
+ );
136
+ const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
137
+ expect(caption.textContent, `slot ${JSON.stringify(blank)}`).toBe("Temp spaces");
138
+ }
139
+ });
140
+
141
+ it("falls back for a TEXT vnode inside a fragment", () => {
142
+ // Vue normalizes text children into TEXT vnodes (and a compiled slot's
143
+ // whitespace root becomes one directly) — blank text must be absent.
144
+ const c = mount(() =>
145
+ h(HkMenuSection, { label: "Temp spaces" }, {
146
+ label: () => [h(Fragment, null, [createTextVNode(" "), createTextVNode("")])],
147
+ }),
148
+ );
149
+
150
+ const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
151
+ expect(caption.textContent).toBe("Temp spaces");
152
+ });
153
+
154
+ it("updates the visible caption when the label prop changes", async () => {
155
+ // Fences the render-function caption resolution: a cached computed
156
+ // would keep the first nodes.
157
+ const label = ref("Temp spaces");
158
+ const c = mount(() => h(HkMenuSection, { label: label.value }));
159
+ expect(c.querySelector(".hk-menu-section-label")!.textContent).toBe("Temp spaces");
160
+
161
+ label.value = "Permanent spaces";
162
+ await nextTick();
163
+ expect(c.querySelector(".hk-menu-section-label")!.textContent).toBe("Permanent spaces");
164
+ expect(c.querySelector(".hk-menu-section")!.getAttribute("aria-label")).toBe(
165
+ "Permanent spaces",
166
+ );
167
+ });
168
+
169
+ it("renders the default slot's rows inside the group, after the caption", () => {
170
+ const c = mount(() =>
171
+ h(
172
+ HkMenuSection,
173
+ { label: "Temp spaces" },
174
+ {
175
+ default: () => [
176
+ h("button", { type: "button", class: "hk-menu-action-item" }, "Scratch"),
177
+ h("button", { type: "button", class: "hk-menu-action-item" }, "Long-term"),
178
+ ],
179
+ },
180
+ ),
181
+ );
182
+
183
+ const group = c.querySelector<HTMLElement>(".hk-menu-section")!;
184
+ const rows = group.querySelectorAll(".hk-menu-action-item");
185
+ expect(Array.from(rows).map((row) => row.textContent)).toEqual(["Scratch", "Long-term"]);
186
+ // caption + the two rows, in that ORDER (rows must never precede the
187
+ // band that names them).
188
+ expect(group.children).toHaveLength(3);
189
+ expect(group.querySelector(".hk-menu-section-label")!.nextElementSibling).toBe(rows[0]);
190
+ });
191
+ });
@@ -0,0 +1,95 @@
1
+ import { Comment, Fragment, Text, defineComponent } from "vue";
2
+
3
+ /** True when slot content carries something visible. A compiled `v-if`
4
+ * root is a COMMENT vnode (not an empty array), a text slot child may be
5
+ * normalized into a TEXT vnode, a fragment may wrap only comments/blank
6
+ * text, and whitespace-only strings are not a caption — none of them may
7
+ * leave a padded, empty aria-hidden band. */
8
+ function hasVisibleContent(nodes: readonly unknown[]): boolean {
9
+ for (const node of nodes) {
10
+ if (typeof node === "string") {
11
+ if (node.trim() !== "") return true;
12
+ continue;
13
+ }
14
+ if (typeof node === "number") return true;
15
+ if (node && typeof node === "object") {
16
+ const vnode = node as { type?: unknown; children?: unknown };
17
+ if (vnode.type === Comment) continue;
18
+ if (vnode.type === Text) {
19
+ if (typeof vnode.children === "string" && vnode.children.trim() !== "") return true;
20
+ continue;
21
+ }
22
+ if (vnode.type === Fragment) {
23
+ if (typeof vnode.children === "string") {
24
+ if (vnode.children.trim() !== "") return true;
25
+ } else if (Array.isArray(vnode.children)) {
26
+ if (hasVisibleContent(vnode.children)) return true;
27
+ }
28
+ continue;
29
+ }
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * HkMenuSection — a captioned run of menu rows.
38
+ *
39
+ * The small caption ABOVE a run of rows is part of the menu-item grammar
40
+ * (see _menu-item.scss, @mixin section / @mixin section-label), but it is
41
+ * hand-rolled at call sites across this library and its consumer's popup
42
+ * menus, with vertical insets from 0 to 6px and no shared token. This
43
+ * component is the ONE markup for it:
44
+ *
45
+ * <HkMenuSection label="Temp spaces">
46
+ * <HkMenuActionItem … />
47
+ * </HkMenuSection>
48
+ *
49
+ * Accessibility follows the HkTemplateField precedent: grouping is a
50
+ * `role="group"` wrapper carrying the group's name in `aria-label`, and
51
+ * the visible caption band inside is `aria-hidden="true"`. The wrapper
52
+ * already announces the name, so leaving the visible band in the a11y
53
+ * tree would double-announce it (and a bare heading band was invisible to
54
+ * screen readers before that wave).
55
+ *
56
+ * `label` is both the accessible group name and the default visible
57
+ * caption text; the optional `label` slot supplies richer visible content
58
+ * (a badge, a count) while `aria-label` keeps the prop's plain string.
59
+ * The slot must be NON-INTERACTIVE content: it renders inside the
60
+ * aria-hidden band, so a focusable node there would be an axe
61
+ * `aria-hidden-focus` violation, and its visible text must not contradict
62
+ * the accessible name (keep the prop as the plain group name). The
63
+ * default slot is the rows.
64
+ *
65
+ * Metrics come from the global adopter rules in styles/admin-tokens
66
+ * (`@include mi.section` / `@include mi.section-label`), loaded by the
67
+ * canonical styles entry — deliberately NO local scss import, so hosts
68
+ * that render the classes by hand and this component share one
69
+ * declaration (the HkAvatarButton precedent).
70
+ */
71
+ export default defineComponent({
72
+ name: "HkMenuSection",
73
+ props: {
74
+ /** Accessible group name AND the default visible caption text. */
75
+ label: { type: String, required: true },
76
+ },
77
+ setup(props, { slots }) {
78
+ // Resolve the caption INSIDE the render function: a computed caches
79
+ // the slot's vnodes and a pre-built-node slot goes stale when the
80
+ // parent re-renders. An empty result ([] / comment vnodes / blank
81
+ // strings) falls back to the prop text instead of leaving a padded,
82
+ // empty aria-hidden band.
83
+ return () => {
84
+ const caption = slots.label?.();
85
+ return (
86
+ <div class="hk-menu-section" role="group" aria-label={props.label}>
87
+ <div class="hk-menu-section-label" aria-hidden="true">
88
+ {caption && hasVisibleContent(caption) ? caption : props.label}
89
+ </div>
90
+ {slots.default?.()}
91
+ </div>
92
+ );
93
+ };
94
+ },
95
+ });
@@ -1,3 +1,5 @@
1
+ @use "./menu-item" as mi;
2
+
1
3
  // HkTemplateField / HkTemplateText — the fill-template surfaces.
2
4
  // Self-sufficient styling (house rule): the field box, label, hint and
3
5
  // error rows live here rather than borrowing HkInput's sheet, and the
@@ -185,14 +187,26 @@
185
187
  }
186
188
 
187
189
  .hk-tpl-group-label {
188
- padding: var(--space-6, 6px) var(--space-8, 8px) var(--space-2, 2px);
189
- font-size: var(--text-2xs, 0.625rem);
190
- font-weight: 600;
191
- letter-spacing: 0.02em;
190
+ // The menu grammar's shared section-caption band (_menu-item.scss
191
+ // @mixin section-label), same as HkMenuSection's
192
+ // .hk-menu-section-label — the hand-rolled bands across this library
193
+ // and its consumer's popup menus (0–6px vertical insets) converge here.
194
+ // hikari's theme-menu caption (.s-theme-menu-label) stays hand-rolled
195
+ // on purpose: its 4/6/4 band sits ~8px clear of its rows, so it never
196
+ // exhibited the glued-to-the-first-row defect this grammar fixes.
197
+ // This adopter inherits the grammar's shift too, it is not a band-only
198
+ // change: vertical rhythm 6/8/2 → 4/6/8 (up, bottom-heavy) AND the
199
+ // inline inset 8 → 6px, i.e. the caption steps 2px LEFT toward
200
+ // .hk-tpl-row's content edge — the requested 偏左 direction.
201
+ @include mi.section-label;
202
+ // The local alias is this adopter's default: hosts scope
203
+ // --hi-color-muted for this panel and the rest of this sheet sources
204
+ // muted through the same alias. The grammar's
205
+ // --hk-menu-section-label-color still overrides it for a host that
206
+ // themes captions as a family.
207
+ color: var(--hk-menu-section-label-color, var(--hi-color-muted));
192
208
  // No text-transform: labels arrive from the host (usually i18n
193
209
  // strings) and the translator owns the casing.
194
- color: var(--hi-color-muted);
195
- user-select: none;
196
210
  }
197
211
 
198
212
  .hk-tpl-row {
@@ -41,6 +41,7 @@ describe("HkTemplateField stylesheet contract", () => {
41
41
  const tsx = readFileSync(resolve(componentDir, "HkTemplateField.tsx"), "utf8");
42
42
  const rendererTsx = readFileSync(resolve(componentDir, "HkTemplateText.tsx"), "utf8");
43
43
  const grammars = readFileSync(resolve(componentDir, "templateGrammar.ts"), "utf8");
44
+ const scss = readFileSync(resolve(componentDir, "HkTemplateField.scss"), "utf8");
44
45
  const css = compile(resolve(componentDir, "HkTemplateField.scss"), {
45
46
  style: "expanded",
46
47
  }).css;
@@ -99,12 +100,59 @@ describe("HkTemplateField stylesheet contract", () => {
99
100
 
100
101
  it("keeps the heading band on the label class, not the group wrapper", () => {
101
102
  // The wrapper is a bare layout box; carrying the band's chrome
102
- // (padding/font-weight) there would pad every row run.
103
- expect(css).toMatch(/\.hk-tpl-group-label\s*\{[^}]*font-weight:\s*600/s);
103
+ // (padding/font-weight) there would pad every row run. The band is an
104
+ // ADOPTER of the menu grammar's section-caption mixin, and the pins
105
+ // below are satisfied ONLY by the grammar's token form: a restored
106
+ // hand-rolled band (literal padding / a bare `font-weight: 600` /
107
+ // `@include` removed) must fail. R2 proved an optional-group regex
108
+ // matched both spellings, so the previous pin was vacuous.
109
+ expect(
110
+ css,
111
+ ".hk-tpl-group-label must take its bottom band from the grammar's token",
112
+ ).toMatch(
113
+ /\.hk-tpl-group-label\s*\{[^}]*padding-block-end:\s*var\(--hk-menu-section-label-pad-bottom,/s,
114
+ );
115
+ expect(
116
+ css,
117
+ ".hk-tpl-group-label must take its weight from the grammar's token with 600 as the fallback",
118
+ ).toMatch(
119
+ /\.hk-tpl-group-label\s*\{[^}]*font-weight:\s*var\(--hk-menu-section-label-weight,\s*600\)/s,
120
+ );
121
+ expect(
122
+ scss,
123
+ "the source must ADOPT the grammar (@include mi.section-label), not restate its values",
124
+ ).toMatch(/\.hk-tpl-group-label\s*\{[^}]*@include mi\.section-label;/s);
104
125
  expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*font-weight/s);
105
126
  expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*padding/s);
106
127
  });
107
128
 
129
+ it("keeps the label band's declarations to the grammar binding plus the colour hook", () => {
130
+ // R4 appended `padding: 2px 16px;` after the @include and all five
131
+ // shipped pins stayed green. EVERY rule whose selector mentions the band
132
+ // must be EXACTLY the grammar binding plus the one colour line — any
133
+ // later padding / font-size / font-weight / letter-spacing /
134
+ // text-transform, and any SECOND .hk-tpl-group-label rule (R5: the old
135
+ // pin read only the first match), fails.
136
+ const code = scss
137
+ .replace(/\/\*[\s\S]*?\*\//g, "")
138
+ .replace(/^[ \t]*\/\/.*$/gm, "");
139
+ const rules = [...code.matchAll(/([^{}]*\.hk-tpl-group-label[^{}]*)\{([^{}]*)\}/g)];
140
+ expect(rules.length, ".hk-tpl-group-label rule(s) found").toBeGreaterThan(0);
141
+ for (const [, selector, body] of rules) {
142
+ const declarations = (body ?? "")
143
+ .split(";")
144
+ .map((statement) => statement.replace(/\s+/g, " ").trim())
145
+ .filter(Boolean);
146
+ expect(
147
+ declarations,
148
+ `every .hk-tpl-group-label rule must stay the grammar binding + colour hook (${(selector ?? "").trim()})`,
149
+ ).toEqual([
150
+ "@include mi.section-label",
151
+ "color: var(--hk-menu-section-label-color, var(--hi-color-muted))",
152
+ ]);
153
+ }
154
+ });
155
+
108
156
  it("keeps group headings free of text-transform (the host owns casing)", () => {
109
157
  // R2 M13: re-adding `text-transform: uppercase` used to stay green.
110
158
  expect(css).not.toMatch(/\.hk-tpl-group[^{]*\{[^}]*text-transform/s);
@@ -26,6 +26,127 @@ const grammar = readFileSync(join(here, "_menu-item.scss"), "utf-8");
26
26
  const menuTsx = readFileSync(join(here, "HkMenu.tsx"), "utf-8");
27
27
  const themeTsx = readFileSync(join(here, "HkThemePanelContent.tsx"), "utf8");
28
28
  const adminTokens = readFileSync(join(here, "../styles/admin-tokens.scss"), "utf-8");
29
+ const panelScss = readFileSync(join(here, "HkMenuPanel.scss"), "utf-8");
30
+
31
+ /** One node of a parsed SCSS tree: a rule/at-rule block, the
32
+ * statements directly in its body, and its nested blocks. */
33
+ interface ScssBlock {
34
+ prelude: string;
35
+ statements: string[];
36
+ children: ScssBlock[];
37
+ parent: ScssBlock | null;
38
+ }
39
+
40
+ /** Remove comments so the block parser only sees code. */
41
+ function stripScssComments(source: string): string {
42
+ return source
43
+ .replace(/\/\*[\s\S]*?\*\//g, "")
44
+ .replace(/^[ \t]*\/\/.*$/gm, "");
45
+ }
46
+
47
+ /** Parse an SCSS sheet into its block tree. At-rules are blocks like any
48
+ * other (their prelude starts with "@" and is not a selector). */
49
+ function parseScss(source: string): ScssBlock[] {
50
+ const root: ScssBlock = { prelude: "", statements: [], children: [], parent: null };
51
+ const stack: ScssBlock[] = [root];
52
+ let buf = "";
53
+ for (const ch of source) {
54
+ if (ch === "{") {
55
+ const parent = stack[stack.length - 1]!;
56
+ const block: ScssBlock = { prelude: buf.trim(), statements: [], children: [], parent };
57
+ parent.children.push(block);
58
+ stack.push(block);
59
+ buf = "";
60
+ } else if (ch === "}") {
61
+ const block = stack.pop()!;
62
+ if (buf.trim()) block.statements.push(buf.trim());
63
+ buf = "";
64
+ } else if (ch === ";") {
65
+ if (buf.trim()) stack[stack.length - 1]!.statements.push(buf.trim());
66
+ buf = "";
67
+ } else {
68
+ buf += ch;
69
+ }
70
+ }
71
+ return root.children;
72
+ }
73
+
74
+ /** Split a selector list on the commas that are NOT inside parentheses. */
75
+ function splitSelectors(prelude: string): string[] {
76
+ const out: string[] = [];
77
+ let depth = 0;
78
+ let current = "";
79
+ for (const ch of prelude) {
80
+ if (ch === "(") depth += 1;
81
+ else if (ch === ")") depth = Math.max(0, depth - 1);
82
+ if (ch === "," && depth === 0) {
83
+ out.push(current.trim());
84
+ current = "";
85
+ } else {
86
+ current += ch;
87
+ }
88
+ }
89
+ out.push(current.trim());
90
+ return out.filter(Boolean);
91
+ }
92
+
93
+ /** Resolved selector list of a block: its ancestor chain joined with
94
+ * spaces, comma lists expanded (at-rules are not selectors). */
95
+ function resolvedSelectors(block: ScssBlock): string[] {
96
+ const groups: string[][] = [];
97
+ for (let cur: ScssBlock | null = block; cur; cur = cur.parent) {
98
+ if (!cur.prelude || cur.prelude.startsWith("@")) continue;
99
+ groups.unshift(splitSelectors(cur.prelude));
100
+ }
101
+ let resolved = [""];
102
+ for (const group of groups) {
103
+ resolved = resolved.flatMap((prefix) =>
104
+ group.map((sel) => (prefix ? `${prefix} ${sel}` : sel)),
105
+ );
106
+ }
107
+ return resolved;
108
+ }
109
+
110
+ /** Resolved PARENT-selector prefixes of a block (ancestors only). */
111
+ function parentPrefixes(block: ScssBlock): string[] {
112
+ const groups: string[][] = [];
113
+ for (let cur = block.parent; cur; cur = cur.parent) {
114
+ if (!cur.prelude || cur.prelude.startsWith("@")) continue;
115
+ groups.unshift(splitSelectors(cur.prelude));
116
+ }
117
+ let resolved = [""];
118
+ for (const group of groups) {
119
+ resolved = resolved.flatMap((prefix) =>
120
+ group.map((sel) => (prefix ? `${prefix} ${sel}` : sel)),
121
+ );
122
+ }
123
+ return resolved;
124
+ }
125
+
126
+ /** Depth-first flatten of a block tree. */
127
+ function flattenBlocks(blocks: ScssBlock[]): ScssBlock[] {
128
+ return blocks.flatMap((block) => [block, ...flattenBlocks(block.children)]);
129
+ }
130
+
131
+ /** The px fallback a grammar custom property carries through the shared
132
+ * scale: the `4px` in `var(--token, var(--space-4, 4px))`. */
133
+ function pxFallback(body: string, token: string): number {
134
+ const match = body.match(
135
+ new RegExp(`var\\(${token},\\s*var\\(--space-\\d+,\\s*(\\d+)px\\)\\)`),
136
+ );
137
+ expect(
138
+ match,
139
+ `${token} must fall back through a shared --space-* token to px`,
140
+ ).toBeTruthy();
141
+ return Number(match![1]);
142
+ }
143
+
144
+ /** The body of `@mixin <name> { ... }` (single level, mixes are flat). */
145
+ function mixinBody(name: string): string {
146
+ const match = grammar.match(new RegExp(`@mixin ${name} \\{([^}]*)\\}`));
147
+ expect(match, `@mixin ${name} must exist in _menu-item.scss`).toBeTruthy();
148
+ return match![1]!;
149
+ }
29
150
 
30
151
  describe("menu-item grammar contract", () => {
31
152
  it("declares the divider row as a mixin reading the grammar tokens", () => {
@@ -126,4 +247,331 @@ describe("menu-item grammar contract", () => {
126
247
  );
127
248
  expect(grammar.match(/var\(--hk-menu-item-label-cushion\)/g)?.length).toBe(1);
128
249
  });
250
+
251
+ it("gives the section caption a bottom-heavy band (never glued to the first row)", () => {
252
+ // Field report: the caption above a run of rows sat almost flush
253
+ // against the first row (four hand-rolled bands, 2/4/6/8px, no shared
254
+ // token). The rhythm is pinned EXACTLY, not merely ordered: a bare
255
+ // `bottom > top` check lets a top revert (4px → 6px) pass — and the
256
+ // top band moving back down is the "move UP" half of the user
257
+ // direction. Pin both numbers, then pin the order.
258
+ const body = mixinBody("section-label");
259
+ const top = pxFallback(body, "--hk-menu-section-label-pad-top");
260
+ const bottom = pxFallback(body, "--hk-menu-section-label-pad-bottom");
261
+ expect(
262
+ top,
263
+ `caption top band is ${top}px; the shipped value is 4px (the band must sit tight/UP under the panel inset)`,
264
+ ).toBe(4);
265
+ expect(
266
+ bottom,
267
+ `caption bottom band is ${bottom}px; the shipped value is 8px (the clear gap under the caption)`,
268
+ ).toBe(8);
269
+ expect(
270
+ bottom,
271
+ `caption bottom band (${bottom}px) must be strictly larger than its top band (${top}px) — bottom-heavy rhythm`,
272
+ ).toBeGreaterThan(top);
273
+ });
274
+
275
+ it("declares the caption's bands as exactly three token declarations", () => {
276
+ // Presence-based pins survive a LATER override (R3 P7: appending
277
+ // `padding-inline: 16px; padding-block-end: 2px;` after the tokens
278
+ // re-created the field defect 4/16/2 while every value pin stayed
279
+ // green). The padding set must be EXACTLY the three grammar
280
+ // declarations — a fourth declaration, a bare shorthand or a literal
281
+ // added later fails here.
282
+ const body = mixinBody("section-label");
283
+ const paddingDecls = [...body.matchAll(/\bpadding[\w-]*\s*:[^;]+;/g)].map((match) =>
284
+ match[0].replace(/\s+/g, " ").trim(),
285
+ );
286
+ expect(paddingDecls, "exactly the three grammar padding declarations").toEqual([
287
+ "padding-block-start: var(--hk-menu-section-label-pad-top, var(--space-4, 4px));",
288
+ "padding-inline: var(--hk-menu-section-label-pad-x, var(--space-6, 6px));",
289
+ "padding-block-end: var(--hk-menu-section-label-pad-bottom, var(--space-8, 8px));",
290
+ ]);
291
+ expect(
292
+ body,
293
+ "no bare padding shorthand (it would override the token bands)",
294
+ ).not.toMatch(/(^|[;{\s])padding\s*:/);
295
+ expect(body, "capitalization stays the translator's").not.toContain("text-transform");
296
+ });
297
+
298
+ it("keeps the caption's desktop typography on the grammar tokens", () => {
299
+ const body = mixinBody("section-label");
300
+ expect(body).toMatch(
301
+ /font-size:\s*var\(--hk-menu-section-label-font,\s*var\(--text-2xs,\s*0\.625rem\)\)/,
302
+ );
303
+ expect(body).toMatch(
304
+ /letter-spacing:\s*var\(--hk-menu-section-label-tracking,\s*0\.02em\)/,
305
+ );
306
+ expect(body).toMatch(/user-select:\s*none/);
307
+ });
308
+
309
+ it("hangs the caption's inline inset just left of the row content edge", () => {
310
+ // The caption must NOT read as an indented row: its inline inset is a
311
+ // hair smaller than the row grammar's --hk-menu-item-pad-x, so it
312
+ // hugs the section edge slightly left of the leading icon column.
313
+ // SCOPE of that guarantee: the menu row grammar and HkMenuSection —
314
+ // a one-off adopter with its own smaller padding (HkTemplateField's
315
+ // .hk-tpl-row is 4px 8px) lands only a couple of px left, so the
316
+ // shared token keeps the two bands related, not pixel-identical
317
+ // everywhere.
318
+ const body = mixinBody("section-label");
319
+ expect(
320
+ body,
321
+ "caption inline inset must follow the shared --space-6 scale token",
322
+ ).toMatch(/var\(--hk-menu-section-label-pad-x,\s*var\(--space-6,\s*6px\)\)/);
323
+ const inline = pxFallback(body, "--hk-menu-section-label-pad-x");
324
+ const rowPadX = Number(
325
+ grammar.match(/@mixin item-tokens[\s\S]*?--hk-menu-item-pad-x:\s*(\d+)px/)?.[1],
326
+ );
327
+ expect(rowPadX, "row grammar pad-x parsed from item-tokens").toBeGreaterThan(0);
328
+ expect(
329
+ inline,
330
+ `caption inline inset (${inline}px) must stay smaller than the row pad-x (${rowPadX}px)`,
331
+ ).toBeLessThan(rowPadX);
332
+ });
333
+
334
+ it("registers the caption in every sheet-context list that registers the row band", () => {
335
+ // Resolved-selector scan, not a top-level block count: for every block
336
+ // that swaps the ROW sheet tokens we resolve its parent selector
337
+ // prefix and require an exactly matching caption block that swaps the
338
+ // CAPTION sheet tokens. A sheet-context list nested inside an existing
339
+ // block (a third context added under the panel list) therefore fails
340
+ // even though a depth-0 scan would never see it.
341
+ const blocks = flattenBlocks(parseScss(stripScssComments(grammar)));
342
+ const ruleIndex = blocks.flatMap((block) =>
343
+ resolvedSelectors(block).map((selector) => ({ selector, block })),
344
+ );
345
+ const rowSwaps = blocks.filter((block) =>
346
+ block.statements.some((stmt) => stmt.includes("@include item-sheet-tokens")),
347
+ );
348
+ // Extraction sanity (never trust a 0-hit scan): the shipped sheet
349
+ // lists do swap row tokens somewhere.
350
+ expect(rowSwaps.length, "row sheet-token swaps found").toBeGreaterThan(0);
351
+
352
+ const contexts = new Set<string>();
353
+ for (const rowSwap of rowSwaps) {
354
+ const prefixes = parentPrefixes(rowSwap);
355
+ expect(prefixes.length, "a row token swap has a selector ancestor").toBeGreaterThan(0);
356
+ for (const prefix of prefixes) {
357
+ contexts.add(prefix);
358
+ const captionSelector = `${prefix} .hk-menu-section-label`;
359
+ const captionBlock = ruleIndex.find(
360
+ (entry) => entry.selector === captionSelector,
361
+ )?.block;
362
+ expect(
363
+ captionBlock,
364
+ `sheet context "${prefix}" widens the rows but has no "${captionSelector}" caption block`,
365
+ ).toBeTruthy();
366
+ expect(
367
+ captionBlock!.statements.some((stmt) =>
368
+ stmt.includes("@include section-label-sheet-tokens"),
369
+ ),
370
+ `sheet context "${prefix}" must swap the caption's sheet tokens`,
371
+ ).toBe(true);
372
+ }
373
+ }
374
+
375
+ // The shipped sheet surfaces are all covered (dropping one from a
376
+ // context list fails here, not just for the panel-shaped ones).
377
+ for (const context of [
378
+ ".hk-select-sheet-panel",
379
+ ".hk-popover-panel.hk-is-sheet",
380
+ ".hk-drawer-panel",
381
+ ".hk-modal-content",
382
+ ]) {
383
+ expect(
384
+ [...contexts],
385
+ `sheet surface ${context} must widen the rows (and therefore be checked for a caption)`,
386
+ ).toContain(context);
387
+ }
388
+ });
389
+
390
+ it("widens the caption to the touch geometry inside sheets", () => {
391
+ // Sheet values are the touch band: 6px top / 8px inline / 12px bottom
392
+ // and the 12px caption font. A drifted sheet entry would leave the
393
+ // caption at desktop rhythm inside a bottom sheet.
394
+ const body = mixinBody("section-label-sheet-tokens");
395
+ expect(body).toMatch(/--hk-menu-section-label-pad-top:\s*var\(--space-6,\s*6px\)/);
396
+ expect(body).toMatch(/--hk-menu-section-label-pad-x:\s*var\(--space-8,\s*8px\)/);
397
+ expect(body).toMatch(/--hk-menu-section-label-pad-bottom:\s*var\(--space-12,\s*12px\)/);
398
+ expect(body).toMatch(/--hk-menu-section-label-font:\s*var\(--text-xs,\s*0\.75rem\)/);
399
+ });
400
+
401
+ it("never uppercases the caption (translators own casing)", () => {
402
+ // The HkTemplateField rule: caption strings are host i18n strings;
403
+ // text-transform would fight locales whose casing rules differ.
404
+ expect(mixinBody("section-label")).not.toContain("text-transform");
405
+ });
406
+
407
+ it("publishes the section caption globally and only binds it to the grammar", () => {
408
+ // Hosts render .hk-menu-section / .hk-menu-section-label by hand (the
409
+ // workspace picker does) and cannot import the scss partial, so the
410
+ // global sheet binds the classes to the grammar mixins. EVERY rule
411
+ // whose selector mentions the section classes may contain NOTHING but
412
+ // that binding: an adopter that adds a later padding declaration
413
+ // (R3 P5) or a scoped duplicate that restates the band (R3 P8) must
414
+ // fail — a "one global rule" count missed both.
415
+ const blocks = flattenBlocks(parseScss(stripScssComments(adminTokens)));
416
+ const sectionRules = blocks.flatMap((block) =>
417
+ resolvedSelectors(block)
418
+ .filter((selector) => selector.includes("hk-menu-section"))
419
+ .map((selector) => ({ selector, block })),
420
+ );
421
+ // Extraction sanity (never trust a 0-hit scan): the two global
422
+ // adopters are found.
423
+ expect(
424
+ sectionRules.map((entry) => entry.selector),
425
+ "the two global section adopters",
426
+ ).toEqual(expect.arrayContaining([".hk-menu-section", ".hk-menu-section-label"]));
427
+
428
+ for (const { selector, block } of sectionRules) {
429
+ const statements = block.statements.map((stmt) => stmt.replace(/\s+/g, " ").trim());
430
+ expect(
431
+ statements,
432
+ `section rule "${selector}" must bind the grammar only, got: ${statements.join("; ")}`,
433
+ ).toHaveLength(1);
434
+ expect(
435
+ statements[0],
436
+ `section rule "${selector}" must be an @include of the grammar mixin`,
437
+ ).toMatch(/^@include mi\.(?:section|section-label)$/);
438
+ expect(
439
+ statements.join("; "),
440
+ `section rule "${selector}" must not declare padding`,
441
+ ).not.toMatch(/\bpadding/);
442
+ }
443
+
444
+ // The exact binding per class (nothing else, no later override).
445
+ const statementsFor = (selector: string) => {
446
+ const block = sectionRules.find((entry) => entry.selector === selector)?.block;
447
+ expect(block, `the global ${selector} adopter rule`).toBeTruthy();
448
+ return block!.statements.map((stmt) => stmt.replace(/\s+/g, " ").trim());
449
+ };
450
+ expect(statementsFor(".hk-menu-section")).toEqual(["@include mi.section"]);
451
+ expect(statementsFor(".hk-menu-section-label")).toEqual(["@include mi.section-label"]);
452
+ });
453
+
454
+ it("keeps the grammar's own section rules to the sheet token swap only", () => {
455
+ // The grammar must not style the band directly: @mixin section-label
456
+ // owns the metrics (pinned separately), and a direct
457
+ // .hk-menu-section-label rule is ONLY the sheet-context token swap.
458
+ // R4 appended (a) an unconditional
459
+ // .hk-menu-section-label { @include section-label-sheet-tokens; }
460
+ // — touch geometry on desktop — and (b) a later
461
+ // .hk-menu-section-label { padding-block-end: 2px; }
462
+ // and every pin stayed green.
463
+ const blocks = flattenBlocks(parseScss(stripScssComments(grammar)));
464
+ // Derive the allowlist from the registration lists themselves: only a
465
+ // context that swaps the ROW sheet tokens may scope a caption rule
466
+ // (.hk-select-sheet-panel / .hk-popover-panel.hk-is-sheet /
467
+ // .hk-drawer-panel, plus the narrow mobile .hk-modal-content band).
468
+ const rowSwaps = blocks.filter((block) =>
469
+ block.statements.some((stmt) => stmt.includes("@include item-sheet-tokens")),
470
+ );
471
+ expect(rowSwaps.length, "row sheet-token swaps found").toBeGreaterThan(0);
472
+ const sheetContexts = new Set(rowSwaps.flatMap((rowSwap) => parentPrefixes(rowSwap)));
473
+
474
+ const sectionBlocks = blocks.flatMap((block) =>
475
+ resolvedSelectors(block)
476
+ .filter((selector) => selector.includes(".hk-menu-section"))
477
+ .map((selector) => ({ selector, block })),
478
+ );
479
+ // Extraction sanity (never trust a 0-hit scan): the shipped sheet
480
+ // entries are found.
481
+ expect(sectionBlocks.length, "grammar caption sheet-token entries found").toBeGreaterThan(0);
482
+ for (const { selector, block } of sectionBlocks) {
483
+ // A grammar rule for the band may only be scoped by a REGISTERED
484
+ // sheet context: an unconditional rule puts the sheet geometry on
485
+ // desktop, and any other ancestor (.hk-menu-level, .hk-menu-list)
486
+ // would be a stray desktop restyle.
487
+ const scoped = selector.match(/^(.+) \.hk-menu-section-label$/);
488
+ expect(
489
+ scoped,
490
+ `grammar section rule "${selector}" must be sheet-scoped, not unconditional`,
491
+ ).toBeTruthy();
492
+ const context = scoped![1]!;
493
+ expect(
494
+ [...sheetContexts],
495
+ `grammar section rule "${selector}" is scoped by "${context}", which is not a registered sheet context`,
496
+ ).toContain(context);
497
+ if (context === ".hk-modal-content") {
498
+ // .hk-modal-content exists at EVERY viewport (HkModal docks via a
499
+ // media query — the element itself is always there): a sheet swap
500
+ // scoped by it OUTSIDE the narrow band puts touch geometry on
501
+ // DESKTOP modals. The legitimate rule is nested in the band, and
502
+ // resolvedSelectors drops at-rule preludes, so walk the parent
503
+ // chain for the @media (max-width: 767px) ancestor explicitly.
504
+ let mobileBand = false;
505
+ for (let ancestor = block.parent; ancestor; ancestor = ancestor.parent) {
506
+ if (/^@media\s*\(max-width:\s*767px\)$/.test(ancestor.prelude.trim())) {
507
+ mobileBand = true;
508
+ break;
509
+ }
510
+ }
511
+ expect(
512
+ mobileBand,
513
+ `grammar section rule "${selector}" scopes .hk-modal-content OUTSIDE the @media (max-width: 767px) band — its sheet geometry would land on desktop modals`,
514
+ ).toBe(true);
515
+ }
516
+ for (const statement of block.statements) {
517
+ const normalized = statement.replace(/\s+/g, " ").trim();
518
+ expect(
519
+ normalized,
520
+ `grammar section rule "${selector}" may only swap the caption's sheet tokens, got: ${normalized}`,
521
+ ).toMatch(
522
+ /^(?:@include section-label-sheet-tokens|--hk-menu-section-label-[\w-]+\s*:\s*[^;]+)$/,
523
+ );
524
+ }
525
+ }
526
+ });
527
+
528
+ it("pins the wrapper to the level rhythm and publishes the list's own rhythm", () => {
529
+ // Rows declare no margins: the inter-row rhythm IS the parent's flex
530
+ // gap (.hk-menu-level 2px, .hk-menu-list 4px). A group box therefore
531
+ // takes over that rhythm, its default must mirror the level's 2px,
532
+ // and .hk-menu-list must publish its own 4px. The declarations are
533
+ // collected through the block parser (including the nested `& + &`
534
+ // block): a regex over the raw mixin text stopped at the first `}`,
535
+ // so a later `gap: 0;` after the stack rule re-introduced the S1
536
+ // defect with every pin green (R5 mutation A).
537
+ const sectionMixin = flattenBlocks(parseScss(stripScssComments(grammar))).find(
538
+ (block) => block.prelude === "@mixin section",
539
+ );
540
+ expect(sectionMixin, "@mixin section must exist").toBeTruthy();
541
+ const declarations = flattenBlocks([sectionMixin!]).flatMap((block) =>
542
+ block.statements.map((statement) => statement.replace(/\s+/g, " ").trim()),
543
+ );
544
+ expect(
545
+ declarations,
546
+ "the wrapper must be a flex box (the gap is inert otherwise)",
547
+ ).toContain("display: flex");
548
+ expect(
549
+ declarations,
550
+ "the wrapper's children must stack vertically, never in a row",
551
+ ).toContain("flex-direction: column");
552
+ // The 6px stack margin belongs to the SIBLING rule (`& + &`), so only
553
+ // the SECOND and later sections are separated. Moving it into the mixin
554
+ // body would push 6px above the very first section too.
555
+ const stackOwners = flattenBlocks([sectionMixin!]).filter((block) =>
556
+ block.statements.some((statement) =>
557
+ statement.replace(/\s+/g, " ").trim().startsWith("margin-block-start:"),
558
+ ),
559
+ );
560
+ expect(
561
+ stackOwners.map((block) => block.prelude),
562
+ "the stack margin must live in the `& + &` block only",
563
+ ).toEqual(["& + &"]);
564
+ const spacingDecls = declarations.filter((declaration) =>
565
+ /^(?:gap|margin[\w-]*)\s*:/.test(declaration),
566
+ );
567
+ expect(spacingDecls, "exactly the wrapper's two spacing declarations").toEqual([
568
+ "gap: var(--hk-menu-section-gap, var(--space-2, 2px))",
569
+ "margin-block-start: var(--hk-menu-section-stack, var(--space-6, 6px))",
570
+ ]);
571
+ // The one shipped surface with a different row rhythm publishes it.
572
+ expect(
573
+ panelScss,
574
+ ".hk-menu-list must publish its 4px row rhythm for a captioned run",
575
+ ).toMatch(/\.hk-menu-list\s*\{[^}]*--hk-menu-section-gap:\s*var\(--space-4,\s*4px\)/s);
576
+ });
129
577
  });
@@ -28,10 +28,28 @@
28
28
  * glyph ink high against box-centered icons; a half-pixel down
29
29
  * nudge re-centers the perceived mass (tunable per host).
30
30
  *
31
+ * Section caption (a captioned run of rows):
32
+ * The caption band ABOVE a run of rows is its own band, never a row.
33
+ * Its block rhythm is deliberately BOTTOM-HEAVY (desktop 4px above /
34
+ * 8px below, sheets 6/12): the panel already insets the surface above
35
+ * the caption, so the caption's own top band stays tight, while the
36
+ * bottom band is the separation that must read as "this caption heads
37
+ * the rows below, it is not one of them". The section wrapper is where
38
+ * the parent's row rhythm moves when rows go behind a group box, so its
39
+ * default gap MIRRORS the level rhythm (2px, --hk-menu-section-gap);
40
+ * a surface whose rhythm differs publishes that token (HkMenuPanel's
41
+ * 4px list; a margin-separated consumer list sets 0). Its inline inset
42
+ * is a hair SMALLER than the row's --hk-menu-item-pad-x, so the caption
43
+ * hangs just left of the rows' leading icon column and reads as a
44
+ * section title hugging the edge rather than as an indented row. No
45
+ * text-transform: translators own casing (the HkTemplateField rule).
46
+ *
31
47
  * Adoption: `@use "./menu-item" as mi;` then
32
48
  * .your-row { @include mi.item; }
33
49
  * .your-row .your-icon { @include mi.icon; } // wrap TSX side!
34
50
  * .your-row .your-label { @include mi.label; }
51
+ * .your-section { @include mi.section; }
52
+ * .your-section-label { @include mi.section-label; }
35
53
  * Keep the component's own class names — this file owns metrics and
36
54
  * states only, never selectors of the adopting rows.
37
55
  */
@@ -65,6 +83,18 @@
65
83
  --hk-menu-item-icon-box: 20px;
66
84
  }
67
85
 
86
+ /* Mobile bottom-sheet geometry for the section caption. The caption is a
87
+ * sibling BAND, not a row, so it carries its own token names: the
88
+ * sheet-context lists below register .hk-menu-section-label separately
89
+ * (rows pick up item-sheet-tokens; a caption inside the same panel must
90
+ * not be widened by the row mixin's token names). */
91
+ @mixin section-label-sheet-tokens {
92
+ --hk-menu-section-label-pad-top: var(--space-6, 6px);
93
+ --hk-menu-section-label-pad-x: var(--space-8, 8px);
94
+ --hk-menu-section-label-pad-bottom: var(--space-12, 12px);
95
+ --hk-menu-section-label-font: var(--text-xs, 0.75rem);
96
+ }
97
+
68
98
  /* Sheet context. The tokens are re-declared ON each adopting row, not
69
99
  * merely on the panel: a custom property declared on an element always
70
100
  * beats an inherited value, and @mixin item declares the desktop set at
@@ -73,8 +103,9 @@
73
103
  * Therefore every adopter MUST @include mi.item at a TOP-LEVEL (0,1,0)
74
104
  * rule — a nested include (e.g. inside its parent menu selector)
75
105
  * compiles to a higher specificity and silently defeats this binding
76
- * (HkBreadcrumb was caught exactly this way). New row families must
77
- * also register their class in this list. */
106
+ * (HkBreadcrumb was caught exactly this way). New row families and band
107
+ * elements (the divider, the section caption) must also register their
108
+ * class in this list. */
78
109
  .hk-select-sheet-panel,
79
110
  .hk-popover-panel.hk-is-sheet,
80
111
  .hk-drawer-panel {
@@ -94,6 +125,12 @@
94
125
  .hk-tag-dot {
95
126
  @include item-sheet-tokens;
96
127
  }
128
+
129
+ /* The caption band's own sheet entry — same parent list, so every sheet
130
+ * surface that widens its rows widens their caption too. */
131
+ .hk-menu-section-label {
132
+ @include section-label-sheet-tokens;
133
+ }
97
134
  }
98
135
 
99
136
  /* Modal-hosted rows ride the sheet geometry too: unlike the three
@@ -115,6 +152,12 @@
115
152
  .hk-tag-dot {
116
153
  @include item-sheet-tokens;
117
154
  }
155
+
156
+ /* Same registration rule as the panel list above: the caption rides
157
+ * the modal's sheet geometry without its own media query. */
158
+ .hk-menu-section-label {
159
+ @include section-label-sheet-tokens;
160
+ }
118
161
  }
119
162
  }
120
163
 
@@ -242,3 +285,53 @@
242
285
  background: rgb(var(--color-border, 100 100 100) / 18%);
243
286
  border: none;
244
287
  }
288
+
289
+ /* ── Section caption band ────────────────────────────────────────────
290
+ * The small caption ABOVE a run of rows (the workspace picker's
291
+ * 「临时空间」 band is the field case). Every value is a custom property
292
+ * with a grammar fallback, exactly like the row tokens, so a host can
293
+ * restyle one surface without forking the band. The bottom band is the
294
+ * separation from the first row (bottom-heavy on purpose — see the
295
+ * header comment); the inline inset hangs just left of the rows' icon
296
+ * column. The 8px band is NOT the whole caption→row gap: the wrapper's
297
+ * own gap adds 2px on the level default (4px in .hk-menu-list), so the
298
+ * rendered separation is 10/12px on desktop and 14px in sheets. Sheet
299
+ * geometry arrives through the sheet-context lists above, never through
300
+ * a per-component media query. NO text-transform: translators own
301
+ * casing (the HkTemplateField rule). */
302
+ @mixin section-label {
303
+ padding-block-start: var(--hk-menu-section-label-pad-top, var(--space-4, 4px));
304
+ padding-inline: var(--hk-menu-section-label-pad-x, var(--space-6, 6px));
305
+ padding-block-end: var(--hk-menu-section-label-pad-bottom, var(--space-8, 8px));
306
+ font-size: var(--hk-menu-section-label-font, var(--text-2xs, 0.625rem));
307
+ font-weight: var(--hk-menu-section-label-weight, 600);
308
+ letter-spacing: var(--hk-menu-section-label-tracking, 0.02em);
309
+ color: var(--hk-menu-section-label-color, rgb(var(--color-muted)));
310
+ user-select: none;
311
+ }
312
+
313
+ /* ── Section wrapper ─────────────────────────────────────────────────
314
+ * The role="group" box HkMenuSection renders: one caption plus its rows.
315
+ * Rows declare no margins — the inter-row rhythm is the parent's flex gap
316
+ * (.hk-menu-level 2px, .hk-menu-list 4px) — so a group box is where that
317
+ * rhythm must move. The default --hk-menu-section-gap therefore MIRRORS
318
+ * the level rhythm (2px), which keeps an unconfigured surface correct;
319
+ * every surface whose rhythm differs publishes the token: .hk-menu-list
320
+ * sets 4px, and a consumer list that separates rows with margins instead
321
+ * sets 0 and keeps its margins (those still apply inside the wrapper).
322
+ * Stacked sections take a 6px stack margin so two captioned runs read as
323
+ * two groups, not one long list. The wrapper stays chrome-free — the
324
+ * caption owns the band.
325
+ *
326
+ * MUST be included at a TOP-LEVEL (0,1,0) rule, like @mixin item: the
327
+ * `& + &` stack rule compiles to a broken descendant selector when the
328
+ * mixin is nested inside a parent menu selector. */
329
+ @mixin section {
330
+ display: flex;
331
+ flex-direction: column;
332
+ gap: var(--hk-menu-section-gap, var(--space-2, 2px));
333
+
334
+ & + & {
335
+ margin-block-start: var(--hk-menu-section-stack, var(--space-6, 6px));
336
+ }
337
+ }
package/src/index.ts CHANGED
@@ -718,6 +718,7 @@ export { default as HkContextMenuProvider } from "./components/HkContextMenuProv
718
718
  export { default as HkMenuPanel } from "./components/HkMenuPanel";
719
719
  export { default as HkMenuActionItem } from "./components/HkMenuActionItem";
720
720
  export { default as HkMenuIdentityItem } from "./components/HkMenuIdentityItem";
721
+ export { default as HkMenuSection } from "./components/HkMenuSection";
721
722
  export { default as HkTagDotRow } from "./components/HkTagDotRow";
722
723
  export { default as HkRowActions } from "./components/HkRowActions";
723
724
  export { default as HkPopupSelect } from "./components/HkPopupSelect";
@@ -790,6 +790,23 @@
790
790
  }
791
791
  }
792
792
 
793
+ /* The section caption and its group wrapper (hikari _menu-item.scss
794
+ * @mixin section / @mixin section-label): the small band above a run of
795
+ * rows, e.g. 「临时空间」 in the workspace picker, plus the role="group"
796
+ * wrapper HkMenuSection renders. Declared globally here because host apps
797
+ * render these classes by HAND (the workspace picker's own markup) and
798
+ * cannot import the scss partial — the grammar (and every metric
799
+ * fallback) lives in _menu-item.scss. The caption's SHEET geometry rides
800
+ * the grammar's sheet-context lists, so it must NOT be re-declared here;
801
+ * this file only binds the class names to the grammar. */
802
+ .hk-menu-section {
803
+ @include mi.section;
804
+ }
805
+
806
+ .hk-menu-section-label {
807
+ @include mi.section-label;
808
+ }
809
+
793
810
  /* ── User identity header (shared) ─ */
794
811
  /* One identity block for every account surface — the admin user
795
812
  dropdown's header, a mobile drawer's user panel footer — so all