@celestia-island/hikari 0.46.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1,8 +1,12 @@
1
- /* Alternative sign-in methods as an icon button group (login card). The
2
- block container `.s-auth-methods` and its spacing are owned by
3
- HkAuthCard's slot styles (styles/admin-tokens.scss); this file only
4
- styles the divider and the icon group row the component renders into
5
- that slot. */
1
+ /* Alternative sign-in methods as CENTERED INDEPENDENT framed icon tiles
2
+ * (login card). The block container `.s-auth-methods` and its spacing are
3
+ * owned by HkAuthCard's slot styles (styles/admin-tokens.scss); this file
4
+ * only styles the divider and the tile row the component renders into that
5
+ * slot.
6
+ *
7
+ * Deliberately NOT a button group (2026-09-14 user direction): no shared
8
+ * track, no shared frame — each provider carries its own border so the row
9
+ * stays as wide as its content, centered in the card. */
6
10
 
7
11
  /* "Other ways to sign in" divider row between rules. */
8
12
  .s-auth-methods-divider {
@@ -24,16 +28,91 @@
24
28
  }
25
29
 
26
30
  /* The component's own wrapper is `display: contents` — layout-wise the
27
- divider and the group row remain direct children of HkAuthCard's
31
+ divider and the tile row remain direct children of HkAuthCard's
28
32
  `.s-auth-methods` slot container. */
29
33
  .s-auth-methods-list {
30
34
  display: contents;
31
35
  }
32
36
 
