@celestia-island/hikari 0.52.0 → 0.53.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.52.0",
3
+ "version": "0.53.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",
@@ -22,9 +22,19 @@
22
22
  display: flex;
23
23
  flex-direction: column;
24
24
  background: var(--hk-drawer-bg, var(--hi-color-surface, rgba(240,244,248,0.95)));
25
- backdrop-filter: blur(12px);
25
+ // Token-gated (was a hard blur(12px)): the drawer resizes through
26
+ // animated width/height, and a backdrop-filter on a resizing fixed
27
+ // layer re-rasters the blurred region every frame — on phones that
28
+ // lag shows as patchy flicker along the moving edge. The mobile
29
+ // guard below turns it off ≤767px; hosts restore a finish per
30
+ // surface with --hk-drawer-blur / --hk-drawer-blur-mobile.
31
+ backdrop-filter: var(--hk-drawer-blur, blur(12px));
26
32
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16);
27
33
  outline: none;
34
+ // Scope layout/style invalidation to the drawer subtree — the page
35
+ // behind a phone drawer is usually streaming, and containment keeps
36
+ // those invalidations from cascading into each other.
37
+ contain: layout style;
28
38
  transition:
29
39
  width var(--duration-normal, 0.3s) ease,
30
40
  height var(--duration-normal, 0.3s) ease;
@@ -178,6 +188,20 @@
178
188
  // family's 44px touch lift. Same geometry, same tokens, one family.
179
189
  // ------
