@celestia-island/hikari 0.54.4 → 0.54.8
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/HkBlankCanvas.scss +29 -0
- package/src/components/HkBlankCanvas.test.tsx +147 -0
- package/src/components/HkBlankCanvas.tsx +125 -0
- package/src/components/HkWaterfall.animate.test.tsx +63 -0
- package/src/components/HkWaterfall.scss +43 -0
- package/src/components/HkWaterfall.test.tsx +407 -0
- package/src/components/HkWaterfall.tsx +489 -0
- package/src/index.ts +10 -0
- package/src/styles/admin-tokens.scss +16 -3
- package/src/styles/authCardWidth.test.ts +343 -0
- package/src/theme/authCard.ts +38 -0
- package/src/theme/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.hk-blank-canvas {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100%;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
min-height: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* The mount is absolute so the host's renderer can size itself from the
|
|
10
|
+
element's box without this component ever contributing layout of its own
|
|
11
|
+
(a padded or scrolling parent would make the renderer's own measurements
|
|
12
|
+
disagree with the surface it draws on). */
|
|
13
|
+
.hk-blank-canvas-mount {
|
|
14
|
+
position: absolute;
|
|
15
|
+
inset: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* Overlay chrome (device cards, leader lines, HUD) sits above the surface.
|
|
19
|
+
Pointer events pass through the layer and are re-enabled per child, so the
|
|
20
|
+
canvas keeps receiving gestures between the cards. */
|
|
21
|
+
.hk-blank-canvas-overlay {
|
|
22
|
+
position: absolute;
|
|
23
|
+
inset: 0;
|
|
24
|
+
pointer-events: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.hk-blank-canvas-overlay > * {
|
|
28
|
+
pointer-events: auto;
|
|
29
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkBlankCanvas from "./HkBlankCanvas";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkBlankCanvas tests — the size-ready gate, the resize/teardown signals and
|
|
8
|
+
* the overlay layer. The gate is the part worth pinning: initialising a
|
|
9
|
+
* renderer against a 0 × 0 element is what makes a 3D view flash a wrong
|
|
10
|
+
* first frame.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const apps: App[] = [];
|
|
14
|
+
let originalRect: typeof HTMLElement.prototype.getBoundingClientRect;
|
|
15
|
+
let nextSize = { width: 0, height: 0 };
|
|
16
|
+
/** Captured by the ResizeObserver stub so a test can drive a resize. */
|
|
17
|
+
let observed: (() => void) | null = null;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
observed = null;
|
|
21
|
+
originalRect = HTMLElement.prototype.getBoundingClientRect;
|
|
22
|
+
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
|
|
23
|
+
if (this.classList?.contains("hk-blank-canvas-mount")) {
|
|
24
|
+
return {
|
|
25
|
+
width: nextSize.width,
|
|
26
|
+
height: nextSize.height,
|
|
27
|
+
top: 0,
|
|
28
|
+
left: 0,
|
|
29
|
+
right: nextSize.width,
|
|
30
|
+
bottom: nextSize.height,
|
|
31
|
+
x: 0,
|
|
32
|
+
y: 0,
|
|
33
|
+
toJSON: () => ({}),
|
|
34
|
+
} as DOMRect;
|
|
35
|
+
}
|
|
36
|
+
return originalRect.call(this);
|
|
37
|
+
};
|
|
38
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
|
|
39
|
+
constructor(callback: () => void) {
|
|
40
|
+
observed = callback;
|
|
41
|
+
}
|
|
42
|
+
observe(): void {}
|
|
43
|
+
disconnect(): void {}
|
|
44
|
+
unobserve(): void {}
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const app of apps.splice(0)) app.unmount();
|
|
50
|
+
HTMLElement.prototype.getBoundingClientRect = originalRect;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** The component measures inside a rAF, so tests wait one. */
|
|
54
|
+
const oneFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
55
|
+
|
|
56
|
+
function mount(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
|
|
57
|
+
const container = document.createElement("div");
|
|
58
|
+
document.body.appendChild(container);
|
|
59
|
+
const Root = defineComponent({
|
|
60
|
+
setup() {
|
|
61
|
+
return () => h(HkBlankCanvas, props as never, slots);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const app = createApp(Root);
|
|
65
|
+
app.mount(container);
|
|
66
|
+
apps.push(app);
|
|
67
|
+
return container;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("HkBlankCanvas", () => {
|
|
71
|
+
it("does not mount while the element measures 0x0", async () => {
|
|
72
|
+
const onMount = vi.fn();
|
|
73
|
+
nextSize = { width: 0, height: 0 };
|
|
74
|
+
const root = mount({ onMount });
|
|
75
|
+
await nextTick();
|
|
76
|
+
await oneFrame();
|
|
77
|
+
expect(onMount).not.toHaveBeenCalled();
|
|
78
|
+
expect(root.querySelector(".hk-blank-canvas")?.hasAttribute("data-ready")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("mounts once the element has a size, and reports it", async () => {
|
|
82
|
+
const onMount = vi.fn();
|
|
83
|
+
nextSize = { width: 800, height: 600 };
|
|
84
|
+
const root = mount({ onMount });
|
|
85
|
+
await nextTick();
|
|
86
|
+
await oneFrame();
|
|
87
|
+
expect(onMount).toHaveBeenCalledTimes(1);
|
|
88
|
+
const [el, size] = onMount.mock.calls[0];
|
|
89
|
+
expect((el as HTMLElement).classList.contains("hk-blank-canvas-mount")).toBe(true);
|
|
90
|
+
expect(size).toEqual({ width: 800, height: 600 });
|
|
91
|
+
await nextTick();
|
|
92
|
+
expect(root.querySelector(".hk-blank-canvas")?.hasAttribute("data-ready")).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reports a later size change as a resize, never as a second mount", async () => {
|
|
96
|
+
const onMount = vi.fn();
|
|
97
|
+
const onResize = vi.fn();
|
|
98
|
+
nextSize = { width: 800, height: 600 };
|
|
99
|
+
mount({ onMount, onResize });
|
|
100
|
+
await nextTick();
|
|
101
|
+
await oneFrame();
|
|
102
|
+
expect(onMount).toHaveBeenCalledTimes(1);
|
|
103
|
+
|
|
104
|
+
nextSize = { width: 1024, height: 768 };
|
|
105
|
+
observed?.();
|
|
106
|
+
await nextTick();
|
|
107
|
+
expect(onMount).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(onResize).toHaveBeenCalledWith({ width: 1024, height: 768 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not report a resize when the box did not change", async () => {
|
|
112
|
+
const onResize = vi.fn();
|
|
113
|
+
nextSize = { width: 800, height: 600 };
|
|
114
|
+
mount({ onResize });
|
|
115
|
+
await nextTick();
|
|
116
|
+
await oneFrame();
|
|
117
|
+
observed?.();
|
|
118
|
+
await nextTick();
|
|
119
|
+
expect(onResize).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("signals teardown only after a successful mount", async () => {
|
|
123
|
+
const onUnmount = vi.fn();
|
|
124
|
+
nextSize = { width: 0, height: 0 };
|
|
125
|
+
mount({ onUnmount });
|
|
126
|
+
await nextTick();
|
|
127
|
+
await oneFrame();
|
|
128
|
+
for (const app of apps.splice(0)) app.unmount();
|
|
129
|
+
expect(onUnmount).not.toHaveBeenCalled();
|
|
130
|
+
|
|
131
|
+
// The same teardown after a real mount does fire.
|
|
132
|
+
nextSize = { width: 640, height: 480 };
|
|
133
|
+
mount({ onUnmount });
|
|
134
|
+
await nextTick();
|
|
135
|
+
await oneFrame();
|
|
136
|
+
for (const app of apps.splice(0)) app.unmount();
|
|
137
|
+
expect(onUnmount).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("renders the overlay above the mount", async () => {
|
|
141
|
+
nextSize = { width: 10, height: 10 };
|
|
142
|
+
const root = mount({}, { overlay: () => h("div", { class: "hud" }, "hud") });
|
|
143
|
+
await nextTick();
|
|
144
|
+
const overlay = root.querySelector(".hk-blank-canvas-overlay");
|
|
145
|
+
expect(overlay?.querySelector(".hud")?.textContent).toBe("hud");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
onBeforeUnmount,
|
|
4
|
+
onMounted,
|
|
5
|
+
ref,
|
|
6
|
+
shallowRef,
|
|
7
|
+
type PropType,
|
|
8
|
+
} from "vue";
|
|
9
|
+
|
|
10
|
+
import "./HkBlankCanvas.scss";
|
|
11
|
+
|
|
12
|
+
/** The size the host's renderer gets told about, in CSS pixels. */
|
|
13
|
+
export interface BlankCanvasSize {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* HkBlankCanvas — the base view for a full-bleed mount.
|
|
20
|
+
*
|
|
21
|
+
* Some views are a canvas and nothing else: the 3D twin hands the whole
|
|
22
|
+
* surface to a three.js renderer, an embedded editor hands it to whatever
|
|
23
|
+
* the host embeds. Their shape is not a layout this library should model, so
|
|
24
|
+
* this component owns only the parts every one of them needs:
|
|
25
|
+
*
|
|
26
|
+
* - **a 100% × 100% host** — the mount element fills its parent, and the
|
|
27
|
+
* component imposes no padding, scroll container or chrome of its own;
|
|
28
|
+
* - **a size-ready gate** — `onMount` is not called until the host has a
|
|
29
|
+
* non-zero size. Initialising a WebGL context (or an editor) against a
|
|
30
|
+
* 0 × 0 element is the cause of the "first frame is wrong, then it
|
|
31
|
+
* settles" flicker; waiting one frame for layout is cheaper than
|
|
32
|
+
* rebuilding the surface;
|
|
33
|
+
* - **resize and teardown signals** — `onResize` on every observed size
|
|
34
|
+
* change, `onUnmount` before the element goes away, so a renderer can
|
|
35
|
+
* dispose its GPU resources;
|
|
36
|
+
* - **an overlay slot** — DOM drawn ABOVE the mount (device cards, leader
|
|
37
|
+
* lines, HUD), which is what the 3D views put over their canvas.
|
|
38
|
+
*
|
|
39
|
+
* The component draws nothing itself: no background, no placeholder, no
|
|
40
|
+
* error state. A host that needs those renders them in the default slot or
|
|
41
|
+
* the overlay.
|
|
42
|
+
*/
|
|
43
|
+
export default defineComponent({
|
|
44
|
+
name: "HkBlankCanvas",
|
|
45
|
+
props: {
|
|
46
|
+
/** Called once, when the mount element first has a non-zero size. */
|
|
47
|
+
onMount: {
|
|
48
|
+
type: Function as PropType<((el: HTMLElement, size: BlankCanvasSize) => void) | undefined>,
|
|
49
|
+
default: undefined,
|
|
50
|
+
},
|
|
51
|
+
/** Called on every later size change (rounded to whole pixels). */
|
|
52
|
+
onResize: {
|
|
53
|
+
type: Function as PropType<((size: BlankCanvasSize) => void) | undefined>,
|
|
54
|
+
default: undefined,
|
|
55
|
+
},
|
|
56
|
+
/** Called before the mount element is removed. */
|
|
57
|
+
onUnmount: {
|
|
58
|
+
type: Function as PropType<(() => void) | undefined>,
|
|
59
|
+
default: undefined,
|
|
60
|
+
},
|
|
61
|
+
/** Accessible label for the mount region. */
|
|
62
|
+
ariaLabel: { type: String, default: undefined },
|
|
63
|
+
},
|
|
64
|
+
setup(props, { slots, expose }) {
|
|
65
|
+
const mountEl = shallowRef<HTMLElement | null>(null);
|
|
66
|
+
const size = ref<BlankCanvasSize>({ width: 0, height: 0 });
|
|
67
|
+
/** Whether `onMount` has fired — the gate that keeps a renderer from
|
|
68
|
+
* initialising against a 0 × 0 element. */
|
|
69
|
+
const ready = ref(false);
|
|
70
|
+
let ro: ResizeObserver | null = null;
|
|
71
|
+
|
|
72
|
+
function measure(): BlankCanvasSize {
|
|
73
|
+
const el = mountEl.value;
|
|
74
|
+
if (!el) return { width: 0, height: 0 };
|
|
75
|
+
const rect = el.getBoundingClientRect();
|
|
76
|
+
return { width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function publish() {
|
|
80
|
+
const next = measure();
|
|
81
|
+
const changed = next.width !== size.value.width || next.height !== size.value.height;
|
|
82
|
+
if (changed) size.value = next;
|
|
83
|
+
if (!ready.value) {
|
|
84
|
+
if (next.width === 0 || next.height === 0) return;
|
|
85
|
+
ready.value = true;
|
|
86
|
+
props.onMount?.(mountEl.value as HTMLElement, next);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (changed) props.onResize?.(next);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
onMounted(() => {
|
|
93
|
+
// One frame first: the element exists but its parent may not have been
|
|
94
|
+
// laid out yet, and the whole point of the gate is not to initialise
|
|
95
|
+
// against the pre-layout box.
|
|
96
|
+
requestAnimationFrame(publish);
|
|
97
|
+
if (typeof ResizeObserver !== "undefined" && mountEl.value) {
|
|
98
|
+
ro = new ResizeObserver(publish);
|
|
99
|
+
ro.observe(mountEl.value);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
onBeforeUnmount(() => {
|
|
104
|
+
ro?.disconnect();
|
|
105
|
+
ro = null;
|
|
106
|
+
if (ready.value) props.onUnmount?.();
|
|
107
|
+
ready.value = false;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expose({ size, ready, measure });
|
|
111
|
+
|
|
112
|
+
return () => (
|
|
113
|
+
<div
|
|
114
|
+
class="hk-blank-canvas"
|
|
115
|
+
aria-label={props.ariaLabel}
|
|
116
|
+
data-ready={ready.value ? "" : undefined}
|
|
117
|
+
>
|
|
118
|
+
<div ref={mountEl} class="hk-blank-canvas-mount">
|
|
119
|
+
{slots.default?.()}
|
|
120
|
+
</div>
|
|
121
|
+
<div class="hk-blank-canvas-overlay">{slots.overlay?.()}</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `animate` is a pass-through: the waterfall hands it to the list
|
|
6
|
+
* transition as `disabled`. The main suite cannot see that — the transition
|
|
7
|
+
* only names its classes during an enter frame — so this file stubs the
|
|
8
|
+
* transition and asserts the flag it receives, which is what a host that
|
|
9
|
+
* suppresses animation during a bulk hydration actually depends on.
|
|
10
|
+
*/
|
|
11
|
+
const received: boolean[] = [];
|
|
12
|
+
|
|
13
|
+
vi.mock("./HkListTransition", () => ({
|
|
14
|
+
default: defineComponent({
|
|
15
|
+
name: "HkListTransition",
|
|
16
|
+
props: { disabled: { type: Boolean, default: false } },
|
|
17
|
+
setup(props, { slots }) {
|
|
18
|
+
return () => {
|
|
19
|
+
received.push(props.disabled);
|
|
20
|
+
return h("div", { class: "stub-list" }, slots.default?.());
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import HkWaterfall from "./HkWaterfall";
|
|
27
|
+
|
|
28
|
+
async function mountWith(animate: boolean) {
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
const Root = defineComponent({
|
|
32
|
+
setup() {
|
|
33
|
+
return () =>
|
|
34
|
+
h(
|
|
35
|
+
HkWaterfall,
|
|
36
|
+
{ items: ["a1"], animate },
|
|
37
|
+
{ card: ({ item }: { item: unknown }) => h("div", { class: "card" }, String(item)) },
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const app = createApp(Root);
|
|
42
|
+
app.mount(container);
|
|
43
|
+
await nextTick();
|
|
44
|
+
return { app, container };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("HkWaterfall animate", () => {
|
|
48
|
+
it("leaves the transition enabled by default", async () => {
|
|
49
|
+
received.length = 0;
|
|
50
|
+
const mounted = await mountWith(true);
|
|
51
|
+
expect(received.length).toBeGreaterThan(0);
|
|
52
|
+
expect(received.every((disabled) => disabled === false)).toBe(true);
|
|
53
|
+
mounted.app.unmount();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("disables the transition when animate is false", async () => {
|
|
57
|
+
received.length = 0;
|
|
58
|
+
const mounted = await mountWith(false);
|
|
59
|
+
expect(received.length).toBeGreaterThan(0);
|
|
60
|
+
expect(received.every((disabled) => disabled === true)).toBe(true);
|
|
61
|
+
mounted.app.unmount();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.hk-waterfall {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
min-height: 0;
|
|
6
|
+
height: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.hk-waterfall-scroll {
|
|
10
|
+
flex: 1 1 auto;
|
|
11
|
+
min-height: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Section rhythm: the day buckets of the chat waterfalls read as separate
|
|
15
|
+
groups, so adjacent buckets get the same gutter as the cards inside them
|
|
16
|
+
(twice the card gap would over-separate the newest sections). */
|
|
17
|
+
.hk-waterfall-bucket + .hk-waterfall-bucket {
|
|
18
|
+
margin-top: var(--space-12);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.hk-waterfall-columns {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: flex-start;
|
|
24
|
+
gap: var(--space-12);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Independent columns (not CSS columns): each keeps its own transition
|
|
28
|
+
group, so a card arriving in one column never reflows the other. */
|
|
29
|
+
.hk-waterfall-column {
|
|
30
|
+
flex: 1 1 0;
|
|
31
|
+
min-width: 0;
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
gap: var(--space-12);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* A bucket with fewer cards than columns leaves an empty column behind.
|
|
38
|
+
Without this it keeps its `flex: 1 1 0` share of the row and the
|
|
39
|
+
surviving card renders at half width beside a ghost; collapsed, the card
|
|
40
|
+
takes the full width until the bucket grows a card for that column. */
|
|
41
|
+
.hk-waterfall-column[data-empty] {
|
|
42
|
+
flex: 0 0 0;
|
|
43
|
+
}
|