@celestia-island/hikari 0.45.9 → 0.47.0

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/DemoApp.tsx +37 -1
  3. package/src/components/HkAuthMethodList.scss +13 -39
  4. package/src/components/HkAuthMethodList.test.tsx +55 -73
  5. package/src/components/HkAuthMethodList.tsx +40 -149
  6. package/src/components/HkIconButtonGroup.scss +104 -0
  7. package/src/components/HkIconButtonGroup.test.tsx +182 -0
  8. package/src/components/HkIconButtonGroup.tsx +152 -0
  9. package/src/components/HkTooltip.tsx +7 -42
  10. package/src/composables/useConnectionProbe.ts +19 -0
  11. package/src/composables/useProgressDialog.ts +24 -0
  12. package/src/errorReporting/state.ts +30 -0
  13. package/src/i18n/context.ts +32 -0
  14. package/src/index.ts +21 -0
  15. package/src/runtime/animationBus.ts +42 -1
  16. package/src/runtime/cronBus.ts +18 -0
  17. package/src/runtime/historySafetyNet.ts +39 -0
  18. package/src/runtime/imageFallback.scss +13 -0
  19. package/src/runtime/imageFallback.test.ts +149 -0
  20. package/src/runtime/imageFallback.ts +149 -0
  21. package/src/runtime/index.ts +31 -0
  22. package/src/runtime/intervalBus.ts +18 -0
  23. package/src/runtime/navigationGuard.ts +20 -0
  24. package/src/runtime/pageLifecycle.ts +21 -1
  25. package/src/runtime/registry.integration.test.ts +309 -0
  26. package/src/runtime/registry.test.ts +279 -0
  27. package/src/runtime/registry.ts +315 -0
  28. package/src/runtime/tooltipBridge.test.ts +213 -0
  29. package/src/runtime/tooltipBridge.ts +314 -0
  30. package/src/runtime/tooltipPosition.test.ts +41 -0
  31. package/src/runtime/tooltipPosition.ts +61 -0
  32. package/src/runtime/useBlockingToast.ts +16 -0
  33. package/src/runtime/useOverlay.ts +27 -1
  34. package/src/runtime/usePopupManager.ts +32 -0
  35. package/src/runtime/useToast.ts +22 -0
