@celestia-island/hikari 0.55.253 → 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/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 +43 -1
- package/src/components/HkMinimap3D.tsx +31 -6
- package/src/components/HkTimeZoneGlobe.test.tsx +9 -0
- 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. */
|
|
@@ -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);
|
|
@@ -424,4 +427,43 @@ describe("HkMinimap3D collapse affordance (ported from the 2D minimap)", () => {
|
|
|
424
427
|
expect(container.querySelector(".hk-mm-collapse-btn")).toBeNull();
|
|
425
428
|
expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
426
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
|
+
});
|
|
427
469
|
});
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* mount.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { Map as MapIcon,
|
|
27
|
+
import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
28
28
|
import {
|
|
29
29
|
computed,
|
|
30
30
|
defineComponent,
|
|
@@ -381,11 +381,36 @@ export default defineComponent({
|
|
|
381
381
|
}
|
|
382
382
|
});
|
|
383
383
|
|
|
384
|
-
/**
|
|
385
|
-
* mounts) and from the expand transition (a
|
|
386
|
-
* has no canvas at mount
|
|
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). */
|
|
387
391
|
function initRenderer(): void {
|
|
388
|
-
if (renderer) return;
|
|
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
|
+
}
|
|
389
414
|
const canvas = canvasRef.value;
|
|
390
415
|
const el = rootRef.value;
|
|
391
416
|
if (!canvas || !el) return;
|
|
@@ -519,7 +544,7 @@ export default defineComponent({
|
|
|
519
544
|
aria-label={t("hikari::board3d.resetView", "Reset view")}
|
|
520
545
|
title={t("hikari::board3d.resetView", "Reset view")}
|
|
521
546
|
>
|
|
522
|
-
<
|
|
547
|
+
<RotateCcw size={11} />
|
|
523
548
|
</button>
|
|
524
549
|
{props.collapsible && (
|
|
525
550
|
<button
|
|
@@ -140,4 +140,13 @@ describe("HkTimeZoneGlobe look contract (source-level)", () => {
|
|
|
140
140
|
expect(src).toContain("minimapCollapsible");
|
|
141
141
|
expect(src).toContain("minimapDefaultCollapsed");
|
|
142
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
|
+
});
|
|
143
152
|
});
|
|
@@ -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
|
});
|