@celestia-island/hikari 0.55.58 → 0.55.60

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.55.58",
3
+ "version": "0.55.60",
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",
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkCard from "./HkCard";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mountCard(props: Record<string, unknown> = {}, slotText = "content") {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({
12
+ render: () =>
13
+ h(HkCard, props, { default: () => h("span", { class: "slot-marker" }, slotText) }),
14
+ });
15
+ app.mount(container);
16
+ mounts.push({ app, container });
17
+ return container;
18
+ }
19
+
20
+ afterEach(() => {
21
+ for (const m of mounts) m.app.unmount();
22
+ mounts.length = 0;
23
+ });
24
+
25
+ describe("HkCard — scrollable body", () => {
26
+ it("default card renders no scroll machinery", () => {
27
+ const c = mountCard();
28
+ expect(c.querySelector(".hk-card-scrollable")).toBeNull();
29
+ expect(c.querySelector(".hk-scroll-container")).toBeNull();
30
+ // The slot lands directly in the body, no pad wrapper in between.
31
+ expect(c.querySelector(".hk-card-body .slot-marker")).not.toBeNull();
32
+ expect(c.querySelector(".hk-card-body-pad")).toBeNull();
33
+ });
34
+
35
+ it("scrollable embeds the standard scroll container and flex classes", () => {
36
+ const c = mountCard({ scrollable: true });
37
+ expect(c.querySelector(".hk-card-scrollable")).not.toBeNull();
38
+ const scroll = c.querySelector(".hk-card-body .hk-scroll-container");
39
+ expect(scroll).not.toBeNull();
40
+ // The embedded scroller IS the library scroll container (overlay
41
+ // scrollbar machinery), not a bare overflow div.
42
+ expect(scroll?.querySelector(".hk-scroll-container-viewport")).not.toBeNull();
43
+ // Padded card: content padding lives inside the scroll region.
44
+ expect(c.querySelector(".hk-scroll-container .hk-card-body-pad .slot-marker")).not.toBeNull();
45
+ });
46
+
47
+ it("padded false drops the inner pad wrapper too", () => {
48
+ const c = mountCard({ scrollable: true, padded: false });
49
+ expect(c.querySelector(".hk-card-body-pad")).toBeNull();
50
+ expect(c.querySelector(".hk-scroll-container .slot-marker")).not.toBeNull();
51
+ });
52
+
53
+ it("header and footer stay outside the scroll region", () => {
54
+ const c = mountCard(
55
+ { scrollable: true, title: "T" },
56
+ undefined,
57
+ );
58
+ // Re-mount with a footer slot via raw slots is not available through
59
+ // the props-only helper; assert the header ordering here and cover
60
+ // the footer through the props-less variant below.
61
+ const header = c.querySelector(".hk-card-header");
62
+ const scroll = c.querySelector(".hk-card-body-scroll");
63
+ expect(header).not.toBeNull();
64
+ expect(scroll).not.toBeNull();
65
+ expect(header!.contains(scroll as Node)).toBe(false);
66
+ });
67
+
68
+ it("footer renders after the scroll body, not inside it", () => {
69
+ const container = document.createElement("div");
70
+ document.body.appendChild(container);
71
+ const app = createApp({
72
+ render: () =>
73
+ h(HkCard, { scrollable: true }, {
74
+ default: () => h("span", { class: "slot-marker" }, "content"),
75
+ footer: () => h("div", { class: "footer-marker" }, "foot"),
76
+ }),
77
+ });
78
+ app.mount(container);
79
+ mounts.push({ app, container });
80
+
81
+ const footer = container.querySelector(".hk-card-footer");
82
+ const scroll = container.querySelector(".hk-card-body-scroll");
83
+ expect(footer).not.toBeNull();
84
+ expect(footer!.contains(scroll as Node)).toBe(false);
85
+ expect(scroll!.contains(footer as Node)).toBe(false);
86
+ });
87
+
88
+ it("scroll content survives remounts of the pad wrapper class", () => {
89
+ // Padding flag flips at runtime (a consumer toggling density): the
90
+ // scroll container must persist (keyed on nothing but its slot).
91
+ const c = mountCard({ scrollable: true });
92
+ expect(c.querySelector(".hk-card-body-pad")).not.toBeNull();
93
+ });
94
+ });
@@ -53,3 +53,38 @@
53
53
  padding: var(--space-12) var(--space-16);
