@celestia-island/hikari 0.40.11 → 0.40.13

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkDrawer.scss +11 -12
  3. package/src/components/HkErrorBoundary.test.tsx +103 -0
  4. package/src/components/HkErrorBoundary.tsx +65 -43
  5. package/src/components/HkErrorLanding.scss +9 -0
  6. package/src/components/HkErrorLanding.test.tsx +8 -0
  7. package/src/components/HkErrorLanding.tsx +12 -1
  8. package/src/components/HkModal.scss +12 -14
  9. package/src/components/HkPopover.scss +7 -0
  10. package/src/components/HkPopover.tsx +20 -9
  11. package/src/components/HkSelect.scss +8 -0
  12. package/src/components/HkSelectPanel.tsx +6 -1
  13. package/src/components/_scrim-fade.scss +59 -0
  14. package/src/components/scrim-fade.contract.test.ts +80 -0
  15. package/src/errorReporting/HkErrorReportingOverlay.scss +9 -0
  16. package/src/errorReporting/HkErrorReportingOverlay.tsx +103 -0
  17. package/src/errorReporting/index.test.tsx +165 -0
  18. package/src/errorReporting/index.ts +158 -0
  19. package/src/errorReporting/state.ts +104 -0
  20. package/src/i18n/locales/ar/errors.json +3 -1
  21. package/src/i18n/locales/de/errors.json +3 -1
  22. package/src/i18n/locales/en/errors.json +3 -1
  23. package/src/i18n/locales/es/errors.json +3 -1
  24. package/src/i18n/locales/fr/errors.json +3 -1
  25. package/src/i18n/locales/ja/errors.json +3 -1
  26. package/src/i18n/locales/ko/errors.json +3 -1
  27. package/src/i18n/locales/pt/errors.json +3 -1
  28. package/src/i18n/locales/ru/errors.json +3 -1
  29. package/src/i18n/locales/zh-Hans/errors.json +3 -1
  30. package/src/i18n/locales/zh-Hant/errors.json +3 -1
  31. package/src/index.ts +10 -0
  32. package/src/components/HkErrorBoundary.scss +0 -67
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.40.11",
3
+ "version": "0.40.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1,3 +1,5 @@
1
+ @use "./scrim-fade" as sf;
2
+
1
3
  .hk-drawer-overlay {
2
4
  position: fixed;
3
5
  inset: 0;
@@ -5,18 +7,15 @@
5
7
  backdrop-filter: var(--hk-drawer-overlay-blur, blur(6px));
6
8
  }
7
9
 