33
- /* The icon button group row: fill the card column and center the
34
- provider tiles inside it (descendant match off `.s-auth-methods` —
35
- the wrapper generates no box of its own). */
36
- .s-auth-methods .hk-icon-group.s-auth-methods-group {
37
+ /* The tile row: fill the card column and center the independent tiles
38
+ inside it. The gap between tiles is the INDEPENDENT-action spacing
39
+ (--space-12) — comfortable air, unlike a group's shoulder-to-shoulder
40
+ track gap. */
41
+ .s-auth-methods .s-auth-methods-tiles {
42
+ display: flex;
43
+ flex-wrap: wrap;
44
+ align-items: center;
45
+ justify-content: center;
46
+ gap: var(--space-12, 0.75rem);
37
47
  width: 100%;
48
+ }
49
+
50
+ /* The HkTooltip wrapper span is the flex child between tiles — let it
51
+ shrink to the button so the row gap (not the wrapper) spaces them. */
52
+ .s-auth-methods .s-auth-methods-tiles > .hk-tooltip-wrapper {
53
+ display: inline-flex;
54
+ flex: none;
55
+ }
56
+
57
+ /* One provider tile: a framed 44px square (the login-chooser tile size,
58
+ same as the icon group's md item). Each tile owns its whole frame —
59
+ border, hover wash, focus ring — with no track around the row. */
60
+ .s-auth-methods-tile {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ width: 2.75rem;
65
+ height: 2.75rem;
66
+ padding: 0;
67
+ font: inherit;
68
+ color: rgb(var(--color-text, 25 30 40));
69
+ background: rgb(var(--color-surface, 255 255 255) / 55%);
70
+ border: 1px solid rgb(var(--color-border, 222 224 228) / 55%);
71
+ border-radius: var(--radius-md, 8px);
72
+ cursor: pointer;
73
+ outline: none;
74
+ transition-property: background-color, border-color, color, box-shadow, opacity, transform;
75
+ transition-duration: var(--duration-short, 0.15s);
76
+ transition-timing-function: ease;
77
+
78
+ /* A real <button disabled> still matches :hover — guard the dead ones. */
79
+ &:hover:not(:disabled) {
80
+ color: rgb(var(--color-text, 25 30 40));
81
+ background: var(--c-primary-faint, rgb(var(--color-primary, 90 140 255) / 5%));
82
+ border-color: var(--c-primary-medium, rgb(var(--color-primary, 90 140 255) / 25%));
83
+ }
84
+
85
+ /* Tactile press feedback — the button-family cadence. */
86
+ &:active:not(:disabled) {
87
+ transform: scale(0.96);
88
+ }
89
+
90
+ /* Outset double ring, same grammar as .hk-btn. */
91
+ &:focus-visible {
92
+ box-shadow:
93
+ 0 0 0 2px rgb(var(--color-surface, 255 255 255)),
94
+ 0 0 0 4px rgb(var(--color-primary, 90 140 255));
95
+ }
96
+
97
+ &:disabled {
98
+ opacity: 0.5;
99
+ cursor: not-allowed;
100
+ }
101
+ }
102
+
103
+ .s-auth-methods-tile-icon {
104
+ display: inline-flex;
105
+ align-items: center;
38
106
  justify-content: center;
107
+ line-height: 0;
108
+ /* Brand marks (svg/img vnodes) at their intrinsic size; lucide comps
109
+ sized by the host. The box never grows past the square. */
110
+ max-width: 100%;
111
+ max-height: 100%;
112
+ }
113
+
114
+ .s-auth-methods-tile-initial {
115
+ font-size: var(--text-sm, 0.8125rem);
116
+ font-weight: 600;
117
+ line-height: 1;
39
118
  }
@@ -28,27 +28,39 @@ const methods = [
28
28
  ];
29
29
 
30
30
  describe("HkAuthMethodList", () => {
31
- it("renders the provider row as an icon button group with an optional divider", () => {
31
+ it("renders centered independent framed tiles with an optional divider", () => {
32
32
  // The card's `methods` slot owns the `.s-auth-methods` container —
33
- // reproduce that context here (the divider and the group row are layout
33
+ // reproduce that context here (the divider and the tile row are layout
34
34
  // children of it through the component's display:contents wrapper).
35
35
  const c = mount(
36
36
  h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { divider: "其他方式登录", methods })),
37
37
  );
38
38
  const divider = c.querySelector(".s-auth-methods-divider");
39
39
  expect(divider?.textContent).toContain("其他方式登录");
40
- const group = c.querySelector<HTMLElement>(".s-auth-methods .hk-icon-group");
41
- expect(group).toBeTruthy();
42
- expect(group!.classList.contains("hk-icon-group-buttons")).toBe(true);
43
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
44
- expect(buttons.length).toBe(2);
40
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
41
+ expect(tiles.length).toBe(2);
45
42
  // Icon-only: the label is the accessible name + tooltip, never visible text.
46
- expect(buttons[0]!.getAttribute("aria-label")).toBe("GitHub");
47
- expect(buttons[1]!.getAttribute("aria-label")).toBe("LinuxDo");
48
- expect(buttons[0]!.textContent).not.toContain("GitHub");
43
+ expect(tiles[0]!.getAttribute("aria-label")).toBe("GitHub");
44
+ expect(tiles[1]!.getAttribute("aria-label")).toBe("LinuxDo");
45
+ expect(tiles[0]!.textContent).not.toContain("GitHub");
49
46
  });
50
47
 
51
- it("wraps every item in a tooltip carrying the provider label", () => {
48
+ it("never wraps the providers in a button-group track", () => {
49
+ // 2026-09-14 user direction: a shared group frame (one wide bordered
50
+ // track with the icons floating inside) is NOT this component's
51
+ // grammar — every provider is an independent framed tile. A regression
52
+ // back to HkIconButtonGroup (or any .hk-icon-group markup) must fail
53
+ // here, because the button group is reserved for selectors and tight
54
+ // action strips.
55
+ const c = mount(
56
+ h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
57
+ );
58
+ expect(c.querySelector(".s-auth-methods .hk-icon-group")).toBeNull();
59
+ expect(c.querySelector(".s-auth-methods [role='group']")).toBeNull();
60
+ expect(c.querySelector(".s-auth-methods [role='radiogroup']")).toBeNull();
61
+ });
62
+
63
+ it("wraps every tile in a tooltip carrying the provider label", () => {
52
64
  const c = mount(
53
65
  h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
54
66
  );
@@ -71,12 +83,12 @@ describe("HkAuthMethodList", () => {
71
83
  },
72
84
  })),
73
85
  );
74
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
75
- buttons[1]!.click();
86
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
87
+ tiles[1]!.click();
76
88
  await nextTick();
77
89
  // Both rows are asserted so a constant-literal emit cannot ride an
78
90
  // accidental fixture coincidence.
79
- buttons[0]!.click();
91
+ tiles[0]!.click();
80
92
  await nextTick();
81
93
  expect(picked).toEqual(["linuxdo", "github"]);
82
94
  });
@@ -95,15 +107,15 @@ describe("HkAuthMethodList", () => {
95
107
  },
96
108
  })),
