@celestia-island/hikari 0.55.250 → 0.55.253

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAdminHeader.test.tsx +17 -4
  3. package/src/components/HkAltSignIn.tsx +14 -10
  4. package/src/components/HkAuthCard.test.tsx +6 -4
  5. package/src/components/HkAuthMethodList.tsx +17 -13
  6. package/src/components/HkBarChart.scss +54 -0
  7. package/src/components/HkBarChart.test.tsx +62 -0
  8. package/src/components/HkBarChart.tsx +114 -0
  9. package/src/components/HkBoard.scale.test.ts +1 -4
  10. package/src/components/HkBoard3D.test.tsx +12 -15
  11. package/src/components/HkBoard3D.tsx +17 -4
  12. package/src/components/HkCalendarHeatmap.barrel.test.ts +2 -1
  13. package/src/components/HkCaptchaWidget.tsx +2 -1
  14. package/src/components/HkCheckbox.test.tsx +6 -2
  15. package/src/components/HkColorPicker.tsx +3 -2
  16. package/src/components/HkConnectionStatus.shell.test.tsx +1 -1
  17. package/src/components/HkContextMenuProvider.tsx +19 -14
  18. package/src/components/HkCookieConsent.test.tsx +5 -5
  19. package/src/components/HkCrossfade.test.tsx +1 -1
  20. package/src/components/HkDatePicker.test.ts +3 -2
  21. package/src/components/HkDateTag.test.tsx +1 -1
  22. package/src/components/HkDateTimePicker.test.ts +3 -2
  23. package/src/components/HkDateTimePicker.tsx +4 -3
  24. package/src/components/HkDonutChart.scss +95 -0
  25. package/src/components/HkDonutChart.test.tsx +87 -0
  26. package/src/components/HkDonutChart.tsx +144 -0
  27. package/src/components/HkFab.test.tsx +4 -2
  28. package/src/components/HkFab.tsx +8 -5
  29. package/src/components/HkFaceShell.barrel.test.ts +3 -2
  30. package/src/components/HkFaceShell.rail.test.tsx +1 -1
  31. package/src/components/HkFileBrowserDialog.test.tsx +1 -4
  32. package/src/components/HkFilePickerField.tsx +3 -2
  33. package/src/components/HkIcon.tsx +8 -5
  34. package/src/components/HkIconButtonGroup.tsx +2 -1
  35. package/src/components/HkInfiniteTail.test.tsx +28 -23
  36. package/src/components/HkInput.affix.test.ts +3 -3
  37. package/src/components/HkInput.password.test.tsx +116 -119
  38. package/src/components/HkInput.tsx +5 -2
  39. package/src/components/HkKeywordSearchModal.tsx +4 -3
  40. package/src/components/HkLocalizedInput.test.tsx +2 -5
  41. package/src/components/HkMarkdownRenderer.tsx +36 -19
  42. package/src/components/HkMediaVisualizer.tsx +9 -2
  43. package/src/components/HkMenu.tsx +4 -16
  44. package/src/components/HkMfaVerifyCard.test.ts +3 -4
  45. package/src/components/HkMinimap3D.test.tsx +73 -7
  46. package/src/components/HkMinimap3D.tsx +64 -6
  47. package/src/components/HkModal.contenthold.test.tsx +1 -1
  48. package/src/components/HkModal.enter-watchdog.test.tsx +1 -1
  49. package/src/components/HkModal.leavepin.test.tsx +1 -1
  50. package/src/components/HkModal.stepflowswap.test.tsx +1 -1
  51. package/src/components/HkModal.transition-completion.test.tsx +1 -1
  52. package/src/components/HkModal.width.test.tsx +2 -1
  53. package/src/components/HkPasswordSurface.tsx +1 -1
  54. package/src/components/HkPhoneInput.tsx +1 -1
  55. package/src/components/HkPlaceholderMarquee.test.ts +3 -11
  56. package/src/components/HkScrollContainer.dock.test.tsx +5 -2
  57. package/src/components/HkScrollContainer.selectionHint.test.ts +5 -1
  58. package/src/components/HkScrollContainer.test.ts +2 -1
  59. package/src/components/HkScrollContainer.tsx +11 -4
  60. package/src/components/HkSelectPanel.test.tsx +2 -2
  61. package/src/components/HkSelectPanel.tsx +19 -2
  62. package/src/components/HkSidebar.rail.test.tsx +1 -1
  63. package/src/components/HkSidebarRailSwap.test.tsx +1 -1
  64. package/src/components/HkTabs.test.ts +2 -2
  65. package/src/components/HkTabs.tsx +9 -4
  66. package/src/components/HkTemplateField.test.tsx +2 -2
  67. package/src/components/HkThirdPartySignInCard.tsx +5 -10
  68. package/src/components/HkTimeZoneGlobe.test.tsx +15 -0
  69. package/src/components/HkTimeZoneGlobe.tsx +8 -1
  70. package/src/components/HkTimeline.bondcell.contract.test.ts +190 -0
  71. package/src/components/HkTimeline.scss +75 -6
  72. package/src/components/HkTimeline.test.tsx +1 -1
  73. package/src/components/HkTreemap.test.tsx +4 -3
  74. package/src/components/HkTreemapStage.test.tsx +4 -3
  75. package/src/components/HkWallpaperBackdrop.test.tsx +10 -9
  76. package/src/components/HkWaterfall.test.tsx +2 -2
  77. package/src/components/HkWindowedItem.scale.test.ts +8 -4
  78. package/src/components/StatusTrayDecor.test.tsx +2 -1
  79. package/src/components/__tests__/hkMarkdownTableWrap.test.ts +6 -1
  80. package/src/components/barChart.test.ts +57 -0
  81. package/src/components/barChart.ts +104 -0
  82. package/src/components/board3dHoverBox.test.ts +3 -1
  83. package/src/components/board3dHoverBox.ts +11 -5
  84. package/src/components/donutGeometry.test.ts +74 -0
  85. package/src/components/donutGeometry.ts +148 -0
  86. package/src/composables/messaging/index.ts +4 -2
  87. package/src/composables/messaging/messaging.test.ts +7 -7
  88. package/src/composables/useApproachEnd.test.ts +3 -3
  89. package/src/composables/useOptionStrip.test.ts +13 -1
  90. package/src/composables/useOverlayScrollbar.ts +4 -2
  91. package/src/composables/usePointerReorder.test.ts +3 -1
  92. package/src/composables/useResourceListModal.tsx +4 -4
  93. package/src/composables/useSidebarRail.test.ts +5 -5
  94. package/src/composables/useSizeMorph.test.ts +3 -3
  95. package/src/composables/useSurfaceMachine.test.ts +4 -4
  96. package/src/demo.ts +15 -5
  97. package/src/env.d.ts +8 -1
  98. package/src/index.ts +25 -3
  99. package/src/overlay/overlayRoot.styles.test.ts +3 -0
  100. package/src/overlay/overlayRoot.ts +6 -2
  101. package/src/plugins/boot-chrome.test.ts +78 -32
  102. package/src/plugins/boot-recovery.test.ts +74 -33
  103. package/src/plugins/bootScriptsPlugin.ts +8 -3
  104. package/src/plugins/buildHashPlugin.ts +14 -5
  105. package/src/render.ts +14 -5
  106. package/src/res/bootChrome.test.ts +2 -1
  107. package/src/runtime/closeGuard.ts +5 -1
  108. package/src/runtime/credentialAutofill.test.ts +4 -2
  109. package/src/runtime/historySafetyNet.ts +37 -13
  110. package/src/runtime/mobileViewport.test.ts +3 -0
  111. package/src/runtime/mobileViewport.ts +5 -3
  112. package/src/runtime/navigationGuard.test.ts +11 -1
  113. package/src/runtime/tooltipBridge.ts +6 -1
  114. package/src/runtime/useClipboard.test.ts +1 -1
  115. package/src/runtime/viewportGutter.test.ts +11 -8
  116. package/src/runtime/windowChrome.ts +15 -12
  117. package/src/scene3d/lighting.test.ts +25 -12
  118. package/src/scene3d/modelLayer.test.ts +16 -8
  119. package/src/test-utils/fakes.ts +49 -0
  120. package/src/test-utils/invalid.ts +20 -0
  121. package/src/theme/solarTimeZones.test.ts +5 -2
  122. package/src/theme/themeDecor.test.ts +2 -1
  123. package/src/theme/useBackgroundLuminance.test.ts +42 -20
  124. package/src/theme/useSolarTime.test.ts +8 -9
  125. package/src/theme/useSolarTime.ts +32 -18
  126. package/src/theme/wallpaper.ts +31 -8
  127. package/src/theme/wallpaperCustom.test.ts +42 -0
  128. package/src/theme/wallpaperShaderRenderer.test.ts +66 -34
  129. package/src/theme/wallpaperSsr.test.ts +4 -2
  130. package/src/utils/dom.test.ts +31 -10
  131. package/src/utils/typing.ts +41 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.250",
