@celestia-island/hikari 0.55.245 → 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 +1 -1
- package/src/components/HkDateTag.scss +33 -0
- package/src/components/HkDateTag.test.tsx +135 -0
- package/src/components/HkDateTag.tsx +99 -0
- package/src/components/HkMenu.scss +19 -0
- package/src/components/HkMenu.tsx +10 -1
- package/src/components/_menu-item.contract.test.ts +136 -1
- package/src/components/_menu-item.scss +43 -0
- package/src/index.ts +2 -0
- package/src/styles/admin-tokens.scss +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
|
|
2
|
+
|
|
3
|
+
/* HkDateTag — the locale-aware stacked date capsule (see the component
|
|
4
|
+
* docblock for the contract). Rows are calendar segments; the year and
|
|
5
|
+
* the optional time row read as context (muted, a touch smaller), the
|
|
6
|
+
* month/day figures carry the full text color. */
|
|
7
|
+
|
|
8
|
+
.hk-date-tag {
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
align-items: center;
|
|
12
|
+
padding: var(--space-2, 2px) var(--space-10, 0.625rem);
|
|
13
|
+
border-radius: var(--radius-full, 999px);
|
|
14
|
+
background: rgb(var(--color-text) / 6%);
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.hk-date-tag__row {
|
|
19
|
+
display: block;
|
|
20
|
+
text-align: center;
|
|
21
|
+
line-height: 1.25;
|
|
22
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
23
|
+
font-variant-numeric: tabular-nums;
|
|
24
|
+
color: rgb(var(--color-text));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Context rows: the year (wherever the locale puts it) and the optional
|
|
28
|
+
* time row — muted and a touch smaller than the month/day figures. */
|
|
29
|
+
.hk-date-tag__row--year,
|
|
30
|
+
.hk-date-tag__row--time {
|
|
31
|
+
color: rgb(var(--color-text) / 55%);
|
|
32
|
+
font-size: calc(var(--text-2xs, 0.625rem) - 1px);
|
|
33
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createApp, h, nextTick } from "vue";
|
|
4
|
+
|
|
5
|
+
import HkDateTagDefault, { HkDateTag } from "./HkDateTag";
|
|
6
|
+
import { setLocale } from "../i18n/context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HkDateTag — the locale-aware date capsule. These cases pin the
|
|
10
|
+
* COMPONENT contract: the row order follows the ACTIVE hikari locale
|
|
11
|
+
* (year on top for zh-Hans, year LAST for en), the order re-derives
|
|
12
|
+
* live when the host flips the locale, `time` appends the muted
|
|
13
|
+
* fourth row, and unparseable input renders the empty capsule shell.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
17
|
+
|
|
18
|
+
// Built in LOCAL time so the calendar fields (2024-10-06 14:30) survive
|
|
19
|
+
// every CI time zone; the capsule renders local calendar segments.
|
|
20
|
+
const SHIP_DATE = new Date(2024, 9, 6, 14, 30);
|
|
21
|
+
|
|
22
|
+
function mountTag(props: { value: string | number | Date; time?: boolean }): HTMLElement {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const app = createApp({ render: () => h(HkDateTag, props) });
|
|
26
|
+
app.mount(container);
|
|
27
|
+
mounts.push({ app, container });
|
|
28
|
+
return container;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rowsOf(container: HTMLElement): HTMLElement[] {
|
|
32
|
+
return [...container.querySelectorAll<HTMLElement>(".hk-date-tag__row")];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function capsuleOf(container: HTMLElement): HTMLElement {
|
|
36
|
+
const el = container.querySelector<HTMLElement>(".hk-date-tag");
|
|
37
|
+
expect(el, "capsule renders").toBeTruthy();
|
|
38
|
+
return el!;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// setLocale mutates the module-global i18n state — restore the default
|
|
42
|
+
// after every case (the HkSolarLocationSetting suite's idiom).
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
45
|
+
app.unmount();
|
|
46
|
+
container.remove();
|
|
47
|
+
}
|
|
48
|
+
await setLocale("en");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("HkDateTag locale ordering", () => {
|
|
52
|
+
it("renders year/month/day rows top to bottom for zh-Hans", async () => {
|
|
53
|
+
await setLocale("zh-Hans");
|
|
54
|
+
const container = mountTag({ value: SHIP_DATE });
|
|
55
|
+
|
|
56
|
+
const capsule = capsuleOf(container);
|
|
57
|
+
expect(capsule.classList.contains("hk-date-tag")).toBe(true);
|
|
58
|
+
const rows = rowsOf(container);
|
|
59
|
+
expect(rows.map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
|
|
60
|
+
// The year row carries its modifier wherever it lands.
|
|
61
|
+
expect(rows[0].classList.contains("hk-date-tag__row--year")).toBe(true);
|
|
62
|
+
expect(rows[1].classList.contains("hk-date-tag__row--year")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("orders month/day/year for en — the year goes LAST", async () => {
|
|
66
|
+
await setLocale("en");
|
|
67
|
+
const container = mountTag({ value: SHIP_DATE });
|
|
68
|
+
|
|
69
|
+
const rows = rowsOf(container);
|
|
70
|
+
expect(rows.map((r) => r.textContent)).toEqual(["10", "06", "2024"]);
|
|
71
|
+
expect(rows[2].classList.contains("hk-date-tag__row--year")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("re-orders the rows live when the locale flips under a mounted capsule", async () => {
|
|
75
|
+
await setLocale("zh-Hans");
|
|
76
|
+
const container = mountTag({ value: SHIP_DATE });
|
|
77
|
+
expect(rowsOf(container).map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
|
|
78
|
+
|
|
79
|
+
await setLocale("en");
|
|
80
|
+
await nextTick();
|
|
81
|
+
expect(rowsOf(container).map((r) => r.textContent)).toEqual(["10", "06", "2024"]);
|
|
82
|
+
|
|
83
|
+
await setLocale("zh-Hans");
|
|
84
|
+
await nextTick();
|
|
85
|
+
expect(rowsOf(container).map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("HkDateTag time row", () => {
|
|
90
|
+
it("appends the muted time row only when asked", async () => {
|
|
91
|
+
await setLocale("zh-Hans");
|
|
92
|
+
const withTime = rowsOf(mountTag({ value: SHIP_DATE, time: true }));
|
|
93
|
+
expect(withTime).toHaveLength(4);
|
|
94
|
+
expect(withTime[3].classList.contains("hk-date-tag__row--time")).toBe(true);
|
|
95
|
+
expect(withTime[3].textContent).toBe("14:30");
|
|
96
|
+
|
|
97
|
+
const withoutTime = rowsOf(mountTag({ value: SHIP_DATE }));
|
|
98
|
+
expect(withoutTime).toHaveLength(3);
|
|
99
|
+
expect(withoutTime.some((r) => r.classList.contains("hk-date-tag__row--time"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("formats the time row for the active locale", async () => {
|
|
103
|
+
await setLocale("en");
|
|
104
|
+
const rows = rowsOf(mountTag({ value: SHIP_DATE, time: true }));
|
|
105
|
+
expect(rows).toHaveLength(4);
|
|
106
|
+
expect(rows[3].textContent).toBe("02:30 PM");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("HkDateTag value normalization", () => {
|
|
111
|
+
it("accepts epoch milliseconds and date strings like the Date form", async () => {
|
|
112
|
+
await setLocale("en");
|
|
113
|
+
const expected = ["10", "06", "2024"];
|
|
114
|
+
expect(rowsOf(mountTag({ value: SHIP_DATE })).map((r) => r.textContent)).toEqual(expected);
|
|
115
|
+
// A number is epoch MILLISECONDS.
|
|
116
|
+
expect(rowsOf(mountTag({ value: SHIP_DATE.getTime() })).map((r) => r.textContent)).toEqual(expected);
|
|
117
|
+
expect(rowsOf(mountTag({ value: "2024-10-06T14:30:00" })).map((r) => r.textContent)).toEqual(expected);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("renders the empty capsule shell for unparseable input", async () => {
|
|
121
|
+
await setLocale("zh-Hans");
|
|
122
|
+
const container = mountTag({ value: "not-a-date" });
|
|
123
|
+
|
|
124
|
+
const capsule = capsuleOf(container);
|
|
125
|
+
expect(capsule.children).toHaveLength(0);
|
|
126
|
+
expect(rowsOf(container)).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("HkDateTag module surface", () => {
|
|
131
|
+
it("exports the same component as the default and named binding", () => {
|
|
132
|
+
expect(HkDateTagDefault).toBe(HkDateTag);
|
|
133
|
+
expect((HkDateTag as unknown as { name: string }).name).toBe("HkDateTag");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
|
|
2
|
+
import { computed, defineComponent, type PropType } from "vue";
|
|
3
|
+
|
|
4
|
+
import { activeLocale } from "../i18n/context";
|
|
5
|
+
import "./HkDateTag.scss";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkDateTag — a locale-aware date capsule: a small pill whose calendar
|
|
9
|
+
* segments are stacked vertically, ONE PER ROW, each centered, ordered
|
|
10
|
+
* the way the ACTIVE hikari locale orders dates (zh: year → month → day
|
|
11
|
+
* top to bottom; en: month → day → year, the year LAST; de: day first …).
|
|
12
|
+
* With the `time` flag a fourth, muted row carries the locale time
|
|
13
|
+
* (hour + minute, 2-digit each).
|
|
14
|
+
*
|
|
15
|
+
* Generic business part (peer of HkTemplateText / HkMarkdownRenderer,
|
|
16
|
+
* NOT of the HkInput family): hosts render compact per-row dates in data
|
|
17
|
+
* tables and cards — wowsp's water-meter ship tables are the first
|
|
18
|
+
* consumer.
|
|
19
|
+
*
|
|
20
|
+
* Contract:
|
|
21
|
+
* - `value` accepts a Date, an epoch-MILLISECONDS number or a
|
|
22
|
+
* date-ish string, normalized with `new Date(value)` (so a number is
|
|
23
|
+
* NEVER seconds — pass what `Date.now()` returns). Unparseable input
|
|
24
|
+
* renders NOTHING but the empty capsule shell; the caller owns the
|
|
25
|
+
* empty state.
|
|
26
|
+
* - The row order is derived from the locale itself, never a hardcoded
|
|
27
|
+
* locale→order map: `Intl.DateTimeFormat(activeLocale(), { year,
|
|
28
|
+
* month, day }).formatToParts(date)` supplies the year/month/day
|
|
29
|
+
* segments in the order that locale writes them. The component is a
|
|
30
|
+
* computed over the reactive i18n state, so a host `setLocale`
|
|
31
|
+
* re-orders the rows live.
|
|
32
|
+
* - `time` (default false) appends the muted time row
|
|
33
|
+
* (`hk-date-tag__row--time`) under the date rows.
|
|
34
|
+
*/
|
|
35
|
+
export const HkDateTag = defineComponent({
|
|
36
|
+
name: "HkDateTag",
|
|
37
|
+
props: {
|
|
38
|
+
/** The date to render — Date, epoch milliseconds or a parseable
|
|
39
|
+
* string. Invalid input renders the empty capsule. */
|
|
40
|
+
value: {
|
|
41
|
+
type: [String, Number, Date] as PropType<string | number | Date>,
|
|
42
|
+
required: true,
|
|
43
|
+
},
|
|
44
|
+
/** Append the muted locale time row (hour + minute, 2-digit). */
|
|
45
|
+
time: { type: Boolean, default: false },
|
|
46
|
+
},
|
|
47
|
+
setup(props) {
|
|
48
|
+
const date = computed<Date | null>(() => {
|
|
49
|
+
const d = new Date(props.value);
|
|
50
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The locale is read through the reactive i18n state (activeLocale
|
|
54
|
+
// reads it on every evaluation), so this computed re-runs — and the
|
|
55
|
+
// rows re-order — whenever the host calls setLocale. One formatter
|
|
56
|
+
// per shape per locale, rebuilt only on a locale/value change (the
|
|
57
|
+
// HkCalendarHeatmap idiom).
|
|
58
|
+
const locale = computed(() => activeLocale());
|
|
59
|
+
|
|
60
|
+
/** The year/month/day segments in the locale's own order. */
|
|
61
|
+
const rows = computed<Intl.DateTimeFormatPart[]>(() => {
|
|
62
|
+
const d = date.value;
|
|
63
|
+
if (d === null) return [];
|
|
64
|
+
return new Intl.DateTimeFormat(locale.value, {
|
|
65
|
+
year: "numeric",
|
|
66
|
+
month: "2-digit",
|
|
67
|
+
day: "2-digit",
|
|
68
|
+
})
|
|
69
|
+
.formatToParts(d)
|
|
70
|
+
.filter((part) => part.type === "year" || part.type === "month" || part.type === "day");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** The locale time (hour + minute, 2-digit) when the capsule opts
|
|
74
|
+
* in — null without `time` or for an invalid date. */
|
|
75
|
+
const timeText = computed<string | null>(() => {
|
|
76
|
+
const d = date.value;
|
|
77
|
+
if (!props.time || d === null) return null;
|
|
78
|
+
return new Intl.DateTimeFormat(locale.value, { hour: "2-digit", minute: "2-digit" }).format(d);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return () => (
|
|
82
|
+
<span class="hk-date-tag">
|
|
83
|
+
{rows.value.map((part) => (
|
|
84
|
+
<span
|
|
85
|
+
key={part.type}
|
|
86
|
+
class={part.type === "year" ? "hk-date-tag__row hk-date-tag__row--year" : "hk-date-tag__row"}
|
|
87
|
+
>
|
|
88
|
+
{part.value}
|
|
89
|
+
</span>
|
|
90
|
+
))}
|
|
91
|
+
{timeText.value !== null && (
|
|
92
|
+
<span key="time" class="hk-date-tag__row hk-date-tag__row--time">{timeText.value}</span>
|
|
93
|
+
)}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export default HkDateTag;
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -555,6 +555,7 @@ export { HkNavSidebar as HNavSidebar } from "./components/HkNavSidebar";
|
|
|
555
555
|
export { HkThemeToggle as HThemeToggle, type ThemeItemScope } from "./components/HkThemeToggle";
|
|
556
556
|
export { HkThemePanelContent as HThemePanelContent } from "./components/HkThemePanelContent";
|
|
557
557
|
export { HkSolarLocationSetting as HSolarLocationSetting } from "./components/HkSolarLocationSetting";
|
|
558
|
+
export { HkDateTag as HDateTag } from "./components/HkDateTag";
|
|
558
559
|
export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
|
|
559
560
|
export { default as HAuthMethodList } from "./components/HkAuthMethodList";
|
|
560
561
|
export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
|
|
@@ -836,6 +837,7 @@ export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
|
|
|
836
837
|
export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
|
|
837
838
|
export { HkThemePanelContent as HkThemePanelContent } from "./components/HkThemePanelContent";
|
|
838
839
|
export { HkSolarLocationSetting as HkSolarLocationSetting } from "./components/HkSolarLocationSetting";
|
|
840
|
+
export { HkDateTag as HkDateTag } from "./components/HkDateTag";
|
|
839
841
|
export { HkAuthCard as HkAuthCard } from "./components/HkAuthCard";
|
|
840
842
|
export { HkSignInCard as HkSignInCard } from "./components/HkSignInCard";
|
|
841
843
|
export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";
|
|
@@ -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
|