@celestia-island/hikari 0.55.249 → 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.
Files changed (159) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAdminHeader.test.tsx +17 -4
  3. package/src/components/HkAffixPicker.scss +15 -43
  4. package/src/components/HkAltSignIn.tsx +14 -10
  5. package/src/components/HkAuthCard.test.tsx +6 -4
  6. package/src/components/HkAuthMethodList.tsx +17 -13
  7. package/src/components/HkBarChart.scss +54 -0
  8. package/src/components/HkBarChart.test.tsx +62 -0
  9. package/src/components/HkBarChart.tsx +114 -0
  10. package/src/components/HkBoard.scale.test.ts +1 -4
  11. package/src/components/HkBoard3D.test.tsx +12 -15
  12. package/src/components/HkBoard3D.tsx +3 -2
  13. package/src/components/HkCalendarHeatmap.barrel.test.ts +2 -1
  14. package/src/components/HkCaptchaWidget.tsx +2 -1
  15. package/src/components/HkCheckbox.test.tsx +6 -2
  16. package/src/components/HkColorPicker.tsx +3 -2
  17. package/src/components/HkConnectionStatus.shell.test.tsx +1 -1
  18. package/src/components/HkContextMenuProvider.tsx +19 -14
  19. package/src/components/HkCookieConsent.test.tsx +5 -5
  20. package/src/components/HkCrossfade.test.tsx +1 -1
  21. package/src/components/HkDatePicker.test.ts +3 -2
  22. package/src/components/HkDateTag.test.tsx +1 -1
  23. package/src/components/HkDateTimePicker.test.ts +3 -2
  24. package/src/components/HkDateTimePicker.tsx +4 -3
  25. package/src/components/HkDonutChart.scss +95 -0
  26. package/src/components/HkDonutChart.test.tsx +87 -0
  27. package/src/components/HkDonutChart.tsx +144 -0
  28. package/src/components/HkFab.test.tsx +4 -2
  29. package/src/components/HkFab.tsx +8 -5
  30. package/src/components/HkFaceShell.barrel.test.ts +3 -2
  31. package/src/components/HkFaceShell.rail.test.tsx +1 -1
  32. package/src/components/HkFileBrowserDialog.test.tsx +1 -4
  33. package/src/components/HkFilePickerField.tsx +3 -2
  34. package/src/components/HkIcon.tsx +8 -5
  35. package/src/components/HkIconButtonGroup.tsx +2 -1
  36. package/src/components/HkInfiniteTail.test.tsx +28 -23
  37. package/src/components/HkInput.affix.test.ts +3 -3
  38. package/src/components/HkInput.password.test.tsx +116 -119
  39. package/src/components/HkInput.tsx +5 -2
  40. package/src/components/HkKeywordSearchModal.tsx +4 -3
  41. package/src/components/HkLocalizedInput.test.tsx +2 -5
  42. package/src/components/HkMarkdownRenderer.tsx +36 -19
  43. package/src/components/HkMediaVisualizer.tsx +9 -2
  44. package/src/components/HkMenu.tsx +4 -16
  45. package/src/components/HkMenuIdentityItem.scss +4 -0
  46. package/src/components/HkMfaVerifyCard.test.ts +3 -4
  47. package/src/components/HkMinimap3D.test.tsx +6 -5
  48. package/src/components/HkMinimap3D.tsx +1 -1
  49. package/src/components/HkModal.contenthold.test.tsx +1 -1
  50. package/src/components/HkModal.enter-watchdog.test.tsx +1 -1
  51. package/src/components/HkModal.leavepin.test.tsx +1 -1
  52. package/src/components/HkModal.stepflowswap.test.tsx +1 -1
  53. package/src/components/HkModal.transition-completion.test.tsx +1 -1
  54. package/src/components/HkModal.width.test.tsx +2 -1
  55. package/src/components/HkPasswordSurface.tsx +1 -1
  56. package/src/components/HkPhoneInput.tsx +1 -1
  57. package/src/components/HkPlaceholderMarquee.test.ts +3 -11
  58. package/src/components/HkScrollContainer.dock.test.tsx +5 -2
  59. package/src/components/HkScrollContainer.selectionHint.test.ts +5 -1
  60. package/src/components/HkScrollContainer.test.ts +2 -1
  61. package/src/components/HkScrollContainer.tsx +11 -4
  62. package/src/components/HkSelectPanel.test.tsx +2 -2
  63. package/src/components/HkSelectPanel.tsx +19 -2
  64. package/src/components/HkSidebar.rail.test.tsx +1 -1
  65. package/src/components/HkSidebarRailSwap.test.tsx +1 -1
  66. package/src/components/HkSolarLocationSetting.test.tsx +31 -21
  67. package/src/components/HkSolarLocationSetting.tsx +13 -14
  68. package/src/components/HkTabs.test.ts +2 -2
  69. package/src/components/HkTabs.tsx +9 -4
  70. package/src/components/HkTagInput.scss +39 -49
  71. package/src/components/HkTagInput.tsx +6 -0
  72. package/src/components/HkTemplateField.scss +24 -10
  73. package/src/components/HkTemplateField.test.tsx +2 -2
  74. package/src/components/HkThirdPartySignInCard.tsx +5 -10
  75. package/src/components/HkTimeZoneGlobe.scss +125 -0
  76. package/src/components/HkTimeZoneGlobe.test.tsx +128 -0
  77. package/src/components/HkTimeZoneGlobe.tsx +385 -0
  78. package/src/components/HkTimeline.test.tsx +1 -1
  79. package/src/components/HkTreemap.test.tsx +4 -3
  80. package/src/components/HkTreemapStage.test.tsx +4 -3
  81. package/src/components/HkUserPicker.scss +11 -18
  82. package/src/components/HkUserPicker.tsx +5 -0
  83. package/src/components/HkWallpaperBackdrop.test.tsx +10 -9
  84. package/src/components/HkWaterfall.styles.test.ts +97 -37
  85. package/src/components/HkWaterfall.test.tsx +2 -2
  86. package/src/components/HkWindowedItem.scale.test.ts +8 -4
  87. package/src/components/StatusTrayDecor.test.tsx +2 -1
  88. package/src/components/__tests__/hkMarkdownTableWrap.test.ts +6 -1
  89. package/src/components/_menu-item.contract.test.ts +66 -0
  90. package/src/components/_menu-item.scss +19 -2
  91. package/src/components/barChart.test.ts +57 -0
  92. package/src/components/barChart.ts +104 -0
  93. package/src/components/board3dHoverBox.test.ts +3 -1
  94. package/src/components/board3dHoverBox.ts +11 -5
  95. package/src/components/donutGeometry.test.ts +74 -0
  96. package/src/components/donutGeometry.ts +148 -0
  97. package/src/composables/messaging/index.ts +4 -2
  98. package/src/composables/messaging/messaging.test.ts +7 -7
  99. package/src/composables/useApproachEnd.test.ts +3 -3
  100. package/src/composables/useOptionStrip.test.ts +13 -1
  101. package/src/composables/useOverlayScrollbar.ts +4 -2
  102. package/src/composables/usePointerReorder.test.ts +3 -1
  103. package/src/composables/useResourceListModal.tsx +4 -4
  104. package/src/composables/useSidebarRail.test.ts +5 -5
  105. package/src/composables/useSizeMorph.test.ts +3 -3
  106. package/src/composables/useSurfaceMachine.test.ts +4 -4
  107. package/src/demo.ts +15 -5
  108. package/src/env.d.ts +8 -1
  109. package/src/i18n/locales/ar/platform.json +90 -1
  110. package/src/i18n/locales/de/platform.json +90 -1
  111. package/src/i18n/locales/en/platform.json +90 -1
  112. package/src/i18n/locales/es/platform.json +90 -1
  113. package/src/i18n/locales/fr/platform.json +90 -1
  114. package/src/i18n/locales/ja/platform.json +90 -1
  115. package/src/i18n/locales/ko/platform.json +90 -1
  116. package/src/i18n/locales/pt/platform.json +90 -1
  117. package/src/i18n/locales/ru/platform.json +90 -1
  118. package/src/i18n/locales/zh-Hans/platform.json +90 -1
  119. package/src/i18n/locales/zh-Hant/platform.json +90 -1
  120. package/src/index.ts +25 -3
  121. package/src/overlay/overlayRoot.styles.test.ts +3 -0
  122. package/src/overlay/overlayRoot.ts +6 -2
  123. package/src/plugins/boot-chrome.test.ts +78 -32
  124. package/src/plugins/boot-recovery.test.ts +74 -33
  125. package/src/plugins/bootScriptsPlugin.ts +8 -3
  126. package/src/plugins/buildHashPlugin.ts +14 -5
  127. package/src/render.ts +14 -5
  128. package/src/res/bootChrome.test.ts +2 -1
  129. package/src/runtime/closeGuard.ts +5 -1
  130. package/src/runtime/credentialAutofill.test.ts +4 -2
  131. package/src/runtime/historySafetyNet.ts +37 -13
  132. package/src/runtime/mobileViewport.test.ts +3 -0
  133. package/src/runtime/mobileViewport.ts +5 -3
  134. package/src/runtime/navigationGuard.test.ts +11 -1
  135. package/src/runtime/tooltipBridge.ts +6 -1
  136. package/src/runtime/useClipboard.test.ts +1 -1
  137. package/src/runtime/viewportGutter.test.ts +11 -8
  138. package/src/runtime/windowChrome.ts +15 -12
  139. package/src/scene3d/lighting.test.ts +25 -12
  140. package/src/scene3d/modelLayer.test.ts +16 -8
  141. package/src/styles/admin-tokens.scss +9 -0
  142. package/src/test-utils/fakes.ts +49 -0
  143. package/src/test-utils/invalid.ts +20 -0
  144. package/src/theme/solarGlobeAlignment.test.ts +88 -0
  145. package/src/theme/solarGlobeMap.test.ts +66 -0
  146. package/src/theme/solarGlobeMap.ts +193 -0
  147. package/src/theme/solarTimeZoneLocales.test.ts +92 -0
  148. package/src/theme/solarTimeZones.test.ts +142 -0
  149. package/src/theme/solarTimeZones.ts +190 -0
  150. package/src/theme/themeDecor.test.ts +2 -1
  151. package/src/theme/useBackgroundLuminance.test.ts +42 -20
  152. package/src/theme/useSolarTime.test.ts +8 -9
  153. package/src/theme/useSolarTime.ts +32 -18
  154. package/src/theme/wallpaper.ts +31 -8
  155. package/src/theme/wallpaperCustom.test.ts +42 -0
  156. package/src/theme/wallpaperShaderRenderer.test.ts +66 -34
  157. package/src/theme/wallpaperSsr.test.ts +4 -2
  158. package/src/utils/dom.test.ts +31 -10
  159. 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.249",