3
+ "version": "0.55.253",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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: any[]) => {
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: () => (attrs as any).onSelect?.(item.key),
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 as any[]);
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 as unknown as () => 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 as never}
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 as unknown as typeof ResizeObserver;
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 } as unknown as MediaQueryList);
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 as unknown as () => 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 as unknown as {
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) => (c as unknown as { element?: HTMLElement }).element === label);
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) => (c as unknown as { isCSS2DObject?: boolean }).isCSS2DObject).length;
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
- // A consumer casting the view to a mutable Map must not be able to
418
- // desynchronise the board from its own scene.
419
- (engine!.objects() as unknown as Map<string, unknown>).clear();
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 as unknown as { minDistance: number; maxDistance: number };
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
- (c) => c.constructor.name === "AmbientLight",
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
- (c) => c.constructor.name === "DirectionalLight",
637
- ) as unknown as { dispose: () => void; shadow: { dispose: () => void } };
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];
@@ -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
@@ -86,6 +88,7 @@ import {
86
88
  type LightingRigOptions,
87
89
  } from "../scene3d/lighting";
88
90
  import { BOARD3D_HELPERS_LAYER, BOARD3D_MAIN_LAYER, fitDistance } from "../utils/scene3d";
91
+ import { asPropType } from "../utils/typing";
89
92
  import {
90
93
  createHoverBox,
91
94
  isVisibleInHierarchy,
@@ -330,17 +333,23 @@ export default defineComponent({
330
333
  interactive: { type: Boolean, default: true },
331
334
  /** Pin the stereoscopic minimap bottom-right. */
332
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 },
333
342
  /** Vertical field of view in degrees. STATIC after mount (the
334
343
  * camera is built in init; remount to change it). */
335
344
  fov: { type: Number, default: 45 },
336
345
  /** Starting eye / orbit target. STATIC after mount (a non-finite
337
346
  * or short array falls back to the defaults). */
338
347
  initialPosition: {
339
- type: Array as unknown as PropType<[number, number, number]>,
348
+ type: asPropType<[number, number, number]>(Array),
340
349
  default: () => [22, 16, 26],
341
350
  },
342
351
  initialTarget: {
343
- type: Array as unknown as PropType<[number, number, number]>,
352
+ type: asPropType<[number, number, number]>(Array),
344
353
  default: () => [0, 0, 0],
345
354
  },
346
355
  /** Orbit distance window. STATIC after mount (applied to the
@@ -1155,7 +1164,11 @@ export default defineComponent({
1155
1164
  )
1156
1165
  ) : null}
1157
1166
  {props.minimap && engineRef.value && !failed.value ? (
1158
- <HkMinimap3D engine={engineRef.value} />
1167
+ <HkMinimap3D
1168
+ engine={engineRef.value}
1169
+ collapsible={props.minimapCollapsible}
1170
+ defaultCollapsed={props.minimapDefaultCollapsed}
1171
+ />
1159
1172
  ) : null}
1160
1173
  {slots.default ? <div class="hk-board3d-overlay">{slots.default()}</div> : null}
1161
1174
  </div>
@@ -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 as unknown as { name?: string };
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 as unknown as Record<string, unknown>)[name];
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
- const c = mount(h(HkCheckbox, { modelValue: null as unknown as boolean }));
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(h(HkCheckbox, { modelValue: null as unknown as boolean, type: "radio" }));
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: Array as unknown as PropType<[number, number]>, default: undefined },
146
+ sRange: { type: asPropType<[number, number]>(Array), default: undefined },
146
147
  /** Optional lightness safe band, 0–1. */
147
- lRange: { type: Array as unknown as PropType<[number, number]>, default: undefined },
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,