8
- .hk-drawer-overlay-enter-active {
9
- transition: opacity var(--hi-duration-normal, 0.3s) var(--hi-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
10
- }
11
-
12
- .hk-drawer-overlay-leave-active {
13
- transition: opacity var(--hi-duration-normal, 0.3s) var(--hi-ease-in, cubic-bezier(0.4, 0, 1, 1));
14
- }
15
-
16
- .hk-drawer-overlay-enter-from,
17
- .hk-drawer-overlay-leave-to {
18
- opacity: 0;
19
- }
10
+ // Overlay enter/leave — fade-only classes from the shared window-layer
11
+ // contract (./_scrim-fade.scss); the panel below owns all the motion.
12
+ @include sf.scrim-fade(
13
+ "hk-drawer-overlay",
14
+ $enter-duration: var(--hi-duration-normal, 0.3s),
15
+ $enter-ease: var(--hi-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
16
+ $leave-duration: var(--hi-duration-normal, 0.3s),
17
+ $leave-ease: var(--hi-ease-in, cubic-bezier(0.4, 0, 1, 1))
18
+ );
20
19
 
21
20
  .hk-drawer-panel {
22
21
  position: fixed;
@@ -0,0 +1,103 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, type Component } from "vue";
3
+
4
+ import HkErrorBoundary from "./HkErrorBoundary";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mountWith(child: Component, props: Record<string, unknown> = {}) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({
12
+ render: () => h(HkErrorBoundary, props, { default: () => h(child) }),
13
+ });
14
+ app.config.errorHandler = () => {}; // Silence Vue's own duplicate logging.
15
+ app.mount(container);
16
+ mounts.push({ app, container });
17
+ return container;
18
+ }
19
+
20
+ const Boom = defineComponent({
21
+ name: "Boom",
22
+ setup() {
23
+ throw new TypeError("Cannot read properties of undefined (reading 'length')");
24
+ },
25
+ render: () => h("div", "never"),
26
+ });
27
+
28
+ const Fine = defineComponent({
29
+ name: "Fine",
30
+ render: () => h("div", { class: "fine-child" }, "fine"),
31
+ });
32
+
33
+ afterEach(() => {
34
+ for (const { app, container } of mounts) {
35
+ app.unmount();
36
+ container.remove();
37
+ }
38
+ mounts.length = 0;
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ describe("HkErrorBoundary", () => {
43
+ it("renders children while nothing throws", () => {
44
+ const el = mountWith(Fine);
45
+ expect(el.querySelector(".fine-child")).not.toBeNull();
46
+ expect(el.querySelector(".hk-error-landing")).toBeNull();
47
+ });
48
+
49
+ it("captures a crash and renders the inline error landing", async () => {
50
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
51
+ const el = mountWith(Boom);
52
+ await nextTick();
53
+
54
+ const landing = el.querySelector(".hk-error-landing");
55
+ expect(landing).not.toBeNull();
56
+ expect(landing!.classList.contains("is-inline")).toBe(true);
57
+ expect(el.querySelector(".hk-error-landing__code")!.textContent).toBe("TypeError");
58
+ expect(el.querySelector(".hk-error-landing__desc")!.textContent)
59
+ .toBe("Cannot read properties of undefined (reading 'length')");
60
+ // Raw details ride in the JSON tree.
61
+ expect(el.querySelector(".s-tool-json-tree")).not.toBeNull();
62
+ expect(errorSpy).toHaveBeenCalled();
63
+ });
64
+
65
+ it("falls back to the default headline when no title is given", async () => {
66
+ vi.spyOn(console, "error").mockImplementation(() => {});
67
+ const el = mountWith(Boom);
68
+ await nextTick();
69
+ expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("Something went wrong");
70
+ });
71
+
72
+ it("honours a custom title override", async () => {
73
+ vi.spyOn(console, "error").mockImplementation(() => {});
74
+ const el = mountWith(Boom, { errorTitle: "发生了错误" });
75
+ await nextTick();
76
+ expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("发生了错误");
77
+ });
78
+
79
+ it("recovers via the retry action", async () => {
80
+ vi.spyOn(console, "error").mockImplementation(() => {});
81
+ const el = mountWith(Boom, { retryLabel: "重试" });
82
+ await nextTick();
83
+
84
+ const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>("button"));
85
+ const retry = buttons.find((b) => b.textContent === "重试");
86
+ expect(retry).toBeDefined();
87
+ retry!.click();
88
+ await nextTick();
89
+ // The slot rerender throws again, so the landing stays — but the error
90
+ // ref was cleared and recaptured (boundary proven by a fresh record).
91
+ expect(el.querySelector(".hk-error-landing")).not.toBeNull();
92
+ });
93
+
94
+ it("keeps the custom fallback render prop contract", async () => {
95
+ vi.spyOn(console, "error").mockImplementation(() => {});
96
+ const el = mountWith(Boom, {
97
+ fallback: (err: string, retry: () => void) => h("div", { class: "custom-fallback" }, [err.slice(0, 10), h("button", { onClick: retry }, "go")]),
98
+ });
99
+ await nextTick();
100
+ expect(el.querySelector(".custom-fallback")).not.toBeNull();
101
+ expect(el.querySelector(".hk-error-landing")).toBeNull();
102
+ });
103
+ });
@@ -1,14 +1,38 @@
1
- import { Copy, RefreshCw, TriangleAlert as AlertTriangle } from "lucide-vue-next";
2
- import { defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
3
-
4
-
1
+ import { Copy, RefreshCw } from "lucide-vue-next";
2
+ import { computed, defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
5
3
 
6
4
  import { useClipboard } from "../runtime/useClipboard";
7
5
  import { useI18n } from "../i18n/context";
8
6
  import HButton from "./HkButton";
9
- import HScrollContainer from "./HkScrollContainer";
10
- import "./HkErrorBoundary.scss";
7
+ import { HkErrorLanding } from "./HkErrorLanding";
8
+ import { HkJsonTree } from "./HkJsonTree";
9
+
10
+ interface CapturedError {
11
+ name: string;
12
+ message: string;
13
+ stack: string;
14
+ }
11
15
 
16
+ function captureError(err: unknown): CapturedError {
17
+ if (err instanceof Error) {
18
+ return { name: err.name || "Error", message: err.message, stack: err.stack || "" };
19
+ }
20
+ return { name: "Error", message: String(err), stack: "" };
21
+ }
22
+
23
+ function formatError(err: CapturedError): string {
24
+ return err.stack ? `${err.name}: ${err.message}\n\n${err.stack}` : `${err.name}: ${err.message}`;
25
+ }
26
+
27
+ /**
28
+ * HkErrorBoundary — inline crash guard rendering the shared error landing.
29
+ *
30
+ * Captures descendant errors via `onErrorCaptured` and stops propagation.
31
+ * The built-in fallback is the same HkErrorLanding card the family's
32
+ * full-page takeovers use (inline variant): tone icon, headline, the error
33
+ * name as the code chip, the message as the description, the raw
34
+ * name/message/stack in a collapsible JSON tree, plus retry / copy actions.
35
+ */
12
36
  export default defineComponent({
13
37
  name: "HkErrorBoundary",
14
38
  props: {
@@ -21,15 +45,12 @@ export default defineComponent({
21
45
  setup(props, { slots }) {
22
46
  const clipboard = useClipboard();
23
47
  const { t } = useI18n();
24
- const error = ref<string | null>(null);
48
+ const error = ref<CapturedError | null>(null);
25
49
 
26
50
  onErrorCaptured((err) => {
27
- const msg =
28
- err instanceof Error
29
- ? `${err.name}: ${err.message}\n\n${err.stack || ""}`
30
- : String(err);
31
- console.error(`[ErrorBoundary:${props.name}]`, msg);
32
- error.value = msg;
51
+ const captured = captureError(err);
52
+ console.error(`[ErrorBoundary:${props.name}]`, err);
53
+ error.value = captured;
33
54
  return false;
34
55
  });
35
56
 
@@ -37,48 +58,49 @@ export default defineComponent({
37
58
  error.value = null;
38
59
  }
39
60
 
40
- function copyError() {
41
- clipboard.copy(error.value!);
42
- }
61
+ const detailsValue = computed(() => {
62
+ if (!error.value) return undefined;
63
+ const record: Record<string, unknown> = {
64
+ name: error.value.name,
65
+ message: error.value.message,
66
+ };
67
+ if (error.value.stack) record.stack = error.value.stack;
68
+ if (props.name !== "unknown") record.boundary = props.name;
69
+ return record;
70
+ });
43
71
 
44
72
  return () => {
45
73
  if (error.value === null) {
46
74
  return slots.default?.();
47
75
  }
48
76
 
77
+ const err = error.value;
78
+
49
79
  if (props.fallback) {
50
- return props.fallback(error.value, retry);
80
+ return props.fallback(formatError(err), retry);
51
81
  }
52
82
 
53
83
  return (
54
- <div class="hk-error-boundary">
55
- <div class="hk-error-boundary-card">
56
- <div class="hk-error-boundary-header">
57
- <AlertTriangle size={16} class="hk-error-boundary-icon" />
58
- <span class="hk-error-boundary-label">{props.errorTitle || t("hikari::errorBoundary.title", "Component Error")}</span>
59
- {props.name !== "unknown" && (
60
- <span class="hk-error-boundary-tag">{props.name}</span>
61
- )}
62
- </div>
63
- <div class="hk-error-boundary-msg">
64
- <div style={{ maxHeight: "12rem" }}>
65
- <HScrollContainer>
66
- {error.value}
67
- </HScrollContainer>
68
- </div>
69
- </div>
70
- <div class="hk-error-boundary-actions">
71
- <HButton variant="ghost" size="sm" onClick={copyError}>
72
- <Copy size={12} />
73
- {props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
74
- </HButton>
75
- <HButton variant="outline" size="sm" onClick={retry}>
84
+ <HkErrorLanding
85
+ variant="inline"
86
+ title={props.errorTitle || t("hikari::errors.defaultTitle", "Something went wrong")}
87
+ description={err.message}
88
+ code={err.name}
89
+ >
90
+ {{
91
+ default: () => <HkJsonTree value={detailsValue.value} ariaLabel="stack trace" />,
92
+ actions: () => [
93
+ <HButton variant="primary" size="sm" onClick={retry}>
76
94
  <RefreshCw size={12} />
77
95
  {props.retryLabel || t("hikari::errorBoundary.retry", "Retry")}
78
- </HButton>
79
- </div>
80
- </div>
81
- </div>
96
+ </HButton>,
97
+ <HButton variant="ghost" size="sm" onClick={() => clipboard.copy(formatError(err))}>
98
+ <Copy size={12} />
99
+ {props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
100
+ </HButton>,
101
+ ],
102
+ }}
103
+ </HkErrorLanding>
82
104
  );
83
105
  };
84
106
  },
@@ -181,6 +181,15 @@
181
181
  margin-top: var(--space-16, 1rem);
182
182
  }
183
183
 
184
+ // Inline variant: the same card without the viewport takeover — used when
185
+ // HkErrorBoundary captures a crash inside a pane and swaps in the landing
186
+ // in-place instead of covering the whole screen.
187
+ .hk-error-landing.is-inline {
188
+ min-height: 0;
189
+ padding: var(--space-20, 1.25rem) var(--space-12, 0.75rem);
190
+ background: transparent;
191
+ }
192
+
184
193
  @media (max-width: 480px) {
185
194
  .hk-error-landing__card {
186
195
  padding: 1.5rem 1.125rem;
@@ -11,6 +11,7 @@ interface MountOptions {
11
11
  code?: string;
12
12
  status?: number;
13
13
  tone?: "error" | "warning" | "info";
14
+ variant?: "page" | "inline";
14
15
  detailsOpen?: boolean;
15
16
  details?: () => ReturnType<typeof h>;
16
17
  actions?: () => ReturnType<typeof h>;
@@ -28,6 +29,7 @@ function mountLanding(opts: MountOptions = {}) {
28
29
  code: opts.code ?? "",
29
30
  status: opts.status,
30
31
  tone: opts.tone ?? "error",
32
+ variant: opts.variant ?? "page",
31
33
  detailsOpen: opts.detailsOpen ?? true,
32
34
  }, {
33
35
  ...(opts.details ? { default: opts.details } : {}),
@@ -99,4 +101,10 @@ describe("HkErrorLanding", () => {
99
101
  expect(el.querySelector(".fake-action")).not.toBeNull();
100
102
  expect(el.querySelector(".fake-brand")).not.toBeNull();
101
103
  });
104
+
105
+ it("applies the inline variant class without the page backdrop", () => {
106
+ const el = mountLanding({ variant: "inline", title: "Boom" });
107
+ const root = el.querySelector(".hk-error-landing")!;
108
+ expect(root.classList.contains("is-inline")).toBe(true);
109
+ });
102
110
  });
@@ -7,6 +7,15 @@ import "./HkErrorLanding.scss";
7
7
  /** Visual severity of the landing icon and accents. */
8
8
  export type HErrorTone = "error" | "warning" | "info";
9
9
 
10
+ /**
11
+ * Layout variant of the landing.
12
+ * - `page` (default): owns a full-viewport backdrop — for overlays and
13
+ * standalone error pages.
14
+ * - `inline`: drops the backdrop and viewport height so the same card can
15
+ * live inside a pane captured by HkErrorBoundary.
16
+ */
17
+ export type HErrorLandingVariant = "page" | "inline";
18
+
10
19
  /**
11
20
  * HkErrorLanding — the shared full-page error landing.
12
21
  *
@@ -33,6 +42,8 @@ export const HkErrorLanding = defineComponent({
33
42
  /** HTTP status chip, e.g. 400. */
34
43
  status: { type: Number, default: undefined },
35
44
  tone: { type: String as PropType<HErrorTone>, default: "error" },
45
+ /** Layout variant: `page` (viewport backdrop) or `inline` (in-flow card). */
46
+ variant: { type: String as PropType<HErrorLandingVariant>, default: "page" },
36
47
  /** Initial expansion of the raw-details section. */
37
48
  detailsOpen: { type: Boolean, default: true },
38
49
  },
@@ -48,7 +59,7 @@ export const HkErrorLanding = defineComponent({
48
59
  }
49
60
 
50
61
  return () => (
51
- <div class={`hk-error-landing is-${props.tone}`}>
62
+ <div class={`hk-error-landing is-${props.tone}${props.variant === "inline" ? " is-inline" : ""}`}>
52
63
  <div class="hk-error-landing__card">
53
64
  {slots.brand?.()}
54
65
 
@@ -6,6 +6,8 @@
6
6
  // colors, spacing, and typography. Component-specific overrides
7
7
  // use the --hk-modal-* namespace.
8
8
 
9
+ @use "./scrim-fade" as sf;
10
+
9
11
  // ------
10
12
  // Root wrapper — positioned via popup-manager z-index
11
13
  // ------
@@ -33,20 +35,16 @@
33
35
  pointer-events: auto;
34
36
  }
35
37
 
36
- .hk-modal-overlay-enter-active {
37
- transition: opacity var(--hk-modal-duration, 0.3s)
38
- var(--hk-modal-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
39
- }
40
-
41
- .hk-modal-overlay-leave-active {
42
- transition: opacity var(--hk-modal-duration, 0.3s)
43
- var(--hk-modal-ease-in, cubic-bezier(0.4, 0, 1, 1));
44
- }
45
-
46
- .hk-modal-overlay-enter-from,
47
- .hk-modal-overlay-leave-to {
48
- opacity: 0;
49
- }
38
+ // Overlay enter/leave — fade-only classes emitted from the shared
39
+ // window-layer contract (./_scrim-fade.scss): the curtain dims the whole
40
+ // viewport in place, never sliding or scaling with the content frame.
41
+ @include sf.scrim-fade(
42
+ "hk-modal-overlay",
43
+ $enter-duration: var(--hk-modal-duration, 0.3s),
44
+ $enter-ease: var(--hk-modal-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
45
+ $leave-duration: var(--hk-modal-duration, 0.3s),
46
+ $leave-ease: var(--hk-modal-ease-in, cubic-bezier(0.4, 0, 1, 1))
47
+ );
50
48
 
51
49
  // ------
52
50
  // Content frame
@@ -1,4 +1,5 @@
1
1
  @use "./menu-item" as mi;
2
+ @use "./scrim-fade" as sf;
2
3
 
3
4
  .hk-popover-backdrop {
4
5
  position: fixed;
@@ -211,6 +212,12 @@
211
212
  transform: translateY(100%);
212
213
  }
213
214
 
215
+ /* Sheet scrim fade — shared window-layer contract (./_scrim-fade.scss):
216
+ * opacity-only, own transition name, in place. The scrim used to mount
217
+ * and unmount bare, snapping the dim curtain on and off around the
218
+ * sliding panel. */
219
+ @include sf.scrim-fade("hk-popover-scrim");
220
+
214
221
  @media (prefers-reduced-motion: reduce) {
215
222
  .hk-popover-sheet-enter-active,
216
223
  .hk-popover-sheet-leave-active {
@@ -101,8 +101,12 @@ export default defineComponent({
101
101
  // Open/close motion reported into the unified animation context
102
102
  // through the same standard hook wiring every surface shares
103
103
  // (useSurfaceTransition) — this component was the pattern's
104
- // origin and now consumes it like the rest.
105
- const anim = useSurfaceTransition(300).hooks();
104
+ // origin and now consumes it like the rest. Scrim and panel ride
105
+ // separate named tracks (window-layer contract, ./_scrim-fade.scss)
106
+ // so the backdrop's fade reports and settles on its own.
107
+ const surfaceAnim = useSurfaceTransition(300);
108
+ const anim = surfaceAnim.hooks();
109
+ const scrimAnim = surfaceAnim.hooks("scrim");
106
110
 
107
111
  // Suppress native browser tooltips while the popover is open — and
108
112
  // not just on the anchor itself: a `title` on ANY descendant fires
@@ -525,13 +529,20 @@ export default defineComponent({
525
529
 
526
530
  return () => (
527
531
  <Teleport to="body">
528
- {sheetMode.value && props.modelValue && props.closeOnBackdrop && (
529
- <div
530
- class="hk-popover-scrim"
531
- style={{ zIndex: backdropZ.value }}
532
- onClick={() => close()}
533
- />
534
- )}
532
+ {/* Window-layer contract (./_scrim-fade.scss): the sheet scrim
533
+ fades in place under its own transition name — it used to
534
+ mount and unmount bare, snapping the dim curtain on and off
535
+ around the sliding panel. z rides the live registration,
536
+ which outlives the close until the leave fade finishes. */}
537
+ <Transition name="hk-popover-scrim" appear {...scrimAnim}>
538
+ {sheetMode.value && props.modelValue && props.closeOnBackdrop ? (
539
+ <div
540
+ class="hk-popover-scrim"
541
+ style={{ zIndex: backdropZ.value }}
542
+ onClick={() => close()}
543
+ />
544
+ ) : null}
545
+ </Transition>
535
546
  {props.backdrop && !sheetMode.value && props.modelValue && (
536
547
  <div
537
548
  class="hk-popover-backdrop"
@@ -1,5 +1,6 @@
1
1
  @use "../tokens";
2
2
  @use "./menu-item" as mi;
3
+ @use "./scrim-fade" as sf;
3
4
 
4
5
  /* HkSelect — popup select component */
5
6
 
@@ -401,6 +402,13 @@
401
402
  transform: translateY(100%);
402
403
  }
403
404
 
405
+ /* Scrim fade — the shared window-layer contract (./_scrim-fade.scss):
406
+ * the dim curtain fades in place under its OWN transition name while the
407
+ * panel above slides. It once shared this `hk-select-sheet` name, so the
408
+ * panel's translateY(100%) enter pair dragged the backdrop up from the
409
+ * bottom edge on phones (2026-09-06 report). */
410
+ @include sf.scrim-fade("hk-select-sheet-scrim");
411
+
404
412
  @media (prefers-reduced-motion: reduce) {
405
413
  .hk-select-sheet-enter-active,
406
414
  .hk-select-sheet-leave-active,
@@ -520,7 +520,12 @@ export default defineComponent({
520
520
  if (sheetMode.value) {
521
521
  return (
522
522
  <Teleport to="body">
523
- <Transition name="hk-select-sheet" appear {...sheetScrimAnim.hooks("scrim")}>
523
+ {/* Window-layer contract (./_scrim-fade.scss): the scrim fades
524
+ in place under its OWN transition name. It once shared the
525
+ panel's `hk-select-sheet` name, so the panel's
526
+ translateY(100%) enter pair slid the dim curtain up from
527
+ the bottom edge on phones (2026-09-06 report). */}
528
+ <Transition name="hk-select-sheet-scrim" appear {...sheetScrimAnim.hooks("scrim")}>
524
529
  {props.open ? (
525
530
  <div
526
531
  class="hk-select-sheet-scrim"
@@ -0,0 +1,59 @@
1
+ // _scrim-fade.scss
2
+ //
3
+ // Window-layer animation contract — the one place every modal-context
4
+ // surface's dim curtain is defined (2026-09-06 user directive).
5
+ //
6
+ // Every windowed surface in hikari (HkModal, HkDrawer, the sheet-docked
7
+ // HkSelectPanel, the sheet-docked HkPopover) is TWO independent layers:
8
+ //
9
+ // overlay / scrim — position: fixed; inset: 0; painted one band below
10
+ // its panel. It renders inside its OWN <Transition> whose name ends
11
+ // in -overlay / -scrim and is NEVER the panel's transition name, and
12
+ // its classes animate OPACITY ONLY: the dim curtain appears over the
13
+ // whole viewport at once, in place — it never slides, scales, or
14
+ // clips, on any breakpoint.
15
+ // panel / content — its own <Transition> with the surface's pop/slide
16
+ // name (desktop: height-clip or anchored pop; phones: bottom-sheet
17
+ // translateY). Transforms live here and nowhere else.
18
+ //
19
+ // Why this file exists: the HkSelectPanel sheet scrim reused the panel's
20
+ // `hk-select-sheet` transition name, so the panel's enter-from pair
21
+ // (`transform: translateY(100%)`) applied to the backdrop too — on phones
22
+ // every second-level dropdown sheet dragged its dim curtain up from the
23
+ // bottom edge instead of fading it in globally. First-level modals were
24
+ // fine (their overlay already had a distinct name), which is what made
25
+ // the drift look deliberate.
26
+ //
27
+ // Surfaces generate their fade classes through `scrim-fade()` so the rule
28
+ // is structural, not per-file discipline. Durations and easings accept
29
+ // any CSS value including var() tokens; the defaults are the sheet family
30
+ // feel (0.25s out / 0.2s in, matching the panel opacity timings).
31
+ // Enforced by scrim-fade.contract.test.ts against the compiled css.
32
+
33
+ @mixin scrim-fade(
34
+ $name,
35
+ $enter-duration: 0.25s,
36
+ $enter-ease: ease-out,
37
+ $leave-duration: 0.2s,
38
+ $leave-ease: ease-in
39
+ ) {
40
+ .#{$name}-enter-active {
41
+ transition: opacity $enter-duration $enter-ease;
42
+ }
43
+
44
+ .#{$name}-leave-active {
45
+ transition: opacity $leave-duration $leave-ease;
46
+ }
47
+
48
+ .#{$name}-enter-from,
49
+ .#{$name}-leave-to {
50
+ opacity: 0;
51
+ }
52
+
53
+ @media (prefers-reduced-motion: reduce) {
54
+ .#{$name}-enter-active,
55
+ .#{$name}-leave-active {
56
+ transition: none;
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Source contract for the window-layer animation rules (2026-09-06 user
3
+ * directive). Every modal-context surface's dim curtain — the
4
+ * HkModal/HkDrawer overlay, the sheet scrim of HkSelectPanel and
5
+ * HkPopover — is an INDEPENDENT full-screen layer that fades in and out
6
+ * in place: opacity only, own transition name, never the panel's.
7
+ *
8
+ * The bug this pins shut: the HkSelectPanel sheet scrim reused the
9
+ * panel's `hk-select-sheet` transition name, so the panel's
10
+ * `translateY(100%)` enter pair applied to the backdrop too — on phones
11
+ * every second-level dropdown sheet dragged its dim curtain up from the
12
+ * bottom edge, while first-level modals (distinct overlay name) looked
13
+ * right.
14
+ *
15
+ * The classes are GENERATED by the shared mixin (_scrim-fade.scss), so
16
+ * this test compiles each surface stylesheet and pins the emitted css —
17
+ * it holds no matter how a surface authors its rules, and fails the
18
+ * moment a curtain regains a transform or a panel loses its slide.
19
+ */
20
+ import { describe, expect, it } from "vitest";
21
+ import { readFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import * as sass from "sass";
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const read = (f: string) => readFileSync(join(here, f), "utf-8");
28
+
29
+ const CURTAINS: Array<{ file: string; curtain: string }> = [
30
+ { file: "HkModal.scss", curtain: "hk-modal-overlay" },
31
+ { file: "HkDrawer.scss", curtain: "hk-drawer-overlay" },
32
+ { file: "HkSelect.scss", curtain: "hk-select-sheet-scrim" },
33
+ { file: "HkPopover.scss", curtain: "hk-popover-scrim" },
34
+ ];
35
+
36
+ /** One compiled rule block for `<curtain>-<phase>`, or "" when absent. */
37
+ function rule(css: string, curtain: string, phase: string): string {
38
+ return css.match(new RegExp(`\\.${curtain}-${phase}[^{]*\\{[^}]*\\}`))?.[0] ?? "";
39
+ }
40
+
41
+ describe("window-layer scrim fade contract", () => {
42
+ for (const { file, curtain } of CURTAINS) {
43
+ it(`${curtain} (${file}) fades in place — opacity only, no transform`, () => {
44
+ const css = sass.compile(join(here, file)).css;
45
+ for (const phase of ["enter-active", "leave-active"]) {
46
+ const block = rule(css, curtain, phase);
47
+ expect(block, `${curtain}-${phase} rule missing`).toContain("transition: opacity");
48
+ expect(block, `${curtain}-${phase} must not move the curtain`).not.toContain("transform");
49
+ }
50
+ for (const phase of ["enter-from", "leave-to"]) {
51
+ const block = rule(css, curtain, phase);
52
+ expect(block, `${curtain}-${phase} rule missing`).toContain("opacity: 0");
53
+ expect(block, `${curtain}-${phase} must not move the curtain`).not.toContain("transform");
54
+ }
55
+ });
56
+ }
57
+
58
+ it("the sheet PANELS keep their slide — the contract never flattens the panel", () => {
59
+ const select = sass.compile(join(here, "HkSelect.scss")).css;
60
+ expect(rule(select, "hk-select-sheet", "enter-from")).toContain("translateY(100%)");
61
+ const popover = sass.compile(join(here, "HkPopover.scss")).css;
62
+ expect(rule(popover, "hk-popover-sheet", "enter-from")).toContain("translateY(100%)");
63
+ });
64
+
65
+ it("the select sheet scrim rides a transition name distinct from the panel's", () => {
66
+ const src = read("HkSelectPanel.tsx");
67
+ const names = Array.from(src.matchAll(/<Transition name="([^"]+)"/g), (m) => m[1]);
68
+ expect(names).toContain("hk-select-sheet-scrim");
69
+ expect(names).toContain("hk-select-sheet");
70
+ expect(src).toContain('sheetScrimAnim.hooks("scrim")');
71
+ });
72
+
73
+ it("the popover sheet scrim fades instead of mounting bare", () => {
74
+ const src = read("HkPopover.tsx");
75
+ expect(src).toContain('<Transition name="hk-popover-scrim"');
76
+ expect(src).toContain('surfaceAnim.hooks("scrim")');
77
+ // The panel's sheet name stays the slide family — distinct from the scrim.
78
+ expect(src).toContain('"hk-popover-sheet"');
79
+ });
80
+ });
@@ -0,0 +1,9 @@
1
+ // HkErrorReportingOverlay — fixed full-viewport host for the error landing.
2
+ // Sits above every popup band (modal 1000 / dropdown 2000 / tooltip 3000 /
3
+ // toast 4000): an uncaught-error takeover must never be covered.
4
+
5
+ .hk-error-reporting-overlay {
6
+ position: fixed;
7
+ inset: 0;
8
+ z-index: 5000;
9
+ }