180
190
  @media (max-width: 767px) {
191
+ // Phones: docked drawers never backdrop-filter — the panel resizes
192
+ // through animated width/height, and a backdrop-filter on a resizing
193
+ // fixed layer re-rasters the blurred region every frame; on a phone
194
+ // GPU that lag shows as patchy flicker along the moving edge (same
195
+ // family guard as the modal/select sheets, 2026-09-15 chest report).
196
+ // The scrim's dim alone reads correctly there.
197
+ .hk-drawer-overlay {
198
+ backdrop-filter: var(--hk-drawer-overlay-blur-mobile, none);
199
+ }
200
+
201
+ .hk-drawer-panel {
202
+ backdrop-filter: var(--hk-drawer-blur-mobile, none);
203
+ }
204
+
181
205
  .hk-drawer-bottom {
182
206
  bottom: var(--hk-sheet-bottom-gap, 0px);
183
207
  border-radius: var(--hk-modal-radius, var(--radius-lg, 12px))
@@ -68,9 +68,14 @@
68
68
  // (capped) height and pins it as px on every content change — the
69
69
  // transition below animates old→new px. Both pinned values are
70
70
  // explicit px, so this needs no interpolate-size keyword support.
71
+ // clip-path rides the same token pair for the mobile sheet's reveal
72
+ // morph (--hk-sheet-morph: clip below): the pin lands instantly and
73
+ // the top edge sweeps up paint-only — no per-frame layout, no
74
+ // per-frame backdrop re-raster over the resizing fixed layer.
71
75
  transition:
72
76
  height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
73
- max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
77
+ max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
78
+ clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
74
79
  background: var(--hk-modal-bg, var(--hi-color-surface, rgba(240,244,248,0.95)));
75
80
  backdrop-filter: var(--hk-modal-blur, none);
76
81
  border: 1px solid var(--hk-modal-border-color, var(--hi-color-border, rgba(100,100,100,0.08)));
@@ -402,6 +407,19 @@
402
407
  // - rounded top corners only, full width
403
408
  // ------
404
409
  @media (max-width: 767px) {
410
+ // Docked sheets never re-filter their backdrop per frame: the sheet
411
+ // is a fixed, full-width layer whose geometry changes with every
412
+ // content morph, and a backdrop-filter on a resizing layer re-rasters
413
+ // the blurred region each frame — on a phone GPU that lag shows as
414
+ // patchy flicker along the moving edge (2026-09-15 chest report:
415
+ // model dialog etc. while content streamed). The scrim already dims
416
+ // the page, so an opaque-ish sheet reads identically. Hosts restore
417
+ // the finish per surface with --hk-modal-blur-mobile /
418
+ // --hk-modal-bg-mobile.
419
+ .hk-modal-overlay {
420
+ backdrop-filter: var(--hk-modal-overlay-blur-mobile, none);
421
+ }
422
+
405
423
  .hk-modal-content {
406
424
  // !important beats the inline maxWidth (width prop); base width: 100%
407
425
  // already applies at every viewport, so only the cap is re-asserted.
@@ -419,6 +437,22 @@
419
437
  left: 0;
420
438
  right: 0;
421
439
  transform: none;
440
+ // Content morphs reveal by clip instead of animating height (see
441
+ // useSizeMorph): the pin lands instantly and the top edge sweeps up
442
+ // as a paint-only clip with the box's own corner radii — the same
443
+ // "content rides rigidly" grammar as the desktop unveil, and the
444
+ // CSS flag keeps the media query the single owner of the behavior.
445
+ --hk-sheet-morph: clip;
446
+ // Scope layout/style invalidation to the sheet subtree: the page
447
+ // behind a phone sheet is usually streaming (chat updates), and
448
+ // containment keeps those invalidations — and the sheet's own —
449
+ // from cascading into each other.
450
+ contain: layout style;
451
+ backdrop-filter: var(--hk-modal-blur-mobile, none);
452
+ background: var(
453
+ --hk-modal-bg-mobile,
454
+ var(--hk-modal-bg, var(--hi-color-surface, rgba(240, 244, 248, 0.95)))
455
+ );
422
456
  border-radius: var(--hk-modal-radius, var(--radius-lg, 12px))
423
457
  var(--hk-modal-radius, var(--radius-lg, 12px))
424
458
  0 0;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Source contract for the sheet family's morph-performance grammar
3
+ * (2026-09-15 chest mobile report: bottom sheets — model dialog, select
4
+ * sheets, drawers — flickered in patches while their height changed;
5
+ * a phone's raster of a backdrop-filtered fixed layer lags the layer's
6
+ * animated geometry, and an animated `height` relaid out the sheet
7
+ * subtree every frame on the same main thread that was streaming the
8
+ * page behind).
9
+ *
10
+ * The family answer, pinned here so a refactor cannot silently regress:
11
+ * - growth morphs REVEAL through clip-path (--hk-sheet-morph: clip on
12
+ * the docking surfaces, useSizeMorph does the pin-instantly +
13
+ * sweep-up dance) — paint/compositor-level, zero per-frame layout;
14
+ * the frame stylesheet carries the clip-path transition on the SAME
15
+ * duration/ease tokens as the height transition, so the stretch look
16
+ * and its reduced-motion / data-css-animations governance are intact
17
+ * - docked phone surfaces NEVER backdrop-filter (mobile-guard tokens
18
+ * defaulting to none: --hk-modal-blur-mobile, --hk-modal-overlay-
19
+ * blur-mobile, --hk-drawer-blur-mobile, --hk-drawer-overlay-blur-
20
+ * mobile, --hk-popover-blur-mobile) — the scrim's dim reads alone
21
+ * - docking surfaces carry `contain: layout style` so the streaming
22
+ * page behind and the sheet subtree stop invalidating into each
23
+ * other
24
+ *
25
+ * The JS half of the contract (pin instantly, sweep from the old edge,
26
+ * clear on transitionend / new dance / stop) lives in
27
+ * composables/useSizeMorph.test.ts.
28
+ */
29
+ import { beforeAll, describe, expect, it } from "vitest";
30
+ import { readFileSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ const read = (name: string): string => readFileSync(join(here, name), "utf-8");
36
+ const modal = read("HkModal.scss");
37
+ const select = read("HkSelect.scss");
38
+ const drawer = read("HkDrawer.scss");
39
+ const popover = read("HkPopover.scss");
40
+
41
+ describe("sheet family morph-performance contract", () => {
42
+ let mobileModal = "";
43
+ beforeAll(() => {
44
+ mobileModal = modal.slice(modal.indexOf("@media (max-width: 767px)"));
45
+ });
46
+
47
+ // Declaration assertions are LINE-ANCHORED (property → value →
48
+ // semicolon, own line): a plain toContain() on the token happily
49
+ // matches the explanatory COMMENT beside the rule — R2 mutation N2
50
+ // proved the select-sheet flag could be deleted with the suite still
51
+ // green because its comment mentions the same token.
52
+
53
+ it("flags the docked modal sheet for clip-mode growth morphs", () => {
54
+ expect(mobileModal).toMatch(/^[ \t]*--hk-sheet-morph:[ \t]*clip;$/m);
55
+ });
56
+
57
+ it("carries a clip-path transition on the morph timing tokens", () => {
58
+ // Same duration/ease pair as the height morph — the reveal must keep
59
+ // the stretch look, not invent new timing.
60
+ const frame = modal.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
61
+ expect(frame).toMatch(
62
+ /^[ \t]*clip-path var\(--duration-fast, 0\.15s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
63
+ );
64
+ });
65
+
66
+ it("never backdrop-filters the docked modal sheet or its scrim", () => {
67
+ expect(mobileModal).toMatch(
68
+ /^[ \t]*backdrop-filter:[ \t]*var\(--hk-modal-blur-mobile, none\);$/m,
69
+ );
70
+ expect(mobileModal).toMatch(
71
+ /^[ \t]*backdrop-filter:[ \t]*var\(--hk-modal-overlay-blur-mobile, none\);$/m,
72
+ );
73
+ });
74
+
75
+ it("scopes the docked modal sheet with layout containment", () => {
76
+ expect(mobileModal).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
77
+ });
78
+
79
+ it("applies the same grammar to the select sheet panel", () => {
80
+ const panel = select.match(/\.hk-select-sheet-panel\s*{[\s\S]*?^}/m)?.[0] ?? "";
81
+ expect(panel).toMatch(/^[ \t]*--hk-sheet-morph:[ \t]*clip;$/m);
82
+ expect(panel).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
83
+ expect(panel).toMatch(
84
+ /^[ \t]*clip-path var\(--duration-fast, 0\.15s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
85
+ );
86
+ // Opaque already — the panel must not regress to a translucent
87
+ // finish that re-composites against the streaming page.
88
+ expect(panel).toMatch(/^[ \t]*background:[ \t]*rgb\(var\(--color-surface\)\);$/m);
89
+ });
90
+
91
+ it("token-gates the drawer panel blur and guards it on phones", () => {
92
+ // Desktop keeps its finish through the token; the hard-coded blur
93
+ // must not return (it escaped the mobile guard entirely).
94
+ const panel = drawer.match(/\.hk-drawer-panel\s*{[^}]*}/)?.[0] ?? "";
95
+ expect(panel).toMatch(
96
+ /^[ \t]*backdrop-filter:[ \t]*var\(--hk-drawer-blur, blur\(12px\)\);$/m,
97
+ );
98
+ expect(panel).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
99
+
100
+ const mobile = drawer.slice(drawer.indexOf("@media (max-width: 767px)"));
101
+ expect(mobile).toMatch(
102
+ /^[ \t]*backdrop-filter:[ \t]*var\(--hk-drawer-blur-mobile, none\);$/m,
103
+ );
104
+ expect(mobile).toMatch(
105
+ /^[ \t]*backdrop-filter:[ \t]*var\(--hk-drawer-overlay-blur-mobile, none\);$/m,
106
+ );
107
+ });
108
+
109
+ it("keeps the drawer's mobile rules in ONE media block", () => {
110
+ // HkDrawer.sheetfamily.test.ts slices the source from the FIRST
111
+ // max-width: 767px block and regex-matches rules after it — where
112
+ // that first block sits decides which rules its slice can even see
113
+ // (a guard block placed before the .hk-drawer-bottom base rule once
114
+ // redirected the family match to the base rule and reddened the
115
+ // contract). One block, one concern: the mobile guards live inside
116
+ // the family block, and no second block may wander in.
117
+ expect(drawer.match(/@media \(max-width: 767px\)/g)).toHaveLength(1);
118
+ });
119
+
120
+ it("guards the popover sheet variant the same way", () => {
121
+ const sheet = popover.match(/\.hk-popover-panel\.hk-is-sheet\s*{[\s\S]*?^}/m)?.[0] ?? "";
122
+ expect(sheet).toMatch(
123
+ /^[ \t]*backdrop-filter:[ \t]*var\(--hk-popover-blur-mobile, none\);$/m,
124
+ );
125
+ expect(sheet).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
126
+ });
127
+ });
@@ -106,6 +106,15 @@
106
106
  * rule's max-content width would over-constrain it (left+right+width
107
107
  * drops `right` in LTR) and dock it at content width instead. */
108
108
  width: auto;
109
+ /* Docked popover sheets never backdrop-filter: the sheet is a fixed
110
+ * full-width layer over a usually-streaming page, and a
111
+ * backdrop-filter re-rasters the blurred region on every repaint
112
+ * behind it — on phones that lag shows as patchy flicker (same
113
+ * family guard as the modal/select sheets, 2026-09-15). Hosts
114
+ * restore a finish with --hk-popover-blur-mobile. Containment
115
+ * scopes the sheet's layout/style invalidations away from the page. */
116
+ backdrop-filter: var(--hk-popover-blur-mobile, none);
117
+ contain: layout style;
109
118
  /* The base .hk-popover-panel max-width clamp (calc(100vw - 16px), the
110
119
  * anchored panel's ratchet guard) must NOT leak into the sheet: with
111
120
  * the inline left:0 + right:0 docking the clamp over-constrains the
@@ -304,11 +304,19 @@
304
304
  * cannot fire a CSS transition, so HkSelectPanel's useSizeMorph pins
305
305
  * the measured natural height as px (see useSizeMorph) and the
306
306
  * transition below animates old→new px; `interpolate-size: allow-keywords`
307
- * stays harmless for the enter/leave slide. */
307
+ * stays harmless for the enter/leave slide. Growth reveals through
308
+ * clip-path instead (--hk-sheet-morph: clip, see useSizeMorph): the
309
+ * pin lands instantly and the top edge sweeps up paint-only — the
310
+ * sheet is a fixed full-width layer, and an animated height relaid
311
+ * out + repainted it every frame (mobile patchy-flicker source,
312
+ * 2026-09-15). Same duration/ease tokens, same stretch look. */
308
313
  interpolate-size: allow-keywords;
314
+ --hk-sheet-morph: clip;
315
+ contain: layout style;
309
316
  transition:
310
317
  height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
311
- max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
318
+ max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
319
+ clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
312
320
  max-height: min(
313
321
  72vh,
314
322
  calc(
@@ -253,3 +253,177 @@ describe("useSizeMorph", () => {
253
253
  expect(h.frame.style.height).toBe("");
254
254
  });
255
255
  });
256
+
257
+ // ── Clip reveal mode (--hk-sheet-morph: clip, mobile sheets) ─────────
258
+ // Growth morphs reveal through paint-only clip-path instead of an
259
+ // animated height: the pin lands instantly and the top edge sweeps up,
260
+ // keeping the stretch look without per-frame layout on the fixed,
261
+ // backdrop-carrying sheet layer (2026-09-15 mobile flicker report).
262
+
263
+ /** happy-dom has no TransitionEvent constructor on some builds — the
264
+ * generic Event plus an assigned propertyName reads the same to the
265
+ * composable's listener. */
266
+ function fireTransitionEnd(el: HTMLElement, propertyName: string): void {
267
+ // happy-dom's TransitionEvent (when present) ignores the init dict's
268
+ // propertyName — always build the generic event and assign the field.
269
+ const ev = new Event("transitionend");
270
+ Object.defineProperty(ev, "propertyName", { value: propertyName });
271
+ el.dispatchEvent(ev);
272
+ }
273
+
274
+ describe("useSizeMorph clip reveal", () => {
275
+ it("reveals growth through clip-path with an instant pin", async () => {
276
+ const h = mountHarness(300);
277
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
278
+ h.start();
279
+ expect(h.frame.style.height).toBe("300px");
280
+
281
+ h.setNatural(360);
282
+ FakeResizeObserver.instances[0]!.callback();
283
+ await settle();
284
+ // The pin landed at the new height with no height animation staged.
285
+ expect(h.frame.style.height).toBe("360px");
286
+ // The sweep runs: end-state clip + layer promotion in flight.
287
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
288
+ expect(h.frame.style.willChange).toBe("clip-path");
289
+ expect(h.frame.style.transition).toBe("");
290
+
291
+ fireTransitionEnd(h.frame, "clip-path");
292
+ expect(h.frame.style.clipPath).toBe("");
293
+ expect(h.frame.style.willChange).toBe("");
294
+ });
295
+
296
+ it("starts the sweep from the old visual edge (delta inset)", () => {
297
+ const h = mountHarness(300);
298
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
299
+ h.start();
300
+
301
+ // The dance overwrites the inline clip synchronously (start → flush
302
+ // → end), so the START state is only observable at the forced-layout
303
+ // flush: wrap the frame's offsetHeight getter to record the clip
304
+ // each flush reads.
305
+ const reads: string[] = [];
306
+ const desc = Object.getOwnPropertyDescriptor(h.frame, "offsetHeight")!;
307
+ Object.defineProperty(h.frame, "offsetHeight", {
308
+ configurable: true,
309
+ get: () => {
310
+ reads.push(h.frame.style.clipPath);
311
+ return (desc.get as () => number)();
312
+ },
313
+ });
314
+
315
+ h.setNatural(420);
316
+ h.remeasure();
317
+ // Flush sequence: the released measure (clip ""), then the staged
318
+ // start state — the reveal hides exactly the 120px the sheet grew
319
+ // (420 − 300), putting the visible top edge back at the old line.
320
+ expect(reads).toEqual([
321
+ "",
322
+ "inset(120px 0 0 0 round 0px 0px 0px 0px)",
323
+ ]);
324
+ expect(h.frame.style.height).toBe("420px");
325
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
326
+ });
327
+
328
+ it("keeps the height morph for shrink and sub-threshold growth", async () => {
329
+ const h = mountHarness(400);
330
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
331
+ h.start();
332
+
333
+ // Shrink: no clip state, the pin flips under the height transition.
334
+ h.setNatural(320);
335
+ FakeResizeObserver.instances[0]!.callback();
336
+ await settle();
337
+ expect(h.frame.style.height).toBe("320px");
338
+ expect(h.frame.style.clipPath).toBe("");
339
+ expect(h.frame.style.willChange).toBe("");
340
+
341
+ // Sub-threshold growth (2px < REVEAL_MIN_PX): snaps, no reveal.
342
+ h.setNatural(322);
343
+ h.remeasure();
344
+ expect(h.frame.style.height).toBe("322px");
345
+ expect(h.frame.style.clipPath).toBe("");
346
+ });
347
+
348
+ it("never clips without the mode flag (desktop height morph intact)", async () => {
349
+ const h = mountHarness(300);
350
+ h.start();
351
+
352
+ h.setNatural(400);
353
+ FakeResizeObserver.instances[0]!.callback();
354
+ await settle();
355
+ expect(h.frame.style.height).toBe("400px");
356
+ expect(h.frame.style.clipPath).toBe("");
357
+ expect(h.frame.style.willChange).toBe("");
358
+ });
359
+
360
+ it("clears an in-flight reveal when a new dance starts", async () => {
361
+ const h = mountHarness(300);
362
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
363
+ h.start();
364
+
365
+ h.setNatural(380);
366
+ h.remeasure();
367
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
368
+
369
+ // A second growth lands before transitionend fired: the stale clip
370
+ // must come off inside the new dance, then the new reveal stages.
371
+ h.setNatural(450);
372
+ h.remeasure();
373
+ expect(h.frame.style.height).toBe("450px");
374
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
375
+ expect(h.frame.style.willChange).toBe("clip-path");
376
+ });
377
+
378
+ it("clears an interrupted reveal inside the next dance (no transitionend)", () => {
379
+ const h = mountHarness(300);
380
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
381
+ h.start();
382
+
383
+ h.setNatural(380);
384
+ h.remeasure();
385
+ expect(h.frame.style.clipPath).not.toBe("");
386
+
387
+ // A SHRINK lands before the reveal's transitionend fired: unlike a
388
+ // follow-up growth (which restages its own clip), the height-morph
389
+ // branch writes no clip at all — the dance-start teardown is the
390
+ // only thing that returns the frame to CSS ownership (R1 mutation
391
+ // M1 evidence: without it the stale inset(0px) + will-change ride
392
+ // the shrink and linger at rest).
393
+ h.setNatural(310);
394
+ h.remeasure();
395
+ expect(h.frame.style.height).toBe("310px");
396
+ expect(h.frame.style.clipPath).toBe("");
397
+ expect(h.frame.style.willChange).toBe("");
398
+ });
399
+
400
+ it("releases the clip state on stop so the leave animation owns the frame", async () => {
401
+ const h = mountHarness(300);
402
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
403
+ h.start();
404
+
405
+ h.setNatural(380);
406
+ h.remeasure();
407
+ expect(h.frame.style.clipPath).not.toBe("");
408
+
409
+ h.stop();
410
+ expect(h.frame.style.height).toBe("");
411
+ expect(h.frame.style.clipPath).toBe("");
412
+ expect(h.frame.style.willChange).toBe("");
413
+ });
414
+
415
+ it("ignores transitionend events for other properties", async () => {
416
+ const h = mountHarness(300);
417
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
418
+ h.start();
419
+
420
+ h.setNatural(360);
421
+ h.remeasure();
422
+ fireTransitionEnd(h.frame, "height");
423
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
424
+ fireTransitionEnd(h.frame, "opacity");
425
+ expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
426
+ fireTransitionEnd(h.frame, "clip-path");
427
+ expect(h.frame.style.clipPath).toBe("");
428
+ });
429
+ });
@@ -8,6 +8,11 @@ import { onBeforeUnmount, type Ref } from "vue";
8
8
  const CHROME_ALLOWANCE_FLOOR = 96;
9
9
  const CHROME_ALLOWANCE_SLACK = 32;
10
10
 
11
+ /** Growth below this (px) snaps instead of revealing — under half a
12
+ * text row the sweep is imperceptible and not worth promoting a
13
+ * layer for. */
14
+ const REVEAL_MIN_PX = 3;
15
+
11
16
  export interface SizeMorph {
12
17
  /** Arm the morph: observe the content and pin the frame's natural
13
18
  * height on every change. Call once the surface finished its open
@@ -48,6 +53,17 @@ export interface SizeMorph {
48
53
  * pin chases the target with the CSS transition — bounded, one-shot
49
54
  * choreography, not an infinite per-frame animation.
50
55
  *
56
+ * Growth morphs on clip-mode surfaces (the mobile sheet docking, flagged
57
+ * `--hk-sheet-morph: clip` in CSS) reveal instead of animating height:
58
+ * the new pin lands instantly and the box's top edge sweeps up through
59
+ * `clip-path: inset()` — same duration/ease tokens as the height
60
+ * transition, same "content rides rigidly" grammar as the modal unveil,
61
+ * but paint/compositor-level: no per-frame layout and no per-frame
62
+ * backdrop-filter re-raster over the resizing fixed layer (the mobile
63
+ * patchy-flicker source, 2026-09-15 chest report). Shrinks and every
64
+ * height-mode surface keep the height transition — the rare direction
65
+ * is not worth the flex-compression look-ahead trade.
66
+ *
51
67
  * Reduced motion / the global animation switch stay honored: the frame's
52
68
  * transition-duration collapses to one frame under
53
69
  * `html[data-css-animations="0"]`, so the pin updates snap.
@@ -68,10 +84,70 @@ export function useSizeMorph(
68
84
  * bodies that overflow at rest, plus subpixel slack. See the guard in
69
85
  * remeasure(). */
70
86
  let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
87
+ /** In-flight clip reveal (clip-mode growth morph): the frame whose
88
+ * inline clip-path/will-change must come off again once the sweep
89
+ * lands, plus the listener that does it. */
90
+ let revealEl: HTMLElement | null = null;
91
+ let revealEnd: ((ev: Event) => void) | null = null;
92
+
93
+ /** Tear down an in-flight clip reveal: drop the listener and return
94
+ * the inline clip/will-change to CSS ownership. Safe to call when no
95
+ * reveal is running (every dance start, stop, and unmount). */
96
+ function stopReveal(): void {
97
+ if (revealEl && revealEnd) {
98
+ revealEl.removeEventListener("transitionend", revealEnd);
99
+ }
100
+ if (revealEl) {
101
+ revealEl.style.clipPath = "";
102
+ revealEl.style.willChange = "";
103
+ }
104
+ revealEl = null;
105
+ revealEnd = null;
106
+ }
107
+
108
+ /** Clip-mode opt-in, owned by CSS: the modal's mobile media block
109
+ * sets `--hk-sheet-morph: clip` inside its ≤767px query, and the
110
+ * select sheet sets it on its class rule directly (that class only
111
+ * renders in JS-gated sheet mode, so the breakpoint still owns the
112
+ * behavior); a host can override per surface either way (an inline
113
+ * custom property wins over the stylesheet's). */
114
+ function clipMode(f: HTMLElement): boolean {
115
+ const inline = f.style.getPropertyValue("--hk-sheet-morph").trim();
116
+ if (inline) return inline === "clip";
117
+ try {
118
+ return getComputedStyle(f).getPropertyValue("--hk-sheet-morph").trim() === "clip";
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /** The frame's corner radii for the reveal's `round` clause — the
125
+ * moving clip edge keeps the sheet's own rounded corners instead of
126
+ * shaving them straight for the sweep's duration. First token only
127
+ * (horizontal radius); non-px values degrade to square. */
128
+ function cornerRadii(f: HTMLElement): string {
129
+ let cs: CSSStyleDeclaration | null = null;
130
+ try {
131
+ cs = getComputedStyle(f);
132
+ } catch {
133
+ cs = null;
134
+ }
135
+ const first = (v: string | undefined): string => {
136
+ const token = (v ?? "").trim().split(/\s+/)[0] ?? "";
137
+ return token.endsWith("px") ? token : "0px";
138
+ };
139
+ return [
140
+ first(cs?.borderTopLeftRadius),
141
+ first(cs?.borderTopRightRadius),
142
+ first(cs?.borderBottomRightRadius),
143
+ first(cs?.borderBottomLeftRadius),
144
+ ].join(" ");
145
+ }
71
146
 
72
147
  function release(): void {
73
148
  const f = frame.value;
74
149
  if (f) f.style.height = "";
150
+ stopReveal();
75
151
  pinned = 0;
76
152
  }
77
153
 
@@ -98,15 +174,23 @@ export function useSizeMorph(
98
174
  if (!f || !c) return;
99
175
  // 1. Disable transitions, release the pin and measure the frame's
100
176
  // natural (CSS-capped) height in one layout flush.
101
- // 2. Re-establish the OLD pin (still transition-disabled) and flush
102
- // it, so the style history is exactly "old height" when the live
103
- // CSS transition returns.
104
- // 3. Flip to the NEW pin under the live transition — the computed
105
- // value changes old→new, so the height transition animates.
177
+ // 2. Clip-mode growth: pin the NEW height outright and stage the
178
+ // clip start (still transition-disabled), so the reveal that
179
+ // follows sweeps a fully-laid-out box — layout happens once,
180
+ // here, never per frame.
181
+ // Otherwise re-establish the OLD pin (still transition-disabled)
182
+ // and flush it, so the style history is exactly "old height" when
183
+ // the live CSS transition returns.
184
+ // 3. Flip to the new state under the live transition — a real
185
+ // computed-value change, so the CSS transition animates.
106
186
  // No paint happens between the steps: they run in one task and the
107
187
  // layout flushes are invisible to the screen.
108
188
  const inlineTransition = f.style.transition;
109
189
  f.style.transition = "none";
190
+ // A second content change mid-reveal restarts from the new delta
191
+ // (the settle debounce already collapses bursts; this makes it a
192
+ // hard guarantee that no stale clip survives into the new dance).
193
+ stopReveal();
110
194
  f.style.height = "";
111
195
  const natural = f.offsetHeight;
112
196
  if (natural <= 0) {
@@ -130,12 +214,51 @@ export function useSizeMorph(
130
214
  f.style.transition = inlineTransition;
131
215
  return;
132
216
  }
133
- if (pinned > 0) f.style.height = `${pinned}px`;
134
- // Flush the old-pin state before re-enabling the transition.
217
+ const next = Math.round(natural);
218
+ const growth = next - pinned;
219
+ // Clip reveal (see the composable doc): the pin lands instantly and
220
+ // the top edge sweeps up through paint-only clip-path, with the
221
+ // box's own corner radii riding the moving edge. The frame's
222
+ // stylesheet owns the clip-path transition (duration/ease tokens
223
+ // shared with the height transition), so reduced-motion and the
224
+ // global animation switch collapse it exactly like the height morph
225
+ // they already govern. Everything else — shrink, first pin,
226
+ // sub-threshold growth, height-mode surfaces — keeps the height
227
+ // morph below (desktop stays exactly as it was).
228
+ const reveal = pinned > 0 && growth >= REVEAL_MIN_PX && clipMode(f);
229
+ let radii = "";
230
+ if (reveal) {
231
+ radii = cornerRadii(f);
232
+ f.style.height = `${next}px`;
233
+ f.style.clipPath = `inset(${growth}px 0 0 0 round ${radii})`;
234
+ } else if (pinned > 0) {
235
+ f.style.height = `${pinned}px`;
236
+ }
237
+ // Flush the staged state (new pin + clip start, or the old pin)
238
+ // before re-enabling the transition, so the sweep starts from the
239
+ // old visual edge / the height transition starts from the old pin.
135
240
  void f.offsetHeight;
136
241
  f.style.transition = inlineTransition;
137
- f.style.height = `${Math.round(natural)}px`;
138
- pinned = Math.round(natural);
242
+ if (reveal) {
243
+ const onEnd = (ev: Event): void => {
244
+ // transitionend bubbles: a descendant animating its own
245
+ // clip-path must not end the frame's reveal early.
246
+ if (
247
+ ev.target === f &&
248
+ (ev as TransitionEvent).propertyName === "clip-path"
249
+ ) {
250
+ stopReveal();
251
+ }
252
+ };
253
+ f.addEventListener("transitionend", onEnd);
254
+ revealEl = f;
255
+ revealEnd = onEnd;
256
+ f.style.willChange = "clip-path";
257
+ f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
258
+ } else {
259
+ f.style.height = `${next}px`;
260
+ }
261
+ pinned = next;
139
262
  // Self-heal the allowance on every VALIDATED pin: chrome that grew
140
263
  // after calibration (an async footer, a header slot mounting
141
264
  // mid-open) updates the baseline instead of tripping the guard on
@@ -204,6 +327,7 @@ export function useSizeMorph(
204
327
  ro?.disconnect();
205
328
  if (settleTimer) clearTimeout(settleTimer);
206
329
  if (raf) cancelAnimationFrame(raf);
330
+ stopReveal();
207
331
  });
208
332
 
209
333
  return { start, stop, remeasure };
@@ -54,6 +54,13 @@ localeCache.set("en", enFallback);
54
54
  // every language without knowing which locale is currently active.
55
55
  let mergedMessages: Record<string, Messages> = {};
56
56
 
57
+ /** The app-selected hikari locale ("en" until setLocale runs). Date and
58
+ * time formatters consume this so display follows the user's chosen
59
+ * language instead of the browser's. */
60
+ export function activeLocale(): string {
61
+ return state.locale;
62
+ }
63
+
57
64
  export async function setLocale(locale: string): Promise<void> {
58
65
  if (!localeCache.has(locale)) {
59
66
  localeCache.set(locale, buildLocaleMessages(locale));
package/src/index.ts CHANGED
@@ -268,6 +268,7 @@ export {
268
268
  export {
269
269
  useI18n,
270
270
  setLocale,
271
+ activeLocale,
271
272
  mergeMessages,
272
273
  } from "./i18n/context";
273
274
 
@@ -455,6 +456,8 @@ export {
455
456
  formatPriceUsd,
456
457
  formatRelativeTime,
457
458
  formatDateTime,
459
+ formatDate,
460
+ formatTime,
458
461
  formatMs,
459
462
  type RelativeTimeT,
460
463
  } from "./utils/format";
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { formatRelativeTime, type RelativeTimeT } from "./format";
3
+ import { formatDate, formatDateTime, formatRelativeTime, formatTime, type RelativeTimeT } from "./format";
4
4
 
5
5
  const MIN = 60_000;
6
6
  const HOUR = 3_600_000;
@@ -114,3 +114,47 @@ describe("formatRelativeTime", () => {
114
114
  expect(calls).toHaveLength(0);
115
115
  });
116
116
  });
117
+
118
+ // ── Locale awareness (2026-09-15) ────────────────────────────────────
119
+ // Every locale-sensitive formatter must follow the app-selected hikari
120
+ // locale (setLocale), not the browser default: a zh-Hans app on an en
121
+ // browser used to render dates as "9/13/2026" next to zh words.
122
+
123
+ describe("locale-aware date formatting", () => {
124
+ it("formatDate composes the locale's full day label", async () => {
125
+ const { setLocale } = await import("../i18n/context");
126
+ const d = new Date(2026, 8, 13);
127
+ await setLocale("zh-Hans");
128
+ expect(formatDate(d, { month: "short", day: "numeric" })).toBe("9月13日");
129
+ await setLocale("en");
130
+ expect(formatDate(d, { month: "short", day: "numeric" })).toBe("Sep 13");
131
+ // Whole-date default: no opts = the locale's own date format.
132
+ expect(formatDate(d)).toContain("2026");
133
+ });
134
+
135
+ it("formatTime follows the locale's clock convention", async () => {
136
+ const { setLocale } = await import("../i18n/context");
137
+ const d = new Date(2026, 8, 13, 15, 24);
138
+ await setLocale("en");
139
+ expect(formatTime(d)).toMatch(/3:24/);
140
+ await setLocale("zh-Hans");
141
+ expect(formatTime(d)).toMatch(/15:24/);
142
+ });
143
+
144
+ it("formatDateTime renders through the app locale", async () => {
145
+ const { setLocale } = await import("../i18n/context");
146
+ await setLocale("ja");
147
+ const out = formatDateTime(new Date(2026, 8, 13, 15, 24));
148
+ expect(out).toMatch(/2026/);
149
+ // ja month rendering carries the 月 particle from the ja locale data.
150
+ expect(out).toMatch(/9月13日|9\/13/);
151
+ await setLocale("en");
152
+ });
153
+
154
+ it("formatDate/formatTime return empty for missing or invalid input", () => {
155
+ expect(formatDate("")).toBe("");
156
+ expect(formatDate("nope")).toBe("");
157
+ expect(formatTime(0)).toBe("");
158
+ expect(formatTime("junk")).toBe("");
159
+ });
160
+ });
@@ -5,7 +5,14 @@
5
5
  * Consolidates the hand-rolled copies previously scattered across
6
6
  * arona (formatDate/formatUptime/formatNumber) and shittim-chest
7
7
  * (formatTokenCount/formatMediaTime).
8
- */
8
+ *
9
+ * Every locale-sensitive formatter resolves its locale through the
10
+ * hikari i18n state (activeLocale()), NOT the browser default: the app
11
+ * selects its language explicitly (setLocale on switch), and dates that
12
+ * follow the browser while every word around them follows the app read
13
+ * as mixed-language output (2026-09-15 user report: "9月 13" headings
14
+ * under a zh-Hans app on an en browser). */
15
+ import { activeLocale } from "../i18n/context";
9
16
 
10
17
  /** "1234" -> "1.2k", "2500000" -> "2.5M". */
11
18
  export function formatTokenCount(n: number): string {
@@ -75,14 +82,15 @@ export function formatRelativeTime(
75
82
  t?.("common.time.weeksAgo", "{n} w ago", { n: weeks }) ?? `${weeks}w ago`
76
83
  );
77
84
  }
78
- return d.toLocaleDateString();
85
+ return d.toLocaleDateString(activeLocale());
79
86
  }
80
87
 
81
88
  // Media timestamps ("m:ss") already live on the media-player kit — one
82
89
  // definition, re-exported so `../utils/format` is the single import site.
83
90
  export { formatMediaTime } from "../components/HkMediaControlBar";
84
91
 
85
- /** Absolute timestamp formatting with a shared locale-aware renderer. */
92
+ /** Absolute timestamp formatting with a shared locale-aware renderer.
93
+ * Follows the app-selected hikari locale (see activeLocale). */
86
94
  export function formatDateTime(
87
95
  input: string | number | Date,
88
96
  opts?: { dateStyle?: "short" | "medium" | "long"; timeStyle?: "short" | "medium" },
@@ -90,12 +98,39 @@ export function formatDateTime(
90
98
  if (!input) return "";
91
99
  const d = input instanceof Date ? input : new Date(input);
92
100
  if (isNaN(d.getTime())) return "";
93
- return d.toLocaleString(undefined, {
101
+ return d.toLocaleString(activeLocale(), {
94
102
  dateStyle: opts?.dateStyle ?? "medium",
95
103
  timeStyle: opts?.timeStyle ?? "short",
96
104
  });
97
105
  }
98
106
 
107
+ /** Date-only rendering (no time), following the app-selected hikari
108
+ * locale. `opts` is the raw Intl.DateTimeFormatOptions passthrough —
109
+ * omit it for the locale's whole-date default, or scope to a slice
110
+ * (e.g. { month: "short", day: "numeric" } for day-group headings). */
111
+ export function formatDate(
112
+ input: string | number | Date,
113
+ opts?: Intl.DateTimeFormatOptions,
114
+ ): string {
115
+ if (!input) return "";
116
+ const d = input instanceof Date ? input : new Date(input);
117
+ if (isNaN(d.getTime())) return "";
118
+ return d.toLocaleDateString(activeLocale(), opts);
119
+ }
120
+
121
+ /** Time-only rendering (no date), following the app-selected hikari
122
+ * locale. `opts` is the raw Intl.DateTimeFormatOptions passthrough —
123
+ * omit it for the locale's default (en "3:24 PM", zh "15:24"). */
124
+ export function formatTime(
125
+ input: string | number | Date,
126
+ opts?: Intl.DateTimeFormatOptions,
127
+ ): string {
128
+ if (!input) return "";
129
+ const d = input instanceof Date ? input : new Date(input);
130
+ if (isNaN(d.getTime())) return "";
131
+ return d.toLocaleTimeString(activeLocale(), opts);
132
+ }
133
+
99
134
  /** Milliseconds for latency/duration displays: sub-second keeps one
100
135
  * decimal, >=1s switches to whole seconds (and beyond to minutes).
101
136
  * Thousands-grouped for the rare large value. */