@celestia-island/hikari 0.42.1 → 0.43.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkColorSchemeDialog.test.ts +14 -8
  3. package/src/components/HkColorSchemeEditor.test.ts +35 -11
  4. package/src/components/HkColorSchemeEditor.tsx +54 -31
  5. package/src/components/HkImageLightbox.contract.test.ts +44 -0
  6. package/src/components/HkImageLightbox.scss +34 -1
  7. package/src/components/HkImageViewer.tsx +8 -1
  8. package/src/components/HkMinimap.scss +10 -0
  9. package/src/components/HkMinimap.toolbarOnly.test.tsx +54 -0
  10. package/src/components/HkMinimap.tsx +44 -35
  11. package/src/components/HkThemeToggle.contract.test.ts +28 -0
  12. package/src/components/HkThemeToggle.scss +11 -1
  13. package/src/i18n/locales/ar/platform.json +1 -0
  14. package/src/i18n/locales/ar/time.json +9 -0
  15. package/src/i18n/locales/de/platform.json +1 -0
  16. package/src/i18n/locales/de/time.json +9 -0
  17. package/src/i18n/locales/en/platform.json +1 -0
  18. package/src/i18n/locales/en/time.json +9 -0
  19. package/src/i18n/locales/es/platform.json +1 -0
  20. package/src/i18n/locales/es/time.json +9 -0
  21. package/src/i18n/locales/fr/platform.json +1 -0
  22. package/src/i18n/locales/fr/time.json +9 -0
  23. package/src/i18n/locales/ja/platform.json +1 -0
  24. package/src/i18n/locales/ja/time.json +9 -0
  25. package/src/i18n/locales/ko/platform.json +1 -0
  26. package/src/i18n/locales/ko/time.json +9 -0
  27. package/src/i18n/locales/pt/platform.json +1 -0
  28. package/src/i18n/locales/pt/time.json +9 -0
  29. package/src/i18n/locales/ru/platform.json +1 -0
  30. package/src/i18n/locales/ru/time.json +9 -0
  31. package/src/i18n/locales/zh-Hans/platform.json +1 -0
  32. package/src/i18n/locales/zh-Hans/time.json +9 -0
  33. package/src/i18n/locales/zh-Hant/platform.json +1 -0
  34. package/src/i18n/locales/zh-Hant/time.json +9 -0
  35. package/src/i18n/timeKeys.test.ts +79 -0
  36. package/src/utils/format.test.ts +116 -0
  37. package/src/utils/format.ts +16 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.42.1",