@@ -0,0 +1,182 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkIconButtonGroup from "./HkIconButtonGroup";
5
+
6
+ /**
7
+ * HkIconButtonGroup contract tests:
8
+ * - "buttons" mode: no selection state, select fires on every click
9
+ * - "single" mode: radiogroup semantics — v-model update + data-active,
10
+ * no re-fire on the already-active key
11
+ * - "multiple" mode: toggle set in v-model, aria-pressed mirrors state
12
+ * - tooltips: every labeled item rides an HkTooltip wrapper (label text),
13
+ * an explicit `tooltip: ""` suppresses it
14
+ * - disabled items / disabled group stay dead
15
+ * - extra attrs (aria-label) fall through onto the root
16
+ *
17
+ * House style: no @vue/test-utils dependency — raw createApp mounts on a
18
+ * shared container list torn down after each case.
19
+ */
20
+
21
+ const mounts: Array<ReturnType<typeof createApp>> = [];
22
+
23
+ function mountComp(render: () => ReturnType<typeof h>): HTMLElement {
24
+ const container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ const app = createApp(defineComponent({ setup: () => render }));
27
+ mounts.push(app);
28
+ app.mount(container);
29
+ return container;
30
+ }
31
+
32
+ afterEach(() => {
33
+ for (const app of mounts.splice(0)) app.unmount();
34
+ for (const popup of [...document.querySelectorAll(".hk-tooltip-popup")]) popup.remove();
35
+ });
36
+
37
+ const options = [
38
+ { key: "totp", label: "验证器 App" },
39
+ { key: "passkey", label: "Passkey" },
40
+ ];
41
+
42
+ describe("HkIconButtonGroup", () => {
43
+ it("renders plain action buttons in buttons mode and emits select on every click", async () => {
44
+ const picked: string[] = [];
45
+ const c = mountComp(() =>
46
+ h(HkIconButtonGroup, { options, onSelect: (k: string) => picked.push(k) }),
47
+ );
48
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
49
+ expect(group.getAttribute("role")).toBe("group");
50
+ expect(group.classList.contains("hk-icon-group-buttons")).toBe(true);
51
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
52
+ expect(buttons.length).toBe(2);
53
+ expect(group.querySelectorAll(".hk-tooltip-wrapper").length).toBe(2);
54
+ buttons[0]!.click();
55
+ await Promise.resolve();
56
+ buttons[0]!.click();
57
+ await Promise.resolve();
58
+ expect(picked).toEqual(["totp", "totp"]);
59
+ // No selection state exists in buttons mode.
60
+ expect(c.querySelectorAll("[data-active]").length).toBe(0);
61
+ });
62
+
63
+ it("updates v-model and marks the active item in single mode", async () => {
64
+ const model = ref<string | null>(null);
65
+ const c = mountComp(() =>
66
+ h(HkIconButtonGroup, {
67
+ options,
68
+ mode: "single",
69
+ modelValue: model.value,
70
+ "onUpdate:modelValue": (v: string | string[]) => {
71
+ model.value = v as string;
72
+ },
73
+ }),
74
+ );
75
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
76
+ expect(group.getAttribute("role")).toBe("radiogroup");
77
+ const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
78
+ expect(buttons[0]!.getAttribute("role")).toBe("radio");
79
+ expect(buttons[0]!.getAttribute("aria-checked")).toBe("false");
80
+ buttons[0]!.click();
81
+ await nextTick();
82
+ expect(model.value).toBe("totp");
83
+ expect(buttons[0]!.dataset.active).toBe("true");
84
+ expect(buttons[0]!.getAttribute("aria-checked")).toBe("true");
85
+ });
86
+
87
+ it("does not re-fire when the active key is re-clicked in single mode", async () => {
88
+ const events: string[] = [];
89
+ const c = mountComp(() =>
90
+ h(HkIconButtonGroup, {
91
+ options,
92
+ mode: "single",
93
+ modelValue: "totp",
94
+ "onUpdate:modelValue": () => events.push("update"),
95
+ onSelect: (k: string) => events.push(`select:${k}`),
96
+ }),
97
+ );
98
+ const button = c.querySelector<HTMLButtonElement>(".hk-icon-group-item")!;
99
+ expect(button.dataset.active).toBe("true");
100
+ button.click();
101
+ await Promise.resolve();
102
+ expect(events).toEqual([]);
103
+ });
104
+
105
+ it("toggles keys into and out of the v-model array in multiple mode", async () => {
106
+ const seen: Array<string | string[]> = [];
107
+ const c = mountComp(() =>
108
+ h(HkIconButtonGroup, {
109
+ options,
110
+ mode: "multiple",
111
+ modelValue: ["passkey"],
112
+ "onUpdate:modelValue": (v: string | string[]) => seen.push(v),
113
+ }),
114
+ );
115
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
116
+ expect(group.getAttribute("role")).toBe("group");
117
+ const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
118
+ expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true");
119
+ buttons[0]!.click();
120
+ buttons[1]!.click();
121
+ await Promise.resolve();
122
+ expect(seen).toEqual([["passkey", "totp"], []]);
123
+ });
124
+
125
+ it("suppresses the tooltip with an explicit empty override and keeps the label as the a11y name", () => {
126
+ const c = mountComp(() =>
127
+ h(HkIconButtonGroup, {
128
+ options: [
129
+ { key: "a", label: "Alpha" },
130
+ { key: "b", label: "Beta", tooltip: "" },
131
+ ],
132
+ }),
133
+ );
134
+ expect(c.querySelectorAll(".hk-tooltip-wrapper").length).toBe(1);
135
+ const beta = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")[1]!;
136
+ expect(beta.getAttribute("aria-label")).toBe("Beta");
137
+ // The HkTooltip popup owns the hover text — the button carries no title.
138
+ expect(beta.getAttribute("title")).toBeNull();
139
+ });
140
+
141
+ it("renders the given icon vnode and falls back to the label initial without one", () => {
142
+ const c = mountComp(() =>
143
+ h(HkIconButtonGroup, {
144
+ options: [
145
+ { key: "a", label: "Alpha", icon: h("span", { class: "glyph" }) },
146
+ { key: "b", label: "Beta" },
147
+ ],
148
+ }),
149
+ );
150
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
151
+ expect(buttons[0]!.querySelector(".glyph")).toBeTruthy();
152
+ expect(buttons[1]!.querySelector<HTMLElement>(".hk-icon-group-item-initial")?.textContent).toBe("B");
153
+ });
154
+
155
+ it("keeps disabled items and the group-level disable inert", async () => {
156
+ const picked: string[] = [];
157
+ const c = mountComp(() =>
158
+ h(HkIconButtonGroup, {
159
+ options: [
160
+ { key: "a", label: "Alpha", disabled: true },
161
+ { key: "b", label: "Beta" },
162
+ ],
163
+ disabled: true,
164
+ onSelect: (k: string) => picked.push(k),
165
+ }),
166
+ );
167
+ const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
168
+ expect(buttons.every((b) => b.disabled)).toBe(true);
169
+ buttons[1]!.click();
170
+ await Promise.resolve();
171
+ expect(picked).toEqual([]);
172
+ });
173
+
174
+ it("falls extra attrs through onto the group root", () => {
175
+ const c = mountComp(() =>
176
+ h(HkIconButtonGroup, { options, "aria-label": "验证方式", "data-test": "probe" }),
177
+ );
178
+ const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
179
+ expect(group.getAttribute("aria-label")).toBe("验证方式");
180
+ expect(group.getAttribute("data-test")).toBe("probe");
181
+ });
182
+ });
@@ -0,0 +1,152 @@
1
+ import { computed, defineComponent, type PropType } from "vue";
2
+ import HkTooltip from "./HkTooltip";
3
+ import type { TooltipPlacement } from "../runtime/tooltipPosition";
4
+ import "./HkIconButtonGroup.scss";
5
+
6
+ /** One selectable icon in the group. */
7
+ export interface HkIconButtonGroupOption {
8
+ /** Stable identity — the value surfaced through v-model / select. */
9
+ key: string;
10
+ /** Accessible name AND the default tooltip text. Required on purpose:
11
+ * an icon-only control without a label is an a11y hole. */
12
+ label: string;
13
+ /** Icon-only content: a prebuilt vnode (brand SVG, lucide component,
14
+ * <img>) or anything renderable. Typed loose on purpose (same as
15
+ * HkAuthMethodList entries): hosts materialize hikari against their
16
+ * own vue minor, and a hard VNode type breaks typecheck whenever the
17
+ * host's vue differs. Missing icons fall back to the label's initial. */
18
+ icon?: unknown;
19
+ disabled?: boolean;
20
+ /** Tooltip text override; an EMPTY string suppresses the tooltip. */
21
+ tooltip?: string;
22
+ }
23
+
24
+ /**
25
+ * HkIconButtonGroup — the icon-only variant of the button-group family
26
+ * (the same centered strip grammar as HkTabs, minus the text).
27
+ *
28
+ * Three working modes:
29
+ * - "buttons" a plain action group (no selection state) — the auth
30
+ * card's OAuth provider row
31
+ * - "single" a radiogroup: one active key (v-model), re-clicking the
32
+ * active key is a no-op — radio semantics
33
+ * - "multiple" a toggle group: v-model is a string[] of active keys,
34
+ * every item carries aria-pressed
35
+ *
36
+ * Every item is icon-only and rides one size taller than a standard
37
+ * icon button (md = 44px vs the 32px icon button / 40px text button) —
38
+ * the Windows/macOS-style login chooser. Because an icon alone is
39
+ * unlabelable at a glance, each item wraps itself in an HkTooltip
40
+ * (text = the option's label unless overridden), so the hover reveal is
41
+ * built in rather than left to every consumer.
42
+ *
43
+ * Known a11y limitation (documented 2026-09-13): single mode uses a
44
+ * radiogroup role with every item tabbable, not the full WAI-APG roving
45
+ * tabindex. All-items-tabbable keeps keyboard reachability identical
46
+ * across the three modes; roving focus is a follow-up if a host needs
47
+ * strict APG conformance.
48
+ */
49
+ export default defineComponent({
50
+ name: "HkIconButtonGroup",
51
+ inheritAttrs: false,
52
+ props: {
53
+ options: { type: Array as unknown as () => HkIconButtonGroupOption[], required: true },
54
+ /** Active key ("single") or keys ("multiple"); null when nothing is
55
+ * active — required/ignored by "buttons" mode. */
56
+ modelValue: { type: [String, Array] as PropType<string | string[] | null>, default: null },
57
+ mode: { type: String as PropType<"buttons" | "single" | "multiple">, default: "buttons" },
58
+ size: { type: String as PropType<"sm" | "md" | "lg">, default: "md" },
59
+ disabled: { type: Boolean, default: false },
60
+ tooltipPlacement: { type: String as PropType<TooltipPlacement>, default: "top" },
61
+ tooltipDelay: { type: Number, default: 300 },
62
+ },
63
+ emits: {
64
+ /** v-model for "single" (string) and "multiple" (string[]). */
65
+ "update:modelValue": (_value: string | string[]) => true,
66
+ /** An item was activated (or toggled). Never fires for a no-op
67
+ * re-click of the already-active key in "single" mode. */
68
+ select: (_key: string) => true,
69
+ },
70
+ setup(props, { emit, attrs }) {
71
+ const cls = computed(() => [
72
+ "hk-icon-group",
73
+ `hk-icon-group-${props.mode}`,
74
+ `hk-icon-group-${props.size}`,
75
+ ]);
76
+
77
+ const groupRole = computed(() => (props.mode === "single" ? "radiogroup" : "group"));
78
+
79
+ function isActive(key: string): boolean {
80
+ if (props.mode === "multiple") {
81
+ return Array.isArray(props.modelValue) && props.modelValue.includes(key);
82
+ }
83
+ if (props.mode === "single") {
84
+ return props.modelValue === key;
85
+ }
86
+ return false;
87
+ }
88
+
89
+ function onSelect(option: HkIconButtonGroupOption) {
90
+ if (props.disabled || option.disabled) return;
91
+ if (props.mode === "single") {
92
+ if (isActive(option.key)) return; // radio semantics: no re-fire
93
+ emit("update:modelValue", option.key);
94
+ } else if (props.mode === "multiple") {
95
+ const current = Array.isArray(props.modelValue) ? props.modelValue : [];
96
+ const next = current.includes(option.key)
97
+ ? current.filter((k) => k !== option.key)
98
+ : [...current, option.key];
99
+ emit("update:modelValue", next);
100
+ }
101
+ emit("select", option.key);
102
+ }
103
+
104
+ return () => (
105
+ <div class={cls.value} role={groupRole.value} {...attrs}>
106
+ {props.options.map((option) => {
107
+ const tooltip = option.tooltip !== undefined ? option.tooltip : option.label;
108
+ const active = isActive(option.key);
109
+ const button = (
110
+ <button
111
+ key={option.key}
112
+ type="button"
113
+ class="hk-icon-group-item"
114
+ data-key={option.key}
115
+ data-active={active || undefined}
116
+ role={props.mode === "single" ? "radio" : undefined}
117
+ aria-checked={props.mode === "single" ? active : undefined}
118
+ aria-pressed={props.mode === "multiple" ? active : undefined}
119
+ aria-label={option.label}
120
+ title={undefined} // the HkTooltip popup owns the hover text
121
+ disabled={props.disabled || option.disabled}
122
+ onClick={() => onSelect(option)}
123
+ >
124
+ <span class="hk-icon-group-item-icon">
125
+ {option.icon != null
126
+ ? option.icon
127
+ : (
128
+ <span class="hk-icon-group-item-initial" aria-hidden="true">
129
+ {option.label.charAt(0).toUpperCase()}
130
+ </span>
131
+ )}
132
+ </span>
133
+ </button>
134
+ );
135
+ // An empty tooltip override suppresses the wrapper entirely.
136
+ return tooltip
137
+ ? (
138
+ <HkTooltip
139
+ key={option.key}
140
+ text={tooltip}
141
+ placement={props.tooltipPlacement}
142
+ delay={props.tooltipDelay}
143
+ >
144
+ {button}
145
+ </HkTooltip>
146
+ )
147
+ : button;
148
+ })}
149
+ </div>
150
+ );
151
+ },
152
+ });
@@ -1,13 +1,13 @@
1
1
  import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