97
109
  );
98
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
99
- expect(buttons.length).toBe(3);
100
- // The prebuilt brand vnode renders inside the icon column.
101
- expect(buttons[0]!.querySelector(".brand-gh")).toBeTruthy();
110
+ const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
111
+ expect(tiles.length).toBe(3);
112
+ // The prebuilt brand vnode renders inside the icon box.
113
+ expect(tiles[0]!.querySelector(".brand-gh")).toBeTruthy();
102
114
  // A missing icon falls back to the label initial (never an empty box).
103
- expect(buttons[2]!.querySelector<HTMLElement>(".hk-icon-group-item-initial")?.textContent).toBe("F");
115
+ expect(tiles[2]!.querySelector<HTMLElement>(".s-auth-methods-tile-initial")?.textContent).toBe("F");
104
116
  // Disabled entries render dead and swallow clicks.
105
- expect(buttons[1]!.disabled).toBe(true);
106
- buttons[1]!.click();
117
+ expect(tiles[1]!.disabled).toBe(true);
118
+ tiles[1]!.click();
107
119
  await nextTick();
108
120
  expect(picked).toBe("");
109
121
  });
@@ -1,23 +1,27 @@
1
1
  import { defineComponent } from "vue";
2
- import HkIconButtonGroup from "./HkIconButtonGroup";
2
+ import HkTooltip from "./HkTooltip";
3
3
  import "./HkAuthMethodList.scss";
4
4
 
5
5
  /**
6
6
  * HkAuthMethodList — the auth card's third-party sign-in block (the
7
7
  * "other ways to sign in" row under the credentials form).
8
8
  *
9
- * Since 2026-09-13 (user direction) the block renders as the ICON
10
- * BUTTON GROUP by default: one centered `HkIconButtonGroup` row of
11
- * icon-only provider buttons (mode "buttons", size md) instead of the
12
- * stacked full-width [icon | label] buttons. Provider identity is
13
- * revealed on hover — every item wraps itself in an HkTooltip with the
14
- * provider label — which matches the platform login-chooser pattern
15
- * (Windows/macOS account tiles) and keeps the card compact when a
16
- * deployment offers several providers. The `select` event carries the
17
- * provider `key` exactly as before, so consumers swap rendering
18
- * without touching their OAuth flow.
9
+ * Since 2026-09-14 (user direction) the block renders as CENTERED
10
+ * INDEPENDENT icon tiles: every provider is its own framed 44px icon
11
+ * button with its own tooltip — NOT a button group. A group track (one
12
+ * shared frame stretched across the card with the icons floating in
13
+ * the middle) reads as an oversized empty box; the button-group
14
+ * component is reserved for its two fundamental jobs — a selector
15
+ * (single/multiple) and a tight action strip (see HkIconButtonGroup).
19
16
  *
20
- * The wrapper stays `display: contents`: the divider and the group row
17
+ * Provider identity is revealed on hover: each tile wraps itself in an
18
+ * HkTooltip carrying the provider label, which matches the platform
19
+ * login-chooser pattern (Windows/macOS account tiles) and keeps the
20
+ * card compact when a deployment offers several providers. The
21
+ * `select` event carries the provider `key` exactly as before, so
22
+ * consumers swap rendering without touching their OAuth flow.
23
+ *
24
+ * The wrapper stays `display: contents`: the divider and the tile row
21
25
  * remain layout children of HkAuthCard's `.s-auth-methods` slot
22
26
  * container (it owns the side padding and vertical rhythm).
23
27
  */
