@celestia-island/hikari 0.55.250 → 0.55.251
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/HkAdminHeader.test.tsx +17 -4
- package/src/components/HkAltSignIn.tsx +14 -10
- package/src/components/HkAuthCard.test.tsx +6 -4
- package/src/components/HkAuthMethodList.tsx +17 -13
- package/src/components/HkBarChart.scss +54 -0
- package/src/components/HkBarChart.test.tsx +62 -0
- package/src/components/HkBarChart.tsx +114 -0
- package/src/components/HkBoard.scale.test.ts +1 -4
- package/src/components/HkBoard3D.test.tsx +12 -15
- package/src/components/HkBoard3D.tsx +3 -2
- package/src/components/HkCalendarHeatmap.barrel.test.ts +2 -1
- package/src/components/HkCaptchaWidget.tsx +2 -1
- package/src/components/HkCheckbox.test.tsx +6 -2
- package/src/components/HkColorPicker.tsx +3 -2
- package/src/components/HkConnectionStatus.shell.test.tsx +1 -1
- package/src/components/HkContextMenuProvider.tsx +19 -14
- package/src/components/HkCookieConsent.test.tsx +5 -5
- package/src/components/HkCrossfade.test.tsx +1 -1
- package/src/components/HkDatePicker.test.ts +3 -2
- package/src/components/HkDateTag.test.tsx +1 -1
- package/src/components/HkDateTimePicker.test.ts +3 -2
- package/src/components/HkDateTimePicker.tsx +4 -3
- package/src/components/HkDonutChart.scss +95 -0
- package/src/components/HkDonutChart.test.tsx +87 -0
- package/src/components/HkDonutChart.tsx +144 -0
- package/src/components/HkFab.test.tsx +4 -2
- package/src/components/HkFab.tsx +8 -5
- package/src/components/HkFaceShell.barrel.test.ts +3 -2
- package/src/components/HkFaceShell.rail.test.tsx +1 -1
- package/src/components/HkFileBrowserDialog.test.tsx +1 -4
- package/src/components/HkFilePickerField.tsx +3 -2
- package/src/components/HkIcon.tsx +8 -5
- package/src/components/HkIconButtonGroup.tsx +2 -1
- package/src/components/HkInfiniteTail.test.tsx +28 -23
- package/src/components/HkInput.affix.test.ts +3 -3
- package/src/components/HkInput.password.test.tsx +116 -119
- package/src/components/HkInput.tsx +5 -2
- package/src/components/HkKeywordSearchModal.tsx +4 -3
- package/src/components/HkLocalizedInput.test.tsx +2 -5
- package/src/components/HkMarkdownRenderer.tsx +36 -19
- package/src/components/HkMediaVisualizer.tsx +9 -2
- package/src/components/HkMenu.tsx +4 -16
- package/src/components/HkMfaVerifyCard.test.ts +3 -4
- package/src/components/HkMinimap3D.test.tsx +6 -5
- package/src/components/HkMinimap3D.tsx +1 -1
- package/src/components/HkModal.contenthold.test.tsx +1 -1
- package/src/components/HkModal.enter-watchdog.test.tsx +1 -1
- package/src/components/HkModal.leavepin.test.tsx +1 -1
- package/src/components/HkModal.stepflowswap.test.tsx +1 -1
- package/src/components/HkModal.transition-completion.test.tsx +1 -1
- package/src/components/HkModal.width.test.tsx +2 -1
- package/src/components/HkPasswordSurface.tsx +1 -1
- package/src/components/HkPhoneInput.tsx +1 -1
- package/src/components/HkPlaceholderMarquee.test.ts +3 -11
- package/src/components/HkScrollContainer.dock.test.tsx +5 -2
- package/src/components/HkScrollContainer.selectionHint.test.ts +5 -1
- package/src/components/HkScrollContainer.test.ts +2 -1
- package/src/components/HkScrollContainer.tsx +11 -4
- package/src/components/HkSelectPanel.test.tsx +2 -2
- package/src/components/HkSelectPanel.tsx +19 -2
- package/src/components/HkSidebar.rail.test.tsx +1 -1
- package/src/components/HkSidebarRailSwap.test.tsx +1 -1
- package/src/components/HkTabs.test.ts +2 -2
- package/src/components/HkTabs.tsx +9 -4
- package/src/components/HkTemplateField.test.tsx +2 -2
- package/src/components/HkThirdPartySignInCard.tsx +5 -10
- package/src/components/HkTimeline.test.tsx +1 -1
- package/src/components/HkTreemap.test.tsx +4 -3
- package/src/components/HkTreemapStage.test.tsx +4 -3
- package/src/components/HkWallpaperBackdrop.test.tsx +10 -9
- package/src/components/HkWaterfall.test.tsx +2 -2
- package/src/components/HkWindowedItem.scale.test.ts +8 -4
- package/src/components/StatusTrayDecor.test.tsx +2 -1
- package/src/components/__tests__/hkMarkdownTableWrap.test.ts +6 -1
- package/src/components/barChart.test.ts +57 -0
- package/src/components/barChart.ts +104 -0
- package/src/components/board3dHoverBox.test.ts +3 -1
- package/src/components/board3dHoverBox.ts +11 -5
- package/src/components/donutGeometry.test.ts +74 -0
- package/src/components/donutGeometry.ts +148 -0
- package/src/composables/messaging/index.ts +4 -2
- package/src/composables/messaging/messaging.test.ts +7 -7
- package/src/composables/useApproachEnd.test.ts +3 -3
- package/src/composables/useOptionStrip.test.ts +13 -1
- package/src/composables/useOverlayScrollbar.ts +4 -2
- package/src/composables/usePointerReorder.test.ts +3 -1
- package/src/composables/useResourceListModal.tsx +4 -4
- package/src/composables/useSidebarRail.test.ts +5 -5
- package/src/composables/useSizeMorph.test.ts +3 -3
- package/src/composables/useSurfaceMachine.test.ts +4 -4
- package/src/demo.ts +15 -5
- package/src/env.d.ts +8 -1
- package/src/index.ts +25 -3
- package/src/overlay/overlayRoot.styles.test.ts +3 -0
- package/src/overlay/overlayRoot.ts +6 -2
- package/src/plugins/boot-chrome.test.ts +78 -32
- package/src/plugins/boot-recovery.test.ts +74 -33
- package/src/plugins/bootScriptsPlugin.ts +8 -3
- package/src/plugins/buildHashPlugin.ts +14 -5
- package/src/render.ts +14 -5
- package/src/res/bootChrome.test.ts +2 -1
- package/src/runtime/closeGuard.ts +5 -1
- package/src/runtime/credentialAutofill.test.ts +4 -2
- package/src/runtime/historySafetyNet.ts +37 -13
- package/src/runtime/mobileViewport.test.ts +3 -0
- package/src/runtime/mobileViewport.ts +5 -3
- package/src/runtime/navigationGuard.test.ts +11 -1
- package/src/runtime/tooltipBridge.ts +6 -1
- package/src/runtime/useClipboard.test.ts +1 -1
- package/src/runtime/viewportGutter.test.ts +11 -8
- package/src/runtime/windowChrome.ts +15 -12
- package/src/scene3d/lighting.test.ts +25 -12
- package/src/scene3d/modelLayer.test.ts +16 -8
- package/src/test-utils/fakes.ts +49 -0
- package/src/test-utils/invalid.ts +20 -0
- package/src/theme/solarTimeZones.test.ts +5 -2
- package/src/theme/themeDecor.test.ts +2 -1
- package/src/theme/useBackgroundLuminance.test.ts +42 -20
- package/src/theme/useSolarTime.test.ts +8 -9
- package/src/theme/useSolarTime.ts +32 -18
- package/src/theme/wallpaper.ts +31 -8
- package/src/theme/wallpaperCustom.test.ts +42 -0
- package/src/theme/wallpaperShaderRenderer.test.ts +66 -34
- package/src/theme/wallpaperSsr.test.ts +4 -2
- package/src/utils/dom.test.ts +31 -10
- package/src/utils/typing.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
3
|
|
|
4
|
+
/** The slice of HkMenu's item shape this stub renders. */
|
|
5
|
+
interface MenuItemStub {
|
|
6
|
+
key: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
checked?: boolean;
|
|
9
|
+
danger?: boolean;
|
|
10
|
+
children?: MenuItemStub[];
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
// HkMenu renders its levels through HkSelectPanel, which teleports to
|
|
5
14
|
// body — replace just HkMenu (imported by the component from
|
|
6
15
|
// ./HkMenu) with an inline passthrough that renders the header slot
|
|
@@ -10,18 +19,19 @@ import { createApp, h } from "vue";
|
|
|
10
19
|
// re-declared as an emit, which would strip it from props).
|
|
11
20
|
vi.mock("./HkMenu", async () => {
|
|
12
21
|
const { defineComponent, h } = await import("vue");
|
|
22
|
+
const { asPropType } = await import("../utils/typing");
|
|
13
23
|
const HMenuStub = defineComponent({
|
|
14
24
|
name: "HMenu",
|
|
15
25
|
props: {
|
|
16
26
|
open: { type: Boolean, default: false },
|
|
17
|
-
items: { type: Array, default: () => [] },
|
|
27
|
+
items: { type: asPropType<MenuItemStub[]>(Array), default: () => [] },
|
|
18
28
|
title: { type: String, default: "" },
|
|
19
29
|
},
|
|
20
30
|
setup(props, { slots, attrs }) {
|
|
21
31
|
return () => {
|
|
22
32
|
if (!props.open) return null;
|
|
23
33
|
const rows: ReturnType<typeof h>[] = [];
|
|
24
|
-
const flatten = (items:
|
|
34
|
+
const flatten = (items: MenuItemStub[]) => {
|
|
25
35
|
for (const item of items ?? []) {
|
|
26
36
|
rows.push(
|
|
27
37
|
h(
|
|
@@ -31,7 +41,10 @@ vi.mock("./HkMenu", async () => {
|
|
|
31
41
|
"data-key": item.key,
|
|
32
42
|
"data-checked": item.checked || undefined,
|
|
33
43
|
"data-danger": item.danger || undefined,
|
|
34
|
-
onClick: () =>
|
|
44
|
+
onClick: () => {
|
|
45
|
+
const onSelect = attrs.onSelect;
|
|
46
|
+
if (typeof onSelect === "function") onSelect(item.key);
|
|
47
|
+
},
|
|
35
48
|
},
|
|
36
49
|
item.label,
|
|
37
50
|
),
|
|
@@ -39,7 +52,7 @@ vi.mock("./HkMenu", async () => {
|
|
|
39
52
|
if (item.children?.length) flatten(item.children);
|
|
40
53
|
}
|
|
41
54
|
};
|
|
42
|
-
flatten(props.items
|
|
55
|
+
flatten(props.items);
|
|
43
56
|
return h("div", { class: "menu-stub" }, [...(slots.header?.() ?? []), ...rows]);
|
|
44
57
|
};
|
|
45
58
|
},
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { defineComponent, ref } from "vue";
|
|
2
|
-
import HkPopover from "./HkPopover";
|
|
1
|
+
import { defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
import HkPopover, { type PopupPlacement } from "./HkPopover";
|
|
3
3
|
import HkMenuActionItem from "./HkMenuActionItem";
|
|
4
|
+
import { asPropType } from "../utils/typing";
|
|
4
5
|
import "./HkAltSignIn.scss";
|
|
5
6
|
|
|
7
|
+
/** One alternative sign-in entry offered in the popover. */
|
|
8
|
+
export interface HkAltSignInEntry {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
icon?: Record<string, unknown>;
|
|
12
|
+
danger?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
16
|
* Shared "alternative sign-in" entry for auth cards (P77).
|
|
8
17
|
*
|
|
@@ -23,18 +32,13 @@ export default defineComponent({
|
|
|
23
32
|
label: { type: String, required: true },
|
|
24
33
|
/** Alternative sign-in entries offered in the dropdown. */
|
|
25
34
|
entries: {
|
|
26
|
-
type: Array
|
|
27
|
-
key: string;
|
|
28
|
-
label: string;
|
|
29
|
-
icon?: Record<string, unknown>;
|
|
30
|
-
danger?: boolean;
|
|
31
|
-
}>,
|
|
35
|
+
type: asPropType<HkAltSignInEntry[]>(Array),
|
|
32
36
|
required: true,
|
|
33
37
|
},
|
|
34
38
|
disabled: { type: Boolean, default: false },
|
|
35
39
|
/** Popover placement around the trigger row. */
|
|
36
40
|
placement: {
|
|
37
|
-
type: String
|
|
41
|
+
type: String as PropType<PopupPlacement>,
|
|
38
42
|
default: "top",
|
|
39
43
|
} as const,
|
|
40
44
|
},
|
|
@@ -71,7 +75,7 @@ export default defineComponent({
|
|
|
71
75
|
<HkPopover
|
|
72
76
|
modelValue={open.value}
|
|
73
77
|
onUpdate:modelValue={(v: boolean) => (open.value = v)}
|
|
74
|
-
placement={props.placement
|
|
78
|
+
placement={props.placement}
|
|
75
79
|
anchorRef={anchor.value}
|
|
76
80
|
>
|
|
77
81
|
<div class="hk-alt-signin__panel" role="menu">
|
|
@@ -6,23 +6,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import { createApp, h } from "vue";
|
|
7
7
|
|
|
8
8
|
import { HkAuthCard } from "./HkAuthCard";
|
|
9
|
+
import { fakeAs } from "../test-utils/fakes";
|
|
9
10
|
|
|
10
11
|
/** Injectable ResizeObserver: captures the callback so tests can fire
|
|
11
12
|
* content changes deterministically (happy-dom's own RO never fires —
|
|
12
13
|
* there is no layout engine). Same harness as useSizeMorph.test.ts. */
|
|
13
|
-
class FakeResizeObserver {
|
|
14
|
+
class FakeResizeObserver implements ResizeObserver {
|
|
14
15
|
static instances: FakeResizeObserver[] = [];
|
|
15
16
|
callback: () => void;
|
|
16
17
|
observed: Element[] = [];
|
|
17
18
|
disconnected = false;
|
|
18
19
|
|
|
19
|
-
constructor(callback: () => void) {
|
|
20
|
+
constructor(callback: (...args: never[]) => void) {
|
|
20
21
|
this.callback = callback;
|
|
21
22
|
FakeResizeObserver.instances.push(this);
|
|
22
23
|
}
|
|
23
24
|
observe(el: Element): void {
|
|
24
25
|
this.observed.push(el);
|
|
25
26
|
}
|
|
27
|
+
unobserve(): void {}
|
|
26
28
|
disconnect(): void {
|
|
27
29
|
this.disconnected = true;
|
|
28
30
|
}
|
|
@@ -77,7 +79,7 @@ function fireTransitionEnd(el: HTMLElement, propertyName: string) {
|
|
|
77
79
|
beforeEach(() => {
|
|
78
80
|
FakeResizeObserver.instances = [];
|
|
79
81
|
naturalHeight = 140;
|
|
80
|
-
globalThis.ResizeObserver = FakeResizeObserver
|
|
82
|
+
globalThis.ResizeObserver = FakeResizeObserver;
|
|
81
83
|
Object.defineProperty(offsetHeightOwner(), "offsetHeight", {
|
|
82
84
|
configurable: true,
|
|
83
85
|
get: () => naturalHeight,
|
|
@@ -194,7 +196,7 @@ describe("HkAuthCard", () => {
|
|
|
194
196
|
|
|
195
197
|
it("snaps unclipped under prefers-reduced-motion without arming the backstop", () => {
|
|
196
198
|
vi.useFakeTimers();
|
|
197
|
-
vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true }
|
|
199
|
+
vi.spyOn(window, "matchMedia").mockReturnValue(fakeAs<MediaQueryList>({ matches: true }));
|
|
198
200
|
naturalHeight = 140;
|
|
199
201
|
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
200
202
|
const wrapper = wrapperOf(c);
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { defineComponent } from "vue";
|
|
2
2
|
import HkTooltip from "./HkTooltip";
|
|
3
|
+
import { asPropType } from "../utils/typing";
|
|
3
4
|
import "./HkAuthMethodList.scss";
|
|
4
5
|
|
|
6
|
+
/** One third-party sign-in provider tile. */
|
|
7
|
+
export interface HkAuthMethodListEntry {
|
|
8
|
+
key: string;
|
|
9
|
+
/** Accessible name + tooltip text (the hover reveal). */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Prebuilt icon vnode (brand SVG, <img>, …) rendered inside the
|
|
12
|
+
* framed tile. Typed loose on purpose (same as HkIconButtonGroup
|
|
13
|
+
* options): hosts materialize hikari against their own vue store,
|
|
14
|
+
* and a hard VNode type breaks typecheck whenever the host's vue
|
|
15
|
+
* minor differs. A missing icon falls back to the label's
|
|
16
|
+
* initial. */
|
|
17
|
+
icon?: unknown;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
/**
|
|
6
22
|
* HkAuthMethodList — the auth card's third-party sign-in block (the
|
|
7
23
|
* "other ways to sign in" row under the credentials form).
|
|
@@ -34,19 +50,7 @@ export default defineComponent({
|
|
|
34
50
|
divider: { type: String, default: "" },
|
|
35
51
|
/** Provider entries, in render order. */
|
|
36
52
|
methods: {
|
|
37
|
-
type: Array
|
|
38
|
-
key: string;
|
|
39
|
-
/** Accessible name + tooltip text (the hover reveal). */
|
|
40
|
-
label: string;
|
|
41
|
-
/** Prebuilt icon vnode (brand SVG, <img>, …) rendered inside the
|
|
42
|
-
* framed tile. Typed loose on purpose (same as HkIconButtonGroup
|
|
43
|
-
* options): hosts materialize hikari against their own vue store,
|
|
44
|
-
* and a hard VNode type breaks typecheck whenever the host's vue
|
|
45
|
-
* minor differs. A missing icon falls back to the label's
|
|
46
|
-
* initial. */
|
|
47
|
-
icon?: unknown;
|
|
48
|
-
disabled?: boolean;
|
|
49
|
-
}>,
|
|
53
|
+
type: asPropType<HkAuthMethodListEntry[]>(Array),
|
|
50
54
|
required: true,
|
|
51
55
|
},
|
|
52
56
|
},
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// HkBarChart anatomy — self-sufficient styling (the component
|
|
2
|
+
// self-sufficiency guard): every class lives here, no host atomic or
|
|
3
|
+
// utility layer. Ink comes from theme tokens so scheme flips repaint
|
|
4
|
+
// with no re-render.
|
|
5
|
+
//
|
|
6
|
+
// Upstreamed from wowsp's PlaytimeTrendChart.scss.
|
|
7
|
+
//
|
|
8
|
+
// AI-generated (GLM-5.3 via ZCode), 2026-10-31 — SySL-1.0 §2.3.
|
|
9
|
+
|
|
10
|
+
.hk-bar-chart {
|
|
11
|
+
display: block;
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: auto;
|
|
14
|
+
|
|
15
|
+
&--empty {
|
|
16
|
+
height: 120px;
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
font-size: var(--text-sm, 13px);
|
|
21
|
+
color: var(--hi-color-muted);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&__grid {
|
|
25
|
+
stroke: rgb(var(--color-text) / 0.12);
|
|
26
|
+
stroke-width: 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
&__axis {
|
|
30
|
+
fill: rgb(var(--color-text) / 0.55);
|
|
31
|
+
font-size: 10px;
|
|
32
|
+
text-anchor: end;
|
|
33
|
+
font-variant-numeric: tabular-nums;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&__x {
|
|
37
|
+
fill: rgb(var(--color-text) / 0.55);
|
|
38
|
+
font-size: 10px;
|
|
39
|
+
text-anchor: middle;
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&__bar {
|
|
44
|
+
fill: rgb(var(--color-primary));
|
|
45
|
+
outline: none;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
transition: filter 0.16s ease;
|
|
48
|
+
|
|
49
|
+
&:hover,
|
|
50
|
+
&:focus-visible {
|
|
51
|
+
filter: brightness(1.08) saturate(1.15);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkBarChart from "./HkBarChart";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const BARS = [
|
|
25
|
+
{ label: "Mon", value: 3600, hint: "Mon: 1h" },
|
|
26
|
+
{ label: "Tue", value: 7200, hint: "Tue: 2h" },
|
|
27
|
+
{ label: "Wed", value: 0 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe("HkBarChart", () => {
|
|
31
|
+
it("renders one labeled bar per bucket and skips zero buckets' paths", () => {
|
|
32
|
+
const el = mount(<HkBarChart bars={BARS} />);
|
|
33
|
+
const bars = el.querySelectorAll(".hk-bar-chart__bar");
|
|
34
|
+
expect(bars).toHaveLength(2); // the zero bucket has no path
|
|
35
|
+
expect([...bars].map((b) => b.getAttribute("data-hint"))).toEqual(["Mon: 1h", "Tue: 2h"]);
|
|
36
|
+
const xs = [...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent);
|
|
37
|
+
expect(xs).toEqual(["Mon", "Tue", "Wed"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("formats axis guides through the value formatter", () => {
|
|
41
|
+
const el = mount(
|
|
42
|
+
<HkBarChart bars={BARS} formatValue={(v) => `${Math.round(v / 3600)}h`} />,
|
|
43
|
+
);
|
|
44
|
+
const axis = [...el.querySelectorAll(".hk-bar-chart__axis")].map((t) => t.textContent);
|
|
45
|
+
// niceMax(7200s) = 10000s → guides 0 / 5000 / 10000.
|
|
46
|
+
expect(axis).toEqual(["0h", "1h", "3h"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("shows the empty state when every bucket is zero", () => {
|
|
50
|
+
const el = mount(
|
|
51
|
+
<HkBarChart bars={[{ label: "a", value: 0 }]} emptyText="nothing here" />,
|
|
52
|
+
);
|
|
53
|
+
expect(el.querySelector(".hk-bar-chart--empty")?.textContent).toBe("nothing here");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("carries the chart-level aria label", () => {
|
|
57
|
+
const el = mount(<HkBarChart bars={BARS} ariaLabel="weekly playtime" />);
|
|
58
|
+
expect(el.querySelector(".hk-bar-chart")?.getAttribute("aria-label")).toBe(
|
|
59
|
+
"weekly playtime",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { computed, defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { barLayout, type BarSlot } from "./barChart";
|
|
4
|
+
import "./HkBarChart.scss";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkBarChart — one bar per bucket over a hand-drawn declarative SVG (no
|
|
8
|
+
* chart library; the HkTreemap/HkCalendarHeatmap dependency-free line).
|
|
9
|
+
*
|
|
10
|
+
* Upstreamed from wowsp's playtime trend chart (2026-10 user direction:
|
|
11
|
+
* the apps' best charts become standard hikari business components).
|
|
12
|
+
* Gridlines at 0 / half / a nice max, x labels under every bar, ink
|
|
13
|
+
* styled in SCSS from theme tokens so scheme flips repaint without a
|
|
14
|
+
* re-render, and hover hints through the consumer's own tooltip channel
|
|
15
|
+
* (`hint` lands in both `aria-label` and the `data-hint` attribute the
|
|
16
|
+
* wowsp delegated-tooltip hook reads — hikari itself renders no native
|
|
17
|
+
* title bubble).
|
|
18
|
+
*
|
|
19
|
+
* Dumb by design: the caller owns aggregation and passes the settled
|
|
20
|
+
* buckets in. All geometry lives in ./barChart (pure, pinned by its own
|
|
21
|
+
* tests).
|
|
22
|
+
*
|
|
23
|
+
* AI-generated (GLM-5.3 via ZCode), 2026-10-31 — SySL-1.0 §2.3.
|
|
24
|
+
*/
|
|
25
|
+
export default defineComponent({
|
|
26
|
+
name: "HkBarChart",
|
|
27
|
+
props: {
|
|
28
|
+
/** The bars, in axis order (oldest → newest for trend charts). */
|
|
29
|
+
bars: {
|
|
30
|
+
type: Array as PropType<{ label: string; value: number; hint?: string }[]>,
|
|
31
|
+
required: true,
|
|
32
|
+
},
|
|
33
|
+
/** Frame size (the SVG viewBox; CSS scales it). */
|
|
34
|
+
width: { type: Number, default: 640 },
|
|
35
|
+
height: { type: Number, default: 220 },
|
|
36
|
+
/** Value formatter for the axis guides and the aria label. */
|
|
37
|
+
formatValue: {
|
|
38
|
+
type: Function as PropType<(v: number) => string>,
|
|
39
|
+
default: (v: number) => String(Math.round(v)),
|
|
40
|
+
},
|
|
41
|
+
/** Accessible name for the whole chart; defaults to a value summary. */
|
|
42
|
+
ariaLabel: { type: String, default: null },
|
|
43
|
+
/** Rendered when every bucket is zero / the array is empty. */
|
|
44
|
+
emptyText: { type: String, default: "No data" },
|
|
45
|
+
},
|
|
46
|
+
emits: {
|
|
47
|
+
/** A bar was clicked (its bucket index). */
|
|
48
|
+
select: (index: number) => typeof index === "number",
|
|
49
|
+
},
|
|
50
|
+
setup(props, { emit }) {
|
|
51
|
+
const layout = computed(() =>
|
|
52
|
+
barLayout(props.bars.map((b) => b.value), {
|
|
53
|
+
width: props.width,
|
|
54
|
+
height: props.height,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
const empty = computed(() => props.bars.every((b) => !(b.value > 0)));
|
|
58
|
+
const aria = computed(
|
|
59
|
+
() =>
|
|
60
|
+
props.ariaLabel ??
|
|
61
|
+
props.bars.map((b) => `${b.label} ${props.formatValue(b.value)}`).join(", "),
|
|
62
|
+
);
|
|
63
|
+
const hintOf = (b: { label: string; value: number; hint?: string }, i: number): string =>
|
|
64
|
+
b.hint ?? `${b.label}: ${props.formatValue(b.value)}`;
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
if (empty.value) {
|
|
68
|
+
return <div class="hk-bar-chart hk-bar-chart--empty">{props.emptyText}</div>;
|
|
69
|
+
}
|
|
70
|
+
const l = layout.value;
|
|
71
|
+
return (
|
|
72
|
+
<svg
|
|
73
|
+
class="hk-bar-chart"
|
|
74
|
+
viewBox={`0 0 ${l.width} ${l.height}`}
|
|
75
|
+
preserveAspectRatio="xMidYMid meet"
|
|
76
|
+
role="img"
|
|
77
|
+
aria-label={aria.value}
|
|
78
|
+
>
|
|
79
|
+
{l.gridlines.map((g) => (
|
|
80
|
+
<g key={g.value}>
|
|
81
|
+
<line
|
|
82
|
+
class="hk-bar-chart__grid"
|
|
83
|
+
x1={l.sidePad}
|
|
84
|
+
y1={g.y}
|
|
85
|
+
x2={l.width - 8}
|
|
86
|
+
y2={g.y}
|
|
87
|
+
/>
|
|
88
|
+
<text class="hk-bar-chart__axis" x={l.sidePad - 6} y={g.y + 3}>
|
|
89
|
+
{props.formatValue(g.value)}
|
|
90
|
+
</text>
|
|
91
|
+
</g>
|
|
92
|
+
))}
|
|
93
|
+
{l.bars.map((s: BarSlot, i: number) => (
|
|
94
|
+
<g key={i}>
|
|
95
|
+
{s.path ? (
|
|
96
|
+
<path
|
|
97
|
+
class="hk-bar-chart__bar"
|
|
98
|
+
d={s.path}
|
|
99
|
+
data-hint={hintOf(props.bars[i], i)}
|
|
100
|
+
aria-label={hintOf(props.bars[i], i)}
|
|
101
|
+
tabindex={0}
|
|
102
|
+
onClick={() => emit("select", i)}
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
<text class="hk-bar-chart__x" x={s.centerX} y={l.baselineY + 14}>
|
|
106
|
+
{props.bars[i].label}
|
|
107
|
+
</text>
|
|
108
|
+
</g>
|
|
109
|
+
))}
|
|
110
|
+
</svg>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
});
|
|
@@ -33,10 +33,7 @@ async function mountBoard() {
|
|
|
33
33
|
// The board reads its viewport size at mount (clientWidth/Height, i.e. the
|
|
34
34
|
// layout box): pin it on the prototype for the duration so the camera's
|
|
35
35
|
// clamp has a real viewport to work with, the way a laid-out page would.
|
|
36
|
-
const proto = HTMLElement.prototype
|
|
37
|
-
clientWidth: number;
|
|
38
|
-
clientHeight: number;
|
|
39
|
-
};
|
|
36
|
+
const proto = HTMLElement.prototype;
|
|
40
37
|
const beforeW = Object.getOwnPropertyDescriptor(proto, "clientWidth");
|
|
41
38
|
const beforeH = Object.getOwnPropertyDescriptor(proto, "clientHeight");
|
|
42
39
|
Object.defineProperty(proto, "clientWidth", {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
8
8
|
import * as THREE from "three";
|
|
9
|
+
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
|
9
10
|
|
|
10
11
|
const H = vi.hoisted(() => {
|
|
11
12
|
class FakeWebGLRenderer {
|
|
@@ -186,7 +187,7 @@ describe("HkBoard3D", () => {
|
|
|
186
187
|
expect(body.userData.board3dId).toBe("node-1");
|
|
187
188
|
expect(label.classList.contains("hk-board3d-label")).toBe(true);
|
|
188
189
|
// Label rides the object as a CSS2D child at the requested offset.
|
|
189
|
-
const tag = body.children.find((c) =>
|
|
190
|
+
const tag = body.children.find((c) => c instanceof CSS2DObject && c.element === label);
|
|
190
191
|
expect(tag).toBeDefined();
|
|
191
192
|
expect(tag!.position.y).toBe(2);
|
|
192
193
|
|
|
@@ -329,7 +330,7 @@ describe("HkBoard3D", () => {
|
|
|
329
330
|
};
|
|
330
331
|
engine!.setObject("node", { object: body, label: chip() });
|
|
331
332
|
const labelChildren = () =>
|
|
332
|
-
body.children.filter((c) =>
|
|
333
|
+
body.children.filter((c) => c instanceof CSS2DObject).length;
|
|
333
334
|
expect(labelChildren()).toBe(1);
|
|
334
335
|
|
|
335
336
|
// Re-registering the same id must REPLACE its chip, not stack a second.
|
|
@@ -414,9 +415,9 @@ describe("HkBoard3D", () => {
|
|
|
414
415
|
const body = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 6), new THREE.MeshBasicMaterial());
|
|
415
416
|
engine!.setObject("node", { object: body });
|
|
416
417
|
|
|
417
|
-
//
|
|
418
|
-
// desynchronise the board from its own scene.
|
|
419
|
-
(engine!.objects()
|
|
418
|
+
// An untyped consumer reaching for the mutable Map API must not be able
|
|
419
|
+
// to desynchronise the board from its own scene.
|
|
420
|
+
Map.prototype.clear.call(engine!.objects());
|
|
420
421
|
expect(engine!.objects().has("node")).toBe(true);
|
|
421
422
|
engine!.setObject("node", null);
|
|
422
423
|
expect(body.parent).toBeNull();
|
|
@@ -448,7 +449,7 @@ describe("HkBoard3D", () => {
|
|
|
448
449
|
// radius to `max(min, min(max, r))`, so a reversed window pins the
|
|
449
450
|
// eye at 600 and defeats every fit (the mocked controls in this
|
|
450
451
|
// suite do not clamp, hence the direct assertion).
|
|
451
|
-
const controls = engine!.controls
|
|
452
|
+
const controls = engine!.controls;
|
|
452
453
|
expect(controls.minDistance).toBe(2);
|
|
453
454
|
expect(controls.maxDistance).toBe(600);
|
|
454
455
|
|
|
@@ -615,10 +616,8 @@ describe("HkBoard3D", () => {
|
|
|
615
616
|
engine!.applyLighting({ ambientIntensity: 0.9 });
|
|
616
617
|
engine!.fitGround({ min: [0, 0, 0], max: [10, 0, 10] });
|
|
617
618
|
engine!.setAmbientIntensity(0.3);
|
|
618
|
-
const ambient = engine!.scene.children.find(
|
|
619
|
-
|
|
620
|
-
) as unknown as { intensity: number };
|
|
621
|
-
expect(ambient.intensity).toBeCloseTo(0.3, 5);
|
|
619
|
+
const ambient = engine!.scene.children.find((c) => c instanceof THREE.AmbientLight);
|
|
620
|
+
expect(ambient!.intensity).toBeCloseTo(0.3, 5);
|
|
622
621
|
});
|
|
623
622
|
|
|
624
623
|
it("disposes the lighting rig (and its shadow map) on unmount", async () => {
|
|
@@ -632,11 +631,9 @@ describe("HkBoard3D", () => {
|
|
|
632
631
|
ambientIntensity: 0.9,
|
|
633
632
|
points: [{ color: [1, 0, 0], intensity: 2, range: 10, position: [0, 3, 0] }],
|
|
634
633
|
});
|
|
635
|
-
const sun = engine!.scene.children.find(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const sunSpy = vi.spyOn(sun, "dispose");
|
|
639
|
-
const shadowSpy = vi.spyOn(sun.shadow, "dispose");
|
|
634
|
+
const sun = engine!.scene.children.find((c) => c instanceof THREE.DirectionalLight);
|
|
635
|
+
const sunSpy = vi.spyOn(sun!, "dispose");
|
|
636
|
+
const shadowSpy = vi.spyOn(sun!.shadow, "dispose");
|
|
640
637
|
const scene = engine!.scene;
|
|
641
638
|
|
|
642
639
|
const m = mounts.splice(0)[0];
|
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
type LightingRigOptions,
|
|
87
87
|
} from "../scene3d/lighting";
|
|
88
88
|
import { BOARD3D_HELPERS_LAYER, BOARD3D_MAIN_LAYER, fitDistance } from "../utils/scene3d";
|
|
89
|
+
import { asPropType } from "../utils/typing";
|
|
89
90
|
import {
|
|
90
91
|
createHoverBox,
|
|
91
92
|
isVisibleInHierarchy,
|
|
@@ -336,11 +337,11 @@ export default defineComponent({
|
|
|
336
337
|
/** Starting eye / orbit target. STATIC after mount (a non-finite
|
|
337
338
|
* or short array falls back to the defaults). */
|
|
338
339
|
initialPosition: {
|
|
339
|
-
type:
|
|
340
|
+
type: asPropType<[number, number, number]>(Array),
|
|
340
341
|
default: () => [22, 16, 26],
|
|
341
342
|
},
|
|
342
343
|
initialTarget: {
|
|
343
|
-
type:
|
|
344
|
+
type: asPropType<[number, number, number]>(Array),
|
|
344
345
|
default: () => [0, 0, 0],
|
|
345
346
|
},
|
|
346
347
|
/** Orbit distance window. STATIC after mount (applied to the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import * as barrel from "../index";
|
|
3
|
+
import { asIndexable } from "../utils/typing";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The public barrel guard for the calendar heatmap's exports (R3 on the
|
|
@@ -15,7 +16,7 @@ import * as barrel from "../index";
|
|
|
15
16
|
*/
|
|
16
17
|
describe("the public barrel", () => {
|
|
17
18
|
it("exports the heatmap component under both family names", () => {
|
|
18
|
-
const hk = barrel.HkCalendarHeatmap
|
|
19
|
+
const hk = asIndexable(barrel.HkCalendarHeatmap);
|
|
19
20
|
expect(hk, "HkCalendarHeatmap must be re-exported").toBeTruthy();
|
|
20
21
|
expect(hk.name).toBe("HkCalendarHeatmap");
|
|
21
22
|
// The H- alias rides the HCard/HkCard family convention.
|
|
@@ -2,6 +2,7 @@ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, type
|
|
|
2
2
|
import { ShieldAlert } from "lucide-vue-next";
|
|
3
3
|
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
|
+
import { asIndexable } from "../utils/typing";
|
|
5
6
|
|
|
6
7
|
import "./HkCaptchaWidget.scss";
|
|
7
8
|
|
|
@@ -65,7 +66,7 @@ type GrecaptchaApi = {
|
|
|
65
66
|
};
|
|
66
67
|
|
|
67
68
|
function getGlobal(name: string): unknown {
|
|
68
|
-
return (window
|
|
69
|
+
return asIndexable(window)[name];
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export const HkCaptchaWidget = defineComponent({
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkCheckbox from "./HkCheckbox";
|
|
5
|
+
import { invalidInput } from "../test-utils/invalid";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* HkCheckbox contract tests:
|
|
@@ -89,7 +90,8 @@ describe("HkCheckbox model states", () => {
|
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
it("maps modelValue=null to the indeterminate dash without checking the input", () => {
|
|
92
|
-
|
|
93
|
+
// deliberately type-invalid: JS consumers pass null to the boolean prop
|
|
94
|
+
const c = mount(h(HkCheckbox, { modelValue: invalidInput<boolean>(null) }));
|
|
93
95
|
const box = c.querySelector<HTMLElement>(".hk-checkbox-box")!;
|
|
94
96
|
expect(box.getAttribute("data-indeterminate")).toBe("");
|
|
95
97
|
expect(box.hasAttribute("data-checked")).toBe(false);
|
|
@@ -107,7 +109,9 @@ describe("HkCheckbox model states", () => {
|
|
|
107
109
|
expect(checked.querySelector(".hk-checkbox-icon")).toBeNull();
|
|
108
110
|
|
|
109
111
|
// null in radio mode: no dash — indeterminate is checkbox-only.
|
|
110
|
-
const unset = mount(
|
|
112
|
+
const unset = mount(
|
|
113
|
+
h(HkCheckbox, { modelValue: invalidInput<boolean>(null), type: "radio" }),
|
|
114
|
+
);
|
|
111
115
|
expect(unset.querySelector(".hk-checkbox-indeterminate")).toBeNull();
|
|
112
116
|
expect(unset.querySelector(".hk-checkbox-dot")).toBeNull();
|
|
113
117
|
});
|
|
@@ -2,6 +2,7 @@ import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType }
|
|
|
2
2
|
import { clampRgbToBands, hueDelta, rgbToHsl, wrapHue, type HueClamp } from "../theme/tokenGroups";
|
|
3
3
|
import { useOverlay } from "../runtime/useOverlay";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
|
+
import { asPropType } from "../utils/typing";
|
|
5
6
|
import HInput from "./HkInput";
|
|
6
7
|
import HPopover from "./HkPopover";
|
|
7
8
|
import "./HkColorPicker.scss";
|
|
@@ -142,9 +143,9 @@ export default defineComponent({
|
|
|
142
143
|
/** Optional physical-color clamp: hue locked to center±range (degrees, circular). */
|
|
143
144
|
hueClamp: { type: Object as PropType<HueClamp>, default: undefined },
|
|
144
145
|
/** Optional saturation safe band, 0–1. */
|
|
145
|
-
sRange: { type:
|
|
146
|
+
sRange: { type: asPropType<[number, number]>(Array), default: undefined },
|
|
146
147
|
/** Optional lightness safe band, 0–1. */
|
|
147
|
-
lRange: { type:
|
|
148
|
+
lRange: { type: asPropType<[number, number]>(Array), default: undefined },
|
|
148
149
|
},
|
|
149
150
|
emits: {
|
|
150
151
|
change: (_rgb: { r: number; g: number; b: number }) => true,
|
|
@@ -18,7 +18,7 @@ describe("HkConnectionStatus (shell)", () => {
|
|
|
18
18
|
it("renders an inert unknown state with zero network calls", () => {
|
|
19
19
|
const fetchSpy = vi.fn();
|
|
20
20
|
const holder = globalThis.fetch;
|
|
21
|
-
globalThis.fetch = fetchSpy
|
|
21
|
+
globalThis.fetch = fetchSpy;
|
|
22
22
|
try {
|
|
23
23
|
const container = document.createElement("div");
|
|
24
24
|
document.body.appendChild(container);
|