2
2
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
3
- import { ancestorZoom } from "../runtime/cssZoom";
3
+ import { tooltipPositionStyle, type TooltipPlacement } from "../runtime/tooltipPosition";
4
4
  import "./HkTooltip.scss";
5
5
 
6
6
  export default defineComponent({
7
7
  name: "HkTooltip",
8
8
  props: {
9
9
  text: { type: String, required: true },
10
- placement: { type: String as PropType<"top" | "bottom" | "left" | "right">, default: "top" },
10
+ placement: { type: String as PropType<TooltipPlacement>, default: "top" },
11
11
  delay: { type: Number, default: 300 },
12
12
  maxWidth: { type: String, default: undefined },
13
13
  },
@@ -33,46 +33,11 @@ export default defineComponent({
33
33
  function updatePosition() {
34
34
  if (!wrapperRef.value) return;
35
35
  const rect = wrapperRef.value.getBoundingClientRect();
36
- const gap = 8;
37
- // The tooltip teleports to <body> — inside any root CSS zoom
38
- // subtree — while `rect` is already in the root visual space
39
- // (standardized zoom reports ancestor zoom applied). The browser
40
- // scales the tooltip's local px back up at paint, so the visual
41
- // rect values must be written divided by the cumulative zoom or
42
- // the tooltip drifts zoom× off its trigger (chest's root-level
43
- // manual DPI scale).
44
- const z = ancestorZoom(document.body);
45
- const local = (v: number) => `${v / z}px`;
46
- const style: CSSProperties = {};
47
-
48
- if (props.maxWidth) {
49
- style.maxWidth = props.maxWidth;
50
- }
51
-
52
- switch (props.placement) {
53
- case "top":
54
- style.top = local(rect.top - gap);
55
- style.left = local(rect.left + rect.width / 2);
56
- style.transform = "translate(-50%, -100%)";
57
- break;
58
- case "bottom":
59
- style.top = local(rect.bottom + gap);
60
- style.left = local(rect.left + rect.width / 2);
61
- style.transform = "translate(-50%, 0)";
62
- break;
63
- case "left":
64
- style.top = local(rect.top + rect.height / 2);
65
- style.left = local(rect.left - gap);
66
- style.transform = "translate(-100%, -50%)";
67
- break;
68
- case "right":
69
- style.top = local(rect.top + rect.height / 2);
70
- style.left = local(rect.right + gap);
71
- style.transform = "translate(0, -50%)";
72
- break;
73
- }
74
-
75
- tooltipStyle.value = style;
36
+ // Placement geometry lives in the shared runtime helper (it also
37
+ // serves the document-level tooltip bridge) — including the
38
+ // ancestor-zoom division that keeps teleported popups pinned to
39
+ // their trigger inside scaled roots.
40
+ tooltipStyle.value = tooltipPositionStyle(rect, props.placement, props.maxWidth);
76
41
  }
77
42
 
78
43
  function show() {
@@ -1,6 +1,7 @@
1
1
  import { onMounted, onUnmounted, ref, type Ref } from "vue";
2
2
 
3
3
  import { scheduleInterval, scheduleIntervalAfter, type IntervalHandle } from "../runtime/intervalBus";
4
+ import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
4
5
 
5
6
  /** Fetch-based connectivity probe — the hikari-local successor of the
6
7
  * plana-ui probe. Polls `/api/health` (or a custom endpoint set via
@@ -28,6 +29,20 @@ const DEFAULT_RETRY_TOTAL = 3;
28
29
 
29
30
  let probeEndpoint: string | (() => string) = "";
30
31
 
32
+ // Runtime-registry reporting (the "context of contexts"): the probe is a
33
+ // per-component composable, so the MODULE is the hook — one registry
34
+ // entry counting live probe instances, with the configured endpoint in
35
+ // the read facet. Registration API unchanged.
36
+ let liveProbes = 0;
37
+ let probeRuntime: HkRuntimeHandle | null = null;
38
+ function ensureProbeRuntime(): HkRuntimeHandle {
39
+ return (probeRuntime ??= reportHkRuntime("connectionProbe", {
40
+ kind: "hook",
41
+ description: "The fetch-based connectivity probe: per-component /api/health polling with retry and countdown.",
42
+ read: () => ({ liveProbes, endpoint: endpointUrl() }),
43
+ }));
44
+ }
45
+
31
46
  /** Override the probed endpoint (defaults to same-origin `/api/health`). */
32
47
  export function setProbeEndpoint(endpoint: string | (() => string)): void {
33
48
  probeEndpoint = endpoint;
@@ -127,11 +142,15 @@ export function useConnectionProbe(): {
127
142
  }
128
143
 
129
144
  onMounted(() => {
145
+ liveProbes += 1;
146
+ ensureProbeRuntime().pulse({ liveProbes });
130
147
  void probeOnce();
131
148
  tickHandle = scheduleInterval(applyCountdown, 1000);
132
149
  });
133
150
 
134
151
  onUnmounted(() => {
152
+ liveProbes = Math.max(0, liveProbes - 1);
153
+ probeRuntime?.pulse({ liveProbes });
135
154
  pollHandle?.disconnect();
136
155
  pollHandle = null;
137
156
  tickHandle?.disconnect();
@@ -1,5 +1,7 @@
1
1
  import { reactive } from "vue";
2
2
 
3
+ import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
4
+
3
5
  export interface ProgressDialogState {
4
6
  open: boolean;
5
7
  title: string;
@@ -24,7 +26,27 @@ const state = reactive<ProgressDialogState>({
24
26
  max: 100,
25
27
  });
26
28
 
29
+ // Runtime-registry reporting (the "context of contexts"). Lazy: the
30
+ // progress-dialog context reports itself on first use and pulses on
31
+ // open/close, so the live dialog tree is answerable from
32
+ // readHkRuntime("progressDialog") without importing this module.
33
+ let runtimeReport: HkRuntimeHandle | null = null;
34
+ function ensureRuntimeReport(): HkRuntimeHandle {
35
+ return (runtimeReport ??= reportHkRuntime("progressDialog", {
36
+ kind: "context",
37
+ description: "The progress dialog context: the single global task-progress surface (title, log tail, value/max).",
38
+ read: () => ({
39
+ open: state.open,
40
+ title: state.title,
41
+ logLines: state.logs.length,
42
+ value: state.value,
43
+ max: state.max,
44
+ }),
45
+ }));
46
+ }
47
+
27
48
  export function showProgressDialog(opts: { title: string }): ProgressDialogHandle {
49
+ ensureRuntimeReport().pulse({ open: true, title: opts.title });
28
50
  state.title = opts.title;
29
51
  state.logs = [];
30
52
  state.value = null;
@@ -43,10 +65,12 @@ export function showProgressDialog(opts: { title: string }): ProgressDialogHandl
43
65
  },
44
66
  close() {
45
67
  state.open = false;
68
+ runtimeReport?.pulse({ open: false });
46
69
  },
47
70
  };
48
71
  }
49
72
 
50
73
  export function useProgressDialog(): ProgressDialogState {
74
+ ensureRuntimeReport();
51
75
  return state;
52
76
  }
@@ -1,5 +1,7 @@
1
1
  import { ref, type Ref } from "vue";
2
2
 
3
+ import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
4
+
3
5
  /** Where a reported error came from. */
4
6
  export type HkErrorSource = "vue" | "window" | "rejection" | "manual";
5
7
 
@@ -66,6 +68,10 @@ export function getErrorReportingOptions(): HkErrorReportingOptions {
66
68
 
67
69
  export function setErrorReportingOptions(options: HkErrorReportingOptions): void {
68
70
  activeOptions = options;
71
+ runtimeReport?.setMeta({
72
+ captureWindow: options.captureWindow !== false,
73
+ captureRejection: options.captureRejection !== false,
74
+ });
69
75
  }
70
76
 
71
77
  export function useErrorReportingState(): Ref<HkReportedError | null> {
@@ -86,6 +92,11 @@ export function reportError(err: unknown, source: HkErrorSource, info?: string):
86
92
 
87
93
  if (currentError.value !== null) return null;
88
94
 
95
+ // Accepted onto the landing (first-error-wins): later errors while the
96
+ // landing is up still log/fire onError above but do not count.
97
+ acceptedReports += 1;
98
+ runtimeReport?.pulse({ reports: acceptedReports });
99
+
89
100
  const normalized = normalizeError(err);
90
101
  const record: HkReportedError = {
91
102
  source,
@@ -102,3 +113,22 @@ export function reportError(err: unknown, source: HkErrorSource, info?: string):
102
113
  export function clearErrorReportingState(): void {
103
114
  currentError.value = null;
104
115
  }
116
+
117
+ // Runtime-registry reporting (the "context of contexts"): the error
118
+ // reporting module is a global singleton, so it reports itself at module
119
+ // init and pulses on every accepted error. The read facet keeps the
120
+ // current landing card observable (name/message/source only — never the
121
+ // whole error object) from readHkRuntime("errorReporting").
122
+ let acceptedReports = 0;
123
+ const runtimeReport: HkRuntimeHandle = reportHkRuntime("errorReporting", {
124
+ kind: "plugin",
125
+ description: "The global error reporting landing: app.config.errorHandler + window/rejection hooks with the full-screen error card.",
126
+ meta: { captureWindow: activeOptions.captureWindow !== false, captureRejection: activeOptions.captureRejection !== false },
127
+ read: () => ({
128
+ reports: acceptedReports,
129
+ landingUp: currentError.value !== null,
130
+ current: currentError.value
131
+ ? { name: currentError.value.name, message: currentError.value.message, source: currentError.value.source }
132
+ : null,
133
+ }),
134
+ });
@@ -1,5 +1,7 @@
1
1
  import { reactive } from "vue";
2
2
 
3
+ import { reportHkRuntime } from "../runtime/registry";
4
+
3
5
  type Messages = Record<string, string>;
4
6
 
5
7
  interface LocaleModule {
@@ -63,6 +65,7 @@ export async function setLocale(locale: string): Promise<void> {
63
65
  ...(mergedMessages["en"] ?? {}),
64
66
  ...(mergedMessages[locale] ?? {}),
65
67
  };
68
+ runtime.pulse({ locale });
66
69
  }
67
70
 
68
71
  export function mergeMessages(userMessages: Messages, locale?: string): void {
@@ -82,6 +85,7 @@ export function mergeMessages(userMessages: Messages, locale?: string): void {
82
85
  ...(mergedMessages["en"] ?? {}),
83
86
  ...(mergedMessages[state.locale] ?? {}),
84
87
  };
88
+ runtime.pulse({ locale: state.locale });
85
89
  }
86
90
 
87
91
  export function useI18n() {
@@ -96,3 +100,31 @@ export function useI18n() {
96
100
 
97
101
  return { t, locale: state.locale };
98
102
  }
103
+
104
+ // Runtime-registry reporting (the "context of contexts"): the i18n
105
+ // context is a module singleton, so it reports itself at module init and
106
+ // pulses on every locale switch / message merge. The facets make the
107
+ // locale tree reachable and steerable from anywhere via
108
+ // readHkRuntime("i18n") / writeHkRuntime("i18n", …) without importing
109
+ // this module.
110
+ const runtime = reportHkRuntime("i18n", {
111
+ kind: "context",
112
+ description: "The i18n context: active locale, message tables and per-locale merged overrides.",
113
+ meta: { locale: state.locale },
114
+ read: () => ({
115
+ locale: state.locale,
116
+ keys: Object.keys(state.messages).length,
117
+ mergedLocales: Object.keys(mergedMessages),
118
+ }),
119
+ write: (op) => {
120
+ if (op.type === "setLocale" && typeof op.locale === "string") {
121
+ void setLocale(op.locale);
122
+ return;
123
+ }
124
+ if (op.type === "mergeMessages" && typeof op.messages === "object" && op.messages !== null) {
125
+ mergeMessages(op.messages as Messages, typeof op.locale === "string" ? op.locale : undefined);
126
+ return;
127
+ }
128
+ throw new Error(`unknown i18n write op "${op.type}"`);
129
+ },
130
+ });
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export { default as HExpansionPanel } from "./components/HkExpansionPanel";
24
24
  export { default as HFab, type HFabAction } from "./components/HkFab";
25
25
  export { default as HIcon } from "./components/HkIcon";
26
26
  export { default as HIconButton } from "./components/HkIconButton";
27
+ export { default as HIconButtonGroup, type HkIconButtonGroupOption } from "./components/HkIconButtonGroup";
27
28
  export { default as HImageLightbox } from "./components/HkImageLightbox";
28
29
  export { default as HImagePreview, type ImagePreviewObjectFit } from "./components/HkImagePreview";
29
30
  export { default as HInput } from "./components/HkInput";
@@ -239,6 +240,26 @@ export {
239
240
  type SafeAreaInsets,
240
241
  type BackGuard,
241
242
  type BackGuardOptions,
243
+ installHkTooltipBridge,
244
+ type HkTooltipBridgeOptions,
245
+ installHkImageFallback,
246
+ type HkImageFallbackOptions,
247
+ reportHkRuntime,
248
+ getHkRuntimeEntry,
249
+ listHkRuntime,
250
+ readHkRuntime,
251
+ writeHkRuntime,
252
+ hkRuntimeSnapshot,
253
+ useHkRegistry,
254
+ type HkRuntimeKind,
255
+ type HkRuntimeStatus,
256
+ type HkRuntimeMeta,
257
+ type HkRuntimeEntryState,
258
+ type HkRuntimeReport,
259
+ type HkRuntimeHandle,
260
+ type HkRuntimeWriteOp,
261
+ type HkRuntimeSnapshot,
262
+ type HkRuntimeSnapshotEntry,
242
263
  } from "./runtime";
243
264
 
244
265
  // i18n