3
+ "version": "0.55.251",
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
  },
@@ -4,6 +4,8 @@
4
4
  * their own class on top via chipClass), while the popup — search,
5
5
  * selected tags, option rows, custom row — is fully standardized here. */
6
6
 
7
+ @use "./menu-item" as mi;
8
+
7
9
  /* ── chip base ────────────────────────────────────────────────────────
8
10
  * Neutral clickable chip; hosts override visually via their own class
9
11
  * (HkPhoneInput's dial chip, HkLocalizedInput's badge chip, …). */
@@ -232,55 +234,25 @@
232
234
  display: flex;
233
235
  flex-direction: column;
234
236
  gap: 1px;
235
- padding: var(--space-4, 4px) var(--space-6, 6px) var(--space-8, 8px);
237
+ /* GEOMETRY-NEUTRAL band (menu-item WIDTH invariant): block-axis rhythm
238
+ * only, NO inline term — the hosting panel's inset (popout 8px / sheet
239
+ * 16px) is the one horizontal inset these rows get. The old 6px inline
240
+ * term stacked a second inset and pulled the rows' wash 6px inside
241
+ * every sibling family's in the same sheet. */
242
+ padding-block: var(--space-4, 4px) var(--space-8, 8px);
236
243
  }
237
244
 
238
245
  .hk-affix-row {
239
- display: flex;
240
- align-items: center;
241
- gap: var(--space-8, 8px);
242
- width: 100%;
243
- min-height: 2.25rem;
244
- padding: var(--space-4, 4px) var(--space-8, 8px);
245
- border: none;
246
- border-radius: var(--radius-sm, 6px);
247
- background: transparent;
248
- color: rgb(var(--color-text));
249
- font-size: var(--text-sm, 0.8125rem);
250
- text-align: start;
251
- cursor: pointer;
252
- appearance: none;
253
- transition: background 0.1s ease;
254
-
255
- &:hover {
256
- background: rgb(var(--color-primary) / 10%);
257
- outline: none;
258
- }
259
-
260
- /* Keyboard position is drawn INSIDE the row (HkSelectionGrid branch-fix
261
- * pattern) and distinct from the hover tint, so overlapping popup/sheet
262
- * chrome cannot shave a per-side outer ring and focus reads as focus. */
263
- &:focus-visible {
264
- background: rgb(var(--color-primary) / 10%);
265
- outline: 2px solid rgb(var(--color-primary));
266
- outline-offset: -2px;
267
- }
268
-
269
- &[data-active] {
270
- background: rgb(var(--color-primary) / 16%);
271
- font-weight: 600;
272
- }
246
+ /* Unified menu-item spec (_menu-item.scss): one row grammar with the
247
+ * select options and menu rows around it — metrics (34px / 6×12,
248
+ * 44px / 10×16 inside sheets via the shared registration list),
249
+ * reserved 1px border, guarded hover, primary wash + strong border on
250
+ * [data-active], disabled dim, 2px inset focus ring. The sheet reach
251
+ * comes from the registration list, not from here. */
252
+ @include mi.item;
273
253
 
274
254
  &[data-disabled] {
275
- opacity: 0.5;
276
255
  cursor: default;
277
-
278
- /* The button stays keyboard-focusable (data-disabled only), so the
279
- * unpickable row must not paint the pickable tint on focus either. */
280
- &:hover,
281
- &:focus-visible {
282
- background: transparent;
283
- }
284
256
  }
285
257
 
286
258
  /* The "Use <query>" free-text row reads as an add affordance. Truncation
@@ -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];
@@ -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: Array as unknown as PropType<[number, number, number]>,
340
+ type: asPropType<[number, number, number]>(Array),
340
341
  default: () => [22, 16, 26],
341
342
  },
342
343
  initialTarget: {
343
- type: Array as unknown as PropType<[number, number, number]>,
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 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({