3
+ "version": "0.43.0",
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",
@@ -84,22 +84,28 @@ describe("HkColorSchemeDialog extension groups", () => {
84
84
  it("renders the extension section reactively when a group is registered after mount", async () => {
85
85
  mountDialog();
86
86
  await nextTick();
87
- // Zero-cost for consumers that register nothing.
88
- expect(document.body.querySelector(".s-scheme-groups")).toBeNull();
87
+ // The section always renders now (the on-brand content group lives
88
+ // here); before any registration it holds exactly that one panel.
89
+ const before = document.body.querySelector(".s-scheme-groups");
90
+ expect(before).toBeTruthy();
91
+ expect(before!.querySelectorAll(".hk-expansion-panel")).toHaveLength(1);
89
92
 
90
93
  // Late registration (after the dialog is already rendered): the
91
94
  // registry's reactive version invalidates the computed and the
92
- // section appears without a remount.
95
+ // group's panel appears without a remount.
93
96
  registerTokenGroup(DIALOG_GROUP);
94
97
  await nextTick();
95
98
 
96
99
  const section = document.body.querySelector(".s-scheme-groups");
97
100
  expect(section).toBeTruthy();
98
- // Un-sectioned group: one expansion panel, all slots on its grid.
99
- expect(section!.querySelectorAll(".hk-expansion-panel")).toHaveLength(1);
100
- const grid = section!.querySelector(".s-scheme-group-grid");
101
- expect(grid).toBeTruthy();
102
- expect(grid!.querySelectorAll(".hk-color-picker")).toHaveLength(2);
101
+ // The late group's panel lands after the on-brand content panel.
102
+ expect(section!.querySelectorAll(".hk-expansion-panel")).toHaveLength(2);
103
+ // The late group's own grid: scoped by its panel title, 2 slots.
104
+ const groupPanel = [...section!.querySelectorAll<HTMLElement>(".hk-expansion-panel")].find(
105
+ (p) => p.querySelector(".hk-expansion-panel-title")?.textContent === "Dialog wires",
106
+ );
107
+ expect(groupPanel).toBeTruthy();
108
+ expect(groupPanel!.querySelectorAll(".hk-color-picker")).toHaveLength(2);
103
109
  });
104
110
 
105
111
  it("renders one expansion panel per section of a sectioned group", async () => {
@@ -143,6 +143,23 @@ async function setSwatchHex(swatchIndex: number, hex: string): Promise<void> {
143
143
  await nextTick();
144
144
  }
145
145
 
146
+ /** Set an on-brand content slot (0 = onSolidText, 1 = onSolidIcon) of the
147
+ * active mode to a hex color — the pair edits inside the extended-colors
148
+ * section's first group panel, not the accent row. */
149
+ async function setOnSolidHex(slotIndex: number, hex: string): Promise<void> {
150
+ const swatches = document.body.querySelectorAll(
151
+ ".s-scheme-groups .s-scheme-group-grid .hk-color-picker-swatch-btn",
152
+ );
153
+ (swatches[slotIndex] as HTMLButtonElement).click();
154
+ await settle();
155
+ const input = document.body.querySelector(
156
+ ".hk-color-picker-hex-row input.hk-input-element",
157
+ ) as HTMLInputElement | null;
158
+ input!.value = hex;
159
+ input!.dispatchEvent(new Event("input", { bubbles: true }));
160
+ await nextTick();
161
+ }
162
+
146
163
  beforeEach(() => {
147
164
  // Pin the effective mode so edits deterministically target the dark side.
148
165
  useTheme().setMode("dark");
@@ -156,14 +173,21 @@ afterEach(() => {
156
173
  });
157
174
 
158
175
  describe("HkColorSchemeEditor", () => {
159
- it("renders the seven accent pickers plus the two on-solid content pickers", async () => {
176
+ it("renders seven accent pickers and moves the on-solid pair into the extended section", async () => {
160
177
  const { container } = mountEditor();
161
178
  await nextTick();
162
179
 
163
180
  const pickers = container.querySelectorAll(".s-scheme-colors .hk-color-picker");
164
- expect(pickers).toHaveLength(9); // 7 accents + onSolidText + onSolidIcon
165
- // No groups registered in this file yet: the extension section is absent.
166
- expect(container.querySelector(".s-scheme-groups")).toBeNull();
181
+ expect(pickers).toHaveLength(7); // accents only — the on-solid pair moved out
182
+ // The extended section is always rendered: the on-brand content group
183
+ // lives there even with no extension token group registered.
184
+ const groups = container.querySelector(".s-scheme-groups");
185
+ expect(groups).not.toBeNull();
186
+ expect(groups!.querySelector(".s-scheme-groups-title")).not.toBeNull();
187
+ const onSolidPickers = groups!.querySelectorAll(
188
+ ".s-scheme-group-grid .hk-color-picker",
189
+ );
190
+ expect(onSolidPickers).toHaveLength(2); // onSolidText + onSolidIcon
167
191
  });
168
192
 
169
193
  it("editing a token updates getDraft() with the edited RGB", async () => {
@@ -175,15 +199,15 @@ describe("HkColorSchemeEditor", () => {
175
199
  expect(draft.dark.primary).toEqual({ r: 255, g: 0, b: 0 });
176
200
  });
177
201
 
178
- it("editing an on-solid content color updates getDraft()", async () => {
202
+ it("editing an on-brand content color updates getDraft()", async () => {
179
203
  const { ref } = mountEditor();
180
204
  await nextTick();
181
205
 
182
- // 9th picker in the grid = onSolidIcon (7 accents, then text, then icon).
206
+ // 2nd picker in the on-brand group grid = onSolidIcon (text first).
183
207
  const swatches = document.body.querySelectorAll(
184
- ".s-scheme-colors .hk-color-picker-swatch-btn",
208
+ ".s-scheme-groups .s-scheme-group-grid .hk-color-picker-swatch-btn",
185
209
  );
186
- (swatches[8] as HTMLButtonElement).click();
210
+ (swatches[1] as HTMLButtonElement).click();
187
211
  await settle();
188
212
  const input = document.body.querySelector(
189
213
  ".hk-color-picker-hex-row input.hk-input-element",
@@ -237,8 +261,8 @@ describe("HkColorSchemeEditor", () => {
237
261
  const { ref } = mountEditor({ initialDark: legacyDark });
238
262
  await nextTick();
239
263
 
240
- // 8th picker in the grid = onSolidText (7 accents, then text, then icon).
241
- await setSwatchHex(7, "00ff00");
264
+ // 1st picker in the on-brand group grid = onSolidText.
265
+ await setOnSolidHex(0, "00ff00");
242
266
  expect(ref.value!.getDraft().dark.onSolidText).toEqual({ r: 0, g: 255, b: 0 });
243
267
 
244
268
  ref.value!.reset();
@@ -259,7 +283,7 @@ describe("HkColorSchemeEditor", () => {
259
283
  });
260
284
  await nextTick();
261
285
 
262
- await setSwatchHex(7, "00ff00");
286
+ await setOnSolidHex(0, "00ff00");
263
287
  expect(ref.value!.getDraft().dark.onSolidText).toEqual({ r: 0, g: 255, b: 0 });
264
288
 
265
289
  ref.value!.reset();
@@ -123,14 +123,16 @@ export interface HCustomTheme {
123
123
  * their own surface (tabs, drawers, panels) instead of the built-in modal.
124
124
  *
125
125
  * Edits the seven accent tokens (primary/secondary/accent/success/error/
126
- * warning/info) plus the on-solid content colors (onSolidText — text on
127
- * brand fills, onSolidIcon — icons/shapes such as the switch thumb) per
128
- * mode (dark/light); the remaining surface tokens are derived. When
129
- * extension token groups are registered (`registerTokenGroup` /
130
- * `registerTokenGroupConfig`), the "Extended colors" section renders one
131
- * Material expansion panel per group — sub-sectioned groups get one panel
132
- * per section — with the slots laid out on a 2–3 column grid of
133
- * hue-clamped pickers showing full localized color names.
126
+ * warning/info) per mode (dark/light); the remaining surface tokens are
127
+ * derived. The on-solid content colors (onSolidText — text on brand
128
+ * fills, onSolidIcon — icons/shapes such as the switch thumb) are
129
+ * user-choosable too and edit as the FIRST group of the "Extended
130
+ * colors" section (2026-09-11 user direction: moved out of the accent
131
+ * row). When extension token groups are registered
132
+ * (`registerTokenGroup` / `registerTokenGroupConfig`), the section
133
+ * renders one Material expansion panel per group — sub-sectioned groups
134
+ * get one panel per section — with the slots laid out on a 2–3 column
135
+ * grid of hue-clamped pickers showing full localized color names.
134
136
  *
135
137
  * Exposes `reset()` (re-seed from props + current effective mode) and
136
138
  * `getDraft()` (snapshot the current edits as a `HCustomTheme`) for the
@@ -365,6 +367,40 @@ export const HkColorSchemeEditor = defineComponent({
365
367
  return panels;
366
368
  }
367
369
 
370
+ // ── On-brand content colors as their own extended group ──────────
371
+ // The two content tokens (text / icons on solid brand fills) USED to
372
+ // sit in the top accent row (2026-09-11 user direction: they are
373
+ // niche, rarely-tuned colors — they belong with the other extension
374
+ // palettes, as the section's FIRST group). Editing them here writes
375
+ // the exact same draft slots as before; only the location moved.
376
+ function renderOnSolidGroup() {
377
+ return (
378
+ <HExpansionPanel
379
+ key="on-solid-content"
380
+ title={t("hikari::theme.onSolidGroupTitle", "On-brand content")}
381
+ subtitle={countLabel(contentTokens.length)}
382
+ >
383
+ <div class="s-scheme-group-grid">
384
+ {contentTokens.map((key) => {
385
+ // Optional slots: an older prefilled custom theme may omit them.
386
+ const rgb = currentTokens.value[key] ?? WHITE;
387
+ return (
388
+ <HColorPicker
389
+ key={key}
390
+ r={rgb.r}
391
+ g={rgb.g}
392
+ b={rgb.b}
393
+ label={t(`hikari::theme.tokens.${key}`)}
394
+ layout="row"
395
+ onChange={(next: { r: number; g: number; b: number }) => updateToken(key, next)}
396
+ />
397
+ );
398
+ })}
399
+ </div>
400
+ </HExpansionPanel>
401
+ );
402
+ }
403
+
368
404
  return () => (
369
405
  <div class="s-scheme-dialog">
370
406
  {props.showName && (
@@ -393,31 +429,18 @@ export const HkColorSchemeEditor = defineComponent({
393
429
  onChange={(rgb: { r: number; g: number; b: number }) => updateToken(key, rgb)}
394
430
  />
395
431
  ))}
396
- {contentTokens.map((key) => {
397
- // Optional slots: an older prefilled custom theme may omit them.
398
- const rgb = currentTokens.value[key] ?? WHITE;
399
- return (
400
- <HColorPicker
401
- key={key}
402
- r={rgb.r}
403
- g={rgb.g}
404
- b={rgb.b}
405
- label={t(`hikari::theme.tokens.${key}`)}
406
- onChange={(next: { r: number; g: number; b: number }) => updateToken(key, next)}
407
- />
408
- );
409
- })}
410
432
  </div>
411
- {registeredGroups.value.length > 0 && (
412
- <div class="s-scheme-groups">
413
- <div class="s-scheme-groups-title">
414
- {t("hikari::theme.extendedColors", "Extended colors")}
415
- </div>
416
- <div class="s-scheme-group-panels">
417
- {registeredGroups.value.map((group) => renderGroup(group))}
418
- </div>
433
+ {/* Always rendered now: the on-brand content group lives here even
434
+ when no extension token group is registered. */}
435
+ <div class="s-scheme-groups">
436
+ <div class="s-scheme-groups-title">
437
+ {t("hikari::theme.extendedColors", "Extended colors")}
419
438
  </div>
420
- )}
439
+ <div class="s-scheme-group-panels">
440
+ {renderOnSolidGroup()}
441
+ {registeredGroups.value.map((group) => renderGroup(group))}
442
+ </div>
443
+ </div>
421
444
  </div>
422
445
  );
423
446
  },
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Source contract for the immersive lightbox layout (2026-09-11 field
3
+ * report wave). Pinned as scss-text assertions because the failure class
4
+ * is invisible to DOM tests: happy-dom has no layout engine, so a
5
+ * collapsed flex chain (the "white lightbox" — viewer 0px tall inside a
6
+ * full-size modal frame) and a cascade-losing hover state (the floating
7
+ * close button melting into the page) both pass every render test.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import { readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const read = (f: string): string => readFileSync(join(here, f), "utf-8");
16
+
17
+ describe("HkImageLightbox layout contract", () => {
18
+ it("makes the modal scroll region a flex container so the body chain can fill the frame", () => {
19
+ const css = read("HkImageLightbox.scss");
20
+ // The lightbox override block must carry the display itself: the inner
21
+ // only carries flex: 1, which is inert inside a block parent — without
22
+ // this the stage collapses to its padding and the viewer renders 0px
23
+ // tall (white canvas, dead gestures).
24
+ const block = css.slice(
25
+ css.indexOf(".hk-modal-content.hk-image-lightbox"),
26
+ css.indexOf(".hk-image-lightbox-stage"),
27
+ );
28
+ expect(block).toContain(".hk-modal-body-scroll");
29
+ expect(block).toMatch(/\.hk-modal-body-scroll\s*\{[^}]*display:\s*flex/);
30
+ });
31
+
32
+ it("re-asserts the dark chip on hover/focus above the window-close cascade", () => {
33
+ const css = read("HkImageLightbox.scss");
34
+ // window-close.scss's :hover paints the light theme tint and loses to
35
+ // nothing here — the lightbox hover must re-assert the real background
36
+ // (not just the custom properties) at higher specificity, and push the
37
+ // color into the glyph span (which keeps its own white declaration).
38
+ expect(css).toContain("&.hk-window-close:hover");
39
+ expect(css).toContain("&.hk-window-close:focus-visible");
40
+ const hoverBlock = css.slice(css.indexOf("&.hk-window-close:hover"));
41
+ expect(hoverBlock.indexOf("background:")).toBeGreaterThan(-1);
42
+ expect(hoverBlock).toContain(".hk-icon-button-icon");
43
+ });
44
+ });
@@ -8,6 +8,13 @@
8
8
  // The frame is a fixed-size stage; the body chain (body → scroll →
9
9
  // inner) is flattened to pure flex so the HImageViewer fills it, and the
10
10
  // floating close button rides on top of the stage.
11
+ //
12
+ // The scroll link MUST become a flex container itself: the inner below it
13
+ // only carries `flex: 1`, which does nothing inside a block parent —
14
+ // without `display: flex` here the chain collapses to the stage's own
15
+ // padding and the viewer renders 0px tall (the 2026-09-11 "white
16
+ // lightbox" field report: full-size modal frame, invisible canvas, and
17
+ // every wheel/pointer gesture dead because their surface had no area).
11
18
  // ------
12
19
 
13
20
  .hk-modal-content.hk-image-lightbox {
@@ -28,6 +35,8 @@
28
35
  flex: 1;
29
36
  min-height: 0;
30
37
  overflow: hidden;
38
+ display: flex;
39
+ flex-direction: column;
31
40
  }
32
41
 
33
42
  .hk-modal-body-inner {
@@ -61,6 +70,17 @@
61
70
  // Floating close button — legible on any image: translucent dark chip
62
71
  // with a blur, light glyph. Colors ride HIconButton's variable layer
63
72
  // (--hi-icon-button-*) so the icon span picks them up too.
73
+ //
74
+ // The hover/focus state must RE-ASSERT the real `background`/`color`
75
+ // properties, not just the custom properties: the shared window-close
76
+ // grammar (imported after this sheet, equal specificity on :hover) wins
77
+ // the cascade tie and paints the near-transparent light-theme tint over
78
+ // the dark chip, while the glyph span keeps its own
79
+ // `color: var(--hi-icon-button-icon-color)` (white) — white glyph on the
80
+ // white modal, the button visibly vanished (2026-09-11 field report).
81
+ // The selectors carry the extra `.hk-window-close` class the lightbox
82
+ // button always wears, so the pair here outranks window-close's
83
+ // `&:hover` regardless of bundle order.
64
84
  // ------
65
85
 
66
86
  .hk-image-lightbox-close.hk-icon-button {
@@ -77,11 +97,24 @@
77
97
  color: var(--hi-icon-button-icon-color);
78
98
  backdrop-filter: blur(6px);
79
99
 
80
- &:hover {
100
+ &:hover,
101
+ &:focus-visible {
81
102
  --hi-icon-button-bg: rgb(0 0 0 / 65%);
82
103
  --hi-icon-button-icon-color: #fff;
83
104
  }
84
105
 
106
+ // (0,4,0) with the pseudo-class counted — beats
107
+ // `.hk-icon-button.hk-window-close:hover` at (0,3,0).
108
+ &.hk-window-close:hover,
109
+ &.hk-window-close:focus-visible {
110
+ background: var(--hi-icon-button-bg);
111
+ color: var(--hi-icon-button-icon-color);
112
+
113
+ .hk-icon-button-icon {
114
+ color: var(--hi-icon-button-icon-color);
115
+ }
116
+ }
117
+
85
118
  &:focus-visible {
86
119
  outline: 2px solid rgb(255 255 255 / 80%);
87
120
  outline-offset: 2px;
@@ -327,7 +327,13 @@ export default defineComponent({
327
327
  </div>
328
328
  )}
329
329
 
330
- {loaded.value && isZoomed.value && (
330
+ {/* The controls are visible for the WHOLE session, not only once
331
+ zoomed past fit (2026-09-11 user report: at fit the viewer
332
+ offered no affordance at all — no buttons, no percent, no hint
333
+ that wheel/pinch/drag exist). At fit the viewport rect says
334
+ nothing worth drawing, so the minimap runs toolbar-only and
335
+ grows the overview map back the moment zooming starts. */}
336
+ {loaded.value && (
331
337
  <HMinimap
332
338
  imageSrc={props.src}
333
339
  imageBounds={contentBounds.value}
@@ -344,6 +350,7 @@ export default defineComponent({
344
350
  minZoomPercent={Math.max(1, Math.round(fit.value * 100))}
345
351
  maxZoomPercent={Math.round(MAX_ZOOM * 100)}
346
352
  showReset
353
+ toolbarOnly={!isZoomed.value}
347
354
  onZoomTo={(percent: number) => setZoom(percent / 100)}
348
355
  onReset={resetView}
349
356
  onPanDelta={onPanDelta}
@@ -41,6 +41,16 @@
41
41
  pointer-events: auto;
42
42
  }
43
43
 
44
+ // Toolbar-only form (no overview map): shrink-wrap the card around the
45
+ // zoom bar and drop the map's separator — the bar IS the whole surface.
46
+ .hk-minimap[data-toolbar-only] {
47
+ width: auto;
48
+
49
+ .hk-mm-zoom-bar {
50
+ border-top: none;
51
+ }
52
+ }
53
+
44
54
  .hk-mm-zoom-btn {
45
55
  display: flex;
46
56
  align-items: center;
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h } from "vue";
3
+
4
+ import HkMinimap from "./HkMinimap";
5
+
6
+ const mounts: ReturnType<typeof createApp>[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ afterEach(() => {
10
+ for (const app of mounts.splice(0)) app.unmount();
11
+ for (const el of containers.splice(0)) el.remove();
12
+ });
13
+
14
+ function mountMinimap(props: Record<string, unknown>): HTMLElement {
15
+ const container = document.createElement("div");
16
+ document.body.appendChild(container);
17
+ containers.push(container);
18
+
19
+ const Host = defineComponent({
20
+ setup() {
21
+ return () => h("div", [h(HkMinimap, props)]);
22
+ },
23
+ });
24
+ const app = createApp(Host);
25
+ mounts.push(app);
26
+ app.mount(container);
27
+ return container;
28
+ }
29
+
30
+ describe("HkMinimap toolbar-only form", () => {
31
+ it("renders nothing without boxes, an image, or the toolbar-only flag", () => {
32
+ const c = mountMinimap({ zoomPercent: 100 });
33
+ expect(c.querySelector(".hk-minimap")).toBeNull();
34
+ });
35
+
36
+ it("renders only the zoom bar under toolbarOnly (no overview map)", () => {
37
+ const c = mountMinimap({ zoomPercent: 100, toolbarOnly: true });
38
+ const root = c.querySelector<HTMLElement>(".hk-minimap");
39
+ expect(root).not.toBeNull();
40
+ expect(root!.hasAttribute("data-toolbar-only")).toBe(true);
41
+ expect(root!.querySelector(".hk-minimap-svg")).toBeNull();
42
+ expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
43
+ expect(root!.querySelectorAll(".hk-mm-zoom-btn").length).toBe(2);
44
+ });
45
+
46
+ it("keeps the overview map when toolbarOnly is not set and an image is given", () => {
47
+ const c = mountMinimap({ imageSrc: "data:image/gif;base64,", toolbarOnly: false });
48
+ const root = c.querySelector<HTMLElement>(".hk-minimap");
49
+ expect(root).not.toBeNull();
50
+ expect(root!.hasAttribute("data-toolbar-only")).toBe(false);
51
+ expect(root!.querySelector(".hk-minimap-svg")).not.toBeNull();
52
+ expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
53
+ });
54
+ });
@@ -65,6 +65,12 @@ export default defineComponent({
65
65
  /** Show the reset/fit button in the zoom bar (chest only rendered it
66
66
  * when a reset handler was wired up). */
67
67
  showReset: { type: Boolean, default: false },
68
+ /** Toolbar-only form: render just the zoom bar, no overview map. For
69
+ * hosts whose viewport rect is uninformative at rest (e.g. the image
70
+ * lightbox at fit zoom) but that still want the zoom controls
71
+ * visible; the map is expected to reappear once it has something to
72
+ * say (the viewer switches this off when zoomed past fit). */
73
+ toolbarOnly: { type: Boolean, default: false },
68
74
  /** Optional prop-callback surface (alternative to the emits). */
69
75
  onZoomTo: { type: Function as PropType<(percent: number) => void>, default: undefined },
70
76
  onReset: { type: Function as PropType<() => void>, default: undefined },
@@ -180,7 +186,7 @@ export default defineComponent({
180
186
  });
181
187
 
182
188
  return () => {
183
- if (props.boxes.length === 0 && !props.imageSrc) return null;
189
+ if (props.boxes.length === 0 && !props.imageSrc && !props.toolbarOnly) return null;
184
190
 
185
191
  const s = scale.value;
186
192
  const vr = viewportRect.value;
@@ -213,42 +219,45 @@ export default defineComponent({
213
219
  ref={rootRef}
214
220
  class="hk-minimap"
215
221
  data-dragging={dragging.value ? "" : undefined}
222
+ data-toolbar-only={props.toolbarOnly ? "" : undefined}
216
223
  >
217
- <svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
218
- {imgPos && (
219
- <image
220
- href={props.imageSrc}
221
- x={imgPos[0]}
222
- y={imgPos[1]}
223
- width={ib.w * s}
224
- height={ib.h * s}
225
- preserveAspectRatio="none"
226
- class="hk-mm-image"
224
+ {!props.toolbarOnly && (
225
+ <svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
226
+ {imgPos && (
227
+ <image
228
+ href={props.imageSrc}
229
+ x={imgPos[0]}
230
+ y={imgPos[1]}
231
+ width={ib.w * s}
232
+ height={ib.h * s}
233
+ preserveAspectRatio="none"
234
+ class="hk-mm-image"
235
+ />
236
+ )}
237
+ {boxRects}
238
+ {hubP && (
239
+ <circle
240
+ cx={hubP[0]}
241
+ cy={hubP[1]}
242
+ r={3}
243
+ fill="rgb(var(--color-primary))"
244
+ filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
245
+ />
246
+ )}
247
+ <rect
248
+ x={vr.x}
249
+ y={vr.y}
250
+ width={Math.max(1, vr.w)}
251
+ height={Math.max(1, vr.h)}
252
+ fill="none"
253
+ stroke="rgb(var(--color-primary))"
254
+ stroke-width="1"
255
+ stroke-dasharray="3 2"
256
+ rx="2"
257
+ opacity="0.85"
227
258
  />
228
- )}
229
- {boxRects}
230
- {hubP && (
231
- <circle
232
- cx={hubP[0]}
233
- cy={hubP[1]}
234
- r={3}
235
- fill="rgb(var(--color-primary))"
236
- filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
237
- />
238
- )}
239
- <rect
240
- x={vr.x}
241
- y={vr.y}
242
- width={Math.max(1, vr.w)}
243
- height={Math.max(1, vr.h)}
244
- fill="none"
245
- stroke="rgb(var(--color-primary))"
246
- stroke-width="1"
247
- stroke-dasharray="3 2"
248
- rx="2"
249
- opacity="0.85"
250
- />
251
- </svg>
259
+ </svg>
260
+ )}
252
261
  <div class="hk-mm-zoom-bar">
253
262
  <button
254
263
  class="hk-mm-zoom-btn"
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Source contract for the theme-row leading cell sizing (2026-09-11
3
+ * field report: chest's 28px three-dot palette swatch overflowed its
4
+ * 16px generic icon cell and bled into the row gap — at fractional zoom
5
+ * rounding the dots visually overlapped the row name). happy-dom has no
6
+ * layout engine, so the geometry contract is pinned as an scss-text
7
+ * assertion: the theme-row lead cell must carry an explicit size that
8
+ * fits the widest host lead mark, not just the mixin's generic box.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const read = (f: string): string => readFileSync(join(here, f), "utf-8");
17
+
18
+ describe("HkThemeToggle row lead-cell contract", () => {
19
+ it("sizes the theme-row lead cell to fit a 28px swatch mark", () => {
20
+ const css = read("HkThemeToggle.scss");
21
+ const block = css.match(
22
+ /\.s-theme-item-btn \.hk-menu-item-icon\s*\{[^}]*\}/,
23
+ );
24
+ expect(block).not.toBeNull();
25
+ expect(block![0]).toContain("width: 28px");
26
+ expect(block![0]).toContain("height: 28px");
27
+ });
28
+ });
@@ -188,9 +188,19 @@
188
188
  @include mi.label;
189
189
  }
190
190
 
191
- /* Leading check / customize glyphs share the spec's fixed icon cell. */
191
+ /* Leading check / customize glyphs share the spec's fixed icon cell —
192
+ * sized for the WIDEST lead mark hosts render there, not the generic
193
+ * 16px box: chest's three-dot palette swatch is 28px (3×8px dots + 2px
194
+ * gaps), and a mark wider than its cell bleeds symmetrically out of the
195
+ * cell into the row gap — at fractional zoom rounding the dots visually
196
+ * overlap the name (2026-09-11 field report). A uniform 28px cell keeps
197
+ * every row's name starting at the same x (swatch rows and icon rows
198
+ * stay aligned) and leaves the bleed zero room at any zoom. */
192
199
  .s-theme-item-btn .hk-menu-item-icon {
193
200
  @include mi.icon;
201
+
202
+ width: 28px;
203
+ height: 28px;
194
204
  }
195
205
 
196
206
  /* Custom rows keep the name clear of the overlaid delete button. */
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "أيقونات على لون العلامة",
42
42
  "hikari::theme.groupCount": "{count} ألوان",
43
43
  "hikari::theme.extendedColors": "ألوان موسعة",
44
+ "hikari::theme.onSolidGroupTitle": "المحتوى على لون العلامة",
44
45
  "hikari::secret.title": "سر",
45
46
  "hikari::secret.notice": "يُعرض هذا السر مرة واحدة فقط. انسخه واحفظه قبل الإغلاق.",
46
47
  "hikari::secret.copy": "نسخ",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "الآن",
4
+ "common.time.minutesAgo": "قبل {n} دقيقة",
5
+ "common.time.hoursAgo": "قبل {n} ساعة",
6
+ "common.time.daysAgo": "قبل {n} يوم",
7
+ "common.time.weeksAgo": "قبل {n} أسبوع"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "Symbole auf Markenfarbe",
42
42
  "hikari::theme.groupCount": "{count} Farben",
43
43
  "hikari::theme.extendedColors": "Erweiterte Farben",
44
+ "hikari::theme.onSolidGroupTitle": "Inhalte auf Brandfarben",
44
45
  "hikari::secret.title": "Geheimnis",
45
46
  "hikari::secret.notice": "Dieses Geheimnis wird nur einmal angezeigt. Kopieren und speichern Sie es vor dem Schließen.",
46
47
  "hikari::secret.copy": "Kopieren",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Gerade eben",
4
+ "common.time.minutesAgo": "vor {n} Min.",
5
+ "common.time.hoursAgo": "vor {n} Std.",
6
+ "common.time.daysAgo": "vor {n} Tg.",
7
+ "common.time.weeksAgo": "vor {n} Wo."
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "Icons on brand fill",
42
42
  "hikari::theme.groupCount": "{count} colors",
43
43
  "hikari::theme.extendedColors": "Extended colors",
44
+ "hikari::theme.onSolidGroupTitle": "On-brand content",
44
45
  "hikari::secret.title": "Secret",
45
46
  "hikari::secret.notice": "This secret is shown only once. Copy and store it before closing.",
46
47
  "hikari::secret.copy": "Copy",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Just now",
4
+ "common.time.minutesAgo": "{n} min ago",
5
+ "common.time.hoursAgo": "{n} h ago",
6
+ "common.time.daysAgo": "{n} d ago",
7
+ "common.time.weeksAgo": "{n} w ago"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "Iconos sobre color de marca",
42
42
  "hikari::theme.groupCount": "{count} colores",
43
43
  "hikari::theme.extendedColors": "Colores extendidos",
44
+ "hikari::theme.onSolidGroupTitle": "Contenido sobre el color de marca",
44
45
  "hikari::secret.title": "Secreto",
45
46
  "hikari::secret.notice": "Este secreto se muestra solo una vez. Cópielo y guárdelo antes de cerrar.",
46
47
  "hikari::secret.copy": "Copiar",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Ahora mismo",
4
+ "common.time.minutesAgo": "hace {n} min",
5
+ "common.time.hoursAgo": "hace {n} h",
6
+ "common.time.daysAgo": "hace {n} d",
7
+ "common.time.weeksAgo": "hace {n} sem"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "Icônes sur la couleur de marque",
42
42
  "hikari::theme.groupCount": "{count} couleurs",
43
43
  "hikari::theme.extendedColors": "Couleurs étendues",
44
+ "hikari::theme.onSolidGroupTitle": "Contenu sur la couleur de marque",
44
45
  "hikari::secret.title": "Secret",
45
46
  "hikari::secret.notice": "Ce secret n'est affiché qu'une seule fois. Copiez-le et conservez-le avant de fermer.",
46
47
  "hikari::secret.copy": "Copier",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "À l'instant",
4
+ "common.time.minutesAgo": "il y a {n} min",
5
+ "common.time.hoursAgo": "il y a {n} h",
6
+ "common.time.daysAgo": "il y a {n} j",
7
+ "common.time.weeksAgo": "il y a {n} sem."
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "ブランド色上のアイコン",
42
42
  "hikari::theme.groupCount": "{count} 色",
43
43
  "hikari::theme.extendedColors": "拡張カラー",
44
+ "hikari::theme.onSolidGroupTitle": "ブランドカラー上のコンテンツ",
44
45
  "hikari::secret.title": "シークレット",
45
46
  "hikari::secret.notice": "このシークレットは一度だけ表示されます。閉じる前にコピーして保存してください。",
46
47
  "hikari::secret.copy": "コピー",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "たった今",
4
+ "common.time.minutesAgo": "{n} 分前",
5
+ "common.time.hoursAgo": "{n} 時間前",
6
+ "common.time.daysAgo": "{n} 日前",
7
+ "common.time.weeksAgo": "{n} 週間前"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "브랜드 색 위의 아이콘",
42
42
  "hikari::theme.groupCount": "색상 {count}개",
43
43
  "hikari::theme.extendedColors": "확장 색상",
44
+ "hikari::theme.onSolidGroupTitle": "브랜드 색상 콘텐츠",
44
45
  "hikari::secret.title": "비밀",
45
46
  "hikari::secret.notice": "이 비밀은 한 번만 표시됩니다. 닫기 전에 복사하여 저장하세요.",
46
47
  "hikari::secret.copy": "복사",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "방금 전",
4
+ "common.time.minutesAgo": "{n}분 전",
5
+ "common.time.hoursAgo": "{n}시간 전",
6
+ "common.time.daysAgo": "{n}일 전",
7
+ "common.time.weeksAgo": "{n}주 전"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "Ícones sobre a cor da marca",
42
42
  "hikari::theme.groupCount": "{count} cores",
43
43
  "hikari::theme.extendedColors": "Cores estendidas",
44
+ "hikari::theme.onSolidGroupTitle": "Conteúdo sobre a cor da marca",
44
45
  "hikari::secret.title": "Segredo",
45
46
  "hikari::secret.notice": "Este segredo é mostrado apenas uma vez. Copie e guarde antes de fechar.",
46
47
  "hikari::secret.copy": "Copiar",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Agora mesmo",
4
+ "common.time.minutesAgo": "há {n} min",
5
+ "common.time.hoursAgo": "há {n} h",
6
+ "common.time.daysAgo": "há {n} d",
7
+ "common.time.weeksAgo": "há {n} sem"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "Значки на фирменном цвете",
42
42
  "hikari::theme.groupCount": "Цветов: {count}",
43
43
  "hikari::theme.extendedColors": "Расширенные цвета",
44
+ "hikari::theme.onSolidGroupTitle": "Контент на фирменном цвете",
44
45
  "hikari::secret.title": "Секрет",
45
46
  "hikari::secret.notice": "Этот секрет показывается только один раз. Скопируйте и сохраните его до закрытия.",
46
47
  "hikari::secret.copy": "Копировать",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Только что",
4
+ "common.time.minutesAgo": "{n} мин. назад",
5
+ "common.time.hoursAgo": "{n} ч. назад",
6
+ "common.time.daysAgo": "{n} дн. назад",
7
+ "common.time.weeksAgo": "{n} нед. назад"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "品牌色上的图标",
42
42
  "hikari::theme.groupCount": "{count} 种颜色",
43
43
  "hikari::theme.extendedColors": "扩展配色",
44
+ "hikari::theme.onSolidGroupTitle": "品牌色内容",
44
45
  "hikari::secret.title": "密钥",
45
46
  "hikari::secret.notice": "此密钥仅显示一次,请先复制并妥善保存。",
46
47
  "hikari::secret.copy": "复制",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "刚刚",
4
+ "common.time.minutesAgo": "{n} 分钟前",
5
+ "common.time.hoursAgo": "{n} 小时前",
6
+ "common.time.daysAgo": "{n} 天前",
7
+ "common.time.weeksAgo": "{n} 周前"
8
+ }
9
+ }
@@ -41,6 +41,7 @@
41
41
  "hikari::theme.tokens.onSolidIcon": "品牌色上的圖示",
42
42
  "hikari::theme.groupCount": "{count} 種顏色",
43
43
  "hikari::theme.extendedColors": "擴充配色",
44
+ "hikari::theme.onSolidGroupTitle": "品牌色內容",
44
45
  "hikari::secret.title": "金鑰",
45
46
  "hikari::secret.notice": "此金鑰僅顯示一次,請先複製並妥善保存。",
46
47
  "hikari::secret.copy": "複製",
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "剛剛",
4
+ "common.time.minutesAgo": "{n} 分鐘前",
5
+ "common.time.hoursAgo": "{n} 小時前",
6
+ "common.time.daysAgo": "{n} 天前",
7
+ "common.time.weeksAgo": "{n} 週前"
8
+ }
9
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { setLocale, useI18n } from "./context";
4
+
5
+ /**
6
+ * Locale key pack for the formatRelativeTime facility
7
+ * (common.time.justNow/minutesAgo/hoursAgo/daysAgo/weeksAgo, served from
8
+ * the new locales/<lang>/time.json bundles). The JSON keys are the literal
9
+ * unprefixed full names because t() resolves by exact full-name match
10
+ * (context.ts merges string leaves under their literal key) and the
11
+ * facility + its host-t consumers call t("common.time.*") unprefixed —
12
+ * the "hikari::" prefix convention is reserved for
13
+ * hikari-component-owned keys. A key missing from any locale silently
14
+ * falls back to English there, so every locale must define all five keys
15
+ * with matching {n} placeholder presence (hikari review lessons
16
+ * #373/#374, same shape as contextKeys.test.ts).
17
+ */
18
+ const modules = import.meta.glob<{ default: Record<string, Record<string, string>> }>(
19
+ "./locales/*/time.json",
20
+ { eager: true },
21
+ );
22
+
23
+ const EXPECTED_LOCALES = [
24
+ "ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant",
25
+ ];
26
+
27
+ const REQUIRED_KEYS = [
28
+ "common.time.justNow",
29
+ "common.time.minutesAgo",
30
+ "common.time.hoursAgo",
31
+ "common.time.daysAgo",
32
+ "common.time.weeksAgo",
33
+ ];
34
+
35
+ describe("common.time locale keys", () => {
36
+ const bundles = Object.entries(modules).map(([path, mod]) => ({
37
+ locale: path.match(/locales\/([^/]+)\/time\.json/)?.[1] ?? "",
38
+ flat: mod.default.time ?? {},
39
+ }));
40
+
41
+ it("covers all 11 time.json locale files", () => {
42
+ expect(bundles.map((b) => b.locale).sort()).toEqual(EXPECTED_LOCALES);
43
+ });
44
+
45
+ it("defines all 5 common.time.* keys in every locale, non-empty", () => {
46
+ for (const { locale, flat } of bundles) {
47
+ for (const key of REQUIRED_KEYS) {
48
+ expect(typeof flat[key], `${locale} must define ${key}`).toBe("string");
49
+ expect((flat[key] ?? "").length, `${locale} ${key} must not be empty`).toBeGreaterThan(0);
50
+ }
51
+ }
52
+ });
53
+
54
+ it("matches the {n} placeholder pattern of en in every locale", () => {
55
+ const en = bundles.find((b) => b.locale === "en");
56
+ expect(en, "en bundle renders").toBeTruthy();
57
+ for (const { locale, flat } of bundles) {
58
+ for (const key of REQUIRED_KEYS) {
59
+ const wantsN = en!.flat[key].includes("{n}");
60
+ expect(flat[key].includes("{n}"), `${locale} ${key} {n} parity`).toBe(wantsN);
61
+ }
62
+ }
63
+ });
64
+
65
+ it("resolves through useI18n at runtime, per locale", async () => {
66
+ await setLocale("zh-Hans");
67
+ const zh = useI18n();
68
+ expect(zh.t("common.time.justNow", "Just now")).toBe("刚刚");
69
+ expect(zh.t("common.time.hoursAgo", "{n} h ago").replace("{n}", "3")).toBe("3 小时前");
70
+
71
+ await setLocale("de");
72
+ const de = useI18n();
73
+ expect(de.t("common.time.daysAgo", "{n} d ago").replace("{n}", "2")).toBe("vor 2 Tg.");
74
+
75
+ await setLocale("en");
76
+ const en = useI18n();
77
+ expect(en.t("common.time.weeksAgo", "{n} w ago")).toBe("{n} w ago");
78
+ });
79
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { formatRelativeTime, type RelativeTimeT } from "./format";
4
+
5
+ const MIN = 60_000;
6
+ const HOUR = 3_600_000;
7
+ const DAY = 86_400_000;
8
+
9
+ function ago(ms: number): Date {
10
+ return new Date(Date.now() - ms);
11
+ }
12
+
13
+ interface CapturedCall {
14
+ key: string;
15
+ fallback: string;
16
+ named?: Record<string, unknown>;
17
+ }
18
+
19
+ /** Fake translator: records every lookup and renders the {n} slot so
20
+ * injection can be asserted end-to-end. */
21
+ function makeCaptureT(): { t: RelativeTimeT; calls: CapturedCall[] } {
22
+ const calls: CapturedCall[] = [];
23
+ const t: RelativeTimeT = (key, fallback, named) => {
24
+ calls.push({ key, fallback, named });
25
+ return fallback.replace("{n}", String(named?.n ?? ""));
26
+ };
27
+ return { t, calls };
28
+ }
29
+
30
+ describe("formatRelativeTime", () => {
31
+ it("returns an empty string for missing input", () => {
32
+ expect(formatRelativeTime("")).toBe("");
33
+ expect(formatRelativeTime(0)).toBe("");
34
+ });
35
+
36
+ it("returns an empty string for unparseable input", () => {
37
+ expect(formatRelativeTime("not-a-date")).toBe("");
38
+ expect(formatRelativeTime(Number.NaN)).toBe("");
39
+ });
40
+
41
+ it("renders the compact English fallback per tier", () => {
42
+ expect(formatRelativeTime(ago(59_000))).toBe("Just now");
43
+ expect(formatRelativeTime(ago(MIN))).toBe("1m ago");
44
+ expect(formatRelativeTime(ago(59 * MIN))).toBe("59m ago");
45
+ expect(formatRelativeTime(ago(HOUR))).toBe("1h ago");
46
+ expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h ago");
47
+ expect(formatRelativeTime(ago(DAY))).toBe("1d ago");
48
+ expect(formatRelativeTime(ago(6 * DAY))).toBe("6d ago");
49
+ });
50
+
51
+ it("adds the weeks tier from 7d up to (excluding) 30d", () => {
52
+ expect(formatRelativeTime(ago(7 * DAY))).toBe("1w ago");
53
+ expect(formatRelativeTime(ago(13 * DAY))).toBe("1w ago");
54
+ expect(formatRelativeTime(ago(14 * DAY))).toBe("2w ago");
55
+ expect(formatRelativeTime(ago(29 * DAY))).toBe("4w ago");
56
+ });
57
+
58
+ it("falls to the absolute-date path at >= 30d", () => {
59
+ const d = ago(30 * DAY);
60
+ // Do not pin a locale-specific rendering; assert the value comes
61
+ // from the same Date's toLocaleDateString().
62
+ expect(formatRelativeTime(d)).toBe(d.toLocaleDateString());
63
+ expect(formatRelativeTime(d).length).toBeGreaterThan(0);
64
+ });
65
+
66
+ it("clamps future timestamps into the justNow tier", () => {
67
+ expect(formatRelativeTime(ago(-5 * MIN))).toBe("Just now");
68
+ expect(formatRelativeTime(ago(-2 * DAY))).toBe("Just now");
69
+ });
70
+
71
+ it("passes the documented keys with injected {n} to the translator", () => {
72
+ const { t, calls } = makeCaptureT();
73
+
74
+ expect(formatRelativeTime(ago(30_000), t)).toBe("Just now");
75
+ expect(calls[0]).toEqual({
76
+ key: "common.time.justNow",
77
+ fallback: "Just now",
78
+ named: undefined,
79
+ });
80
+
81
+ expect(formatRelativeTime(ago(5 * MIN), t)).toBe("5 min ago");
82
+ expect(calls[1]).toEqual({
83
+ key: "common.time.minutesAgo",
84
+ fallback: "{n} min ago",
85
+ named: { n: 5 },
86
+ });
87
+
88
+ expect(formatRelativeTime(ago(3 * HOUR), t)).toBe("3 h ago");
89
+ expect(calls[2]).toEqual({
90
+ key: "common.time.hoursAgo",
91
+ fallback: "{n} h ago",
92
+ named: { n: 3 },
93
+ });
94
+
95
+ expect(formatRelativeTime(ago(2 * DAY), t)).toBe("2 d ago");
96
+ expect(calls[3]).toEqual({
97
+ key: "common.time.daysAgo",
98
+ fallback: "{n} d ago",
99
+ named: { n: 2 },
100
+ });
101
+
102
+ expect(formatRelativeTime(ago(8 * DAY), t)).toBe("1 w ago");
103
+ expect(calls[4]).toEqual({
104
+ key: "common.time.weeksAgo",
105
+ fallback: "{n} w ago",
106
+ named: { n: 1 },
107
+ });
108
+ });
109
+
110
+ it("keeps absolute dates away from the translator", () => {
111
+ const { t, calls } = makeCaptureT();
112
+ const d = ago(45 * DAY);
113
+ expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString());
114
+ expect(calls).toHaveLength(0);
115
+ });
116
+ });
@@ -37,7 +37,8 @@ export function formatPriceUsd(n: number, currency = "$"): string {
37
37
  return `${currency}${n.toFixed(2)}`;
38
38
  }
39
39
 
40
- /** Timestamp -> "Just now" / "5m ago" / "3h ago" / "2d ago" / locale date. */
40
+ /** Timestamp -> "Just now" / "5m ago" / "3h ago" / "2d ago" / "2w ago" /
41
+ * locale date. */
41
42
  export type RelativeTimeT = (
42
43
  key: string,
43
44
  fallback: string,
@@ -46,7 +47,12 @@ export type RelativeTimeT = (
46
47
 
47
48
  /** Relative-time formatting ("just now / {n} min ago / …").
48
49
  * Pass an optional translator for localized variants; defaults to
49
- * compact English text. */
50
+ * compact English text. Tiers: <1min justNow, <60min minutes,
51
+ * <24h hours, <7d days, <30d weeks, otherwise an absolute
52
+ * locale-rendered date (browser locale, like formatDateTime).
53
+ * The translator owns {n} interpolation — hikari's own useI18n().t
54
+ * does NOT interpolate named params, so wrap it (the canonical key
55
+ * set lives in the per-locale i18n time bundles). */
50
56
  export function formatRelativeTime(
51
57
  input: string | number | Date,
52
58
  t?: RelativeTimeT,
@@ -54,7 +60,8 @@ export function formatRelativeTime(
54
60
  if (!input) return "";
55
61
  const d = input instanceof Date ? input : new Date(input);
56
62
  if (isNaN(d.getTime())) return "";
57
- const diff = Date.now() - d.getTime();
63
+ // Clamp future timestamps to "just now" — no negative weeks/days.
64
+ const diff = Math.max(0, Date.now() - d.getTime());
58
65
  const mins = Math.floor(diff / 60000);
59
66
  const hours = Math.floor(diff / 3600000);
60
67
  const days = Math.floor(diff / 86400000);
@@ -62,6 +69,12 @@ export function formatRelativeTime(
62
69
  if (mins < 60) return t?.("common.time.minutesAgo", "{n} min ago", { n: mins }) ?? `${mins}m ago`;
63
70
  if (hours < 24) return t?.("common.time.hoursAgo", "{n} h ago", { n: hours }) ?? `${hours}h ago`;
64
71
  if (days < 7) return t?.("common.time.daysAgo", "{n} d ago", { n: days }) ?? `${days}d ago`;
72
+ if (days < 30) {
73
+ const weeks = Math.floor(days / 7);
74
+ return (
75
+ t?.("common.time.weeksAgo", "{n} w ago", { n: weeks }) ?? `${weeks}w ago`
76
+ );
77
+ }
65
78
  return d.toLocaleDateString();
66
79
  }
67
80