@celestia-island/hikari 0.55.251 → 0.55.254
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/HkAffixPicker.scss +4 -3
- package/src/components/HkBoard3D.tsx +14 -2
- package/src/components/HkLocalePicker.scss +29 -32
- package/src/components/HkLocalePicker.tsx +35 -94
- package/src/components/HkLocalePickerPopup.tsx +3 -1
- package/src/components/HkMinimap.tsx +2 -2
- package/src/components/HkMinimap3D.test.tsx +110 -3
- package/src/components/HkMinimap3D.tsx +89 -6
- package/src/components/HkTimeZoneGlobe.test.tsx +24 -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/src/components/HkTitleBar.scss +27 -12
- package/src/components/HkTitleBar.test.tsx +32 -1
- package/src/components/HkZoomToolbar.tsx +2 -2
- package/src/components/_menu-item.contract.test.ts +80 -0
package/package.json
CHANGED
|
@@ -251,9 +251,10 @@
|
|
|
251
251
|
* comes from the registration list, not from here. */
|
|
252
252
|
@include mi.item;
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
/* data-disabled rides the mixin's face whole (dim + not-allowed +
|
|
255
|
+
guarded hover/focus): the sibling tag-input catalog rows already
|
|
256
|
+
did, and a family-local cursor:default split the two choosers'
|
|
257
|
+
disabled language. */
|
|
257
258
|
|
|
258
259
|
/* The "Use <query>" free-text row reads as an add affordance. Truncation
|
|
259
260
|
* comes from the base .hk-affix-row-label — only the emphasis is new. */
|
|
@@ -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>
|
|
@@ -1,37 +1,34 @@
|
|
|
1
|
-
/* HkLocalePicker — the
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
.hk-locale-picker-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
/* HkLocalePicker — only the TRIGGER is component-owned now; the dropdown
|
|
2
|
+
* is HkLocalePickerPopup (HkMenu) and inherits the menu grammar + pop
|
|
3
|
+
* motion from there. The hand-rolled dropdown sheet (and its pop-motion
|
|
4
|
+
* classes) was removed with the 2026-10-31 sweep rewrite. */
|
|
5
|
+
.hk-locale-picker-trigger {
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: 0.25rem;
|
|
9
|
+
padding: 0.25rem 0.5rem;
|
|
10
|
+
border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
|
|
11
|
+
border-radius: var(--radius-sm, 4px);
|
|
12
|
+
background: transparent;
|
|
13
|
+
color: rgb(var(--color-muted));
|
|
14
|
+
font-size: 0.75rem;
|
|
15
|
+
font-weight: 500;
|
|
16
|
+
font-family: inherit;
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
transition: background 0.15s;
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1)),
|
|
21
|
-
transform var(--hk-pop-out-duration, 0.15s)
|
|
22
|
-
var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1));
|
|
23
|
-
}
|
|
20
|
+
&:hover {
|
|
21
|
+
background: rgb(var(--color-primary) / 8%);
|
|
22
|
+
}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
scale(var(--hk-pop-scale, 0.97));
|
|
30
|
-
}
|
|
24
|
+
&:focus-visible {
|
|
25
|
+
outline: 2px solid rgb(var(--color-primary) / 70%);
|
|
26
|
+
outline-offset: 1px;
|
|
27
|
+
}
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
/* The caret is furniture, not copy — half-strength ink, as the
|
|
30
|
+
pre-rewrite trigger drew it. */
|
|
31
|
+
svg {
|
|
32
|
+
opacity: 0.5;
|
|
36
33
|
}
|
|
37
34
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { defineComponent, ref,
|
|
1
|
+
import { defineComponent, ref, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import HkLocalePickerPopup from "./HkLocalePickerPopup";
|
|
4
4
|
import "./HkLocalePicker.scss";
|
|
5
5
|
|
|
6
6
|
export interface LocaleOption {
|
|
@@ -8,6 +8,15 @@ export interface LocaleOption {
|
|
|
8
8
|
label: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* HkLocalePicker — the compact trigger + dropdown locale picker. The
|
|
13
|
+
* dropdown hand-rolled its own absolutely-positioned stock (inline
|
|
14
|
+
* styles, JS-painted hover, no sheet, no roles — the 2026-10-31 sweep's
|
|
15
|
+
* finding #8); it now rides HkLocalePickerPopup (HkMenu), so the rows
|
|
16
|
+
* speak the menu-item grammar, the panel anchors/cascades/flips like
|
|
17
|
+
* every menu, and phones get the sheet family. The public surface is
|
|
18
|
+
* unchanged: locales / currentLocale props, `select` event.
|
|
19
|
+
*/
|
|
11
20
|
export const HkLocalePicker = defineComponent({
|
|
12
21
|
name: "HkLocalePicker",
|
|
13
22
|
props: {
|
|
@@ -23,110 +32,42 @@ export const HkLocalePicker = defineComponent({
|
|
|
23
32
|
emits: ["select"],
|
|
24
33
|
setup(props, { emit }) {
|
|
25
34
|
const open = ref(false);
|
|
26
|
-
|
|
27
|
-
// this little dropdown moves like the rest of the pop family
|
|
28
|
-
// instead of appearing bare.
|
|
29
|
-
const anim = useSurfaceTransition(250).hooks();
|
|
35
|
+
const trigger = ref<HTMLElement | null>(null);
|
|
30
36
|
|
|
31
|
-
function handleSelect(code: string) {
|
|
32
|
-
|
|
37
|
+
function handleSelect(code: string | string[]) {
|
|
38
|
+
const code0 = Array.isArray(code) ? code[0] : code;
|
|
39
|
+
if (code0) emit("select", code0);
|
|
33
40
|
open.value = false;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
return () => (
|
|
37
|
-
<div style={{ position: "relative" }}>
|
|
44
|
+
<div style={{ position: "relative" }} ref={trigger}>
|
|
38
45
|
<button
|
|
39
46
|
type="button"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
gap: "0.25rem",
|
|
44
|
-
padding: "0.25rem 0.5rem",
|
|
45
|
-
border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
|
|
46
|
-
borderRadius: "var(--radius-sm, 4px)",
|
|
47
|
-
background: "transparent",
|
|
48
|
-
color: "rgb(var(--color-muted))",
|
|
49
|
-
fontSize: "0.75rem",
|
|
50
|
-
fontWeight: 500,
|
|
51
|
-
cursor: "pointer",
|
|
52
|
-
transition: "background 0.15s",
|
|
53
|
-
}}
|
|
47
|
+
class="hk-locale-picker-trigger"
|
|
48
|
+
aria-haspopup="menu"
|
|
49
|
+
aria-expanded={open.value}
|
|
54
50
|
onClick={() => (open.value = !open.value)}
|
|
55
|
-
onBlur={() => setTimeout(() => (open.value = false), 150)}
|
|
56
51
|
>
|
|
57
52
|
{props.currentLocale.toUpperCase()}
|
|
58
|
-
<svg width="10" height="10" viewBox="0 0 10 10"
|
|
59
|
-
<path
|
|
53
|
+
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
54
|
+
<path
|
|
55
|
+
d="M2 3.5l3 3 3-3"
|
|
56
|
+
fill="none"
|
|
57
|
+
stroke="currentColor"
|
|
58
|
+
stroke-width="1.2"
|
|
59
|
+
/>
|
|
60
60
|
</svg>
|
|
61
61
|
</button>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<div
|
|
72
|
-
class="hk-locale-picker-dropdown"
|
|
73
|
-
style={{
|
|
74
|
-
position: "absolute",
|
|
75
|
-
top: "calc(100% + 4px)",
|
|
76
|
-
right: 0,
|
|
77
|
-
minWidth: "120px",
|
|
78
|
-
background: "rgb(var(--color-surface))",
|
|
79
|
-
border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
|
|
80
|
-
borderRadius: "var(--radius-md, 6px)",
|
|
81
|
-
boxShadow: "0 4px 16px rgb(0 0 0 / 12%)",
|
|
82
|
-
// Local stacking inside the header's own context — the
|
|
83
|
-
// app-chrome header-popup band documents the intent (this
|
|
84
|
-
// value is scoped, it never races the popup stack).
|
|
85
|
-
zIndex: "var(--hk-z-float)",
|
|
86
|
-
overflow: "hidden",
|
|
87
|
-
padding: "4px",
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
90
|
-
{props.locales.map((loc) => (
|
|
91
|
-
<button
|
|
92
|
-
key={loc.code}
|
|
93
|
-
type="button"
|
|
94
|
-
style={{
|
|
95
|
-
display: "flex",
|
|
96
|
-
alignItems: "center",
|
|
97
|
-
width: "100%",
|
|
98
|
-
padding: "6px 10px",
|
|
99
|
-
border: "none",
|
|
100
|
-
borderRadius: "4px",
|
|
101
|
-
background: loc.code === props.currentLocale
|
|
102
|
-
? "var(--c-primary-subtle, rgb(var(--color-primary) / 8%))"
|
|
103
|
-
: "transparent",
|
|
104
|
-
color: loc.code === props.currentLocale
|
|
105
|
-
? "var(--c-primary, rgb(var(--color-primary)))"
|
|
106
|
-
: "rgb(var(--color-text))",
|
|
107
|
-
fontSize: "0.75rem",
|
|
108
|
-
cursor: "pointer",
|
|
109
|
-
textAlign: "left",
|
|
110
|
-
transition: "background 0.1s",
|
|
111
|
-
}}
|
|
112
|
-
onMouseenter={(e: MouseEvent) => {
|
|
113
|
-
if (loc.code !== props.currentLocale) {
|
|
114
|
-
(e.currentTarget as HTMLElement).style.background = "var(--c-primary-overlay, rgb(var(--color-primary) / 15%))";
|
|
115
|
-
}
|
|
116
|
-
}}
|
|
117
|
-
onMouseleave={(e: MouseEvent) => {
|
|
118
|
-
if (loc.code !== props.currentLocale) {
|
|
119
|
-
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
120
|
-
}
|
|
121
|
-
}}
|
|
122
|
-
onMousedown={() => handleSelect(loc.code)}
|
|
123
|
-
>
|
|
124
|
-
{loc.label}
|
|
125
|
-
</button>
|
|
126
|
-
))}
|
|
127
|
-
</div>
|
|
128
|
-
)}
|
|
129
|
-
</Transition>
|
|
62
|
+
<HkLocalePickerPopup
|
|
63
|
+
open={open.value}
|
|
64
|
+
triggerRef={trigger.value}
|
|
65
|
+
placement="bottom-end"
|
|
66
|
+
locales={props.locales.map((loc) => ({ code: loc.code, label: loc.label }))}
|
|
67
|
+
currentLocale={props.currentLocale}
|
|
68
|
+
onUpdate:open={(v: boolean) => (open.value = v)}
|
|
69
|
+
onSelect={handleSelect}
|
|
70
|
+
/>
|
|
130
71
|
</div>
|
|
131
72
|
);
|
|
132
73
|
},
|
|
@@ -38,7 +38,9 @@ const HkLocalePickerPopup = defineComponent({
|
|
|
38
38
|
open={props.open}
|
|
39
39
|
anchorRef={anchor}
|
|
40
40
|
placement={
|
|
41
|
-
props.placement === "left-start" ||
|
|
41
|
+
props.placement === "left-start" ||
|
|
42
|
+
props.placement === "right-start" ||
|
|
43
|
+
props.placement === "bottom-end"
|
|
42
44
|
? props.placement
|
|
43
45
|
: "right-start"
|
|
44
46
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Map as MapIcon,
|
|
1
|
+
import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
2
2
|
import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
3
3
|
|
|
4
4
|
import HSlider from "./HkSlider";
|
|
@@ -407,7 +407,7 @@ export default defineComponent({
|
|
|
407
407
|
aria-label={t("hikari::zoomToolbar.reset", "Reset zoom")}
|
|
408
408
|
title={t("hikari::zoomToolbar.reset", "Reset zoom")}
|
|
409
409
|
>
|
|
410
|
-
<
|
|
410
|
+
<RotateCcw size={11} />
|
|
411
411
|
</button>
|
|
412
412
|
)}
|
|
413
413
|
{props.collapsible && (
|
|
@@ -12,12 +12,15 @@ const H = vi.hoisted(() => {
|
|
|
12
12
|
static instances: FakeWebGLRenderer[] = [];
|
|
13
13
|
renders: Array<{ scene: unknown; camera: THREE.PerspectiveCamera }> = [];
|
|
14
14
|
disposed = false;
|
|
15
|
-
|
|
15
|
+
readonly domElement: HTMLCanvasElement;
|
|
16
|
+
constructor(opts: { canvas: HTMLCanvasElement }) {
|
|
17
|
+
this.domElement = opts.canvas;
|
|
16
18
|
FakeWebGLRenderer.instances.push(this);
|
|
17
19
|
}
|
|
18
20
|
setPixelRatio(): void {}
|
|
19
21
|
setSize(): void {}
|
|
20
22
|
setClearColor(): void {}
|
|
23
|
+
forceContextLoss(): void { this.disposed = true; }
|
|
21
24
|
render(scene: unknown, camera: THREE.PerspectiveCamera): void {
|
|
22
25
|
// A real renderer maintains world matrices every pass.
|
|
23
26
|
(scene as THREE.Scene).updateMatrixWorld(true);
|
|
@@ -82,13 +85,13 @@ function fakeEngine(opts: FakeEngineOpts = {}): {
|
|
|
82
85
|
};
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
function mountMap(engine: Board3DEngine) {
|
|
88
|
+
function mountMap(engine: Board3DEngine, extra: Record<string, unknown> = {}) {
|
|
86
89
|
const container = document.createElement("div");
|
|
87
90
|
document.body.appendChild(container);
|
|
88
91
|
const app = createApp(
|
|
89
92
|
defineComponent({
|
|
90
93
|
setup() {
|
|
91
|
-
return () => h(HkMinimap3D, { engine });
|
|
94
|
+
return () => h(HkMinimap3D, { engine, ...extra });
|
|
92
95
|
},
|
|
93
96
|
}),
|
|
94
97
|
);
|
|
@@ -360,3 +363,107 @@ describe("HkMinimap3D", () => {
|
|
|
360
363
|
for (const spy of spies) expect(spy).toHaveBeenCalled();
|
|
361
364
|
});
|
|
362
365
|
});
|
|
366
|
+
|
|
367
|
+
describe("HkMinimap3D collapse affordance (ported from the 2D minimap)", () => {
|
|
368
|
+
it("mounts folded with defaultCollapsed: map-icon button, no GL surface", async () => {
|
|
369
|
+
const { engine, firePostRender } = fakeEngine();
|
|
370
|
+
const { container } = mountMap(engine, { collapsible: true, defaultCollapsed: true });
|
|
371
|
+
await nextTick();
|
|
372
|
+
firePostRender();
|
|
373
|
+
|
|
374
|
+
expect(container.querySelector(".hk-minimap--collapsed"), "folded button renders").toBeTruthy();
|
|
375
|
+
expect(container.querySelector(".hk-minimap3d-canvas"), "no canvas while folded").toBeNull();
|
|
376
|
+
// No GL surface was ever built for a folded-at-mount map.
|
|
377
|
+
expect(H.FakeWebGLRenderer.instances).toHaveLength(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("expands on the folded button and lazily inits the renderer", async () => {
|
|
381
|
+
const { engine, firePostRender } = fakeEngine();
|
|
382
|
+
const { container } = mountMap(engine, { collapsible: true, defaultCollapsed: true });
|
|
383
|
+
await nextTick();
|
|
384
|
+
|
|
385
|
+
const fold = container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed");
|
|
386
|
+
expect(fold).toBeTruthy();
|
|
387
|
+
fold!.click();
|
|
388
|
+
await nextTick();
|
|
389
|
+
await nextTick();
|
|
390
|
+
|
|
391
|
+
expect(container.querySelector(".hk-minimap3d-canvas"), "canvas mounts on expand").toBeTruthy();
|
|
392
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
393
|
+
// The lazy init built the renderer; frames flow again.
|
|
394
|
+
expect(H.FakeWebGLRenderer.instances).toHaveLength(1);
|
|
395
|
+
firePostRender();
|
|
396
|
+
expect(H.FakeWebGLRenderer.instances[0].renders.length).toBe(1);
|
|
397
|
+
// The zoom bar carries the minimize affordance once expanded.
|
|
398
|
+
expect(container.querySelector(".hk-mm-collapse-btn")).toBeTruthy();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("collapsible adds the minimize button; clicking folds the card", async () => {
|
|
402
|
+
const { engine, firePostRender } = fakeEngine();
|
|
403
|
+
const { container } = mountMap(engine, { collapsible: true });
|
|
404
|
+
await nextTick();
|
|
405
|
+
|
|
406
|
+
expect(container.querySelector(".hk-minimap3d-canvas")).toBeTruthy();
|
|
407
|
+
const minimize = container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn");
|
|
408
|
+
expect(minimize, "minimize button rides the zoom bar").toBeTruthy();
|
|
409
|
+
minimize!.click();
|
|
410
|
+
await nextTick();
|
|
411
|
+
|
|
412
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeTruthy();
|
|
413
|
+
expect(container.querySelector(".hk-minimap3d-canvas")).toBeNull();
|
|
414
|
+
// A folded map renders no frames even though the board keeps ticking.
|
|
415
|
+
const renderer = H.FakeWebGLRenderer.instances[0];
|
|
416
|
+
const before = renderer.renders.length;
|
|
417
|
+
firePostRender();
|
|
418
|
+
firePostRender();
|
|
419
|
+
expect(renderer.renders.length).toBe(before);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("stays expanded by default (board pages keep the standing card)", async () => {
|
|
423
|
+
const { engine } = fakeEngine();
|
|
424
|
+
const { container } = mountMap(engine);
|
|
425
|
+
await nextTick();
|
|
426
|
+
expect(container.querySelector(".hk-minimap3d-canvas")).toBeTruthy();
|
|
427
|
+
expect(container.querySelector(".hk-mm-collapse-btn")).toBeNull();
|
|
428
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
describe("HkMinimap3D reopen cycle (the canvas element is rebuilt by v-if)", () => {
|
|
432
|
+
it("re-binds the renderer to the LIVE canvas; helpers never duplicate", async () => {
|
|
433
|
+
const { engine, scene, firePostRender } = fakeEngine();
|
|
434
|
+
const { container } = mountMap(engine, { collapsible: true });
|
|
435
|
+
await nextTick();
|
|
436
|
+
firePostRender();
|
|
437
|
+
expect(H.FakeWebGLRenderer.instances).toHaveLength(1);
|
|
438
|
+
|
|
439
|
+
const helpersOnLayer = () =>
|
|
440
|
+
scene.children.filter((c) => (c.layers.mask & (1 << BOARD3D_HELPERS_LAYER)) !== 0).length;
|
|
441
|
+
expect(helpersOnLayer()).toBe(2); // glyph group + view-plane frame
|
|
442
|
+
|
|
443
|
+
// fold -> unfold: Vue rebuilds the canvas element; the renderer must
|
|
444
|
+
// follow it or the reopened map paints into the detached element.
|
|
445
|
+
container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
|
|
446
|
+
await nextTick();
|
|
447
|
+
container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
|
|
448
|
+
await nextTick();
|
|
449
|
+
await nextTick();
|
|
450
|
+
|
|
451
|
+
const canvasNow = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
|
|
452
|
+
expect(canvasNow, "canvas is back").toBeTruthy();
|
|
453
|
+
const latest = H.FakeWebGLRenderer.instances.at(-1)!;
|
|
454
|
+
expect(latest.domElement).toBe(canvasNow);
|
|
455
|
+
expect(helpersOnLayer()).toBe(2); // rebuilt, NOT duplicated
|
|
456
|
+
firePostRender();
|
|
457
|
+
expect(latest.renders.length).toBe(1);
|
|
458
|
+
|
|
459
|
+
// a second cycle stays correct
|
|
460
|
+
container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
|
|
461
|
+
await nextTick();
|
|
462
|
+
container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
|
|
463
|
+
await nextTick();
|
|
464
|
+
await nextTick();
|
|
465
|
+
const canvas2 = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
|
|
466
|
+
expect(H.FakeWebGLRenderer.instances.at(-1)!.domElement).toBe(canvas2);
|
|
467
|
+
expect(helpersOnLayer()).toBe(2);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -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 {
|
|
27
|
+
import { Map as MapIcon, Minimize2, RotateCcw, 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,36 @@ export default defineComponent({
|
|
|
361
381
|
}
|
|
362
382
|
});
|
|
363
383
|
|
|
364
|
-
|
|
384
|
+
/** GL-surface init, safe to call repeatedly. Called from onMounted
|
|
385
|
+
* (expanded mounts) and from the expand transition (a
|
|
386
|
+
* defaultCollapsed map has no canvas at mount; a fold/unfold cycle
|
|
387
|
+
* REBUILDS the canvas element, so a surviving renderer would keep
|
|
388
|
+
* painting into the detached element and the reopened map would
|
|
389
|
+
* stay empty — detect the swap and rebuild against the live canvas,
|
|
390
|
+
* helpers and post-render hook included). */
|
|
391
|
+
function initRenderer(): void {
|
|
392
|
+
if (renderer && renderer.domElement === canvasRef.value) return;
|
|
393
|
+
removePostRender?.();
|
|
394
|
+
removePostRender = null;
|
|
395
|
+
if (renderer) {
|
|
396
|
+
renderer.dispose();
|
|
397
|
+
renderer.forceContextLoss?.();
|
|
398
|
+
renderer = null;
|
|
399
|
+
}
|
|
400
|
+
const s = scene.value;
|
|
401
|
+
if (s) {
|
|
402
|
+
if (glyph) {
|
|
403
|
+
s.remove(glyph);
|
|
404
|
+
disposeNode(glyph);
|
|
405
|
+
glyph = null;
|
|
406
|
+
}
|
|
407
|
+
if (planeFrame) {
|
|
408
|
+
s.remove(planeFrame);
|
|
409
|
+
planeFrame.geometry.dispose();
|
|
410
|
+
(planeFrame.material as THREE.Material).dispose();
|
|
411
|
+
planeFrame = null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
365
414
|
const canvas = canvasRef.value;
|
|
366
415
|
const el = rootRef.value;
|
|
367
416
|
if (!canvas || !el) return;
|
|
@@ -387,6 +436,12 @@ export default defineComponent({
|
|
|
387
436
|
el.addEventListener("pointerup", onUp);
|
|
388
437
|
el.addEventListener("pointercancel", onUp);
|
|
389
438
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
439
|
+
}
|
|
440
|
+
onMounted(() => {
|
|
441
|
+
if (!collapsed.value) initRenderer();
|
|
442
|
+
});
|
|
443
|
+
watch(collapsed, (folded) => {
|
|
444
|
+
if (!folded) void nextTick(() => initRenderer());
|
|
390
445
|
});
|
|
391
446
|
|
|
392
447
|
onBeforeUnmount(() => {
|
|
@@ -425,7 +480,24 @@ export default defineComponent({
|
|
|
425
480
|
renderer?.forceContextLoss?.();
|
|
426
481
|
});
|
|
427
482
|
|
|
428
|
-
return () =>
|
|
483
|
+
return () => {
|
|
484
|
+
if (collapsed.value) {
|
|
485
|
+
// The folded affordance, mirroring the 2D HkMinimap: a small
|
|
486
|
+
// semi-transparent map-icon button pinned where the card sat.
|
|
487
|
+
return (
|
|
488
|
+
<button
|
|
489
|
+
ref={rootRef}
|
|
490
|
+
type="button"
|
|
491
|
+
class="hk-minimap hk-minimap3d hk-minimap--collapsed"
|
|
492
|
+
aria-label={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
493
|
+
title={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
494
|
+
onClick={() => toggleCollapsed(false)}
|
|
495
|
+
>
|
|
496
|
+
<MapIcon size={16} />
|
|
497
|
+
</button>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return (
|
|
429
501
|
<div
|
|
430
502
|
ref={rootRef}
|
|
431
503
|
class="hk-minimap hk-minimap3d"
|
|
@@ -472,8 +544,19 @@ export default defineComponent({
|
|
|
472
544
|
aria-label={t("hikari::board3d.resetView", "Reset view")}
|
|
473
545
|
title={t("hikari::board3d.resetView", "Reset view")}
|
|
474
546
|
>
|
|
475
|
-
<
|
|
547
|
+
<RotateCcw size={11} />
|
|
476
548
|
</button>
|
|
549
|
+
{props.collapsible && (
|
|
550
|
+
<button
|
|
551
|
+
class="hk-mm-zoom-btn hk-mm-collapse-btn"
|
|
552
|
+
type="button"
|
|
553
|
+
onClick={() => toggleCollapsed(true)}
|
|
554
|
+
aria-label={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
555
|
+
title={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
556
|
+
>
|
|
557
|
+
<Minimize2 size={11} />
|
|
558
|
+
</button>
|
|
559
|
+
)}
|
|
477
560
|
</div>
|
|
478
561
|
{sliderOpen.value && (
|
|
479
562
|
<div class="hk-mm-zoom-pop">
|
|
@@ -497,5 +580,5 @@ export default defineComponent({
|
|
|
497
580
|
)}
|
|
498
581
|
</div>
|
|
499
582
|
);
|
|
500
|
-
},
|
|
583
|
+
}; },
|
|
501
584
|
});
|
|
@@ -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,25 @@ 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
|
+
});
|
|
144
|
+
describe("minimap reset icon contract (user direction 2026-10-31)", () => {
|
|
145
|
+
it("reset buttons use RotateCcw, never the expand-looking Maximize2", () => {
|
|
146
|
+
for (const name of ["HkMinimap.tsx", "HkMinimap3D.tsx", "HkZoomToolbar.tsx"]) {
|
|
147
|
+
const src = readFileSync(join(import.meta.dirname, name), "utf8");
|
|
148
|
+
expect(src, name + " imports RotateCcw").toContain("RotateCcw");
|
|
149
|
+
expect(src, name + " dropped Maximize2").not.toContain("Maximize2");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -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
|
|
|
@@ -18,7 +18,16 @@
|
|
|
18
18
|
--hk-tb-focus: rgb(var(--color-primary, 122 162 247) / 0.6);
|
|
19
19
|
--hk-tb-close-hover: #e81123;
|
|
20
20
|
--hk-tb-close-active: #f1707a;
|
|
21
|
+
/* The close fill is a FIXED danger red, so its ink is fixed too. Deriving
|
|
22
|
+
it from the theme's on-solid choice (the default dark preset picks
|
|
23
|
+
polar-night ink for its pale primary fills) painted a dark X on dark
|
|
24
|
+
red at roughly 1:1 - the host-visible bug both wowsp and momoi filed.
|
|
25
|
+
Hosts may override the token as a pair with --hk-tb-close-hover. */
|
|
26
|
+
--hk-tb-close-ink: #ffffff;
|
|
21
27
|
--hk-tb-radius-close: 0 8px 0 0;
|
|
28
|
+
/* Caption glyph size; hosts that want a larger brand mark set the token
|
|
29
|
+
instead of overriding the icon rule. */
|
|
30
|
+
--hk-tb-icon-size: 16px;
|
|
22
31
|
|
|
23
32
|
position: fixed;
|
|
24
33
|
top: 0;
|
|
@@ -47,8 +56,8 @@
|
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
.hk-titlebar-icon {
|
|
50
|
-
width:
|
|
51
|
-
height:
|
|
59
|
+
width: var(--hk-tb-icon-size);
|
|
60
|
+
height: var(--hk-tb-icon-size);
|
|
52
61
|
display: block;
|
|
53
62
|
pointer-events: none;
|
|
54
63
|
}
|
|
@@ -80,7 +89,13 @@
|
|
|
80
89
|
height: 100%;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
|
|
92
|
+
/* Caption buttons are scoped under the bar on purpose: a host's element
|
|
93
|
+
rules (`button:hover:not(:disabled)` scores (0,2,1)) would otherwise
|
|
94
|
+
out-rank the component's class-level hover and repaint the caption -
|
|
95
|
+
the momoi field report. Scoped selectors sit at (0,3,0), one level
|
|
96
|
+
above any plausible element rule, while the close-specific rules stay
|
|
97
|
+
later in the sheet so they keep winning among themselves. */
|
|
98
|
+
.hk-titlebar .hk-titlebar-btn {
|
|
84
99
|
width: 46px;
|
|
85
100
|
height: var(--hk-tb-height);
|
|
86
101
|
border: none;
|
|
@@ -95,24 +110,24 @@
|
|
|
95
110
|
padding: 0;
|
|
96
111
|
}
|
|
97
112
|
|
|
98
|
-
.hk-titlebar-btn:hover {
|
|
113
|
+
.hk-titlebar .hk-titlebar-btn:hover {
|
|
99
114
|
background: var(--hk-tb-hover);
|
|
100
115
|
color: var(--hk-tb-fg-strong);
|
|
101
116
|
}
|
|
102
117
|
|
|
103
|
-
.hk-titlebar-btn:active {
|
|
118
|
+
.hk-titlebar .hk-titlebar-btn:active {
|
|
104
119
|
background: var(--hk-tb-active);
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
/* Only the rightmost (close) button carries the window's top-right
|
|
108
123
|
corner radius; the other captions highlight as plain rectangles. */
|
|
109
|
-
.hk-titlebar-btn-close {
|
|
124
|
+
.hk-titlebar .hk-titlebar-btn-close {
|
|
110
125
|
border-radius: var(--hk-tb-radius-close);
|
|
111
126
|
}
|
|
112
127
|
|
|
113
128
|
/* RTL mirrors the caption row, so the close button lands top-left and
|
|
114
129
|
the window corner radius flips with it. */
|
|
115
|
-
[dir="rtl"] .hk-titlebar-btn-close {
|
|
130
|
+
[dir="rtl"] .hk-titlebar .hk-titlebar-btn-close {
|
|
116
131
|
border-radius: 8px 0 0 0;
|
|
117
132
|
}
|
|
118
133
|
|
|
@@ -125,17 +140,17 @@
|
|
|
125
140
|
border-radius: 0;
|
|
126
141
|
}
|
|
127
142
|
|
|
128
|
-
.hk-titlebar-btn-close:hover {
|
|
143
|
+
.hk-titlebar .hk-titlebar-btn-close:hover {
|
|
129
144
|
background: var(--hk-tb-close-hover);
|
|
130
|
-
color: var(--
|
|
145
|
+
color: var(--hk-tb-close-ink);
|
|
131
146
|
}
|
|
132
147
|
|
|
133
|
-
.hk-titlebar-btn-close:active {
|
|
148
|
+
.hk-titlebar .hk-titlebar-btn-close:active {
|
|
134
149
|
background: var(--hk-tb-close-active);
|
|
135
|
-
color: var(--
|
|
150
|
+
color: var(--hk-tb-close-ink);
|
|
136
151
|
}
|
|
137
152
|
|
|
138
|
-
.hk-titlebar-btn:focus-visible {
|
|
153
|
+
.hk-titlebar .hk-titlebar-btn:focus-visible {
|
|
139
154
|
outline: 2px solid var(--hk-tb-focus);
|
|
140
155
|
outline-offset: -2px;
|
|
141
156
|
}
|
|
@@ -213,7 +213,38 @@ describe("HkTitleBar stylesheet contract", () => {
|
|
|
213
213
|
it("keeps the windowed radius var wired to the close button", () => {
|
|
214
214
|
const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
|
|
215
215
|
expect(scss).toContain("--hk-tb-radius-close:");
|
|
216
|
-
const body = ruleBody(css, ".hk-titlebar-btn-close");
|
|
216
|
+
const body = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close");
|
|
217
217
|
expect(body).toContain("border-radius: var(--hk-tb-radius-close)");
|
|
218
218
|
});
|
|
219
|
+
|
|
220
|
+
it("inks the close fill from its own token, never the theme's on-solid", () => {
|
|
221
|
+
// The fill is a fixed danger red; the ink must be fixed with it. The
|
|
222
|
+
// theme-derived --hi-color-text-on-danger painted a dark X on dark red
|
|
223
|
+
// under the default dark preset (the wowsp/momoi field report).
|
|
224
|
+
const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
|
|
225
|
+
expect(scss).toContain("--hk-tb-close-ink:");
|
|
226
|
+
const hover = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:hover");
|
|
227
|
+
expect(hover).toContain("color: var(--hk-tb-close-ink)");
|
|
228
|
+
expect(hover).not.toContain("--hi-color-text-on-danger");
|
|
229
|
+
const active = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:active");
|
|
230
|
+
expect(active).toContain("color: var(--hk-tb-close-ink)");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("outranks host element rules with caption-scoped buttons", () => {
|
|
234
|
+
// (0,3,0) beats a host's `button:hover:not(:disabled)` at (0,2,1);
|
|
235
|
+
// the close-specific rules stay later in the sheet, so they still win.
|
|
236
|
+
const hover = ruleBody(css, ".hk-titlebar .hk-titlebar-btn:hover");
|
|
237
|
+
expect(hover).toContain("background: var(--hk-tb-hover)");
|
|
238
|
+
const closeHoverAt = css.indexOf(".hk-titlebar .hk-titlebar-btn-close:hover");
|
|
239
|
+
const hoverAt = css.indexOf(".hk-titlebar .hk-titlebar-btn:hover");
|
|
240
|
+
expect(closeHoverAt).toBeGreaterThan(hoverAt);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("sizes the caption icon through its token", () => {
|
|
244
|
+
const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
|
|
245
|
+
expect(scss).toContain("--hk-tb-icon-size:");
|
|
246
|
+
const body = ruleBody(css, ".hk-titlebar-icon");
|
|
247
|
+
expect(body).toContain("width: var(--hk-tb-icon-size)");
|
|
248
|
+
expect(body).toContain("height: var(--hk-tb-icon-size)");
|
|
249
|
+
});
|
|
219
250
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
2
2
|
import { defineComponent } from "vue";
|
|
3
3
|
|
|
4
4
|
|
|
@@ -62,7 +62,7 @@ export default defineComponent({
|
|
|
62
62
|
ariaLabel={t("hikari::zoomToolbar.reset", "Reset zoom")}
|
|
63
63
|
onClick={() => emit("reset")}
|
|
64
64
|
>
|
|
65
|
-
<
|
|
65
|
+
<RotateCcw size={14} />
|
|
66
66
|
</HButton>
|
|
67
67
|
)}
|
|
68
68
|
</div>
|
|
@@ -35,6 +35,9 @@ const menuScss = readFileSync(join(here, "HkMenu.scss"), "utf-8");
|
|
|
35
35
|
const affixScss = readFileSync(join(here, "HkAffixPicker.scss"), "utf-8");
|
|
36
36
|
const tagInputScss = readFileSync(join(here, "HkTagInput.scss"), "utf-8");
|
|
37
37
|
const tplScss = readFileSync(join(here, "HkTemplateField.scss"), "utf-8");
|
|
38
|
+
const userPickerScss = readFileSync(join(here, "HkUserPicker.scss"), "utf-8");
|
|
39
|
+
const tagInputTsx = readFileSync(join(here, "HkTagInput.tsx"), "utf-8");
|
|
40
|
+
const userPickerTsx = readFileSync(join(here, "HkUserPicker.tsx"), "utf-8");
|
|
38
41
|
const themeTsx = readFileSync(join(here, "HkThemePanelContent.tsx"), "utf8");
|
|
39
42
|
const adminTokens = readFileSync(join(here, "../styles/admin-tokens.scss"), "utf-8");
|
|
40
43
|
const panelScss = readFileSync(join(here, "HkMenuPanel.scss"), "utf-8");
|
|
@@ -242,6 +245,13 @@ describe("menu-item grammar contract", () => {
|
|
|
242
245
|
expect(adminTokens).toMatch(
|
|
243
246
|
/\.s-user-header \{[^}]*padding: var\(--space-10\) var\(--space-14\) var\(--space-12\)/,
|
|
244
247
|
);
|
|
248
|
+
// The drawer is the third hosting surface (.hk-drawer-panel is one of
|
|
249
|
+
// the grammar's sheet contexts) — its twin carries the SHEET pad-x.
|
|
250
|
+
// Without it a drawer identity block kept the bare 14px while the
|
|
251
|
+
// drawer rows around it rode 1px + 16px (2026-10-31 audit finding 9).
|
|
252
|
+
expect(adminTokens).toMatch(
|
|
253
|
+
/\.hk-drawer-panel \.s-user-header \{[^}]*padding-inline: calc\(1px \+ var\(--hk-menu-item-pad-x, 16px\)\)/,
|
|
254
|
+
);
|
|
245
255
|
});
|
|
246
256
|
|
|
247
257
|
it("spends the italic overhang cushion in the label mixin only", () => {
|
|
@@ -777,4 +787,74 @@ describe("menu-item grammar contract", () => {
|
|
|
777
787
|
).toEqual([]);
|
|
778
788
|
}
|
|
779
789
|
});
|
|
790
|
+
|
|
791
|
+
it("keeps the adopted row families on the mixin's geometry (no local padding/min-height)", () => {
|
|
792
|
+
// The 2026-10-31 adoption gave four hand-rolled row families
|
|
793
|
+
// @include mi.item — the mixin OWNS their padding and min-height
|
|
794
|
+
// (6×12 desktop / 10×16 sheet via the registration lists). A local
|
|
795
|
+
// padding or min-height declaration in the family's own rule
|
|
796
|
+
// overrides the mixin at the same specificity and silently forks
|
|
797
|
+
// that family off the grammar again (the pre-adoption stock was
|
|
798
|
+
// exactly such a fork). Layout-axis restatements (flex direction,
|
|
799
|
+
// justify, text-align, color) are content decisions and stay legal.
|
|
800
|
+
const sources: Array<[string, string]> = [
|
|
801
|
+
[".hk-affix-row", affixScss],
|
|
802
|
+
[".hk-tag-input-row", tagInputScss],
|
|
803
|
+
[".hk-tpl-row", tplScss],
|
|
804
|
+
[".hk-user-picker-row", userPickerScss],
|
|
805
|
+
];
|
|
806
|
+
for (const [selector, sheet] of sources) {
|
|
807
|
+
const block = flattenBlocks(parseScss(stripScssComments(sheet))).find(
|
|
808
|
+
(candidate) =>
|
|
809
|
+
candidate.prelude
|
|
810
|
+
.trim()
|
|
811
|
+
.split(",")
|
|
812
|
+
.map((sel) => sel.trim())
|
|
813
|
+
.includes(selector),
|
|
814
|
+
);
|
|
815
|
+
expect(block, `${selector} rule exists`).toBeTruthy();
|
|
816
|
+
const offenders = block!.statements
|
|
817
|
+
.map((stmt) => stmt.replace(/\s+/g, " ").trim())
|
|
818
|
+
.filter((stmt) => /^(padding|min-height)\s*[:]/.test(stmt));
|
|
819
|
+
expect(
|
|
820
|
+
offenders,
|
|
821
|
+
`${selector} declares NO padding/min-height of its own — @mixin item owns the row box (a local declaration forks the family off the grammar)`,
|
|
822
|
+
).toEqual([]);
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("keeps the grammar's data-checked emission beside the legacy state attrs", () => {
|
|
827
|
+
// The sharpest honest gap of the adoption R3: TagInput/UserPicker
|
|
828
|
+
// rows carry BOTH the legacy state (data-selected / .is-selected —
|
|
829
|
+
// tests and hosts key on those) and the grammar's data-checked (the
|
|
830
|
+
// face + the hover guard). A tsx-only revert of the emission keeps
|
|
831
|
+
// every test green while the checked face silently dies to the
|
|
832
|
+
// hover rule — so the emission itself is pinned at source level.
|
|
833
|
+
expect(
|
|
834
|
+
tagInputTsx,
|
|
835
|
+
"HkTagInput must emit data-checked beside its legacy data-selected (the grammar's checked face and hover guard key on it)",
|
|
836
|
+
).toMatch(/data-checked=\{selected \|\| undefined\}/);
|
|
837
|
+
expect(
|
|
838
|
+
userPickerTsx,
|
|
839
|
+
"HkUserPicker must emit data-checked for the multi-mode draft pick beside the legacy .is-selected class",
|
|
840
|
+
).toMatch(/data-checked=\{props\.mode === "multi" && inDraft\(user\.uid\) \|\| undefined\}/);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("sizes the tag-input grip to the adopted row's content box", () => {
|
|
844
|
+
// The grip is fixed-size inside a flex-auto row: taller than the
|
|
845
|
+
// content box and every selected+reorderable row grows past the
|
|
846
|
+
// grammar min-height while siblings stay on it (the 2026-10-31 R2
|
|
847
|
+
// S1 — 28px grip in a 22px box). Pinned at source level: the grip
|
|
848
|
+
// height must equal 34px − 2×6px.
|
|
849
|
+
const grip = flattenBlocks(parseScss(stripScssComments(tagInputScss))).find(
|
|
850
|
+
(block) => block.prelude.trim() === ".hk-tag-input-grip",
|
|
851
|
+
);
|
|
852
|
+
expect(grip, ".hk-tag-input-grip rule exists").toBeTruthy();
|
|
853
|
+
const height = grip!.statements.find((stmt) => stmt.includes("height:"));
|
|
854
|
+
expect(height, "the grip declares a height").toBeTruthy();
|
|
855
|
+
expect(
|
|
856
|
+
height!.replace(/\s+/g, " ").trim(),
|
|
857
|
+
"grip height = 22px (the 34px row's content box: 34 − 2×6px)",
|
|
858
|
+
).toBe("height: 1.375rem");
|
|
859
|
+
});
|
|
780
860
|
});
|