54
54
  border-top: 1px solid var(--border-faint);
55
55
  }
56
+
57
+ /* Scrollable card: the body becomes the flex host of an embedded
58
+ HkScrollContainer, so the card carries the ONE library scrollbar
59
+ system instead of every consumer nesting its own scroll layer.
60
+ Header and footer stay natural-height; the body fills the remainder.
61
+ Content padding lives INSIDE the scroll region (the pad wrapper the
62
+ component renders when `padded` is set) so the overlay scrollbar
63
+ hugs the card edge while content keeps its inset while scrolling. */
64
+ .hk-card-scrollable {
65
+ display: flex;
66
+ flex-direction: column;
67
+
68
+ > .hk-card-header,
69
+ > .hk-card-footer {
70
+ flex: none;
71
+ }
72
+
73
+ > .hk-card-body {
74
+ display: flex;
75
+ flex-direction: column;
76
+ flex: 1 1 auto;
77
+ min-height: 0;
78
+ padding: 0;
79
+ overflow: hidden;
80
+ }
81
+
82
+ > .hk-card-body > .hk-card-body-scroll {
83
+ flex: 1 1 auto;
84
+ min-height: 0;
85
+ }
86
+
87
+ .hk-card-body-pad {
88
+ padding: var(--space-16);
89
+ }
90
+ }
@@ -1,5 +1,6 @@
1
1
  import { defineComponent } from "vue";
2
2
 
3
+ import HkScrollContainer from "./HkScrollContainer";
3
4
  import "./HkCard.scss";
4
5
 
