@celestia-island/hikari 0.54.1 → 0.54.3
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/DemoApp.tsx +2 -1
- package/src/animation/registerAnimations.ts +3 -0
- package/src/components/HkAdminHeader.tsx +20 -25
- package/src/components/HkAdminShell.tsx +4 -4
- package/src/components/HkColorPicker.scss +7 -1
- package/src/components/HkFileBrowserDialog.tsx +1 -1
- package/src/components/HkGaugeRing.scss +38 -0
- package/src/components/HkGaugeRing.tsx +3 -3
- package/src/components/HkModal.close-collapse.test.ts +79 -0
- package/src/components/HkModal.scss +23 -6
- package/src/components/HkNavSidebar.tsx +1 -1
- package/src/components/HkSignInCard.tsx +1 -3
- package/src/components/HkStatCard.scss +6 -1
- package/src/components/HkTable.scss +2 -2
- package/src/components/HkTable.tsx +1 -1
- package/src/components/componentSelfSufficiency.test.ts +152 -0
- package/src/components/textClip.contract.test.ts +186 -0
- package/src/index.ts +1 -1
- package/src/styles/admin-tokens.scss +200 -1
- package/src/styles/index.scss +5 -1
- package/src/styles/theme/scrollbar.scss +80 -0
- package/src/styles/vendoredSync.test.ts +4 -0
- package/src/utils/base64.ts +12 -0
- package/src/utils/format.test.ts +12 -7
- package/src/utils/format.ts +2 -1
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { computed, defineComponent, ref } from "vue";
|
|
5
5
|
import { ChevronDown, ChevronUp, PackageOpen } from "lucide-vue-next";
|
|
6
6
|
import { HIKARI_FONT_MONO } from "./theme/fontContext";
|
|
7
|
+
import { utf8ToBase64 } from "./utils/base64";
|
|
7
8
|
import {
|
|
8
9
|
HButton, HIconButton, HIconButtonGroup, HTooltip, HBadge, HTag, HIcon, HSpinner,
|
|
9
10
|
HProgressBar, HProgressRing, HGaugeRing,
|
|
@@ -164,7 +165,7 @@ export default defineComponent({
|
|
|
164
165
|
const demoAudioSrc = makeSilentWav();
|
|
165
166
|
const demoImageSrc =
|
|
166
167
|
"data:image/svg+xml;base64," +
|
|
167
|
-
|
|
168
|
+
utf8ToBase64(`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900">
|
|
168
169
|
<rect width="1600" height="900" fill="#1b2333"/>
|
|
169
170
|
<g stroke="#2c3a55" stroke-width="1">
|
|
170
171
|
${Array.from({ length: 15 }, (_, i) => `<line x1="${i * 110}" y1="0" x2="${i * 110}" y2="900"/>`).join("")}
|
|
@@ -103,6 +103,9 @@ registerCssAnimation("s-voice-wave-bounce", { infinite: true });
|
|
|
103
103
|
// components/HkPlaceholderMarquee.scss — overflowing placeholder sweep
|
|
104
104
|
// (loops forever; pure CSS, loop geometry on inline custom properties).
|
|
105
105
|
registerCssAnimation("hk-placeholder-marquee-scroll", { infinite: true });
|
|
106
|
+
// styles/admin-tokens.scss — admin header emergency-stop chip while
|
|
107
|
+
// engaged (loops forever).
|
|
108
|
+
registerCssAnimation("s-admin-header-emergency-pulse", { infinite: true });
|
|
106
109
|
// The styles/theme/mixins.scss ambient/glow/entrance keyframes were
|
|
107
110
|
// retired with the legacy Gen-1 sheet vendoring (hikari #476) — their
|
|
108
111
|
// registrar entries went with them.
|
|
@@ -166,7 +166,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
166
166
|
<header
|
|
167
167
|
class={[
|
|
168
168
|
"s-glass-header",
|
|
169
|
-
props.compact ? "
|
|
169
|
+
props.compact ? "s-glass-header--compact" : "",
|
|
170
170
|
]}
|
|
171
171
|
>
|
|
172
172
|
{props.showHamburger && (
|
|
@@ -175,13 +175,13 @@ export const HkAdminHeader = defineComponent({
|
|
|
175
175
|
size="sm"
|
|
176
176
|
onClick={() => emit("hamburger")}
|
|
177
177
|
>
|
|
178
|
-
<Menu size={20} class="
|
|
178
|
+
<Menu size={20} class="s-admin-header-hamburger-icon" />
|
|
179
179
|
</HButton>
|
|
180
180
|
)}
|
|
181
181
|
|
|
182
|
-
<div ref={userTriggerRef} class="
|
|
182
|
+
<div ref={userTriggerRef} class="s-admin-header-user">
|
|
183
183
|
<button
|
|
184
|
-
class="
|
|
184
|
+
class="s-admin-header-avatar"
|
|
185
185
|
aria-label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
|
|
186
186
|
aria-haspopup={props.avatarAction === "drawer" ? "dialog" : "menu"}
|
|
187
187
|
aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
|
|
@@ -191,7 +191,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
191
191
|
<img
|
|
192
192
|
src={props.avatarUrl}
|
|
193
193
|
alt={props.username}
|
|
194
|
-
class="
|
|
194
|
+
class="s-admin-header-avatar-img"
|
|
195
195
|
onError={() => { avatarFailed.value = true; }}
|
|
196
196
|
/>
|
|
197
197
|
) : (
|
|
@@ -205,24 +205,19 @@ export const HkAdminHeader = defineComponent({
|
|
|
205
205
|
</span>
|
|
206
206
|
)}
|
|
207
207
|
{props.avatarAction === "menu" && (
|
|
208
|
-
<div class="
|
|
209
|
-
<Camera size={10} class="
|
|
208
|
+
<div class="s-admin-header-avatar-veil">
|
|
209
|
+
<Camera size={10} class="s-admin-header-veil-icon" />
|
|
210
210
|
</div>
|
|
211
211
|
)}
|
|
212
212
|
</button>
|
|
213
213
|
{/* WHERE am I — the open view's title, not the nickname. The
|
|
214
|
-
|
|
215
|
-
the truncate clip box: the default text-sm box is
|
|
216
|
-
the em advance, so zoom/subpixel rounding in a
|
|
217
|
-
shaves the ink off at the bottom (user report
|
|
218
|
-
the "g" tail of the nickname was visibly cut). */}
|
|
214
|
+
1.5 line-height (in the class) keeps descenders (g, y, p)
|
|
215
|
+
inside the truncate clip box: the default text-sm box is
|
|
216
|
+
exactly the em advance, so zoom/subpixel rounding in a
|
|
217
|
+
scaled root shaves the ink off at the bottom (user report
|
|
218
|
+
2026-09-12: the "g" tail of the nickname was visibly cut). */}
|
|
219
219
|
{props.avatarAction === "menu" && props.title && (
|
|
220
|
-
<span
|
|
221
|
-
class="text-sm font-semibold text-text truncate max-w-[8rem]"
|
|
222
|
-
style={{ lineHeight: "1.5" }}
|
|
223
|
-
>
|
|
224
|
-
{props.title}
|
|
225
|
-
</span>
|
|
220
|
+
<span class="s-admin-header-title">{props.title}</span>
|
|
226
221
|
)}
|
|
227
222
|
</div>
|
|
228
223
|
|
|
@@ -289,15 +284,15 @@ export const HkAdminHeader = defineComponent({
|
|
|
289
284
|
}}
|
|
290
285
|
</HkMenu>
|
|
291
286
|
|
|
292
|
-
<div class="
|
|
287
|
+
<div class="s-admin-header-actions">
|
|
293
288
|
{props.showEmergencyStop && (
|
|
294
289
|
<button
|
|
295
290
|
class={[
|
|
296
|
-
"
|
|
297
|
-
props.emergencyStopLoading
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
291
|
+
"s-admin-header-emergency",
|
|
292
|
+
props.emergencyStopLoading
|
|
293
|
+
? "is-loading"
|
|
294
|
+
: "",
|
|
295
|
+
props.emergencyStopActive ? "is-active" : "",
|
|
301
296
|
]}
|
|
302
297
|
disabled={props.emergencyStopLoading}
|
|
303
298
|
title={props.emergencyStopActive
|
|
@@ -312,7 +307,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
312
307
|
)}
|
|
313
308
|
{slots["emergency-stop-extra"]?.()}
|
|
314
309
|
{(props.actions || []).map((vnode, i) => (
|
|
315
|
-
<span key={i} class="
|
|
310
|
+
<span key={i} class="s-admin-header-action">
|
|
316
311
|
{vnode}
|
|
317
312
|
</span>
|
|
318
313
|
))}
|
|
@@ -45,7 +45,7 @@ export const HkAdminShell = defineComponent({
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
return () => (
|
|
48
|
-
<div class="
|
|
48
|
+
<div class="s-admin-shell">
|
|
49
49
|
{slots.header && (
|
|
50
50
|
<div style={{ flexShrink: 0 }}>
|
|
51
51
|
{slots.header({
|
|
@@ -61,7 +61,7 @@ export const HkAdminShell = defineComponent({
|
|
|
61
61
|
</div>
|
|
62
62
|
)}
|
|
63
63
|
|
|
64
|
-
<div class="
|
|
64
|
+
<div class="s-admin-shell-body" style={{ paddingBottom: props.footerHeight }}>
|
|
65
65
|
{isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
|
|
66
66
|
<aside
|
|
67
67
|
style={{
|
|
@@ -75,8 +75,8 @@ export const HkAdminShell = defineComponent({
|
|
|
75
75
|
{slots.sidebar({ collapsed: false, onNavigate: closeSidebar })}
|
|
76
76
|
</aside>
|
|
77
77
|
)}
|
|
78
|
-
<main class="
|
|
79
|
-
<HScrollContainer class="
|
|
78
|
+
<main class="s-admin-shell-main">
|
|
79
|
+
<HScrollContainer class="s-admin-shell-scroll">
|
|
80
80
|
{/* Padding lives INSIDE the scroll viewport (an inner
|
|
81
81
|
wrapper) so card box-shadows are not clipped at the
|
|
82
82
|
viewport edges. */}
|
|
@@ -71,7 +71,13 @@
|
|
|
71
71
|
margin-bottom: var(--space-4);
|
|
72
72
|
color: rgb(var(--color-text) / var(--hk-field-label-alpha, 72%));
|
|
73
73
|
white-space: nowrap;
|
|
74
|
-
|
|
74
|
+
/* The row layout below clips this label with `overflow: hidden` for its
|
|
75
|
+
* ellipsis, and that clip box IS the line box: at `line-height: 1` the 12px
|
|
76
|
+
* box was shorter than the font's ink box (14.05px Roboto, 17.38px once a
|
|
77
|
+
* CJK fallback renders the glyphs), so every g/j/p/q/y in a colour name lost
|
|
78
|
+
* its tail. 1.5 = 18px, exactly the swatch height in the row layout, so the
|
|
79
|
+
* dense grids keep their geometry. */
|
|
80
|
+
line-height: 1.5;
|
|
75
81
|
transition: color var(--duration-short);
|
|
76
82
|
|
|
77
83
|
.hk-color-picker-swatch-btn:hover & {
|
|
@@ -81,7 +81,7 @@ function formatModified(value: string | number | undefined): string {
|
|
|
81
81
|
if (value === undefined) return "";
|
|
82
82
|
if (typeof value === "number") {
|
|
83
83
|
const date = new Date(value);
|
|
84
|
-
return Number.isNaN(date.getTime()) ? String(value) : date
|
|
84
|
+
return Number.isNaN(date.getTime()) ? String(value) : formatDateTime(date);
|
|
85
85
|
}
|
|
86
86
|
return value;
|
|
87
87
|
}
|
|
@@ -3,6 +3,44 @@
|
|
|
3
3
|
display: inline-flex;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/* The centered read-out. The markup used to borrow the HOST's atomic layer
|
|
7
|
+
* for its whole anatomy (`absolute inset-0 flex flex-col items-center
|
|
8
|
+
* justify-center pointer-events-none` + `text-lg font-bold text-text
|
|
9
|
+
* leading-none` / `text-2xs text-muted mt-0.5`): a host without one (the
|
|
10
|
+
* evernight flasher) rendered the value as a static sibling beside the ring,
|
|
11
|
+
* and a host WITH one (chest/UnoCSS) owned values the design system could not
|
|
12
|
+
* fix. Self-contained rules below instead. */
|
|
13
|
+
.hk-gauge-ring-center {
|
|
14
|
+
position: absolute;
|
|
15
|
+
inset: 0;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* The 1.45 ratio is load-bearing: `leading-none` pinned line-height: 1, which
|
|
24
|
+
* put the descender of any g/j/p/q/y ~0.95px below the 18px line box — it
|
|
25
|
+
* overlapped the label underneath and was cut outright inside a truncating
|
|
26
|
+
* wrapper. 1.45 covers the CJK fallback metrics (ascent+descent up to 1.45em),
|
|
27
|
+
* so the ring owns the ratio instead of borrowing a host utility. */
|
|
28
|
+
.hk-gauge-ring-value {
|
|
29
|
+
font-size: var(--text-lg);
|
|
30
|
+
font-weight: 700;
|
|
31
|
+
line-height: 1.45;
|
|
32
|
+
color: rgb(var(--color-text));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.hk-gauge-ring-label {
|
|
36
|
+
font-size: var(--text-2xs);
|
|
37
|
+
/* Uno's font-size utilities also emit `line-height: 1`; the label is a
|
|
38
|
+
* single clipped-free line, so the host rendering is kept here. */
|
|
39
|
+
line-height: 1;
|
|
40
|
+
color: rgb(var(--color-muted));
|
|
41
|
+
margin-top: 0.125rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
6
44
|
// The 0.8s dash sweep rides an INLINE transition on the progress circles
|
|
7
45
|
// (HkGaugeRing.tsx), so the guard needs !important to beat it.
|
|
8
46
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -115,12 +115,12 @@ export default defineComponent({
|
|
|
115
115
|
))}
|
|
116
116
|
</svg>
|
|
117
117
|
{(props.centerValue || props.centerLabel) && (
|
|
118
|
-
<div class="
|
|
118
|
+
<div class="hk-gauge-ring-center">
|
|
119
119
|
{props.centerValue && (
|
|
120
|
-
<div class="
|
|
120
|
+
<div class="hk-gauge-ring-value">{props.centerValue}</div>
|
|
121
121
|
)}
|
|
122
122
|
{props.centerLabel && (
|
|
123
|
-
<div class="
|
|
123
|
+
<div class="hk-gauge-ring-label">{props.centerLabel}</div>
|
|
124
124
|
)}
|
|
125
125
|
</div>
|
|
126
126
|
)}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the DESKTOP modal close choreography (user report
|
|
3
|
+
* 2026-09-15): the unveil expands the visible box outward from the
|
|
4
|
+
* center (bottom-10% clip + +5% offset at t=0, full box at t=1), but the
|
|
5
|
+
* close had degenerated into a fade + downward drift — the box read as
|
|
6
|
+
* shrinking straight down instead of folding back into its center.
|
|
7
|
+
*
|
|
8
|
+
* The close now runs the unveil's TRUE reverse, which is only safe
|
|
9
|
+
* because the leave pins an identity clip: interpolating
|
|
10
|
+
* `none → inset()` is DISCRETE (the #508 regression — the footer band
|
|
11
|
+
* vanished at the first frame), while identical `inset → inset` shape
|
|
12
|
+
* functions interpolate continuously.
|
|
13
|
+
*
|
|
14
|
+
* Pinned on the stylesheet SOURCE (computed animation geometry is not
|
|
15
|
+
* observable in happy-dom), mirroring the themeToggleChrome /
|
|
16
|
+
* tokenHygiene guard pattern.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
|
|
25
|
+
|
|
26
|
+
/** The desktop choreography block — the mobile sheet block (≤767px)
|
|
27
|
+
* carries its own enter/leave classes and must not leak in. */
|
|
28
|
+
const desktop = src.slice(
|
|
29
|
+
src.indexOf("@media (min-width: 768px)"),
|
|
30
|
+
src.indexOf("@media (max-width: 767px)"),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/** Body of one flat rule (no nested braces in this block). */
|
|
34
|
+
function bodyOf(selector: string): string {
|
|
35
|
+
const escaped = selector.replace(/\./g, "\\.");
|
|
36
|
+
const match = desktop.match(new RegExp(`${escaped}\\s*{([^}]*)}`));
|
|
37
|
+
expect(match, `rule ${selector} must exist in the desktop block`).toBeTruthy();
|
|
38
|
+
return match![1].replace(/\s+/g, " ").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clipOf(selector: string): string {
|
|
42
|
+
const body = bodyOf(selector);
|
|
43
|
+
const match = body.match(/clip-path:\s*([^;]+);?/);
|
|
44
|
+
expect(match, `${selector} must declare a clip-path`).toBeTruthy();
|
|
45
|
+
return match![1].replace(/\s+/g, " ").trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("HkModal desktop close-collapse contract (user report 2026-09-15)", () => {
|
|
49
|
+
it("pins the identity clip in leave-from, identical to enter-to (no discrete clip jump)", () => {
|
|
50
|
+
// The #508 regression guard: leave-from WITHOUT a clip makes
|
|
51
|
+
// none → inset() interpolate discretely and the footer band vanish
|
|
52
|
+
// at the first frame of the close.
|
|
53
|
+
expect(clipOf(".hk-modal-content-leave-from")).toBe(
|
|
54
|
+
clipOf(".hk-modal-content-enter-to"),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("mirrors the unveil geometry: leave-to clips like enter-from", () => {
|
|
59
|
+
// Bottom-10% clip + same rounding on both ends of the mirror — the
|
|
60
|
+
// visible box collapses symmetrically back to 90% centered.
|
|
61
|
+
expect(clipOf(".hk-modal-content-leave-to")).toBe(
|
|
62
|
+
clipOf(".hk-modal-content-enter-from"),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("transitions clip-path on leave (the collapse animates, not snaps)", () => {
|
|
67
|
+
const body = bodyOf(".hk-modal-content-leave-active");
|
|
68
|
+
expect(body).toMatch(/clip-path\s+var\(--hk-modal-duration/);
|
|
69
|
+
expect(body).toMatch(/will-change:\s*[^;]*clip-path/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("keeps the close a rigid ride: no scaleY squash anywhere in the desktop block", () => {
|
|
73
|
+
// History: #512 collapsed via scaleY (content squashed), #524
|
|
74
|
+
// reverted to a bare drift. The clip choreography moves whole
|
|
75
|
+
// pixels of the visible window — the frame itself never distorts.
|
|
76
|
+
expect(desktop).not.toContain("scaleY");
|
|
77
|
+
expect(desktop).not.toContain("transform-origin");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -123,18 +123,26 @@
|
|
|
123
123
|
will-change: transform, opacity, clip-path;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// Close: the
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
126
|
+
// Close: the unveil's TRUE reverse — the visible box collapses back
|
|
127
|
+
// toward the center (top edge sweeps down, bottom edge sweeps up,
|
|
128
|
+
// content rides rigidly — never squashed) while the frame fades
|
|
129
|
+
// (user report 2026-09-15: the fade+drift close read as the box
|
|
130
|
+
// shrinking straight down instead of folding back into its center).
|
|
131
|
+
// The earlier re-clip attempt (#508) broke because leave-from had no
|
|
132
|
+
// clip at all: interpolating `none → inset()` is DISCRETE, so the
|
|
133
|
+
// bottom band (the footer) vanished at the first frame. Pinning the
|
|
134
|
+
// identity clip in leave-from — byte-identical to enter-to — makes
|
|
135
|
+
// the same-shape insets interpolate continuously, which structurally
|
|
136
|
+
// rules that jump out while restoring the symmetric collapse.
|
|
131
137
|
.hk-modal-content-leave-active {
|
|
132
138
|
transition:
|
|
133
139
|
opacity var(--hk-modal-duration, 0.32s)
|
|
134
140
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
135
141
|
transform var(--hk-modal-duration, 0.32s)
|
|
142
|
+
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
143
|
+
clip-path var(--hk-modal-duration, 0.32s)
|
|
136
144
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
137
|
-
will-change: transform, opacity;
|
|
145
|
+
will-change: transform, opacity, clip-path;
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
.hk-modal-content-enter-from {
|
|
@@ -154,11 +162,20 @@
|
|
|
154
162
|
.hk-modal-content-leave-from {
|
|
155
163
|
opacity: 1;
|
|
156
164
|
transform: translate(-50%, -50%);
|
|
165
|
+
// The pinned identity clip: identical to enter-to so the leave's
|
|
166
|
+
// clip interpolates (never jumps) — see the close note above.
|
|
167
|
+
clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
168
|
+
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
.hk-modal-content-leave-to {
|
|
160
172
|
opacity: 0;
|
|
173
|
+
// Mirror of enter-from: shifted +5% with the bottom 10% clipped, the
|
|
174
|
+
// visible box is 90% and centered — both edges ease toward the
|
|
175
|
+
// center line, exactly reversing the unveil.
|
|
161
176
|
transform: translate(-50%, -50%) translateY(5%);
|
|
177
|
+
clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
178
|
+
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
162
179
|
}
|
|
163
180
|
}
|
|
164
181
|
|
|
@@ -75,7 +75,7 @@ export const HkNavSidebar = defineComponent({
|
|
|
75
75
|
icon: () => (IconComp ? h(IconComp, { size: 16 }) : null),
|
|
76
76
|
default: () => (
|
|
77
77
|
<span class="s-nav-item-content">
|
|
78
|
-
<span class="
|
|
78
|
+
<span class="s-nav-item-label">{item.label}</span>
|
|
79
79
|
{item.badge && (
|
|
80
80
|
<span
|
|
81
81
|
class="s-nav-item-badge"
|
|
@@ -118,15 +118,13 @@ export const HkSignInCard = defineComponent({
|
|
|
118
118
|
) : (
|
|
119
119
|
<svg
|
|
120
120
|
xmlns="http://www.w3.org/2000/svg"
|
|
121
|
-
width="16"
|
|
122
|
-
height="16"
|
|
123
121
|
viewBox="0 0 24 24"
|
|
124
122
|
fill="none"
|
|
125
123
|
stroke="currentColor"
|
|
126
124
|
stroke-width="2"
|
|
127
125
|
stroke-linecap="round"
|
|
128
126
|
stroke-linejoin="round"
|
|
129
|
-
class="
|
|
127
|
+
class="s-auth-card-field-icon"
|
|
130
128
|
aria-hidden="true"
|
|
131
129
|
>
|
|
132
130
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
|
@@ -61,7 +61,12 @@
|
|
|
61
61
|
.hk-stat-card-value {
|
|
62
62
|
font-size: var(--text-2xl, 1.5rem);
|
|
63
63
|
font-weight: 700;
|
|
64
|
-
|
|
64
|
+
/* `overflow: hidden` below hangs the ellipsis on the line box, so the line
|
|
65
|
+
* box has to cover the ink box: 1.2 (28.8px at the 24px token) cleared the
|
|
66
|
+
* shipped Latin stacks by only 0.6-1.1px and clipped 2.9-3.1px per edge as
|
|
67
|
+
* soon as a host swapped --font-sans for a CJK fallback (ascent+descent
|
|
68
|
+
* 1.34-1.45em, see theme/fontContext). 1.35 covers those metrics. */
|
|
69
|
+
line-height: 1.35;
|
|
65
70
|
color: rgb(var(--color-text));
|
|
66
71
|
overflow: hidden;
|
|
67
72
|
text-overflow: ellipsis;
|
|
@@ -131,7 +131,7 @@ export default defineComponent({
|
|
|
131
131
|
<div ref={wrapperHostRef} class="hk-table-host">
|
|
132
132
|
<div ref={wrapperRef} class="hk-table-wrapper">
|
|
133
133
|
<table class={tableCls.value}>
|
|
134
|
-
{props.caption && <caption class="sr-only">{props.caption}</caption>}
|
|
134
|
+
{props.caption && <caption class="hk-table-sr-only">{props.caption}</caption>}
|
|
135
135
|
<thead>
|
|
136
136
|
<tr class="hk-table-header-row">
|
|
137
137
|
{props.selectable && (
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Component self-sufficiency guard.
|
|
9
|
+
*
|
|
10
|
+
* A hikari component must carry its own styling. Class literals in a
|
|
11
|
+
* component's TSX are allowed to name hikari's own namespaces (`hk-`, the
|
|
12
|
+
* plana-ui shared surface `s-`, state modifiers `is-`, `hii-`, `hljs`) —
|
|
13
|
+
* those are defined in this package and ship with it. Anything else in a
|
|
14
|
+
* class literal is a HOST-DIALECT utility (`flex`, `inset-0`, `w-7 h-7`,
|
|
15
|
+
* `text-2xs`, `animate-pulse`, …): it resolves only if the consuming app
|
|
16
|
+
* happens to run an atomic-CSS layer, and silently does nothing otherwise.
|
|
17
|
+
*
|
|
18
|
+
* That silent degradation is exactly what shipped: four components
|
|
19
|
+
* (HkAdminHeader, HkAdminShell, HkGaugeRing, HkNavSidebar) spoke the
|
|
20
|
+
* utility dialect, so the evernight flasher rendered its gauge ring with
|
|
21
|
+
* the value unstyled beside the circle and its header with no gap or
|
|
22
|
+
* padding at all — while hosts with UnoCSS (chest) owned values the
|
|
23
|
+
* design system could not fix. Those four were rewritten to component
|
|
24
|
+
* scss (2026-09-15) and the utility sheet they had been propped up with
|
|
25
|
+
* was deleted; this test is the fence that keeps the dialect out.
|
|
26
|
+
*
|
|
27
|
+
* Adding a component that needs a utility class means either giving it a
|
|
28
|
+
* semantic class with rules in its own scss (the house style) or adding
|
|
29
|
+
* it here — and the second should feel like the wrong answer.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const componentsDir = resolve(
|
|
33
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
34
|
+
"..",
|
|
35
|
+
"components",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** Namespaces that are hikari's own (semantic classes, not host dialect). */
|
|
39
|
+
const OWN_NAMESPACE = /^(?:hk-|s-|is-|hii-|hljs)/;
|
|
40
|
+
|
|
41
|
+
/** `"desc"` inside `sortDirection === "desc"` is a comparison operand, not
|
|
42
|
+
* a class name — the only false-positive source a class-literal scan has. */
|
|
43
|
+
const COMPARISON_BEFORE = /(?:===|!==|==|!=)\s*$/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cut the literal source of every `class=` attribute and say whether it is
|
|
47
|
+
* bare class text (`class="a b"` — a token list already) or an expression
|
|
48
|
+
* (`class={…}` — token lists hide inside its string literals).
|
|
49
|
+
*/
|
|
50
|
+
function classAttributeSources(source: string): { text: string; expression: boolean }[] {
|
|
51
|
+
const out: { text: string; expression: boolean }[] = [];
|
|
52
|
+
const attr = "class=";
|
|
53
|
+
for (let i = source.indexOf(attr); i !== -1; i = source.indexOf(attr, i + 1)) {
|
|
54
|
+
const start = i + attr.length;
|
|
55
|
+
const first = source[start];
|
|
56
|
+
if (first === '"' || first === "'") {
|
|
57
|
+
const end = source.indexOf(first, start + 1);
|
|
58
|
+
if (end !== -1) out.push({ text: source.slice(start + 1, end), expression: false });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (first === "{") {
|
|
62
|
+
let depth = 0;
|
|
63
|
+
for (let j = start; j < source.length; j += 1) {
|
|
64
|
+
if (source[j] === "{") depth += 1;
|
|
65
|
+
else if (source[j] === "}") {
|
|
66
|
+
depth -= 1;
|
|
67
|
+
if (depth === 0) {
|
|
68
|
+
out.push({ text: source.slice(start + 1, j), expression: true });
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** String literals in an expression, with the text right before each one. */
|
|
79
|
+
function literals(expression: string): { value: string; before: string }[] {
|
|
80
|
+
const out: { value: string; before: string }[] = [];
|
|
81
|
+
const pattern = /(["'])((?:\\.|(?!\1)[^\\])*)\1/g;
|
|
82
|
+
for (let m = pattern.exec(expression); m !== null; m = pattern.exec(expression)) {
|
|
83
|
+
out.push({ value: m[2], before: expression.slice(0, m.index) });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Host-dialect utility tokens found in the component sources, mapped to
|
|
89
|
+
* the files that write them. */
|
|
90
|
+
function utilityUsages(): Map<string, Set<string>> {
|
|
91
|
+
const usage = new Map<string, Set<string>>();
|
|
92
|
+
for (const name of readdirSync(componentsDir).sort()) {
|
|
93
|
+
if (!name.endsWith(".tsx") || name.endsWith(".test.tsx")) continue;
|
|
94
|
+
const source = readFileSync(join(componentsDir, name), "utf8").replace(/\r\n/g, "\n");
|
|
95
|
+
for (const attr of classAttributeSources(source)) {
|
|
96
|
+
const lists = attr.expression
|
|
97
|
+
? literals(attr.text)
|
|
98
|
+
.filter(({ before }) => !COMPARISON_BEFORE.test(before))
|
|
99
|
+
.map(({ value }) => value)
|
|
100
|
+
: [attr.text];
|
|
101
|
+
for (const list of lists) {
|
|
102
|
+
for (const token of list.split(/\s+/)) {
|
|
103
|
+
if (!token || token.includes("${") || OWN_NAMESPACE.test(token)) continue;
|
|
104
|
+
if (!usage.has(token)) usage.set(token, new Set());
|
|
105
|
+
usage.get(token)!.add(name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return usage;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("component self-sufficiency", () => {
|
|
114
|
+
it("keeps host-dialect utility classes out of component markup", () => {
|
|
115
|
+
const used = utilityUsages();
|
|
116
|
+
const offenders = [...used.keys()].sort();
|
|
117
|
+
expect(
|
|
118
|
+
offenders,
|
|
119
|
+
`components must style themselves (hk-*/s-*/is-* classes + their own scss), ` +
|
|
120
|
+
`not rely on the host's atomic layer: ${offenders
|
|
121
|
+
.map((t) => `${t} (${[...used.get(t)!].join(", ")})`)
|
|
122
|
+
.join("; ")}`,
|
|
123
|
+
).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("still sees the class literals it is supposed to guard", () => {
|
|
127
|
+
// An extractor that silently stopped matching would make the assertion
|
|
128
|
+
// above pass on an empty set. Anchor it: the four rewritten components
|
|
129
|
+
// carry semantic classes, and the scan must find them.
|
|
130
|
+
const seen = new Map<string, Set<string>>();
|
|
131
|
+
for (const name of readdirSync(componentsDir).sort()) {
|
|
132
|
+
if (!name.endsWith(".tsx") || name.endsWith(".test.tsx")) continue;
|
|
133
|
+
const source = readFileSync(join(componentsDir, name), "utf8");
|
|
134
|
+
for (const attr of classAttributeSources(source)) {
|
|
135
|
+
const lists = attr.expression
|
|
136
|
+
? literals(attr.text).map(({ value }) => value)
|
|
137
|
+
: [attr.text];
|
|
138
|
+
for (const list of lists) {
|
|
139
|
+
for (const token of list.split(/\s+/)) {
|
|
140
|
+
if (!token) continue;
|
|
141
|
+
if (!seen.has(token)) seen.set(token, new Set());
|
|
142
|
+
seen.get(token)!.add(name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const anchor of ["s-glass-header", "hk-gauge-ring-center", "s-admin-shell"]) {
|
|
148
|
+
expect(seen.has(anchor), `scan lost ${anchor}`).toBe(true);
|
|
149
|
+
}
|
|
150
|
+
expect(seen.size).toBeGreaterThan(50);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Descender-clipping contract for every hikari stylesheet.
|
|
10
|
+
*
|
|
11
|
+
* `text-overflow: ellipsis` is not a vertical safeguard: the same
|
|
12
|
+
* `overflow: hidden` that truncates the line horizontally clips glyph ink to
|
|
13
|
+
* the padding box, and that box is the LINE BOX. When the line box is shorter
|
|
14
|
+
* than the font's ink box the bottom of every g/j/p/q/y is cut off flat —
|
|
15
|
+
* reported twice by users (the chest header workspace pill, and the 'g' tail
|
|
16
|
+
* of an admin-header nickname). `line-height: 1` is the classic way to build
|
|
17
|
+
* that trap, because it is reached for as a centring fix inside a fixed-height
|
|
18
|
+
* chip.
|
|
19
|
+
*
|
|
20
|
+
* The floor below is `line-height / font-size`. Descender depth is ~0.21em and
|
|
21
|
+
* the tallest ink (accents, Han) needs another ~1.0-1.1em, so Latin stacks need
|
|
22
|
+
* >= 1.10-1.15; the ceiling is set by the CJK fallbacks hikari supports
|
|
23
|
+
* (`theme/fontContext` lets a host swap --font-sans, and YaHei / Noto Sans CJK
|
|
24
|
+
* carry ascent+descent of 1.34-1.45em). 1.30 is the value that keeps a host
|
|
25
|
+
* font swap from turning a clip box into a paper cut, and it still leaves room
|
|
26
|
+
* for the deliberately tight designs (`--hk-menu-item-lh: 1.3`).
|
|
27
|
+
*
|
|
28
|
+
* A rule is only flagged when the clip and the too-small line box sit in the
|
|
29
|
+
* SAME declaration block: that is the combination that actually cuts ink. An
|
|
30
|
+
* inherited line-height from an ancestor is invisible here, which is why the
|
|
31
|
+
* fix for a flagged rule is to declare a safe ratio on the clipping rule
|
|
32
|
+
* itself rather than somewhere up the tree.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
36
|
+
const stylesDir = resolve(componentDir, "../styles");
|
|
37
|
+
|
|
38
|
+
/** line-height / font-size below this clips descenders on some supported font. */
|
|
39
|
+
const SAFE_LINE_HEIGHT_RATIO = 1.3;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reviewed exemptions. Every entry must say why the ink cannot be text or why
|
|
43
|
+
* the clip is intended — an unexplained entry is a bug, not a pass.
|
|
44
|
+
*/
|
|
45
|
+
const ALLOWED: ReadonlyArray<{ file: string; selector: string; why: string }> = [];
|
|
46
|
+
|
|
47
|
+
type Block = { file: string; selector: string; body: string; order: number };
|
|
48
|
+
|
|
49
|
+
/** Walk a compiled stylesheet, flattening at-rules into their inner blocks. */
|
|
50
|
+
function* blocksOf(css: string, file: string, prefix = "", order = { value: 0 }): Generator<Block> {
|
|
51
|
+
let i = 0;
|
|
52
|
+
while (i < css.length) {
|
|
53
|
+
const open = css.indexOf("{", i);
|
|
54
|
+
if (open === -1) return;
|
|
55
|
+
const prelude = css.slice(i, open).trim();
|
|
56
|
+
let depth = 1;
|
|
57
|
+
let j = open + 1;
|
|
58
|
+
while (j < css.length && depth > 0) {
|
|
59
|
+
if (css[j] === "{") depth++;
|
|
60
|
+
else if (css[j] === "}") depth--;
|
|
61
|
+
j++;
|
|
62
|
+
}
|
|
63
|
+
const body = css.slice(open + 1, j - 1);
|
|
64
|
+
if (prelude.startsWith("@")) {
|
|
65
|
+
yield* blocksOf(body, file, prefix, order);
|
|
66
|
+
} else {
|
|
67
|
+
yield { file, selector: prefix ? `${prefix} ${prelude}` : prelude, body, order: order.value++ };
|
|
68
|
+
}
|
|
69
|
+
i = j;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function declaration(body: string, property: string): string | null {
|
|
74
|
+
const match = body.match(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]+)`, "i"));
|
|
75
|
+
return match ? match[1]!.trim() : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clipsOverflow(body: string): boolean {
|
|
79
|
+
return ["overflow", "overflow-x", "overflow-y"].some((property) => {
|
|
80
|
+
const value = declaration(body, property);
|
|
81
|
+
return value !== null && /\b(hidden|clip)\b/.test(value);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function px(value: string | null): number | null {
|
|
86
|
+
if (value === null) return null;
|
|
87
|
+
const match = value.match(/^([\d.]+)px$/);
|
|
88
|
+
return match ? Number(match[1]) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ratioOf(body: string): number | null {
|
|
92
|
+
const lineHeight = declaration(body, "line-height");
|
|
93
|
+
if (lineHeight === null) return null;
|
|
94
|
+
const unitless = lineHeight.match(/^[\d.]+$/);
|
|
95
|
+
if (unitless) return Number(lineHeight);
|
|
96
|
+
const lineHeightPx = px(lineHeight);
|
|
97
|
+
const fontSizePx = px(declaration(body, "font-size"));
|
|
98
|
+
if (lineHeightPx !== null && fontSizePx !== null && fontSizePx > 0) {
|
|
99
|
+
return lineHeightPx / fontSizePx;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sheets(): string[] {
|
|
105
|
+
const components = readdirSync(componentDir)
|
|
106
|
+
.filter((name) => name.endsWith(".scss"))
|
|
107
|
+
.map((name) => resolve(componentDir, name));
|
|
108
|
+
const styles = readdirSync(stylesDir)
|
|
109
|
+
.filter((name) => name.endsWith(".scss"))
|
|
110
|
+
.map((name) => resolve(stylesDir, name));
|
|
111
|
+
return [...components, ...styles];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The element a rule actually styles: the last compound of its selector. */
|
|
115
|
+
function subject(selector: string): string {
|
|
116
|
+
const parts = selector.split(/\s+|>/).filter(Boolean);
|
|
117
|
+
return parts[parts.length - 1] ?? selector;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Rough CSS specificity, enough to pick the rule that wins inside one sheet. */
|
|
121
|
+
function specificity(selector: string): number {
|
|
122
|
+
return (selector.match(/[.#[\]]/g)?.length ?? 0) * 10 + (selector.match(/[a-z]+[a-z0-9-]*/gi)?.length ?? 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Effective `line-height / font-size` for a clipping rule, resolving the
|
|
127
|
+
* cascade *within the sheet*: the clip and the line box very often live in two
|
|
128
|
+
* different blocks for the same element (the row-layout override adds
|
|
129
|
+
* `overflow: hidden`, the base rule declares `line-height: 1`), which is why a
|
|
130
|
+
* per-block scan sees nothing. Rules that declare no line-height are skipped —
|
|
131
|
+
* the inherited value is `normal`, which cannot clip.
|
|
132
|
+
*/
|
|
133
|
+
function effectiveRatio(clip: Block, blocks: Block[]): number | null {
|
|
134
|
+
const element = subject(clip.selector);
|
|
135
|
+
const candidates = blocks
|
|
136
|
+
.filter((block) => block.selector === clip.selector || subject(block.selector) === element)
|
|
137
|
+
.map((block) => ({ block, ratio: ratioOf(block.body) }))
|
|
138
|
+
.filter((entry): entry is { block: Block; ratio: number } => entry.ratio !== null);
|
|
139
|
+
if (!candidates.length) return null;
|
|
140
|
+
candidates.sort(
|
|
141
|
+
(a, b) => specificity(b.block.selector) - specificity(a.block.selector) || b.block.order - a.block.order,
|
|
142
|
+
);
|
|
143
|
+
return candidates[0]!.ratio;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe("stylesheet descender-clipping contract", () => {
|
|
147
|
+
// Compiling ~130 stylesheets takes ~1s on a warm cache and several seconds on
|
|
148
|
+
// a cold one; the default 5s budget turns a slow machine into a red suite.
|
|
149
|
+
it("never pairs a clip box with a line box shorter than the ink box", { timeout: 60_000 }, () => {
|
|
150
|
+
const findings: string[] = [];
|
|
151
|
+
const allowed = new Set(ALLOWED.map((entry) => `${entry.file}|${entry.selector}`));
|
|
152
|
+
|
|
153
|
+
for (const file of sheets()) {
|
|
154
|
+
const name = file.slice(file.indexOf("/src/") + 1);
|
|
155
|
+
// sass keeps `/* … */` comments in expanded output, and a comment that
|
|
156
|
+
// *talks* about `overflow: hidden` or `line-height: 1` would otherwise be
|
|
157
|
+
// parsed as a declaration (that bug made this guard pass while the very
|
|
158
|
+
// clip it was written for was live).
|
|
159
|
+
const css = compile(file, { style: "expanded", loadPaths: [componentDir, stylesDir] }).css.replace(
|
|
160
|
+
/\/\*[\s\S]*?\*\//g,
|
|
161
|
+
"",
|
|
162
|
+
);
|
|
163
|
+
const blocks = [...blocksOf(css, name)];
|
|
164
|
+
for (const block of blocks) {
|
|
165
|
+
if (!clipsOverflow(block.body)) continue;
|
|
166
|
+
const ratio = effectiveRatio(block, blocks);
|
|
167
|
+
if (ratio === null || ratio >= SAFE_LINE_HEIGHT_RATIO) continue;
|
|
168
|
+
if (allowed.has(`${block.file}|${block.selector}`)) continue;
|
|
169
|
+
findings.push(
|
|
170
|
+
`${block.file} — ${block.selector} — clips overflow with an effective line-height ratio of ${ratio.toFixed(2)} (< ${SAFE_LINE_HEIGHT_RATIO}): the ink box of g/j/p/q/y is cut`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(findings.sort(), "clip boxes whose line box is too short for the font's ink").toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps the review list honest", () => {
|
|
179
|
+
const unmatched = ALLOWED.filter((entry) => {
|
|
180
|
+
const file = resolve(componentDir, "..", entry.file.replace(/^src\//, ""));
|
|
181
|
+
return !readFileSync(file, "utf8").includes(entry.selector.split(" ").pop()!);
|
|
182
|
+
}).map((entry) => `${entry.file} — ${entry.selector} (${entry.why})`);
|
|
183
|
+
// A stale exemption hides a future regression: it must be deleted.
|
|
184
|
+
expect(unmatched).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -339,7 +339,7 @@ export type { MessagePayload, MessageSeverity, MessageTransport, NotifyOptions,
|
|
|
339
339
|
export { useResourceListModal } from "./composables/useResourceListModal";
|
|
340
340
|
|
|
341
341
|
export { downloadBlob, downloadTextAsFile } from "./utils/download";
|
|
342
|
-
export { bytesToBase64, base64ToBytes, blobToBase64 } from "./utils/base64";
|
|
342
|
+
export { bytesToBase64, utf8ToBase64, base64ToBytes, blobToBase64 } from "./utils/base64";
|
|
343
343
|
|
|
344
344
|
export { THEME_MODE_STORAGE_KEY } from "./theme/useTheme";
|
|
345
345
|
|
|
@@ -316,14 +316,205 @@
|
|
|
316
316
|
z-index: var(--z-header);
|
|
317
317
|
display: flex;
|
|
318
318
|
align-items: center;
|
|
319
|
+
/* Horizontal rhythm + gap live in the class (not in host utilities):
|
|
320
|
+
* the markup used to carry `px-6 gap-3` / `px-4 gap-2` utility classes,
|
|
321
|
+
* which a host WITHOUT an atomic layer simply did not have — the header
|
|
322
|
+
* silently fell back to this file's old `padding: 0 var(--space-12)`
|
|
323
|
+
* and no gap at all. */
|
|
324
|
+
gap: var(--space-12);
|
|
325
|
+
padding: 0 var(--space-24);
|
|
319
326
|
height: var(--s-header-height);
|
|
320
|
-
padding: 0 var(--space-12);
|
|
321
327
|
background: rgb(var(--color-surface) / var(--opacity-half));
|
|
322
328
|
backdrop-filter: blur(var(--blur-md));
|
|
323
329
|
border-bottom: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
|
|
324
330
|
flex-shrink: 0;
|
|
325
331
|
}
|
|
326
332
|
|
|
333
|
+
.s-glass-header--compact {
|
|
334
|
+
gap: var(--space-8);
|
|
335
|
+
padding: 0 var(--space-16);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* ── Admin header chrome (HkAdminHeader) ────────────────────────────────
|
|
339
|
+
* The header's own layout. Every declaration below replaces a utility
|
|
340
|
+
* class the markup used to depend on (`w-7 h-7`, `rounded-full`,
|
|
341
|
+
* `object-cover`, `max-w-[8rem]`, `animate-pulse`, …) — a host without an
|
|
342
|
+
* atomic layer rendered the header without them, and a host with one
|
|
343
|
+
* (chest/UnoCSS) owned values the design system could not fix. Values are
|
|
344
|
+
* the verified computed ones (audit 2026-09-15 against chest's generated
|
|
345
|
+
* CSS), so no host's rendering changes. */
|
|
346
|
+
|
|
347
|
+
.s-admin-header-user {
|
|
348
|
+
display: flex;
|
|
349
|
+
align-items: center;
|
|
350
|
+
gap: var(--space-8);
|
|
351
|
+
min-width: 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.s-admin-header-avatar {
|
|
355
|
+
position: relative;
|
|
356
|
+
width: var(--space-28);
|
|
357
|
+
height: var(--space-28);
|
|
358
|
+
flex-shrink: 0;
|
|
359
|
+
border-radius: var(--radius-full);
|
|
360
|
+
overflow: hidden;
|
|
361
|
+
cursor: pointer;
|
|
362
|
+
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* The hamburger's leading icon (lucide `Menu`), sized in rem so it tracks
|
|
366
|
+
* a scaled root the way the utility it replaces did. */
|
|
367
|
+
.s-admin-header-hamburger-icon {
|
|
368
|
+
width: var(--space-20);
|
|
369
|
+
height: var(--space-20);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.s-admin-header-avatar-img {
|
|
373
|
+
width: 100%;
|
|
374
|
+
height: 100%;
|
|
375
|
+
object-fit: cover;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* Hover veil: the "change avatar" affordance over the trigger. The Uno
|
|
379
|
+
* form was `group-hover:opacity-100` scoped by a `group` marker class on
|
|
380
|
+
* the button; this descendant selector expresses the same scope. */
|
|
381
|
+
.s-admin-header-avatar-veil {
|
|
382
|
+
position: absolute;
|
|
383
|
+
inset: 0;
|
|
384
|
+
display: flex;
|
|
385
|
+
align-items: center;
|
|
386
|
+
justify-content: center;
|
|
387
|
+
border-radius: var(--radius-full);
|
|
388
|
+
background: rgb(0 0 0 / 30%);
|
|
389
|
+
opacity: 0;
|
|
390
|
+
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.s-admin-header-avatar:hover .s-admin-header-avatar-veil {
|
|
394
|
+
opacity: 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.s-admin-header-veil-icon {
|
|
398
|
+
color: #fff;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* The open view's title. The 1.5 line-height is load-bearing: the default
|
|
402
|
+
* text-sm box is exactly the em advance, so descenders (g, y, p) fall
|
|
403
|
+
* outside the truncate clip box under subpixel rounding (user report
|
|
404
|
+
* 2026-09-12: the "g" tail of the nickname was visibly cut). */
|
|
405
|
+
.s-admin-header-title {
|
|
406
|
+
font-size: var(--text-sm);
|
|
407
|
+
font-weight: 600;
|
|
408
|
+
color: rgb(var(--color-text));
|
|
409
|
+
line-height: 1.5;
|
|
410
|
+
max-width: 8rem;
|
|
411
|
+
overflow: hidden;
|
|
412
|
+
text-overflow: ellipsis;
|
|
413
|
+
white-space: nowrap;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.s-admin-header-actions {
|
|
417
|
+
display: flex;
|
|
418
|
+
align-items: center;
|
|
419
|
+
gap: var(--space-6);
|
|
420
|
+
margin-left: auto;
|
|
421
|
+
flex-shrink: 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.s-admin-header-action {
|
|
425
|
+
display: flex;
|
|
426
|
+
align-items: center;
|
|
427
|
+
gap: var(--space-4);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* Emergency stop chip: quiet destructive by default, solid red + pulse
|
|
431
|
+
* while engaged (`is-active`), dimmed + wait cursor while the request is
|
|
432
|
+
* in flight (`is-loading`). */
|
|
433
|
+
.s-admin-header-emergency {
|
|
434
|
+
padding: var(--space-4) var(--space-12);
|
|
435
|
+
border-width: 1px;
|
|
436
|
+
border-radius: 0.375rem;
|
|
437
|
+
font-size: var(--text-xs);
|
|
438
|
+
/* Uno's font-size utilities also emit `line-height: 1`; the chip
|
|
439
|
+
* rendered that way in chest, so the class keeps it. */
|
|
440
|
+
line-height: 1;
|
|
441
|
+
font-weight: 700;
|
|
442
|
+
cursor: pointer;
|
|
443
|
+
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
444
|
+
background: rgb(220 38 38 / 10%);
|
|
445
|
+
color: #ef4444;
|
|
446
|
+
border-color: rgb(239 68 68 / 40%);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.s-admin-header-emergency:hover {
|
|
450
|
+
background: rgb(220 38 38 / 25%);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.s-admin-header-emergency.is-loading {
|
|
454
|
+
opacity: 0.5;
|
|
455
|
+
cursor: wait;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.s-admin-header-emergency.is-active {
|
|
459
|
+
background: #dc2626;
|
|
460
|
+
color: #fff;
|
|
461
|
+
border-color: #b91c1c;
|
|
462
|
+
animation: s-admin-header-emergency-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
@keyframes s-admin-header-emergency-pulse {
|
|
466
|
+
0%,
|
|
467
|
+
100% {
|
|
468
|
+
opacity: 1;
|
|
469
|
+
}
|
|
470
|
+
50% {
|
|
471
|
+
opacity: 0.5;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
@media (prefers-reduced-motion: reduce) {
|
|
476
|
+
.s-admin-header-emergency.is-active {
|
|
477
|
+
animation: none;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* ── Admin shell layout (HkAdminShell) ──────────────────────────────────
|
|
482
|
+
* Replaces the `flex flex-col h-full w-full overflow-hidden` /
|
|
483
|
+
* `flex-1 min-h-0` utility chain the shell used to require. */
|
|
484
|
+
.s-admin-shell {
|
|
485
|
+
display: flex;
|
|
486
|
+
flex-direction: column;
|
|
487
|
+
height: 100%;
|
|
488
|
+
width: 100%;
|
|
489
|
+
overflow: hidden;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.s-admin-shell-body {
|
|
493
|
+
display: flex;
|
|
494
|
+
flex: 1 1 0%;
|
|
495
|
+
min-height: 0;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.s-admin-shell-main {
|
|
499
|
+
display: flex;
|
|
500
|
+
flex: 1 1 0%;
|
|
501
|
+
flex-direction: column;
|
|
502
|
+
min-width: 0;
|
|
503
|
+
min-height: 0;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
.s-admin-shell-scroll {
|
|
507
|
+
flex: 1 1 0%;
|
|
508
|
+
min-height: 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/* ── Nav sidebar labels (HkNavSidebar) ─ */
|
|
512
|
+
.s-nav-item-label {
|
|
513
|
+
overflow: hidden;
|
|
514
|
+
text-overflow: ellipsis;
|
|
515
|
+
white-space: nowrap;
|
|
516
|
+
}
|
|
517
|
+
|
|
327
518
|
/* ── Popup menu items ─ */
|
|
328
519
|
/* Unified menu-item spec (packages/vue/src/components/_menu-item.scss):
|
|
329
520
|
* one row grammar across menus, selects, theme pills, admin header
|
|
@@ -558,6 +749,14 @@
|
|
|
558
749
|
animation: s-auth-card-in 0.45s var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1)) both;
|
|
559
750
|
}
|
|
560
751
|
|
|
752
|
+
/* Field leading icon (HkSignInCard's inline user glyph) — sized in rem so
|
|
753
|
+
* it tracks a scaled root, the way the `w-4 h-4` utility it replaces did
|
|
754
|
+
* only in hosts that had an atomic layer. */
|
|
755
|
+
.s-auth-card-field-icon {
|
|
756
|
+
width: var(--space-16);
|
|
757
|
+
height: var(--space-16);
|
|
758
|
+
}
|
|
759
|
+
|
|
561
760
|
@keyframes s-auth-card-in {
|
|
562
761
|
from { opacity: 0; transform: translateY(-16px) scale(0.92); }
|
|
563
762
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
package/src/styles/index.scss
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// 2. theme/scale.scss — the L2 scale tokens: spacing, type, radius,
|
|
10
10
|
// blur, opacity, durations, easings, z bands, fonts (vendored from
|
|
11
11
|
// src/scale.scss).
|
|
12
|
-
// 3.
|
|
12
|
+
// 3. theme/scrollbar.scss — the shared overlay-scrollbar chrome
|
|
13
|
+
// (.hk-scrollbar-track/thumb) that attachOverlayScrollbars() attaches
|
|
14
|
+
// to every scroll region (vendored from packages/theme/styles).
|
|
15
|
+
// 4. admin-tokens.scss — the plana-ui shared surface: layout tokens,
|
|
13
16
|
// brand-color aliases and the shared admin component classes.
|
|
14
17
|
//
|
|
15
18
|
// initTheme() (runtime) writes only deltas against this base into a
|
|
@@ -24,4 +27,5 @@
|
|
|
24
27
|
@use "./reset.scss";
|
|
25
28
|
@use "./theme/channels.scss";
|
|
26
29
|
@use "./theme/scale.scss";
|
|
30
|
+
@use "./theme/scrollbar.scss";
|
|
27
31
|
@use "./admin-tokens.scss";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Vendored from packages/theme/styles/_scrollbar.scss so npm registry
|
|
2
|
+
// installs can resolve it — the exports map only exposes ./styles/*, and the
|
|
3
|
+
// overlay scrollbar the composable attaches (attachOverlayScrollbars) renders
|
|
4
|
+
// NOTHING without these rules: every host hid its native bar per the
|
|
5
|
+
// composable's contract and got an unstyled, invisible thumb in exchange
|
|
6
|
+
// (audited 2026-09-15: zero .hk-scrollbar-* rules in any consumer bundle).
|
|
7
|
+
// Edit the upstream file, then re-copy; do not hand-edit here.
|
|
8
|
+
|
|
9
|
+
// _scrollbar.scss — the shared overlay scrollbar chrome.
|
|
10
|
+
//
|
|
11
|
+
// ONE scrollbar system for the whole library: the `.hk-scrollbar-track` /
|
|
12
|
+
// `.hk-scrollbar-thumb` DOM is created imperatively by
|
|
13
|
+
// packages/vue/src/composables/useOverlayScrollbar.ts
|
|
14
|
+
// (attachOverlayScrollbars) and styled here. HkScrollContainer keeps its
|
|
15
|
+
// container-specific rules in HkScrollContainer.scss; every other scroll
|
|
16
|
+
// region in the library attaches the same overlay and hides its native
|
|
17
|
+
// bar with the standard two-liner:
|
|
18
|
+
//
|
|
19
|
+
// scrollbar-width: none;
|
|
20
|
+
// &::-webkit-scrollbar { display: none; }
|
|
21
|
+
|
|
22
|
+
.hk-scrollbar-track {
|
|
23
|
+
position: absolute;
|
|
24
|
+
z-index: 10;
|
|
25
|
+
background: transparent;
|
|
26
|
+
/* Click-through while faded: an invisible rail must never steal
|
|
27
|
+
clicks from the content beneath it (the track hugs the region's
|
|
28
|
+
edge, exactly where end-of-line links/buttons live). The rail
|
|
29
|
+
becomes interactive only while it is visible — the flash window
|
|
30
|
+
after a scroll (and while hovered/dragged within that window). */
|
|
31
|
+
pointer-events: none;
|
|
32
|
+
opacity: 0;
|
|
33
|
+
transition: opacity var(--hi-duration-fast, 0.15s) ease;
|
|
34
|
+
|
|
35
|
+
&[data-axis="horizontal"] {
|
|
36
|
+
left: 4px;
|
|
37
|
+
right: 4px;
|
|
38
|
+
bottom: 2px;
|
|
39
|
+
height: 6px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&:not([data-axis="horizontal"]) {
|
|
43
|
+
top: 4px;
|
|
44
|
+
bottom: 4px;
|
|
45
|
+
right: 2px;
|
|
46
|
+
width: 6px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&.is-scrolling,
|
|
50
|
+
&.is-hovering {
|
|
51
|
+
opacity: 1;
|
|
52
|
+
pointer-events: auto;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.hk-scrollbar-thumb {
|
|
57
|
+
position: absolute;
|
|
58
|
+
border-radius: 3px;
|
|
59
|
+
background: color-mix(in srgb, var(--hi-color-muted) 35%, transparent);
|
|
60
|
+
transition: background var(--hi-duration-fast, 0.15s) ease;
|
|
61
|
+
|
|
62
|
+
.is-hovering &,
|
|
63
|
+
&.is-dragging {
|
|
64
|
+
background: color-mix(in srgb, var(--hi-color-muted) 55%, transparent);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.hk-scrollbar-track[data-axis="horizontal"] .hk-scrollbar-thumb {
|
|
69
|
+
top: 0;
|
|
70
|
+
left: 0;
|
|
71
|
+
height: 100%;
|
|
72
|
+
min-width: 20px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.hk-scrollbar-track:not([data-axis="horizontal"]) .hk-scrollbar-thumb {
|
|
76
|
+
top: 0;
|
|
77
|
+
left: 0;
|
|
78
|
+
width: 100%;
|
|
79
|
+
min-height: 20px;
|
|
80
|
+
}
|
|
@@ -48,6 +48,10 @@ function stripProvenanceHeader(source: string): string {
|
|
|
48
48
|
const VENDORED_FROM_VUE: Array<[string, string]> = [
|
|
49
49
|
["theme/channels.scss", "tokens.scss"],
|
|
50
50
|
["theme/scale.scss", "scale.scss"],
|
|
51
|
+
// The overlay-scrollbar chrome lives with the other theme partials
|
|
52
|
+
// (packages/theme/styles) and is vendored here so npm consumers can
|
|
53
|
+
// resolve it — the exports map only exposes ./styles/*.
|
|
54
|
+
["theme/scrollbar.scss", "../../theme/styles/_scrollbar.scss"],
|
|
51
55
|
];
|
|
52
56
|
|
|
53
57
|
|
package/src/utils/base64.ts
CHANGED
|
@@ -9,6 +9,18 @@ export function bytesToBase64(bytes: ArrayBuffer | Uint8Array): string {
|
|
|
9
9
|
return btoa(binary);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Encode a JS string as base64, UTF-8 first.
|
|
14
|
+
*
|
|
15
|
+
* `btoa` throws `InvalidCharacterError` on any code point above U+00FF, which
|
|
16
|
+
* silently kills whatever called it — the demo gallery died on an em dash
|
|
17
|
+
* inside an inline SVG (`Hikari — zoom / pan demo`). Encode through
|
|
18
|
+
* `TextEncoder` so every string is encodable.
|
|
19
|
+
*/
|
|
20
|
+
export function utf8ToBase64(text: string): string {
|
|
21
|
+
return bytesToBase64(new TextEncoder().encode(text));
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
/** Decode a base64 string into raw bytes. */
|
|
13
25
|
export function base64ToBytes(b64: string): Uint8Array {
|
|
14
26
|
const binary = atob(b64);
|
package/src/utils/format.test.ts
CHANGED
|
@@ -55,12 +55,16 @@ describe("formatRelativeTime", () => {
|
|
|
55
55
|
expect(formatRelativeTime(ago(29 * DAY))).toBe("4w ago");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it("falls to the absolute-date path at >= 30d", () => {
|
|
58
|
+
it("falls to the absolute-date path at >= 30d, in the app locale", async () => {
|
|
59
59
|
const d = ago(30 * DAY);
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// The absolute tier must follow the app-selected hikari locale (the
|
|
61
|
+
// R2 sweep found the old assertion compared the BROWSER default,
|
|
62
|
+
// which proves nothing about locale-following).
|
|
63
|
+
const { setLocale } = await import("../i18n/context");
|
|
64
|
+
await setLocale("zh-Hans");
|
|
65
|
+
expect(formatRelativeTime(d)).toBe(d.toLocaleDateString("zh-Hans"));
|
|
66
|
+
await setLocale("en");
|
|
67
|
+
expect(formatRelativeTime(d)).toBe(d.toLocaleDateString("en"));
|
|
64
68
|
});
|
|
65
69
|
|
|
66
70
|
it("clamps future timestamps into the justNow tier", () => {
|
|
@@ -107,10 +111,11 @@ describe("formatRelativeTime", () => {
|
|
|
107
111
|
});
|
|
108
112
|
});
|
|
109
113
|
|
|
110
|
-
it("keeps absolute dates away from the translator", () => {
|
|
114
|
+
it("keeps absolute dates away from the translator", async () => {
|
|
111
115
|
const { t, calls } = makeCaptureT();
|
|
112
116
|
const d = ago(45 * DAY);
|
|
113
|
-
|
|
117
|
+
const { activeLocale } = await import("../i18n/context");
|
|
118
|
+
expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString(activeLocale()));
|
|
114
119
|
expect(calls).toHaveLength(0);
|
|
115
120
|
});
|
|
116
121
|
});
|
package/src/utils/format.ts
CHANGED
|
@@ -56,7 +56,8 @@ export type RelativeTimeT = (
|
|
|
56
56
|
* Pass an optional translator for localized variants; defaults to
|
|
57
57
|
* compact English text. Tiers: <1min justNow, <60min minutes,
|
|
58
58
|
* <24h hours, <7d days, <30d weeks, otherwise an absolute
|
|
59
|
-
* locale-rendered date (
|
|
59
|
+
* locale-rendered date (the app-selected hikari locale, like
|
|
60
|
+
* formatDateTime).
|
|
60
61
|
* The translator owns {n} interpolation — hikari's own useI18n().t
|
|
61
62
|
* does NOT interpolate named params, so wrap it (the canonical key
|
|
62
63
|
* set lives in the per-locale i18n time bundles). */
|