@@ -35,11 +39,11 @@ export default defineComponent({
35
39
  /** Accessible name + tooltip text (the hover reveal). */
36
40
  label: string;
37
41
  /** Prebuilt icon vnode (brand SVG, <img>, …) rendered inside the
38
- * icon-only button. Typed loose on purpose (same as
39
- * HkIconButtonGroup options): hosts materialize hikari against
40
- * their own vue store, and a hard VNode type breaks typecheck
41
- * whenever the host's vue minor differs. A missing icon falls
42
- * back to the label's initial. */
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. */
43
47
  icon?: unknown;
44
48
  disabled?: boolean;
45
49
  }>,
@@ -47,7 +51,7 @@ export default defineComponent({
47
51
  },
48
52
  },
49
53
  emits: {
50
- /** A provider button was clicked. */
54
+ /** A provider tile was clicked. */
51
55
  select: (_key: string) => true,
52
56
  },
53
57
  setup(props, { emit }) {
@@ -58,18 +62,42 @@ export default defineComponent({
58
62
  <span>{props.divider}</span>
59
63
  </div>
60
64
  )}
61
- <HkIconButtonGroup
62
- class="s-auth-methods-group"
63
- mode="buttons"
64
- size="md"
65
- options={props.methods.map((method) => ({
66
- key: method.key,
67
- label: method.label,
68
- icon: method.icon,
69
- disabled: method.disabled === true,
70
- }))}
71
- onSelect={(key: string) => emit("select", key)}
72
- />
65
+ {/* A plain row, deliberately NOT role="group": every tile is an
66
+ independent action (its own frame, its own tooltip), and the
67
+ divider above names the section for sighted users. Group
68
+ semantics belong to the selector/tight-strip components. */}
69
+ <div class="s-auth-methods-tiles">
70
+ {props.methods.map((method) => {
71
+ const tile = (
72
+ <button
73
+ key={method.key}
74
+ type="button"
75
+ class="s-auth-methods-tile"
76
+ data-key={method.key}
77
+ aria-label={method.label}
78
+ title={undefined} // the HkTooltip popup owns the hover text
79
+ disabled={method.disabled === true}
80
+ onClick={() => emit("select", method.key)}
81
+ >
82
+ <span class="s-auth-methods-tile-icon" aria-hidden="true">
83
+ {method.icon != null
84
+ ? method.icon
85
+ : (
86
+ <span class="s-auth-methods-tile-initial">
87
+ {method.label.charAt(0).toUpperCase()}
88
+ </span>
89
+ )
90
+ }
91
+ </span>
92
+ </button>
93
+ );
94
+ return (
95
+ <HkTooltip key={method.key} text={method.label} placement="top" delay={300}>
96
+ {tile}
97
+ </HkTooltip>
98
+ );
99
+ })}
100
+ </div>
73
101
  </div>
74
102
  );
75
103
  },
@@ -25,13 +25,21 @@ export interface HkIconButtonGroupOption {
25
25
  * HkIconButtonGroup — the icon-only variant of the button-group family
26
26
  * (the same centered strip grammar as HkTabs, minus the text).
27
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
28
+ * The group has exactly TWO fundamental jobs (2026-09-14 user direction
29
+ * — a group is a semantic component, not a generic box to throw buttons
30
+ * into), carried by three working modes:
31
+ * - a SELECTOR: "single" (radiogroup: one active key via v-model,
32
+ * re-clicking the active key is a no-op — radio semantics) or
33
+ * "multiple" (toggle set: v-model is a string[] of active keys,
34
+ * every item carries aria-pressed)
35
+ * - a TIGHT ACTION STRIP: "buttons" (no selection state) — related
36
+ * actions packed shoulder-to-shoulder in ONE shared track, the
37
+ * toolbar grammar
38
+ *
39
+ * Anything else is NOT a group: independent actions that merely live on
40
+ * the same row (e.g. a login card's third-party provider tiles) render
41
+ * as separate framed buttons with comfortable spacing — see
42
+ * HkAuthMethodList for the reference pattern.
35
43
  *
36
44
  * Every item is icon-only and rides one size taller than a standard
37
45
  * icon button (md = 44px vs the 32px icon button / 40px text button) —
@@ -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
@@ -244,6 +244,22 @@ export {
244
244
  type HkTooltipBridgeOptions,
245
245
  installHkImageFallback,
246
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,
247
263
  } from "./runtime";
248
264
 
249
265
  // i18n