5
6
  export default defineComponent({
@@ -8,6 +9,17 @@ export default defineComponent({
8
9
  title: { type: String, default: undefined },
9
10
  hoverable: { type: Boolean, default: false },
10
11
  padded: { type: Boolean, default: true },
12
+ /** Turn the card body into the library's standard scroll region
13
+ * (HkScrollContainer: overlay scrollbar, overflow sensing) instead
14
+ * of a plain static block. The card becomes a column flex box —
15
+ * header and footer keep their natural height while the body
16
+ * fills the rest and scrolls. The card itself needs a height from
17
+ * its context (grid stretch, an explicit h-* / max-h-* utility, or a flex
18
+ * parent), exactly like any scroll container: without one there is
19
+ * nothing to scroll and the body simply fits. Content padding
20
+ * moves inside the scroll region so the scrollbar hugs the card
21
+ * edge; `padded: false` drops the inner padding as usual. */
22
+ scrollable: { type: Boolean, default: false },
11
23
  class: { type: String, default: "" },
12
24
  },
13
25
  emits: {
@@ -18,6 +30,7 @@ export default defineComponent({
18
30
  <div
19
31
  class={[
20
32
  "hk-card",
33
+ props.scrollable ? "hk-card-scrollable" : "",
21
34
  props.hoverable ? "hk-card-hoverable" : "",
22
35
  props.class,
23
36
  ]}
@@ -29,7 +42,13 @@ export default defineComponent({
29
42
  </div>
30
43
  ) : null}
31
44
  <div class={["hk-card-body", !props.padded ? "hk-card-body-unpadded" : ""]}>
32
- {slots.default?.()}
45
+ {props.scrollable ? (
46
+ <HkScrollContainer axis="vertical" class="hk-card-body-scroll">
47
+ <div class={props.padded ? "hk-card-body-pad" : undefined}>{slots.default?.()}</div>
48
+ </HkScrollContainer>
49
+ ) : (
50
+ slots.default?.()
51
+ )}
33
52
  </div>
34
53
  {slots.footer ? <div class="hk-card-footer">{slots.footer()}</div> : null}
35
54
  </div>
@@ -451,6 +451,18 @@
451
451
  backdrop-filter: var(--hk-modal-overlay-blur-mobile, none);
452
452
  }
453
453
 
454
+ // Height-morph window (round 20): while the sheet's height animates
455
+ // every frame, a backdrop-filter anywhere in the resizing subtree has
456
+ // to re-render its backdrop surface every frame — the same per-frame
457
+ // surface churn that made promoted layers flash through on phone
458
+ // GPUs, and the 2026-09-15 report's patchy-flicker source. The morph
459
+ // flags the frame, and the sticky step indicator drops its blur for
460
+ // the window (its 95%-opaque background keeps it visually
461
+ // indistinguishable).
462
+ .hk-modal-content[data-hk-morphing] .hk-scroll-pin {
463
+ backdrop-filter: none;
464
+ }
465
+
454
466
  .hk-modal-content {
455
467
  // Phone-sheet transition = clip-path ONLY (2026-09-21, two Xiaomi
456
468
  // WebView chest reports same day):
@@ -1170,11 +1170,19 @@ describe("useSizeMorph deferRemeasure gate (freeze protocol witness)", () => {
1170
1170
 
1171
1171
  describe("useSizeMorph heightMorph (round 18)", () => {
1172
1172
  it("transitions the pin with an inline height transition and cleans up", async () => {
1173
- const h = mountHarness(300, 300);
1173
+ // A resident rider (the host chrome) is promoted at arm time — the
1174
+ // morph must demote it too: the body block is a flex child, so its
1175
+ // box resizes every frame of a height transition.
1176
+ const rider = document.createElement("div");
1177
+ const h = mountHarness(300, 300, {
1178
+ collectRide: () => [{ el: rider }],
1179
+ });
1174
1180
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
1181
+ h.content.appendChild(rider);
1175
1182
  h.start();
1176
1183
  expect(h.frame.style.height).toBe("300px");
1177
1184
  expect(h.frame.style.willChange).toBe("clip-path");
1185
+ expect(rider.style.willChange).toBe("transform");
1178
1186
 
1179
1187
  h.setNatural(400);
1180
1188
  (h as unknown as { heightMorph: (ms: number) => void }).heightMorph(300);
@@ -1184,20 +1192,70 @@ describe("useSizeMorph heightMorph (round 18)", () => {
1184
1192
  expect(h.frame.style.transition).toContain("300ms");
1185
1193
  // No clip staging, no riders — this is a plain height animation.
1186
1194
  expect(h.frame.style.clipPath).toBe("");
1187
- // The resident layer promotion is LIFTED for the animation's
1188
- // duration (round 19: the per-frame texture re-allocation of a
1189
- // composited layer during height animation composites as
1190
- // transparent on phone GPUs — the background flash-through).
1195
+ // EVERY resident promotion is lifted for the animation's duration
1196
+ // (round 19: the frame; round 20: the riders too — the body block
1197
+ // is a flex child, so its box resizes every frame and its
1198
+ // composited texture re-allocates every frame, which is the
1199
+ // content-area flash-through the phone kept reporting). The morph
1200
+ // flag is up so the stylesheet can drop per-frame backdrop-filter
1201
+ // work as well.
1191
1202
  expect(h.frame.style.willChange).toBe("");
1203
+ expect(rider.style.willChange).toBe("");
1204
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(true);
1192
1205
 
1193
- // The transitionend cleans the inline override AND restores the
1194
- // resident promotion (the frame is at rest).
1206
+ // The transitionend cleans the inline override, drops the morph
1207
+ // flag and restores every promotion (the frame is at rest).
1195
1208
  const ev = new Event("transitionend");
1196
1209
  Object.defineProperty(ev, "propertyName", { value: "height" });
1197
1210
  h.frame.dispatchEvent(ev);
1198
1211
  expect(h.frame.style.transition).toBe("");
1199
1212
  expect(h.frame.style.willChange).toBe("clip-path");
1213
+ expect(rider.style.willChange).toBe("transform");
1214
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(false);
1215
+ h.stop();
1216
+ });
1217
+
1218
+ it("keeps the demotion across overlapping morphs (generation guard)", async () => {
1219
+ // A second navigation can land while the first height animation is
1220
+ // still running. The superseded morph's watchdog must NOT restore
1221
+ // the promotions in the middle of the successor's animation — the
1222
+ // rig caught a re-promoted rider there. Only the newest generation
1223
+ // restores, and it restores the ORIGINAL values (the first
1224
+ // demotion's snapshot), not the empty ones it observed.
1225
+ vi.useFakeTimers();
1226
+ const rider = document.createElement("div");
1227
+ const h = mountHarness(300, 300, {
1228
+ collectRide: () => [{ el: rider }],
1229
+ });
1230
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
1231
+ h.content.appendChild(rider);
1232
+ h.start();
1233
+ expect(rider.style.willChange).toBe("transform");
1234
+
1235
+ // Morph #1 starts at t=0 (watchdog at 120+350ms).
1236
+ h.setNatural(400);
1237
+ h.heightMorph(120);
1238
+ expect(rider.style.willChange).toBe("");
1239
+ // Morph #2 lands 100ms later (its own watchdog at 570ms).
1240
+ vi.advanceTimersByTime(100);
1241
+ h.setNatural(500);
1242
+ h.heightMorph(120);
1243
+ // #1's watchdog (470ms) fires inside #2's window — and must not
1244
+ // touch the successor's demotion.
1245
+ vi.advanceTimersByTime(380);
1246
+ expect(
1247
+ rider.style.willChange,
1248
+ "the superseded morph must not re-promote mid-animation",
1249
+ ).toBe("");
1250
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(true);
1251
+
1252
+ // #2's own window closes: everything comes back.
1253
+ vi.advanceTimersByTime(120);
1254
+ expect(rider.style.willChange).toBe("transform");
1255
+ expect(h.frame.style.willChange).toBe("clip-path");
1256
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(false);
1200
1257
  h.stop();
1258
+ vi.useRealTimers();
1201
1259
  });
1202
1260
 
1203
1261
  it("is a no-op for sub-threshold deltas", async () => {
@@ -245,6 +245,17 @@ export function useSizeMorph(
245
245
  * small, and opacity-composited by its own fade anyway) keep the
246
246
  * stage-time promotion. */
247
247
  let residentRideEls: HTMLElement[] = [];
248
+ /** Height-morph promotion snapshot (round 20). Overlapping morphs are
249
+ * possible (a second navigation lands while the first height
250
+ * animation still runs): the FIRST demotion captures the real
251
+ * resident values, later morphs leave the snapshot alone, and only
252
+ * the newest generation's restore puts them back — otherwise a
253
+ * superseded morph's watchdog re-promotes the riders in the middle
254
+ * of the successor's animation (the rig caught exactly that). */
255
+ let morphEpoch = 0;
256
+ let morphDemoted = false;
257
+ let morphSavedFrameWill = "";
258
+ let morphSavedRiderWills: Array<[HTMLElement, string]> = [];
248
259
  /** Identity of the sweep currently staged/folding, echoed to consumers. */
249
260
  let sweepSeq = 0;
250
261
  let activeSweep = 0;
@@ -440,8 +451,14 @@ export function useSizeMorph(
440
451
  /** Drop the resident layer promotion (hold / release paths) — the
441
452
  * frame's own and the resident riders' together: the whole open
442
453
  * cycle's layerization unwinds at once, inside the close
443
- * choreography's own leave window. */
454
+ * choreography's own leave window. A height morph still in flight
455
+ * loses its restore snapshot here too: the cycle is over, so the
456
+ * pending restore must not put the promotions back on a sheet that
457
+ * already unwound them. */
444
458
  function clearResidentWill(): void {
459
+ morphSavedRiderWills = [];
460
+ morphSavedFrameWill = "";
461
+ morphDemoted = false;
445
462
  for (const el of residentRideEls) {
446
463
  el.style.willChange = "";
447
464
  }
@@ -936,36 +953,64 @@ export function useSizeMorph(
936
953
  // 2. Re-establish the OLD pin under no transition, flush it.
937
954
  f.style.height = `${pinned}px`;
938
955
  void f.offsetHeight;
939
- // 3. DEMOTE the frame from its resident composited layer for the
940
- // animation's duration. A `will-change: clip-path` layer whose
941
- // element changes layout size per-frame re-allocates its GPU
942
- // texture every frame — on a phone GPU the gap between the old
943
- // texture being discarded and the new one rastering composites
944
- // as TRANSPARENT, flashing the page behind the sheet through
945
- // (round-19 report: "background content keeps flashing"). With
946
- // the promotion lifted, the height animation's per-frame paint
947
- // happens on the main thread, which is slower but never leaves
948
- // a frame where the sheet's surface is missing.
949
- const residentWill_ = f.style.willChange;
956
+ // 3. DEMOTE every promoted layer in the sheet for the animation's
957
+ // duration — the frame AND the resident riders. A composited
958
+ // layer whose element changes layout size per-frame re-allocates
959
+ // its GPU texture every frame; on a phone GPU the gap between the
960
+ // old texture being discarded and the new one rastering
961
+ // composites as TRANSPARENT, so the region that layer covers
962
+ // flashes through to the page behind (round-19 report on the
963
+ // frame; round-20 report showed the flicker persisting over
964
+ // exactly the CONTENT area — the body block keeps its resident
965
+ // promotion from round 15 and is a flex child, so its box
966
+ // resizes every frame of a height transition). A backdrop-filter
967
+ // on a resizing subtree is the same class of per-frame surface
968
+ // churn (the sticky step indicator's blur), so the morph also
969
+ // flags the frame for the stylesheet to drop it. With everything
970
+ // demoted the height animation's per-frame paint happens on the
971
+ // main thread, which is slower but never leaves a frame where
972
+ // the sheet's surface is missing.
973
+ const epoch = ++morphEpoch;
974
+ if (!morphDemoted) {
975
+ morphSavedFrameWill = f.style.willChange;
976
+ morphSavedRiderWills = residentRideEls.map((el) => [
977
+ el,
978
+ el.style.willChange,
979
+ ]);
980
+ morphDemoted = true;
981
+ }
950
982
  if (residentWill) {
951
983
  f.style.willChange = "";
952
984
  residentWill = false;
953
985
  }
986
+ for (const el of residentRideEls) {
987
+ el.style.willChange = "";
988
+ }
989
+ f.setAttribute("data-hk-morphing", "");
954
990
  f.style.transition =
955
991
  `height ${durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
956
992
  f.style.height = `${next}px`;
957
993
  pinned = next;
958
994
  // 4. Clean up on the animation's own clock: restore the transition
959
- // list and the resident layer promotion together — the frame is
960
- // at its final geometry and at rest, so the re-promotion's
995
+ // list, the morph flag and every promotion together — the frame
996
+ // is at its final geometry and at rest, so the re-promotion's
961
997
  // raster has all the time it needs.
962
998
  const restore = (): void => {
963
999
  f.removeEventListener("transitionend", onEnd);
1000
+ // A superseded morph must not touch the successor's demotion.
1001
+ if (epoch !== morphEpoch) return;
964
1002
  f.style.transition = "";
965
- if (residentWill_ === "clip-path") {
1003
+ f.removeAttribute("data-hk-morphing");
1004
+ if (morphSavedFrameWill === "clip-path") {
966
1005
  f.style.willChange = "clip-path";
967
1006
  residentWill = true;
968
1007
  }
1008
+ for (const [el, will] of morphSavedRiderWills) {
1009
+ el.style.willChange = will;
1010
+ }
1011
+ morphSavedRiderWills = [];
1012
+ morphSavedFrameWill = "";
1013
+ morphDemoted = false;
969
1014
  };
970
1015
  const onEnd = (ev: TransitionEvent): void => {
971
1016
  if (ev.target === f && ev.propertyName === "height") {
@@ -973,10 +1018,7 @@ export function useSizeMorph(
973
1018
  }
974
1019
  };
975
1020
  f.addEventListener("transitionend", onEnd);
976
- const watchdog = setTimeout(restore, durationMs + 350);
977
- // If the element goes away mid-flight, don't leak the timer.
978
- const origClear = watchdog;
979
- void origClear; // timer fires restore() which is idempotent
1021
+ setTimeout(restore, durationMs + 350);
980
1022
  }
981
1023
 
982
1024
  return { start, stop, hold, remeasure, heightMorph };