@celestia-island/hikari 0.55.57 → 0.55.59

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.57",
3
+ "version": "0.55.59",
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>
@@ -1184,12 +1184,19 @@ describe("useSizeMorph heightMorph (round 18)", () => {
1184
1184
  expect(h.frame.style.transition).toContain("300ms");
1185
1185
  // No clip staging, no riders — this is a plain height animation.
1186
1186
  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).
1191
+ expect(h.frame.style.willChange).toBe("");
1187
1192
 
1188
- // The transitionend cleans the inline override.
1193
+ // The transitionend cleans the inline override AND restores the
1194
+ // resident promotion (the frame is at rest).
1189
1195
  const ev = new Event("transitionend");
1190
1196
  Object.defineProperty(ev, "propertyName", { value: "height" });
1191
1197
  h.frame.dispatchEvent(ev);
1192
1198
  expect(h.frame.style.transition).toBe("");
1199
+ expect(h.frame.style.willChange).toBe("clip-path");
1193
1200
  h.stop();
1194
1201
  });
1195
1202
 
@@ -936,25 +936,47 @@ export function useSizeMorph(
936
936
  // 2. Re-establish the OLD pin under no transition, flush it.
937
937
  f.style.height = `${pinned}px`;
938
938
  void f.offsetHeight;
939
- // 3. Animate to the new pin under an inline height transition —
940
- // the CSS engine owns every frame; there is no staging task, no
941
- // warmup and no per-frame JS at all.
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;
950
+ if (residentWill) {
951
+ f.style.willChange = "";
952
+ residentWill = false;
953
+ }
942
954
  f.style.transition =
943
955
  `height ${durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
944
956
  f.style.height = `${next}px`;
945
957
  pinned = next;
946
- // 4. Clean the inline transition up on the animation's own clock.
958
+ // 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
961
+ // raster has all the time it needs.
962
+ const restore = (): void => {
963
+ f.removeEventListener("transitionend", onEnd);
964
+ f.style.transition = "";
965
+ if (residentWill_ === "clip-path") {
966
+ f.style.willChange = "clip-path";
967
+ residentWill = true;
968
+ }
969
+ };
947
970
  const onEnd = (ev: TransitionEvent): void => {
948
971
  if (ev.target === f && ev.propertyName === "height") {
949
- f.removeEventListener("transitionend", onEnd);
950
- f.style.transition = "";
972
+ restore();
951
973
  }
952
974
  };
953
975
  f.addEventListener("transitionend", onEnd);
954
- setTimeout(() => {
955
- f.removeEventListener("transitionend", onEnd);
956
- f.style.transition = "";
957
- }, durationMs + 350);
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
958
980
  }
959
981
 
960
982
  return { start, stop, hold, remeasure, heightMorph };