@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.251",
3
+ "version": "0.55.253",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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 engine={engineRef.value} />
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
- baseMaterial = new THREE.MeshBasicMaterial({ color: 0x8899aa });
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
- background: var(--hi-color-primary);
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
- background: color-mix(in srgb, var(--hi-color-border) 30%, transparent);
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
- background: color-mix(in srgb, var(--hi-color-border) 30%, transparent);
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