@celestia-island/hikari 0.55.268 → 0.55.270
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/HkAboutModal.scss +9 -6
- package/src/components/HkAboutModal.styles.test.ts +10 -2
- package/src/components/HkAltSignIn.tsx +1 -1
- package/src/components/HkAuthMethodList.test.tsx +8 -5
- package/src/components/HkBoard.tsx +2 -2
- package/src/components/HkColorSchemeDialog.test.ts +3 -3
- package/src/components/HkColorSchemeEditor.test.ts +2 -2
- package/src/components/HkCopyBadge.test.tsx +16 -14
- package/src/components/HkDraggableGrid.scss +2 -2
- package/src/components/HkDraggableList.scss +1 -1
- package/src/components/HkInput.password.test.tsx +33 -18
- package/src/components/HkInput.scss +3 -1
- package/src/components/HkListTransition.scss +5 -1
- package/src/components/HkMasonry.scss +53 -0
- package/src/components/HkMasonry.styles.test.ts +109 -0
- package/src/components/HkMasonry.test.tsx +211 -0
- package/src/components/HkMasonry.tsx +286 -0
- package/src/components/HkMinimap.tsx +1 -1
- package/src/components/HkModalBreadcrumb.test.tsx +1 -1
- package/src/components/HkNodeCanvas.tsx +2 -2
- package/src/components/HkPasswordSurface.scss +1 -1
- package/src/components/HkSelectionGrid.scss +9 -9
- package/src/components/HkSelectionWaterfall.scss +7 -7
- package/src/components/HkStepGuide.scss +1 -1
- package/src/components/HkTable.scss +1 -1
- package/src/components/HkTooltip.test.tsx +60 -60
- package/src/components/HkTooltip.tsx +36 -37
- package/src/composables/useRailIconFlight.test.ts +13 -5
- package/src/index.ts +7 -0
- package/src/runtime/createAuthGuard.ts +1 -1
- package/src/runtime/index.ts +10 -0
- package/src/runtime/tooltipBridge.test.ts +18 -1
- package/src/runtime/tooltipBridge.ts +10 -1
- package/src/runtime/tooltipPosition.test.ts +128 -0
- package/src/runtime/tooltipPosition.ts +55 -0
- package/src/runtime/touchHint.scss +73 -0
- package/src/runtime/touchHint.test.ts +407 -0
- package/src/runtime/touchHint.ts +576 -0
- package/src/styles/admin-tokens.scss +1 -1
- package/src/theme/_field.scss +1 -1
- package/src/theme/slotKinds.test.ts +1 -1
- package/src/theme/standardGroups.ts +1 -1
- package/src/theme/tokenGroups.test.ts +49 -49
- package/src/theme/tokenGroups.ts +4 -4
- package/src/utils/boardCamera.ts +3 -3
- package/src/utils/boardEdges.ts +2 -2
package/package.json
CHANGED
|
@@ -262,16 +262,19 @@
|
|
|
262
262
|
// across both palettes and modes) rather than shipping an unreadable tone
|
|
263
263
|
// colour. The border and background keep the raw channel: only the glyphs owe
|
|
264
264
|
// contrast, the tint is decorative.
|
|
265
|
+
// NOTE (grass constraint, see scripts/sync-vue-mirror.py): the relative-colour
|
|
266
|
+
// form oklch(from ... min/max(l,...) c h) was replaced by an oklab mix toward
|
|
267
|
+
// black/white - an APPROXIMATION of the lightness cap/floor, not channel math.
|
|
265
268
|
.s-about-modal-row-meta[data-tone="positive"] {
|
|
266
269
|
border-color: rgb(var(--color-success) / 32%);
|
|
267
270
|
background: rgb(var(--color-success) / 10%);
|
|
268
|
-
color:
|
|
271
|
+
color: color-mix(in oklab, rgb(var(--color-success)) 60%, black);
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
.s-about-modal-row-meta[data-tone="caution"] {
|
|
272
275
|
border-color: rgb(var(--color-warning) / 32%);
|
|
273
276
|
background: rgb(var(--color-warning) / 10%);
|
|
274
|
-
color:
|
|
277
|
+
color: color-mix(in oklab, rgb(var(--color-warning)) 60%, black);
|
|
275
278
|
}
|
|
276
279
|
|
|
277
280
|
// Red state: the component is not wired up / unreachable. The row then
|
|
@@ -279,21 +282,21 @@
|
|
|
279
282
|
.s-about-modal-row-meta[data-tone="danger"] {
|
|
280
283
|
border-color: rgb(var(--color-error) / 32%);
|
|
281
284
|
background: rgb(var(--color-error) / 10%);
|
|
282
|
-
color:
|
|
285
|
+
color: color-mix(in oklab, rgb(var(--color-error)) 60%, black);
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
// Dark arm: floor the lightness instead of capping it (rationale above). Only
|
|
286
289
|
// the glyphs re-ink — the border and tint already ride the live channel.
|
|
287
290
|
[data-mode="dark"] .s-about-modal-row-meta[data-tone="positive"] {
|
|
288
|
-
color:
|
|
291
|
+
color: color-mix(in oklab, rgb(var(--color-success)) 30%, white);
|
|
289
292
|
}
|
|
290
293
|
|
|
291
294
|
[data-mode="dark"] .s-about-modal-row-meta[data-tone="caution"] {
|
|
292
|
-
color:
|
|
295
|
+
color: color-mix(in oklab, rgb(var(--color-warning)) 30%, white);
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
[data-mode="dark"] .s-about-modal-row-meta[data-tone="danger"] {
|
|
296
|
-
color:
|
|
299
|
+
color: color-mix(in oklab, rgb(var(--color-error)) 30%, white);
|
|
297
300
|
}
|
|
298
301
|
|
|
299
302
|
/* ── Links: licenses, external links, credits names and filings ───────── */
|
|
@@ -251,6 +251,14 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
251
251
|
const TONE = (tone: string) => `.s-about-modal-row-meta[data-tone=${tone}]`;
|
|
252
252
|
const DARK_TONE = (tone: string) => `[data-mode=dark] ${TONE(tone)}`;
|
|
253
253
|
|
|
254
|
+
// GRASS CONSTRAINT (2026-11): the Rust mirror compiles these sheets
|
|
255
|
+
// through `grass`, which cannot parse relative colour syntax — so the
|
|
256
|
+
// seed-relative `oklch(from … min/max(l, …) c h)` arms this contract used
|
|
257
|
+
// to pin are now an oklab MIX toward black/white (an approximation of the
|
|
258
|
+
// lightness cap/floor, not channel math). See HkAboutModal.scss's NOTE and
|
|
259
|
+
// scripts/sync-vue-mirror.py; the approximation is tuned to the same
|
|
260
|
+
// contrast bound.
|
|
261
|
+
//
|
|
254
262
|
// Channel behind each coloured tone, and the WCAG-AA ink derived from it.
|
|
255
263
|
// The pill text is `--text-2xs` (10px) — small text, 4.5:1 — and the raw
|
|
256
264
|
// channel misses that on both shipped palettes (stock light warning sits
|
|
@@ -268,9 +276,9 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
268
276
|
// (shipped once — chest #2027). Reverting an arm to the raw channel, or
|
|
269
277
|
// dropping either mode arm, must turn this red.
|
|
270
278
|
const AA_INK_LIGHT = (channel: string) =>
|
|
271
|
-
`
|
|
279
|
+
`color-mix(in oklab, rgb(var(--color-${channel})) 60%, black)`;
|
|
272
280
|
const AA_INK_DARK = (channel: string) =>
|
|
273
|
-
`
|
|
281
|
+
`color-mix(in oklab, rgb(var(--color-${channel})) 30%, white)`;
|
|
274
282
|
const TONES: Record<string, string> = {
|
|
275
283
|
positive: "success",
|
|
276
284
|
caution: "warning",
|
|
@@ -17,7 +17,7 @@ export interface HkAltSignInEntry {
|
|
|
17
17
|
*
|
|
18
18
|
* A small muted trigger row (styled in the `.s-auth-footer` grammar) that
|
|
19
19
|
* opens a popover listing third-party/alternative credential entries —
|
|
20
|
-
* platform passkey on shittim-chest, feishu OAuth on
|
|
20
|
+
* platform passkey on shittim-chest, feishu OAuth on the ERP adopter — so
|
|
21
21
|
* both apps share one implementation and only differ in content.
|
|
22
22
|
*
|
|
23
23
|
* Entries render as [`HkMenuActionItem`] rows inside an [`HkPopover`]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkAuthMethodList from "./HkAuthMethodList";
|
|
@@ -15,6 +15,7 @@ function mount(node: ReturnType<typeof h>) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
afterEach(() => {
|
|
18
|
+
vi.useRealTimers();
|
|
18
19
|
for (const { app, container } of mounts.splice(0)) {
|
|
19
20
|
app.unmount();
|
|
20
21
|
container.remove();
|
|
@@ -61,17 +62,19 @@ describe("HkAuthMethodList", () => {
|
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
it("wraps every tile in a tooltip carrying the provider label", async () => {
|
|
65
|
+
vi.useFakeTimers();
|
|
64
66
|
const c = mount(
|
|
65
67
|
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
|
|
66
68
|
);
|
|
67
69
|
const wrappers = c.querySelectorAll(".hk-tooltip-wrapper");
|
|
68
70
|
expect(wrappers.length).toBe(2);
|
|
69
|
-
// Popups mount lazily on first engage —
|
|
70
|
-
//
|
|
71
|
+
// Popups mount lazily on first engage — hover each tile through the
|
|
72
|
+
// 300ms show delay (a touch press no longer mounts the popup: the
|
|
73
|
+
// touch half is the long-press hint bar, never the anchored popup).
|
|
71
74
|
for (const w of wrappers) {
|
|
72
|
-
w.dispatchEvent(new
|
|
75
|
+
w.dispatchEvent(new MouseEvent("mouseenter"));
|
|
73
76
|
}
|
|
74
|
-
await
|
|
77
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
75
78
|
const popups = [...document.querySelectorAll(".hk-tooltip-popup .hk-tooltip-content")]
|
|
76
79
|
.map((el) => el.textContent);
|
|
77
80
|
expect(popups).toContain("GitHub");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* HkBoard — the shared node-and-edge board surface.
|
|
3
3
|
*
|
|
4
4
|
* ONE world-camera canvas that every graph-like page builds on (history
|
|
5
|
-
* topic maps,
|
|
5
|
+
* topic maps, operations scenes, PLC panels, and whatever comes next). The
|
|
6
6
|
* board owns the motion feel and the chrome; the page owns the layout:
|
|
7
7
|
*
|
|
8
8
|
* - CAMERA: 5% geometric zoom ladder with an ANIMATED settle (no snapping),
|
|
@@ -221,7 +221,7 @@ export default defineComponent({
|
|
|
221
221
|
let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
|
|
222
222
|
let dragState: { node: BoardNodeInput; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
|
223
223
|
// Every node press is tracked (draggable or not) so read-only boards
|
|
224
|
-
// —
|
|
224
|
+
// — operations scenes, mind maps — still get `nodeClick` on a short tap.
|
|
225
225
|
let pressed: { id: string; pointerId: number; x: number; y: number } | null = null;
|
|
226
226
|
|
|
227
227
|
/** How many of the board's own pixels one DRAWN pixel is worth: the
|
|
@@ -52,8 +52,8 @@ const SECTIONED_GROUP: TokenGroupDefinition = {
|
|
|
52
52
|
label: { en: "Process media", "zh-Hans": "工艺介质" },
|
|
53
53
|
slots: [
|
|
54
54
|
{
|
|
55
|
-
key: "
|
|
56
|
-
label: { en: "
|
|
55
|
+
key: "buffer",
|
|
56
|
+
label: { en: "Buffer", "zh-Hans": "缓冲" },
|
|
57
57
|
defaults: { dark: { r: 20, g: 184, b: 166 }, light: { r: 15, g: 118, b: 110 } },
|
|
58
58
|
},
|
|
59
59
|
],
|
|
@@ -151,7 +151,7 @@ describe("HkColorSchemeDialog extension groups", () => {
|
|
|
151
151
|
);
|
|
152
152
|
expect(labels).toContain("L1 相(黄)");
|
|
153
153
|
expect(labels).toContain("L2 相(绿)");
|
|
154
|
-
expect(labels).toContain("
|
|
154
|
+
expect(labels).toContain("缓冲");
|
|
155
155
|
|
|
156
156
|
await setLocale("en");
|
|
157
157
|
});
|
|
@@ -50,8 +50,8 @@ const SECTIONED_GROUP: TokenGroupDefinition = {
|
|
|
50
50
|
label: { en: "Process media", "zh-Hans": "工艺介质" },
|
|
51
51
|
slots: [
|
|
52
52
|
{
|
|
53
|
-
key: "
|
|
54
|
-
label: { en: "
|
|
53
|
+
key: "buffer",
|
|
54
|
+
label: { en: "Buffer", "zh-Hans": "缓冲" },
|
|
55
55
|
defaults: { dark: { r: 20, g: 184, b: 166 }, light: { r: 15, g: 118, b: 110 } },
|
|
56
56
|
},
|
|
57
57
|
],
|
|
@@ -21,6 +21,7 @@ function mount(renderNode: () => ReturnType<typeof h>) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
afterEach(() => {
|
|
24
|
+
vi.useRealTimers();
|
|
24
25
|
for (const { app, container } of mounts.splice(0)) {
|
|
25
26
|
app.unmount();
|
|
26
27
|
container.remove();
|
|
@@ -128,33 +129,36 @@ describe("HkCopyBadge", () => {
|
|
|
128
129
|
expect(writeText).not.toHaveBeenCalled();
|
|
129
130
|
});
|
|
130
131
|
|
|
131
|
-
/** Engage the lazy tooltip
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
132
|
+
/** Engage the lazy tooltip the way a mouse user does: hover the
|
|
133
|
+
* wrapper through the 300ms show delay. (A touch press no longer
|
|
134
|
+
* mounts the popup — the touch half is the long-press hint bar, so
|
|
135
|
+
* the tap vehicle this helper replaced is gone for good.) */
|
|
136
|
+
async function engageTooltip(c: HTMLElement) {
|
|
137
|
+
c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(new MouseEvent("mouseenter"));
|
|
138
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
139
|
+
await nextTick();
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
it("renders the custom tooltip text", async () => {
|
|
143
|
+
vi.useFakeTimers();
|
|
140
144
|
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
141
145
|
// The popup mounts lazily on first engage (dense-list contract).
|
|
142
146
|
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
143
|
-
engageTooltip(c);
|
|
144
|
-
await nextTick();
|
|
147
|
+
await engageTooltip(c);
|
|
145
148
|
const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
146
149
|
expect(content?.textContent).toBe("Open details");
|
|
147
150
|
});
|
|
148
151
|
|
|
149
152
|
it("shows the localized click-to-copy tooltip by default", async () => {
|
|
153
|
+
vi.useFakeTimers();
|
|
150
154
|
const c = mount(() => h(HkCopyBadge, null, () => "#d.132"));
|
|
151
|
-
engageTooltip(c);
|
|
152
|
-
await nextTick();
|
|
155
|
+
await engageTooltip(c);
|
|
153
156
|
const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
154
157
|
expect(content?.textContent).toBe("Click to copy");
|
|
155
158
|
});
|
|
156
159
|
|
|
157
160
|
it("links the badge to the popup via aria-describedby across the whole lifecycle", async () => {
|
|
161
|
+
vi.useFakeTimers();
|
|
158
162
|
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
159
163
|
const badge = c.querySelector(".hk-badge")!;
|
|
160
164
|
|
|
@@ -163,8 +167,7 @@ describe("HkCopyBadge", () => {
|
|
|
163
167
|
expect(document.body.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
164
168
|
|
|
165
169
|
// Engage: describedby == the popup id.
|
|
166
|
-
engageTooltip(c);
|
|
167
|
-
await nextTick();
|
|
170
|
+
await engageTooltip(c);
|
|
168
171
|
const popup = document.body.querySelector(".hk-tooltip-popup")!;
|
|
169
172
|
expect(badge.getAttribute("aria-describedby")).toBe(popup.getAttribute("id"));
|
|
170
173
|
expect(popup.querySelector(".hk-tooltip-content")!.textContent).toBe("Open details");
|
|
@@ -175,8 +178,7 @@ describe("HkCopyBadge", () => {
|
|
|
175
178
|
expect(badge.getAttribute("aria-describedby")).toBeNull();
|
|
176
179
|
|
|
177
180
|
// Re-engage: returns, reusing the same popup element.
|
|
178
|
-
engageTooltip(c);
|
|
179
|
-
await nextTick();
|
|
181
|
+
await engageTooltip(c);
|
|
180
182
|
expect(badge.getAttribute("aria-describedby")).toBe(
|
|
181
183
|
document.body.querySelector(".hk-tooltip-popup")!.getAttribute("id"),
|
|
182
184
|
);
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
min-height: 44px;
|
|
17
17
|
border: 1px dashed var(--hi-color-primary, #7aa2f7);
|
|
18
18
|
border-radius: var(--radius-sm, 4px);
|
|
19
|
-
background:
|
|
19
|
+
background: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 4%, transparent);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
.hk-draggable-grid-cell {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
transform: scale(0.96);
|
|
48
48
|
border-style: dashed;
|
|
49
49
|
border-color: var(--hi-color-primary, #7aa2f7);
|
|
50
|
-
background:
|
|
50
|
+
background: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 4%, transparent);
|
|
51
51
|
cursor: grabbing;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
transform: scale(0.96);
|
|
32
32
|
border-style: dashed;
|
|
33
33
|
border-color: var(--hi-color-primary, #7aa2f7);
|
|
34
|
-
background:
|
|
34
|
+
background: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 4%, transparent);
|
|
35
35
|
cursor: grabbing;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -34,7 +34,8 @@ const fakeCreateImageData = fakeFn<CanvasRenderingContext2D["createImageData"]>(
|
|
|
34
34
|
* click-to-toggle with auto-hide (revealAutoHideMs)
|
|
35
35
|
* - the strength dot classifies through the shared passwordLevel util
|
|
36
36
|
* unless strengthEvaluator overrides it, and carries a localized
|
|
37
|
-
* tooltip that opens on hover
|
|
37
|
+
* tooltip that opens on hover (desktop) / via the touch long-press
|
|
38
|
+
* hint bar (the touch half is the top bar, never the anchored popup)
|
|
38
39
|
* - the native input stays type="password" at all times — the reveal
|
|
39
40
|
* pass draws on the canvas, so the value is never DOM text
|
|
40
41
|
*/
|
|
@@ -108,10 +109,14 @@ function fireBeforeinput(input: HTMLInputElement, inputType: string) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
afterEach(() => {
|
|
112
|
+
vi.useRealTimers();
|
|
111
113
|
for (const { app, container } of mounts.splice(0)) {
|
|
112
114
|
app.unmount();
|
|
113
115
|
container.remove();
|
|
114
116
|
}
|
|
117
|
+
// The touch hint bar is presenter-owned module state (runtime/
|
|
118
|
+
// touchHint.ts) — tear the node out so a case remounts it fresh.
|
|
119
|
+
document.querySelectorAll(".hk-touch-hint-bar").forEach((el) => el.remove());
|
|
115
120
|
});
|
|
116
121
|
|
|
117
122
|
describe("HkInput password surface identity", () => {
|
|
@@ -566,32 +571,42 @@ describe("HkInput password strength traffic light", () => {
|
|
|
566
571
|
).toBe(false);
|
|
567
572
|
});
|
|
568
573
|
|
|
569
|
-
it("
|
|
574
|
+
it("hints the strength on touch through the long-press bar, never the anchored popup", async () => {
|
|
575
|
+
vi.useFakeTimers();
|
|
570
576
|
const { container } = mountPasswordInput("Password1", {
|
|
571
577
|
passwordTrailing: "strength",
|
|
572
578
|
});
|
|
573
579
|
const wrapper = container.querySelector<HTMLElement>(".hk-tooltip-wrapper")!;
|
|
574
580
|
|
|
581
|
+
// A quick tap shows NOTHING: neither the anchored popup (hover/focus
|
|
582
|
+
// only) nor the top hint bar (the hold never elapsed).
|
|
575
583
|
wrapper.dispatchEvent(
|
|
576
|
-
new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
|
|
584
|
+
new PointerEvent("pointerdown", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
577
585
|
);
|
|
578
|
-
await
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
expect(popup?.classList.contains("hk-tooltip-visible")).toBe(true);
|
|
582
|
-
expect(popup?.textContent).toContain("Fair");
|
|
583
|
-
|
|
584
|
-
// Tap somewhere else: the capture-phase document listener hides it.
|
|
585
|
-
const outside = document.createElement("div");
|
|
586
|
-
document.body.appendChild(outside);
|
|
587
|
-
outside.dispatchEvent(
|
|
588
|
-
new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
|
|
586
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
587
|
+
document.dispatchEvent(
|
|
588
|
+
new PointerEvent("pointerup", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
589
589
|
);
|
|
590
|
-
await
|
|
590
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
591
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
592
|
+
expect(document.querySelector(".hk-touch-hint-bar")).toBeNull();
|
|
591
593
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
594
|
+
// The long-press fires the top-docked bar with the strength text…
|
|
595
|
+
wrapper.dispatchEvent(
|
|
596
|
+
new PointerEvent("pointerdown", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
597
|
+
);
|
|
598
|
+
await vi.advanceTimersByTimeAsync(450);
|
|
599
|
+
const bar = document.querySelector<HTMLElement>(".hk-touch-hint-bar");
|
|
600
|
+
expect(bar?.classList.contains("is-on")).toBe(true);
|
|
601
|
+
expect(bar?.textContent).toContain("Fair");
|
|
602
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
603
|
+
|
|
604
|
+
// …the lift starts the linger window, and the bar retires after it.
|
|
605
|
+
document.dispatchEvent(
|
|
606
|
+
new PointerEvent("pointerup", { pointerType: "touch", pointerId: 1, bubbles: true }),
|
|
607
|
+
);
|
|
608
|
+
await vi.advanceTimersByTimeAsync(2200);
|
|
609
|
+
expect(bar?.classList.contains("is-on")).toBe(false);
|
|
595
610
|
});
|
|
596
611
|
});
|
|
597
612
|
|
|
@@ -256,9 +256,11 @@
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/* Autofill overrides */
|
|
259
|
+
/* The finite duration (instead of `calc(infinity * 1s)`) keeps this sheet
|
|
260
|
+
compilable by the grass compiler the Rust form shares it with. */
|
|
259
261
|
.hk-input-box input,
|
|
260
262
|
.hk-input-box textarea {
|
|
261
|
-
transition: color
|
|
263
|
+
transition: color 100000000s step-end, background-color 100000000s step-end;
|
|
262
264
|
}
|
|
263
265
|
|
|
264
266
|
.hk-input-box input:-webkit-autofill,
|
|
@@ -140,7 +140,11 @@
|
|
|
140
140
|
.hk-list-reveal-enter-from,
|
|
141
141
|
.hk-list-reveal-leave-to {
|
|
142
142
|
opacity: 0;
|
|
143
|
-
|
|
143
|
+
/* grass-compatible fallback: the Rust form compiles this same sheet, and
|
|
144
|
+
* grass cannot parse `calc-size(auto, size * 0.8)` (see the sync script's
|
|
145
|
+
* compile constraints). 80% of the container is the closest equivalent the
|
|
146
|
+
* parser accepts; the enter/leave reveal keeps its opacity fade either way. */
|
|
147
|
+
height: 80%;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
.hk-list-reveal-move {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// AI-generated: developed with a DeepSeek Harness agent (model GLM-5.3-flash); human-directed.
|
|
2
|
+
|
|
3
|
+
/* HkMasonry — the column geometry (see the component docblock for the
|
|
4
|
+
* contract). Independent flex columns, NOT CSS columns: a card is placed
|
|
5
|
+
* whole into one column, so nothing can fragment it and no balancer can
|
|
6
|
+
* go stale under it.
|
|
7
|
+
*
|
|
8
|
+
* Column spacing rides SIBLING MARGINS, never a container `gap` — the
|
|
9
|
+
* same lesson HkWaterfall's sheet records: a gap is spent between every
|
|
10
|
+
* adjacent pair of rendered flex items, including a (live, collapsed)
|
|
11
|
+
* pair, so a trailing empty column would shave the survivor narrower than
|
|
12
|
+
* the host's other full-width children. A sibling margin is only spent
|
|
13
|
+
* where two width-carrying columns actually sit, and an empty column
|
|
14
|
+
* cancels it on both edges.
|
|
15
|
+
*
|
|
16
|
+
* Hosts tune the density with --hk-masonry-gap (vertical card gap inside a
|
|
17
|
+
* column AND the horizontal sibling margin); it defaults to --space-12. */
|
|
18
|
+
|
|
19
|
+
.hk-masonry {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: flex-start;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hk-masonry-col {
|
|
25
|
+
flex: 1 1 0;
|
|
26
|
+
min-width: 0;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
gap: var(--hk-masonry-gap, var(--space-12));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.hk-masonry-col + .hk-masonry-col {
|
|
33
|
+
margin-inline-start: var(--hk-masonry-gap, var(--space-12));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Fewer cards than columns: the empty column must not keep its flex share
|
|
37
|
+
(the survivor would render half width beside a ghost), and with the
|
|
38
|
+
sibling-margin spacing above its collapse costs no gap on either edge. */
|
|
39
|
+
.hk-masonry-col[data-empty] {
|
|
40
|
+
flex: 0 0 0;
|
|
41
|
+
margin-inline-start: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.hk-masonry-col[data-empty] + .hk-masonry-col {
|
|
45
|
+
margin-inline-start: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* The card wrapper is layout-transparent: the slot content owns its own
|
|
49
|
+
chrome, and the wrapper exists so the observer measures ONE stable box
|
|
50
|
+
per key regardless of what the card renders. */
|
|
51
|
+
.hk-masonry-card {
|
|
52
|
+
min-width: 0;
|
|
53
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// AI-generated: developed with a DeepSeek Harness agent (model GLM-5.3-flash); human-directed.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { compile } from "sass";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compiled-stylesheet guard for HkMasonry's column geometry — the axis a
|
|
12
|
+
* runtime suite cannot see (happy-dom applies no stylesheets).
|
|
13
|
+
*
|
|
14
|
+
* The invariant this pins: columns are independent flex stacks whose
|
|
15
|
+
* spacing is SIBLING MARGIN, cancelled on a collapsed ([data-empty])
|
|
16
|
+
* column on both edges — never a container `gap` (a gap is spent on the
|
|
17
|
+
* (live, collapsed) pair too and shaves the survivor narrower than the
|
|
18
|
+
* host's other full-width children), and NEVER a CSS-multicol property
|
|
19
|
+
* (`columns` / `break-inside`), the fragmentation layout this component
|
|
20
|
+
* exists to replace. Same ownership discipline as the HkWaterfall sheet
|
|
21
|
+
* guard: the compiled sheet must contain EXACTLY the rules below, each
|
|
22
|
+
* with EXACTLY its pinned declaration list, and nothing else in the sheet
|
|
23
|
+
* may mention the columns.
|
|
24
|
+
*
|
|
25
|
+
* Vitest stubs CSS, so the runtime suite can never catch a lost rule —
|
|
26
|
+
* this compiles the sheet directly and asserts the contract.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
|
|
31
|
+
const compiled = compile(resolve(componentDir, "HkMasonry.scss"), {
|
|
32
|
+
style: "expanded",
|
|
33
|
+
}).css;
|
|
34
|
+
|
|
35
|
+
// Expanded sass keeps comments inline and may emit an @charset preamble;
|
|
36
|
+
// both must go before rule parsing or they glue onto captured selectors.
|
|
37
|
+
const css = compiled
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
39
|
+
.replace(/^@charset[^;]*;/m, "");
|
|
40
|
+
|
|
41
|
+
function rules(source: string): { selector: string; decls: string[] }[] {
|
|
42
|
+
expect(source, "no at-rule may appear in the compiled sheet").not.toContain(
|
|
43
|
+
"@",
|
|
44
|
+
);
|
|
45
|
+
expect(source, "no nested braces in the compiled sheet").not.toMatch(
|
|
46
|
+
/\{[^{}]*\{/,
|
|
47
|
+
);
|
|
48
|
+
return [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({
|
|
49
|
+
selector: m[1].replace(/\s+/g, " ").trim(),
|
|
50
|
+
decls: m[2]
|
|
51
|
+
.split(";")
|
|
52
|
+
.map((d) => d.replace(/\s+/g, " ").trim())
|
|
53
|
+
.filter(Boolean),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allRules = (): { selector: string; decls: string[] }[] => rules(css);
|
|
58
|
+
|
|
59
|
+
function expectRule(selector: string, decls: string[]): void {
|
|
60
|
+
const hits = allRules().filter((r) => r.selector === selector);
|
|
61
|
+
expect(hits, `exactly one "${selector}" rule`).toHaveLength(1);
|
|
62
|
+
expect(hits[0].decls, `declarations of "${selector}"`).toEqual(decls);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function columnRules(): { selector: string; decls: string[] }[] {
|
|
66
|
+
return allRules().filter((r) => r.selector.includes("hk-masonry-col"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("HkMasonry column spacing contract", () => {
|
|
70
|
+
it("compiles into the expected flat sheet", () => {
|
|
71
|
+
expect(css).toContain(".hk-masonry");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("the column machinery is exactly five rules with exactly these declarations", () => {
|
|
75
|
+
expect(columnRules().map((r) => r.selector).sort()).toEqual(
|
|
76
|
+
[
|
|
77
|
+
".hk-masonry-col",
|
|
78
|
+
".hk-masonry-col + .hk-masonry-col",
|
|
79
|
+
".hk-masonry-col[data-empty]",
|
|
80
|
+
".hk-masonry-col[data-empty] + .hk-masonry-col",
|
|
81
|
+
].sort(),
|
|
82
|
+
);
|
|
83
|
+
expectRule(".hk-masonry-col", [
|
|
84
|
+
"flex: 1 1 0",
|
|
85
|
+
"min-width: 0",
|
|
86
|
+
"display: flex",
|
|
87
|
+
"flex-direction: column",
|
|
88
|
+
"gap: var(--hk-masonry-gap, var(--space-12))",
|
|
89
|
+
]);
|
|
90
|
+
expectRule(".hk-masonry-col + .hk-masonry-col", [
|
|
91
|
+
"margin-inline-start: var(--hk-masonry-gap, var(--space-12))",
|
|
92
|
+
]);
|
|
93
|
+
expectRule(".hk-masonry-col[data-empty]", [
|
|
94
|
+
"flex: 0 0 0",
|
|
95
|
+
"margin-inline-start: 0",
|
|
96
|
+
]);
|
|
97
|
+
expectRule(".hk-masonry-col[data-empty] + .hk-masonry-col", [
|
|
98
|
+
"margin-inline-start: 0",
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("the sheet never speaks fragmentation layout", () => {
|
|
103
|
+
// The whole point of the component: no multicol anywhere, in any rule,
|
|
104
|
+
// under any alias the shorthand family offers.
|
|
105
|
+
expect(css).not.toMatch(/\bcolumns\s*:/);
|
|
106
|
+
expect(css).not.toMatch(/break-inside/);
|
|
107
|
+
expect(css).not.toMatch(/column-(gap|count|fill|width)/);
|
|
108
|
+
});
|
|
109
|
+
});
|