@celestia-island/hikari 0.55.251 → 0.55.253
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/HkBoard3D.tsx +14 -2
- package/src/components/HkMinimap3D.test.tsx +67 -2
- package/src/components/HkMinimap3D.tsx +63 -5
- package/src/components/HkTimeZoneGlobe.test.tsx +15 -0
- package/src/components/HkTimeZoneGlobe.tsx +8 -1
- package/src/components/HkTimeline.bondcell.contract.test.ts +190 -0
- package/src/components/HkTimeline.scss +75 -6
package/package.json
CHANGED
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
* anchors, pointer-events opt-in per chip so drags still orbit;
|
|
34
34
|
* - MINIMAP: HkMinimap3D pinned bottom-right — a second, stereoscopic
|
|
35
35
|
* renderer onto the SAME scene with the main camera glyph, its view
|
|
36
|
-
* plane and content markers layered in;
|
|
36
|
+
* plane and content markers layered in; minimapCollapsible lets the
|
|
37
|
+
* user fold it into the small map-icon button and
|
|
38
|
+
* minimapDefaultCollapsed mounts it folded;
|
|
37
39
|
* - THEME: scene background follows --color-background, re-read on
|
|
38
40
|
* data-mode / data-theme / style mutations; `engine.onTheme` lets the
|
|
39
41
|
* page re-tone its materials on the same signal. With the
|
|
@@ -331,6 +333,12 @@ export default defineComponent({
|
|
|
331
333
|
interactive: { type: Boolean, default: true },
|
|
332
334
|
/** Pin the stereoscopic minimap bottom-right. */
|
|
333
335
|
minimap: { type: Boolean, default: true },
|
|
336
|
+
/** Give the minimap the collapse affordance (minimize button in its
|
|
337
|
+
* zoom bar; folded state = the small map-icon button). */
|
|
338
|
+
minimapCollapsible: { type: Boolean, default: false },
|
|
339
|
+
/** Mount the minimap already folded (with minimapCollapsible the
|
|
340
|
+
* user can bring it back; the renderer then inits on first expand). */
|
|
341
|
+
minimapDefaultCollapsed: { type: Boolean, default: false },
|
|
334
342
|
/** Vertical field of view in degrees. STATIC after mount (the
|
|
335
343
|
* camera is built in init; remount to change it). */
|
|
336
344
|
fov: { type: Number, default: 45 },
|
|
@@ -1156,7 +1164,11 @@ export default defineComponent({
|
|
|
1156
1164
|
)
|
|
1157
1165
|
) : null}
|
|
1158
1166
|
{props.minimap && engineRef.value && !failed.value ? (
|
|
1159
|
-
<HkMinimap3D
|
|
1167
|
+
<HkMinimap3D
|
|
1168
|
+
engine={engineRef.value}
|
|
1169
|
+
collapsible={props.minimapCollapsible}
|
|
1170
|
+
defaultCollapsed={props.minimapDefaultCollapsed}
|
|
1171
|
+
/>
|
|
1160
1172
|
) : null}
|
|
1161
1173
|
{slots.default ? <div class="hk-board3d-overlay">{slots.default()}</div> : null}
|
|
1162
1174
|
</div>
|
|
@@ -82,13 +82,13 @@ function fakeEngine(opts: FakeEngineOpts = {}): {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function mountMap(engine: Board3DEngine) {
|
|
85
|
+
function mountMap(engine: Board3DEngine, extra: Record<string, unknown> = {}) {
|
|
86
86
|
const container = document.createElement("div");
|
|
87
87
|
document.body.appendChild(container);
|
|
88
88
|
const app = createApp(
|
|
89
89
|
defineComponent({
|
|
90
90
|
setup() {
|
|
91
|
-
return () => h(HkMinimap3D, { engine });
|
|
91
|
+
return () => h(HkMinimap3D, { engine, ...extra });
|
|
92
92
|
},
|
|
93
93
|
}),
|
|
94
94
|
);
|
|
@@ -360,3 +360,68 @@ describe("HkMinimap3D", () => {
|
|
|
360
360
|
for (const spy of spies) expect(spy).toHaveBeenCalled();
|
|
361
361
|
});
|
|
362
362
|
});
|
|
363
|
+
|
|
364
|
+
describe("HkMinimap3D collapse affordance (ported from the 2D minimap)", () => {
|
|
365
|
+
it("mounts folded with defaultCollapsed: map-icon button, no GL surface", async () => {
|
|
366
|
+
const { engine, firePostRender } = fakeEngine();
|
|
367
|
+
const { container } = mountMap(engine, { collapsible: true, defaultCollapsed: true });
|
|
368
|
+
await nextTick();
|
|
369
|
+
firePostRender();
|
|
370
|
+
|
|
371
|
+
expect(container.querySelector(".hk-minimap--collapsed"), "folded button renders").toBeTruthy();
|
|
372
|
+
expect(container.querySelector(".hk-minimap3d-canvas"), "no canvas while folded").toBeNull();
|
|
373
|
+
// No GL surface was ever built for a folded-at-mount map.
|
|
374
|
+
expect(H.FakeWebGLRenderer.instances).toHaveLength(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("expands on the folded button and lazily inits the renderer", async () => {
|
|
378
|
+
const { engine, firePostRender } = fakeEngine();
|
|
379
|
+
const { container } = mountMap(engine, { collapsible: true, defaultCollapsed: true });
|
|
380
|
+
await nextTick();
|
|
381
|
+
|
|
382
|
+
const fold = container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed");
|
|
383
|
+
expect(fold).toBeTruthy();
|
|
384
|
+
fold!.click();
|
|
385
|
+
await nextTick();
|
|
386
|
+
await nextTick();
|
|
387
|
+
|
|
388
|
+
expect(container.querySelector(".hk-minimap3d-canvas"), "canvas mounts on expand").toBeTruthy();
|
|
389
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
390
|
+
// The lazy init built the renderer; frames flow again.
|
|
391
|
+
expect(H.FakeWebGLRenderer.instances).toHaveLength(1);
|
|
392
|
+
firePostRender();
|
|
393
|
+
expect(H.FakeWebGLRenderer.instances[0].renders.length).toBe(1);
|
|
394
|
+
// The zoom bar carries the minimize affordance once expanded.
|
|
395
|
+
expect(container.querySelector(".hk-mm-collapse-btn")).toBeTruthy();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("collapsible adds the minimize button; clicking folds the card", async () => {
|
|
399
|
+
const { engine, firePostRender } = fakeEngine();
|
|
400
|
+
const { container } = mountMap(engine, { collapsible: true });
|
|
401
|
+
await nextTick();
|
|
402
|
+
|
|
403
|
+
expect(container.querySelector(".hk-minimap3d-canvas")).toBeTruthy();
|
|
404
|
+
const minimize = container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn");
|
|
405
|
+
expect(minimize, "minimize button rides the zoom bar").toBeTruthy();
|
|
406
|
+
minimize!.click();
|
|
407
|
+
await nextTick();
|
|
408
|
+
|
|
409
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeTruthy();
|
|
410
|
+
expect(container.querySelector(".hk-minimap3d-canvas")).toBeNull();
|
|
411
|
+
// A folded map renders no frames even though the board keeps ticking.
|
|
412
|
+
const renderer = H.FakeWebGLRenderer.instances[0];
|
|
413
|
+
const before = renderer.renders.length;
|
|
414
|
+
firePostRender();
|
|
415
|
+
firePostRender();
|
|
416
|
+
expect(renderer.renders.length).toBe(before);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("stays expanded by default (board pages keep the standing card)", async () => {
|
|
420
|
+
const { engine } = fakeEngine();
|
|
421
|
+
const { container } = mountMap(engine);
|
|
422
|
+
await nextTick();
|
|
423
|
+
expect(container.querySelector(".hk-minimap3d-canvas")).toBeTruthy();
|
|
424
|
+
expect(container.querySelector(".hk-mm-collapse-btn")).toBeNull();
|
|
425
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
@@ -15,13 +15,20 @@
|
|
|
15
15
|
* - ZOOM is an auto-fit radius (content ∪ view plane) with a ± ladder
|
|
16
16
|
* on top: 100% = everything fits, higher = closer;
|
|
17
17
|
* - the helper layer never leaks into the main render (helpers sit on
|
|
18
|
-
* BOARD3D_HELPERS_LAYER, which only the minimap camera enables)
|
|
18
|
+
* BOARD3D_HELPERS_LAYER, which only the minimap camera enables);
|
|
19
|
+
* - COLLAPSE (the 2D HkMinimap's 2026-10-27 affordance, ported): with
|
|
20
|
+
* a "collapsible" prop the zoom bar grows a minimize button and the
|
|
21
|
+
* card folds into the small semi-transparent map-icon button; the
|
|
22
|
+
* render hook skips frames while collapsed and the renderer re-inits
|
|
23
|
+
* lazily on first expand, so "defaultCollapsed" costs nothing at
|
|
24
|
+
* mount.
|
|
19
25
|
*/
|
|
20
26
|
|
|
21
|
-
import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
27
|
+
import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
22
28
|
import {
|
|
23
29
|
computed,
|
|
24
30
|
defineComponent,
|
|
31
|
+
nextTick,
|
|
25
32
|
onBeforeUnmount,
|
|
26
33
|
onMounted,
|
|
27
34
|
ref,
|
|
@@ -62,6 +69,13 @@ export default defineComponent({
|
|
|
62
69
|
name: "HkMinimap3D",
|
|
63
70
|
props: {
|
|
64
71
|
engine: { type: Object as PropType<Board3DEngine>, required: true },
|
|
72
|
+
/** Grow the minimize button in the zoom bar (2026-10-31: ported
|
|
73
|
+
* from the 2D HkMinimap's collapse affordance). */
|
|
74
|
+
collapsible: { type: Boolean, default: false },
|
|
75
|
+
/** Mount straight into the collapsed map-icon state — the renderer
|
|
76
|
+
* then inits lazily on first expand, so a collapsed-at-mount map
|
|
77
|
+
* never builds a GL surface at all. */
|
|
78
|
+
defaultCollapsed: { type: Boolean, default: false },
|
|
65
79
|
},
|
|
66
80
|
setup(props) {
|
|
67
81
|
const { t } = useI18n();
|
|
@@ -70,6 +84,7 @@ export default defineComponent({
|
|
|
70
84
|
const dragging = ref(false);
|
|
71
85
|
const sliderOpen = ref(false);
|
|
72
86
|
const zoomPercent = ref(100);
|
|
87
|
+
const collapsed = ref(props.defaultCollapsed);
|
|
73
88
|
|
|
74
89
|
let renderer: THREE.WebGLRenderer | null = null;
|
|
75
90
|
let mapCamera: THREE.PerspectiveCamera | null = null;
|
|
@@ -287,6 +302,7 @@ export default defineComponent({
|
|
|
287
302
|
}
|
|
288
303
|
|
|
289
304
|
function onFrame(): void {
|
|
305
|
+
if (collapsed.value) return; // folded away: no map frames at all
|
|
290
306
|
if (!renderer || !mapCamera || !scene.value) return;
|
|
291
307
|
const now = performance.now();
|
|
292
308
|
if (now - lastRefit >= REFIT_MS) {
|
|
@@ -343,6 +359,10 @@ export default defineComponent({
|
|
|
343
359
|
zoomPercent.value = 100;
|
|
344
360
|
sliderOpen.value = false;
|
|
345
361
|
}
|
|
362
|
+
function toggleCollapsed(next: boolean): void {
|
|
363
|
+
collapsed.value = next;
|
|
364
|
+
if (next) sliderOpen.value = false;
|
|
365
|
+
}
|
|
346
366
|
|
|
347
367
|
// Slider pop dismissal (same shield as the 2D minimap).
|
|
348
368
|
function onDocPointerDown(e: PointerEvent): void {
|
|
@@ -361,7 +381,11 @@ export default defineComponent({
|
|
|
361
381
|
}
|
|
362
382
|
});
|
|
363
383
|
|
|
364
|
-
onMounted(
|
|
384
|
+
/** Idempotent GL-surface init. Called from onMounted (expanded
|
|
385
|
+
* mounts) and from the expand transition (a defaultCollapsed map
|
|
386
|
+
* has no canvas at mount — the renderer builds on first expand). */
|
|
387
|
+
function initRenderer(): void {
|
|
388
|
+
if (renderer) return;
|
|
365
389
|
const canvas = canvasRef.value;
|
|
366
390
|
const el = rootRef.value;
|
|
367
391
|
if (!canvas || !el) return;
|
|
@@ -387,6 +411,12 @@ export default defineComponent({
|
|
|
387
411
|
el.addEventListener("pointerup", onUp);
|
|
388
412
|
el.addEventListener("pointercancel", onUp);
|
|
389
413
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
414
|
+
}
|
|
415
|
+
onMounted(() => {
|
|
416
|
+
if (!collapsed.value) initRenderer();
|
|
417
|
+
});
|
|
418
|
+
watch(collapsed, (folded) => {
|
|
419
|
+
if (!folded) void nextTick(() => initRenderer());
|
|
390
420
|
});
|
|
391
421
|
|
|
392
422
|
onBeforeUnmount(() => {
|
|
@@ -425,7 +455,24 @@ export default defineComponent({
|
|
|
425
455
|
renderer?.forceContextLoss?.();
|
|
426
456
|
});
|
|
427
457
|
|
|
428
|
-
return () =>
|
|
458
|
+
return () => {
|
|
459
|
+
if (collapsed.value) {
|
|
460
|
+
// The folded affordance, mirroring the 2D HkMinimap: a small
|
|
461
|
+
// semi-transparent map-icon button pinned where the card sat.
|
|
462
|
+
return (
|
|
463
|
+
<button
|
|
464
|
+
ref={rootRef}
|
|
465
|
+
type="button"
|
|
466
|
+
class="hk-minimap hk-minimap3d hk-minimap--collapsed"
|
|
467
|
+
aria-label={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
468
|
+
title={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
469
|
+
onClick={() => toggleCollapsed(false)}
|
|
470
|
+
>
|
|
471
|
+
<MapIcon size={16} />
|
|
472
|
+
</button>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return (
|
|
429
476
|
<div
|
|
430
477
|
ref={rootRef}
|
|
431
478
|
class="hk-minimap hk-minimap3d"
|
|
@@ -474,6 +521,17 @@ export default defineComponent({
|
|
|
474
521
|
>
|
|
475
522
|
<Maximize2 size={11} />
|
|
476
523
|
</button>
|
|
524
|
+
{props.collapsible && (
|
|
525
|
+
<button
|
|
526
|
+
class="hk-mm-zoom-btn hk-mm-collapse-btn"
|
|
527
|
+
type="button"
|
|
528
|
+
onClick={() => toggleCollapsed(true)}
|
|
529
|
+
aria-label={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
530
|
+
title={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
531
|
+
>
|
|
532
|
+
<Minimize2 size={11} />
|
|
533
|
+
</button>
|
|
534
|
+
)}
|
|
477
535
|
</div>
|
|
478
536
|
{sliderOpen.value && (
|
|
479
537
|
<div class="hk-mm-zoom-pop">
|
|
@@ -497,5 +555,5 @@ export default defineComponent({
|
|
|
497
555
|
)}
|
|
498
556
|
</div>
|
|
499
557
|
);
|
|
500
|
-
},
|
|
558
|
+
}; },
|
|
501
559
|
});
|
|
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
3
3
|
import { createApp, h, nextTick } from "vue";
|
|
4
4
|
|
|
5
5
|
import HkTimeZoneGlobe from "./HkTimeZoneGlobe";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
6
8
|
import { setLocale } from "../i18n/context";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -126,3 +128,16 @@ describe("HkTimeZoneGlobe fallback list", () => {
|
|
|
126
128
|
expect(p8.textContent).toContain("上海、台北、香港、新加坡");
|
|
127
129
|
});
|
|
128
130
|
});
|
|
131
|
+
|
|
132
|
+
describe("HkTimeZoneGlobe look contract (source-level)", () => {
|
|
133
|
+
it("renders the base sphere TRANSPARENT (the white-alpha map must read as holo, not a solid ball)", () => {
|
|
134
|
+
const src = readFileSync(join(import.meta.dirname, "HkTimeZoneGlobe.tsx"), "utf8");
|
|
135
|
+
expect(src).toMatch(/MeshBasicMaterial\(\{ color: 0x8899aa, transparent: true \}\)/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("mounts its minimap collapsible AND folded by default (user direction 2026-10-31)", () => {
|
|
139
|
+
const src = readFileSync(join(import.meta.dirname, "HkTimeZoneGlobe.tsx"), "utf8");
|
|
140
|
+
expect(src).toContain("minimapCollapsible");
|
|
141
|
+
expect(src).toContain("minimapDefaultCollapsed");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -169,7 +169,12 @@ export default defineComponent({
|
|
|
169
169
|
function buildScene(eng: Board3DEngine): void {
|
|
170
170
|
// Base sphere — the board-tinted borderless world map.
|
|
171
171
|
const geometry = new THREE.SphereGeometry(GLOBE_RADIUS, 96, 64);
|
|
172
|
-
|
|
172
|
+
// transparent is the WHOLE LOOK: the base map is white-alpha (the
|
|
173
|
+
// ocean carries alpha 0), so without the flag three ignores the
|
|
174
|
+
// texture alpha and renders the sphere as an opaque dark ball.
|
|
175
|
+
// With it the globe reads as a holo: continents and plate lines
|
|
176
|
+
// floating over the theme surface, nothing solid behind them.
|
|
177
|
+
baseMaterial = new THREE.MeshBasicMaterial({ color: 0x8899aa, transparent: true });
|
|
173
178
|
const sphere = new THREE.Mesh(geometry, baseMaterial);
|
|
174
179
|
eng.setObject("globe-base", {
|
|
175
180
|
object: sphere, pickable: false, content: true, marker: "none",
|
|
@@ -346,6 +351,8 @@ export default defineComponent({
|
|
|
346
351
|
<HkBoard3D
|
|
347
352
|
interactive
|
|
348
353
|
minimap
|
|
354
|
+
minimapCollapsible
|
|
355
|
+
minimapDefaultCollapsed
|
|
349
356
|
hoverBox={false}
|
|
350
357
|
transparentBackground
|
|
351
358
|
enablePan={false}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the bond CELLS of the horizontal stepper (2026-10-31
|
|
3
|
+
* user rule).
|
|
4
|
+
*
|
|
5
|
+
* The row is read as one cell per step plus one cell per bond: the steps
|
|
6
|
+
* keep their content width, the leftover splits into EQUAL bond cells, and
|
|
7
|
+
* each bond paints 80 % of its own cell CENTERED, so both ends keep a 10 %
|
|
8
|
+
* air gap. Before that rule the bond filled whatever the step had left after
|
|
9
|
+
* its label, minus two fixed 4px margins — measured on a real engine at the
|
|
10
|
+
* provider wizard's 590px row that put the bond 12px off its own label but
|
|
11
|
+
* only 4px off the next node, i.e. the completed bond read as glued to the
|
|
12
|
+
* following circle instead of sitting in the gap (the user's 圈出来的地方
|
|
13
|
+
* 有点太长 report). The four bonds were already equal in length; what was
|
|
14
|
+
* not equal was the air around them.
|
|
15
|
+
*
|
|
16
|
+
* Every assertion below is anchored to the rule that must carry the
|
|
17
|
+
* declaration, and the colour assertions run against a comment-stripped copy
|
|
18
|
+
* of the source: a declaration that survives only as a comment (while a
|
|
19
|
+
* literal paint stays live) measured as an invisible bond on a real engine,
|
|
20
|
+
* and a whole-file `toContain` cannot tell the two apart.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const raw = readFileSync(join(here, "HkTimeline.scss"), "utf-8");
|
|
29
|
+
|
|
30
|
+
/** The source with comments removed — declarations only. */
|
|
31
|
+
const src = raw
|
|
32
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
33
|
+
.split("\n")
|
|
34
|
+
.map((line) => line.replace(/\/\/.*$/, ""))
|
|
35
|
+
.join("\n");
|
|
36
|
+
|
|
37
|
+
function extractRules(source: string): { selector: string; body: string }[] {
|
|
38
|
+
const rules: { selector: string; body: string }[] = [];
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < source.length) {
|
|
41
|
+
const open = source.indexOf("{", i);
|
|
42
|
+
if (open === -1) break;
|
|
43
|
+
const selector = source.slice(i, open).trim();
|
|
44
|
+
let depth = 1;
|
|
45
|
+
let j = open + 1;
|
|
46
|
+
while (j < source.length && depth > 0) {
|
|
47
|
+
if (source[j] === "{") depth += 1;
|
|
48
|
+
else if (source[j] === "}") depth -= 1;
|
|
49
|
+
j += 1;
|
|
50
|
+
}
|
|
51
|
+
rules.push({ selector, body: source.slice(open + 1, j - 1) });
|
|
52
|
+
i = j;
|
|
53
|
+
}
|
|
54
|
+
return rules;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rules = extractRules(src);
|
|
58
|
+
|
|
59
|
+
function blockFor(selector: string): string {
|
|
60
|
+
const hits = rules.filter((r) =>
|
|
61
|
+
r.selector.split(",").map((s) => s.trim()).includes(selector),
|
|
62
|
+
);
|
|
63
|
+
expect(hits, `exactly one rule carries ${selector}`).toHaveLength(1);
|
|
64
|
+
return hits[0]!.body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The shared spacing expression: the bond's box must read the SAME token
|
|
68
|
+
* the step's gap does, or the cell arithmetic halves itself. */
|
|
69
|
+
const GAP = "var(--hk-timeline-step-gap, var(--space-8, 8px))";
|
|
70
|
+
|
|
71
|
+
const BOND_CELL =
|
|
72
|
+
'.hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"]';
|
|
73
|
+
const BOND_PAINT = `${BOND_CELL}::before`;
|
|
74
|
+
|
|
75
|
+
/** The declaration block of one status, comment-stripped. */
|
|
76
|
+
function statusBlock(status: string): string {
|
|
77
|
+
const at = src.indexOf(`.hk-timeline-step[data-status="${status}"]`);
|
|
78
|
+
expect(at, `${status} status rule must exist`).toBeGreaterThan(-1);
|
|
79
|
+
return src.slice(at, src.indexOf("\n}", at));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Only the bond's own declarations inside a status rule (the indicator may
|
|
83
|
+
* legitimately keep reading the border token for its ring). */
|
|
84
|
+
function bondBlockOf(status: string): string {
|
|
85
|
+
const block = statusBlock(status);
|
|
86
|
+
const at = block.indexOf('[data-el="connector"]');
|
|
87
|
+
expect(at, `${status} must style the bond`).toBeGreaterThan(-1);
|
|
88
|
+
return block.slice(at, block.indexOf("}", at));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("horizontal bond cell contract", () => {
|
|
92
|
+
it("spans the whole cell by cancelling the step's trailing gap", () => {
|
|
93
|
+
const cell = blockFor(BOND_CELL);
|
|
94
|
+
// Without the negated start margin the label→bond air is gap+margin
|
|
95
|
+
// while the bond→node air is margin alone — the 12/4 asymmetry that
|
|
96
|
+
// made the bond look glued to the next circle.
|
|
97
|
+
expect(cell).toContain(`margin-inline: calc(-1 * ${GAP}) 0;`);
|
|
98
|
+
// The box must not paint itself: the visible 80 % is the ::before.
|
|
99
|
+
expect(cell).toContain("background: none;");
|
|
100
|
+
// The ::before resolves percentage insets against this box.
|
|
101
|
+
expect(cell).toContain("position: relative;");
|
|
102
|
+
expect(cell).toContain("&::before");
|
|
103
|
+
// The negated margin is only correct against the step's OWN gap
|
|
104
|
+
// declaration, so pin the partner: drifting `gap` alone silently
|
|
105
|
+
// restores the asymmetry (measured 7.84 / 3.84 on a 12px drift).
|
|
106
|
+
expect(blockFor(".hk-timeline-step")).toContain(`gap: ${GAP};`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("paints 80 % of the cell centred with a 10 % inset on each end", () => {
|
|
110
|
+
// The parser above only yields top-level rules; the paint lives in the
|
|
111
|
+
// nested block, so lift it out of the cell rule's body.
|
|
112
|
+
const paint = /&::before\s*\{([^}]*)\}/.exec(blockFor(BOND_CELL))?.[1] ?? "";
|
|
113
|
+
expect(paint, "the bond's ::before must exist").not.toBe("");
|
|
114
|
+
expect(paint).toContain('content: "";');
|
|
115
|
+
expect(paint).toContain("position: absolute;");
|
|
116
|
+
expect(paint).toContain("inset-block: 0;");
|
|
117
|
+
expect(paint).toContain("inset-inline: 10%;");
|
|
118
|
+
expect(paint).toContain("background: var(--_bond-color);");
|
|
119
|
+
// Percentage PADDING would resolve against the step, not the bond.
|
|
120
|
+
expect(src).not.toMatch(/padding-inline:\s*10%/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps a visible bond floor the collapse measurement can see", () => {
|
|
124
|
+
const cell = blockFor(BOND_CELL);
|
|
125
|
+
// A clamped row must still paint a bond, and the row's max-content
|
|
126
|
+
// width must keep reserving it — otherwise collapse="auto" measures the
|
|
127
|
+
// row narrower than it renders and swaps the window away too early.
|
|
128
|
+
expect(cell).toContain(`min-width: calc(0.5rem + ${GAP});`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("publishes one bond colour token for every status", () => {
|
|
132
|
+
for (const status of ["completed", "active", "pending"]) {
|
|
133
|
+
const bond = bondBlockOf(status);
|
|
134
|
+
expect(bond, `${status} must publish --_bond-color`).toContain("--_bond-color:");
|
|
135
|
+
expect(bond).toContain("background: var(--_bond-color);");
|
|
136
|
+
// No status may paint a literal colour straight onto the box again:
|
|
137
|
+
// in horizontal mode the box itself stays unpainted, and a stray
|
|
138
|
+
// longhand would tint the vertical rail red instead.
|
|
139
|
+
expect(bond, `${status} must not paint the box directly`).not.toMatch(
|
|
140
|
+
/background(-color)?:\s*(?!\s*var\(--_bond-color\))/,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
// The completed bond is the primary tone; the faint ones derive from an
|
|
144
|
+
// OPAQUE tone. `--hi-color-border` already carries the theme's alpha, so
|
|
145
|
+
// 30 % of it computed to about 4.5 % — measured as ~7/255 off a white
|
|
146
|
+
// background (a 248 grey) in full mode, while the window-mode link had
|
|
147
|
+
// already been fixed.
|
|
148
|
+
expect(bondBlockOf("completed")).toContain(
|
|
149
|
+
"--_bond-color: var(--hi-color-primary);",
|
|
150
|
+
);
|
|
151
|
+
for (const status of ["active", "pending"]) {
|
|
152
|
+
const bond = bondBlockOf(status);
|
|
153
|
+
expect(bond, `${status} must not re-multiply the border token's alpha`)
|
|
154
|
+
.toContain("--_bond-color: color-mix(in srgb, var(--hi-color-muted");
|
|
155
|
+
expect(bond).not.toContain("var(--hi-color-border)");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("leaves the vertical rail painting its own box", () => {
|
|
160
|
+
// The cell rule is mode+orientation scoped, so the vertical rail (a rail
|
|
161
|
+
// hanging under the node, not a cell-wide bond) keeps the plain
|
|
162
|
+
// `background: var(--_bond-color)` chain and its absolute geometry.
|
|
163
|
+
const vertical = blockFor(
|
|
164
|
+
'.hk-timeline[data-orientation="vertical"][data-mode="full"] .hk-timeline-step',
|
|
165
|
+
);
|
|
166
|
+
expect(vertical).toContain("width: var(--hk-timeline-link-thickness);");
|
|
167
|
+
expect(vertical).toContain("min-width: 0;");
|
|
168
|
+
expect(vertical).not.toContain("background: none");
|
|
169
|
+
// A bare `.hk-timeline-step [data-el="connector"]` override would hit the
|
|
170
|
+
// vertical rail too and blank it.
|
|
171
|
+
const unscoped = rules.filter(
|
|
172
|
+
(r) =>
|
|
173
|
+
r.selector.trim() === '.hk-timeline-step [data-el="connector"]' &&
|
|
174
|
+
/background:\s*none/.test(r.body),
|
|
175
|
+
);
|
|
176
|
+
expect(unscoped, "no unscoped connector background reset").toHaveLength(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rides the status crossfade on the painted box, not just the frame", () => {
|
|
180
|
+
// The paint moved to the ::before, so the crossfade must cover THAT
|
|
181
|
+
// selector. Asserting the bare selector name proves nothing (the
|
|
182
|
+
// reduced-motion block lists it too), so pin the transition BODY.
|
|
183
|
+
const crossfade = blockFor(BOND_PAINT);
|
|
184
|
+
expect(crossfade).toContain("background-color var(--hk-timeline-motion)");
|
|
185
|
+
// ...and the reduced-motion switch must cover the same box.
|
|
186
|
+
const reducedAt = src.lastIndexOf("@media (prefers-reduced-motion: reduce)");
|
|
187
|
+
expect(reducedAt).toBeGreaterThan(-1);
|
|
188
|
+
expect(src.slice(reducedAt)).toContain(BOND_PAINT);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -312,6 +312,56 @@
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
/* ── Bond cells (2026-10-31 user rule) ──────────────────────────────────
|
|
316
|
+
The horizontal row reads as one cell per step plus one cell per bond: the
|
|
317
|
+
steps keep their content width, the leftover splits into EQUAL bond cells,
|
|
318
|
+
and each bond paints 80 % of its cell CENTERED, so both ends keep a 10 %
|
|
319
|
+
air gap. Two mechanics make that exact:
|
|
320
|
+
|
|
321
|
+
1. The bond's box must span the WHOLE cell. Its cell begins where its own
|
|
322
|
+
label ends, but the step's flex gap already sits in front of it (that
|
|
323
|
+
gap is part of the cell, not extra air), so the box starts one gap
|
|
324
|
+
earlier — hence the negated start margin, which must keep reading the
|
|
325
|
+
SAME `--hk-timeline-step-gap` token the step's `gap` uses. Before this
|
|
326
|
+
the gap counted twice and the bond ran 12px off its label but only 4px
|
|
327
|
+
off the next node: the completed bond read as glued to the next circle.
|
|
328
|
+
2. The painted 80 % cannot be `padding-inline` — a percentage padding
|
|
329
|
+
resolves against the CONTAINING block (the step), not the bond's own
|
|
330
|
+
box, which would inset it by a slice of the step instead of 10 % of the
|
|
331
|
+
cell. So the colour moves to a ::before pinned `inset-inline: 10%`,
|
|
332
|
+
which does resolve against the bond. The status rules below therefore
|
|
333
|
+
publish `--_bond-color`; the vertical rail keeps painting its own box
|
|
334
|
+
(it is a rail hanging under the node, not a cell-wide bond).
|
|
335
|
+
|
|
336
|
+
The floor keeps a squeezed row honest: the cell absorbs the gap on top of
|
|
337
|
+
a half-rem of bond, so the narrowest full row still paints a visible line.
|
|
338
|
+
Its outer size is that floor MINUS the cancelled gap, i.e. half a rem per
|
|
339
|
+
bond — eight of the sixteen pixels the old two 4px margins reserved, so
|
|
340
|
+
`collapse="auto"` (HkTimeline.tsx measures this max-content width) fires
|
|
341
|
+
about 32px earlier than before across a five-step row, never later.
|
|
342
|
+
Bounded cost, accepted: in a row squeezed past the floor (reachable through
|
|
343
|
+
`collapse="never"`, or the full-mode frame that a label change and a
|
|
344
|
+
hysteresis un-collapse render before re-measuring) the box keeps its floor
|
|
345
|
+
and overhangs the next step by a QUARTER of the row's shortfall — ~6px at a
|
|
346
|
+
430px host, ~14px at 400px — so the bond paints under that step's circle
|
|
347
|
+
until the overhang outgrows the 24px circle itself (host ≲ 362px), where a
|
|
348
|
+
visible stub sits beside it. Shrinking the floor to nothing would trade
|
|
349
|
+
this for a bond that vanishes on the narrowest hosts. */
|
|
350
|
+
.hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"] {
|
|
351
|
+
position: relative;
|
|
352
|
+
margin-inline: calc(-1 * var(--hk-timeline-step-gap, var(--space-8, 8px))) 0;
|
|
353
|
+
min-width: calc(0.5rem + var(--hk-timeline-step-gap, var(--space-8, 8px)));
|
|
354
|
+
background: none;
|
|
355
|
+
|
|
356
|
+
&::before {
|
|
357
|
+
content: "";
|
|
358
|
+
position: absolute;
|
|
359
|
+
inset-block: 0;
|
|
360
|
+
inset-inline: 10%;
|
|
361
|
+
background: var(--_bond-color);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
315
365
|
.hk-timeline-step[data-last] {
|
|
316
366
|
flex: 0 0 auto;
|
|
317
367
|
}
|
|
@@ -376,7 +426,11 @@
|
|
|
376
426
|
}
|
|
377
427
|
|
|
378
428
|
[data-el="connector"] {
|
|
379
|
-
|
|
429
|
+
// The bonds paint 80 % of their cell through a ::before (see the
|
|
430
|
+
// full-mode cell rule); the token is the single source for both that
|
|
431
|
+
// paint and the vertical rail's own box.
|
|
432
|
+
--_bond-color: var(--hi-color-primary);
|
|
433
|
+
background: var(--_bond-color);
|
|
380
434
|
}
|
|
381
435
|
}
|
|
382
436
|
|
|
@@ -392,7 +446,12 @@
|
|
|
392
446
|
}
|
|
393
447
|
|
|
394
448
|
[data-el="connector"] {
|
|
395
|
-
|
|
449
|
+
// Opaque-tone derivation, same fix the window-mode link carries:
|
|
450
|
+
// `--hi-color-border` already carries the theme's own alpha, so 30 % of
|
|
451
|
+
// IT computed to about 4.5 % and the bond was not a line on any real
|
|
452
|
+
// surface (measured: ~7/255 off a white background — a 248 grey).
|
|
453
|
+
--_bond-color: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 30%, transparent);
|
|
454
|
+
background: var(--_bond-color);
|
|
396
455
|
}
|
|
397
456
|
}
|
|
398
457
|
|
|
@@ -408,7 +467,12 @@
|
|
|
408
467
|
}
|
|
409
468
|
|
|
410
469
|
[data-el="connector"] {
|
|
411
|
-
|
|
470
|
+
// Opaque-tone derivation, same fix the window-mode link carries:
|
|
471
|
+
// `--hi-color-border` already carries the theme's own alpha, so 30 % of
|
|
472
|
+
// IT computed to about 4.5 % and the bond was not a line on any real
|
|
473
|
+
// surface (measured: ~7/255 off a white background — a 248 grey).
|
|
474
|
+
--_bond-color: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 30%, transparent);
|
|
475
|
+
background: var(--_bond-color);
|
|
412
476
|
}
|
|
413
477
|
}
|
|
414
478
|
|
|
@@ -451,10 +515,14 @@
|
|
|
451
515
|
}
|
|
452
516
|
|
|
453
517
|
// Status crossfade: the data-status driven colors (indicator fill/border,
|
|
454
|
-
// label color, connector fill) transition instead of flipping.
|
|
518
|
+
// label color, connector fill) transition instead of flipping. The
|
|
519
|
+
// horizontal bond's paint lives on its ::before (the 80 % cell rule), so
|
|
520
|
+
// that box rides the crossfade too — without it a status flip snaps there
|
|
521
|
+
// while the vertical rail keeps easing.
|
|
455
522
|
.hk-timeline-step [data-el="indicator"],
|
|
456
523
|
.hk-timeline-step [data-el="label"],
|
|
457
|
-
.hk-timeline-step [data-el="connector"]
|
|
524
|
+
.hk-timeline-step [data-el="connector"],
|
|
525
|
+
.hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"]::before {
|
|
458
526
|
transition:
|
|
459
527
|
background-color var(--hk-timeline-motion) var(--hk-timeline-motion-ease),
|
|
460
528
|
color var(--hk-timeline-motion) var(--hk-timeline-motion-ease),
|
|
@@ -530,7 +598,8 @@ html[data-css-animations="0"] .hk-timeline[data-mode="window"][data-dir] .hk-tim
|
|
|
530
598
|
@media (prefers-reduced-motion: reduce) {
|
|
531
599
|
.hk-timeline-step [data-el="indicator"],
|
|
532
600
|
.hk-timeline-step [data-el="label"],
|
|
533
|
-
.hk-timeline-step [data-el="connector"]
|
|
601
|
+
.hk-timeline-step [data-el="connector"],
|
|
602
|
+
.hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"]::before {
|
|
534
603
|
transition: none;
|
|
535
604
|
}
|
|
536
605
|
|