@celestia-island/hikari 0.40.29 → 0.40.30

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.40.29",
3
+ "version": "0.40.30",
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",
@@ -30,8 +30,9 @@ function formatError(err: CapturedError): string {
30
30
  * Captures descendant errors via `onErrorCaptured` and stops propagation.
31
31
  * The built-in fallback is the same HkErrorLanding card the family's
32
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.
33
+ * name as the tone-matched HkBadge chip, the message as the description,
34
+ * the raw name/message/stack in the fixed-height JSON tree pane, plus
35
+ * retry / copy actions.
35
36
  */
36
37
  export default defineComponent({
37
38
  name: "HkErrorBoundary",
@@ -36,16 +36,38 @@
36
36
  }
37
37
 
38
38
  .hk-error-landing__card {
39
- width: min(430px, 100%);
39
+ position: relative;
40
+ overflow: hidden;
41
+ width: min(440px, 100%);
40
42
  background: rgb(var(--hel-surface));
41
43
  border: 1px solid rgb(var(--hel-border) / 70%);
42
- border-radius: var(--radius-lg, 16px);
44
+ border-radius: var(--radius-xl, 20px);
43
45
  box-shadow: var(--shadow-lg, 0 18px 48px rgb(0 0 0 / 8%));
44
- padding: 2rem 1.75rem;
46
+ padding: 2.25rem 1.75rem 1.75rem;
45
47
  text-align: center;
48
+
49
+ // Tone wash bleeding down from the top edge behind the icon — gives the
50
+ // upper half the designed depth the plain white card used to lack.
51
+ &::before {
52
+ content: "";
53
+ position: absolute;
54
+ inset: 0 0 auto;
55
+ height: 150px;
56
+ background: radial-gradient(58% 100% at 50% 0%, rgb(var(--hel-error) / 8%), transparent 72%);
57
+ pointer-events: none;
58
+ }
59
+ }
60
+
61
+ .hk-error-landing.is-warning .hk-error-landing__card::before {
62
+ background: radial-gradient(58% 100% at 50% 0%, rgb(var(--hel-warning) / 10%), transparent 72%);
63
+ }
64
+
65
+ .hk-error-landing.is-info .hk-error-landing__card::before {
66
+ background: radial-gradient(58% 100% at 50% 0%, rgb(var(--hel-primary) / 7%), transparent 72%);
46
67
  }
47
68
 
48
69
  .hk-error-landing__brand {
70
+ position: relative;
49
71
  display: flex;
50
72
  justify-content: center;
51
73
  margin-bottom: var(--space-16, 1rem);
@@ -56,9 +78,10 @@
56
78
  }
57
79
 
58
80
  .hk-error-landing__icon {
59
- width: 58px;
60
- height: 58px;
61
- margin: 0 auto var(--space-16, 1rem);
81
+ position: relative;
82
+ width: 64px;
83
+ height: 64px;
84
+ margin: 0 auto var(--space-14, 0.875rem);
62
85
  display: flex;
63
86
  align-items: center;
64
87
  justify-content: center;
@@ -69,8 +92,13 @@
69
92
  color: rgb(var(--hel-error));
70
93
  }
71
94
 
72
- background: rgb(var(--hel-error) / 12%);
73
- border: 1px solid rgb(var(--hel-error) / 22%);
95
+ background: linear-gradient(145deg, rgb(var(--hel-error) / 18%), rgb(var(--hel-error) / 6%));
96
+ border: 1px solid rgb(var(--hel-error) / 26%);
97
+ // Layered halo: a soft tone ring hugging the disc plus a long, faint
98
+ // drop shadow — reads as a designed badge instead of a flat circle.
99
+ box-shadow:
100
+ 0 0 0 8px rgb(var(--hel-error) / 5%),
101
+ 0 10px 28px -12px rgb(var(--hel-error) / 45%);
74
102
  }
75
103
 
76
104
  .hk-error-landing.is-warning .hk-error-landing__icon {
@@ -79,8 +107,11 @@
79
107
  color: rgb(var(--hel-warning));
80
108
  }
81
109
 
82
- background: rgb(var(--hel-warning) / 12%);
83
- border-color: rgb(var(--hel-warning) / 24%);
110
+ background: linear-gradient(145deg, rgb(var(--hel-warning) / 20%), rgb(var(--hel-warning) / 7%));
111
+ border-color: rgb(var(--hel-warning) / 28%);
112
+ box-shadow:
113
+ 0 0 0 8px rgb(var(--hel-warning) / 6%),
114
+ 0 10px 28px -12px rgb(var(--hel-warning) / 45%);
84
115
  }
85
116
 
86
117
  .hk-error-landing.is-info .hk-error-landing__icon {
@@ -89,19 +120,63 @@
89
120
  color: rgb(var(--hel-primary));
90
121
  }
91
122
 
92
- background: rgb(var(--hel-primary) / 10%);
93
- border-color: rgb(var(--hel-primary) / 20%);
123
+ background: linear-gradient(145deg, rgb(var(--hel-primary) / 16%), rgb(var(--hel-primary) / 5%));
124
+ border-color: rgb(var(--hel-primary) / 24%);
125
+ box-shadow:
126
+ 0 0 0 8px rgb(var(--hel-primary) / 5%),
127
+ 0 10px 28px -12px rgb(var(--hel-primary) / 40%);
128
+ }
129
+
130
+ // Code / status chips ride on the canonical HkBadge (mono, small). The
131
+ // channels are re-resolved through the landing's --hel-* fallbacks so a
132
+ // theme-less standalone render paints them exactly like the themed one,
133
+ // and the code chip follows the landing tone like the icon does.
134
+ .hk-error-landing__meta {
135
+ position: relative;
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ flex-wrap: wrap;
140
+ gap: var(--space-6, 0.375rem);
141
+ margin-bottom: var(--space-8, 0.5rem);
142
+ }
143
+
144
+ .hk-error-landing__meta .hk-badge:not(.hk-badge-muted) {
145
+ --hk-badge-text: rgb(var(--hel-error));
146
+ --hk-badge-bg: rgb(var(--hel-error) / 10%);
147
+ --hk-badge-border: rgb(var(--hel-error) / 22%);
148
+ }
149
+
150
+ .hk-error-landing.is-warning .hk-error-landing__meta .hk-badge:not(.hk-badge-muted) {
151
+ --hk-badge-text: rgb(var(--hel-warning));
152
+ --hk-badge-bg: rgb(var(--hel-warning) / 12%);
153
+ --hk-badge-border: rgb(var(--hel-warning) / 24%);
154
+ }
155
+
156
+ .hk-error-landing.is-info .hk-error-landing__meta .hk-badge:not(.hk-badge-muted) {
157
+ --hk-badge-text: rgb(var(--hel-primary));
158
+ --hk-badge-bg: rgb(var(--hel-primary) / 10%);
159
+ --hk-badge-border: rgb(var(--hel-primary) / 20%);
160
+ }
161
+
162
+ .hk-error-landing__meta .hk-badge-muted {
163
+ --hk-badge-text: rgb(var(--hel-muted));
164
+ --hk-badge-bg: rgb(var(--hel-muted) / 8%);
165
+ --hk-badge-border: rgb(var(--hel-border) / 80%);
94
166
  }
95
167
 
96
168
  .hk-error-landing__title {
169
+ position: relative;
97
170
  margin: 0;
98
- font-size: var(--text-lg, 1.125rem);
99
- font-weight: 600;
171
+ font-size: var(--text-xl, 1.25rem);
172
+ font-weight: 650;
173
+ letter-spacing: -0.01em;
100
174
  line-height: 1.35;
101
175
  color: rgb(var(--hel-text));
102
176
  }
103
177
 
104
178
  .hk-error-landing__desc {
179
+ position: relative;
105
180
  margin: var(--space-8, 0.5rem) 0 0;
106
181
  font-size: var(--text-sm, 0.8125rem);
107
182
  line-height: 1.6;
@@ -110,69 +185,71 @@
110
185
  overflow-wrap: anywhere;
111
186
  }
112
187
 
113
- .hk-error-landing__meta {
114
- display: flex;
115
- align-items: center;
116
- justify-content: center;
117
- flex-wrap: wrap;
118
- gap: var(--space-6, 0.375rem);
119
- margin-top: var(--space-10, 0.625rem);
120
- }
121
-
122
- .hk-error-landing__code {
123
- font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
124
- font-size: var(--text-xs, 0.75rem);
125
- color: rgb(var(--hel-muted));
126
- background: rgb(var(--hel-muted) / 8%);
127
- border: 1px solid rgb(var(--hel-border) / 80%);
128
- border-radius: var(--radius-sm, 4px);
129
- padding: 0.1em 0.55em;
130
- overflow-wrap: anywhere;
131
- }
132
-
133
- .hk-error-landing__status {
134
- font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
135
- font-size: var(--text-xs, 0.75rem);
136
- color: rgb(var(--hel-muted));
137
- border: 1px dashed rgb(var(--hel-border));
138
- border-radius: var(--radius-sm, 4px);
139
- padding: 0.1em 0.55em;
140
- }
141
-
142
188
  .hk-error-landing__details {
189
+ position: relative;
143
190
  margin-top: var(--space-16, 1rem);
144
191
  text-align: left;
145
192
  }
146
193
 
147
- .hk-error-landing__details-toggle {
148
- display: inline-flex;
194
+ .hk-error-landing__details-label {
195
+ display: flex;
149
196
  align-items: center;
150
197
  gap: 4px;
151
- border: none;
152
- background: transparent;
153
- padding: 2px 4px;
154
- font: inherit;
198
+ padding: 0 2px;
155
199
  font-size: var(--text-xs, 0.75rem);
200
+ font-weight: 500;
201
+ letter-spacing: 0.02em;
156
202
  color: rgb(var(--hel-muted));
157
- cursor: pointer;
158
- border-radius: var(--radius-xs, 4px);
203
+ user-select: none;
204
+ }
159
205
 
160
- &:hover {
161
- color: rgb(var(--hel-text));
162
- background: rgb(var(--hel-muted) / 8%);
163
- }
206
+ // Fixed-height details pane: the raw payload keeps a stable ~20vh
207
+ // footprint. Folding every JSON node still leaves the pane standing at
208
+ // the same height, and a long stack trace can never stretch the card —
209
+ // the family overlay scrollbar (attachOverlayScrollbars) carries the
210
+ // overflow inside the frame. The pane div is the track's positioning
211
+ // context and wraps EXACTLY the scrolling body (useOverlayScrollbar
212
+ // host contract).
213
+ .hk-error-landing__details-pane {
214
+ position: relative;
215
+ margin-top: var(--space-6, 0.375rem);
216
+ height: max(9rem, 20vh);
217
+ height: max(9rem, 20dvh);
218
+ border: 1px solid rgb(var(--hel-border) / 70%);
219
+ border-radius: var(--radius-md, 10px);
220
+ background: rgb(var(--hel-muted) / 4%);
221
+ }
222
+
223
+ .hk-error-landing__details-body {
224
+ height: 100%;
225
+ overflow: auto;
226
+ border-radius: inherit;
227
+ padding: var(--space-4, 0.25rem);
164
228
 
165
- &:focus-visible {
166
- outline: 2px solid rgb(var(--hel-primary) / 60%);
167
- outline-offset: 1px;
229
+ // The overlay scrollbar is the only chrome — hide the native bar.
230
+ scrollbar-width: none;
231
+
232
+ &::-webkit-scrollbar {
233
+ display: none;
168
234
  }
169
235
  }
170
236
 
171
- .hk-error-landing__details-body {
172
- margin-top: var(--space-6, 0.375rem);
237
+ // A nested HkJsonTree fills the pane instead of stacking its own frame
238
+ // and 320px cap — the pane above owns the chrome now (overflow included:
239
+ // the tree must never engage its own scroller).
240
+ .hk-error-landing__details-body .s-tool-json-tree {
241
+ max-height: none;
242
+ height: auto;
243
+ overflow: visible;
244
+ margin: 0;
245
+ padding: 0;
246
+ border: none;
247
+ border-radius: 0;
248
+ background: transparent;
173
249
  }
174
250
 
175
251
  .hk-error-landing__actions {
252
+ position: relative;
176
253
  display: flex;
177
254
  align-items: center;
178
255
  justify-content: center;
@@ -192,6 +269,6 @@
192
269
 
193
270
  @media (max-width: 480px) {
194
271
  .hk-error-landing__card {
195
- padding: 1.5rem 1.125rem;
272
+ padding: 1.75rem 1.125rem 1.5rem;
196
273
  }
197
274
  }
@@ -1,8 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
- import { createApp, h, nextTick } from "vue";
2
+ import { createApp, h } from "vue";
3
3
 
4
4
  import { HkErrorLanding } from "./HkErrorLanding";
5
-
6
5
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
6
 
8
7
  interface MountOptions {
@@ -12,7 +11,6 @@ interface MountOptions {
12
11
  status?: number;
13
12
  tone?: "error" | "warning" | "info";
14
13
  variant?: "page" | "inline";
15
- detailsOpen?: boolean;
16
14
  details?: () => ReturnType<typeof h>;
17
15
  actions?: () => ReturnType<typeof h>;
18
16
  brand?: () => ReturnType<typeof h>;
@@ -30,7 +28,6 @@ function mountLanding(opts: MountOptions = {}) {
30
28
  status: opts.status,
31
29
  tone: opts.tone ?? "error",
32
30
  variant: opts.variant ?? "page",
33
- detailsOpen: opts.detailsOpen ?? true,
34
31
  }, {
35
32
  ...(opts.details ? { default: opts.details } : {}),
36
33
  ...(opts.actions ? { actions: opts.actions } : {}),
@@ -59,7 +56,12 @@ describe("HkErrorLanding", () => {
59
56
  it("renders the given title, description, and meta chips", () => {
60
57
  const el = mountLanding({ title: "OAuth failed", description: "line1\nline2", code: "unknown_provider", status: 400 });
61
58
  expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("OAuth failed");
59
+ // Chips ride on the canonical HkBadge (class fallthrough keeps the
60
+ // landing-level selectors testable).
61
+ expect(el.querySelector(".hk-error-landing__code")!.classList.contains("hk-badge")).toBe(true);
62
+ expect(el.querySelector(".hk-error-landing__code")!.classList.contains("hk-badge-warning")).toBe(false);
62
63
  expect(el.querySelector(".hk-error-landing__code")!.textContent).toBe("unknown_provider");
64
+ expect(el.querySelector(".hk-error-landing__status")!.classList.contains("hk-badge-muted")).toBe(true);
63
65
  expect(el.querySelector(".hk-error-landing__status")!.textContent).toBe("HTTP 400");
64
66
  });
65
67
 
@@ -68,22 +70,55 @@ describe("HkErrorLanding", () => {
68
70
  expect(el.querySelector(".hk-error-landing__meta")).toBeNull();
69
71
  });
70
72
 
71
- it("renders the details slot with the raw JSON tree pane and toggles it", async () => {
73
+ it("renders the details pane always open with a tone-following code chip", () => {
72
74
  const el = mountLanding({
73
75
  details: () => h("pre", { class: "s-tool-json-tree" }, "raw"),
74
76
  });
75
- const toggle = el.querySelector<HTMLButtonElement>(".hk-error-landing__details-toggle")!;
76
- expect(toggle.getAttribute("aria-expanded")).toBe("true");
77
+ // No collapse affordance anymore — the pane is a fixed region.
78
+ expect(el.querySelector(".hk-error-landing__details-toggle")).toBeNull();
79
+ expect(el.querySelector(".hk-error-landing__details-label")!.textContent).toContain("Raw error details");
80
+ expect(el.querySelector(".hk-error-landing__details-pane")).not.toBeNull();
77
81
  expect(el.querySelector(".hk-error-landing__details-body")).not.toBeNull();
78
- toggle.click();
79
- await nextTick();
80
- expect(toggle.getAttribute("aria-expanded")).toBe("false");
81
- expect(el.querySelector(".hk-error-landing__details-body")).toBeNull();
82
82
  });
83
83
 
84
- it("keeps details collapsed when detailsOpen is false", () => {
85
- const el = mountLanding({ detailsOpen: false, details: () => h("pre", {}, "raw") });
86
- expect(el.querySelector(".hk-error-landing__details-body")).toBeNull();
84
+ it("keeps the code chip on the tone variant and the status chip muted", () => {
85
+ const el = mountLanding({ code: "rate_limited", status: 429, tone: "warning" });
86
+ expect(el.querySelector(".hk-error-landing__code")!.classList.contains("hk-badge-warning")).toBe(true);
87
+ expect(el.querySelector(".hk-error-landing__status")!.classList.contains("hk-badge-muted")).toBe(true);
88
+ });
89
+
90
+ it("maps the info tone onto the info chip variant", () => {
91
+ const el = mountLanding({ code: "maintenance", tone: "info" });
92
+ expect(el.querySelector(".hk-error-landing__code")!.classList.contains("hk-badge-info")).toBe(true);
93
+ });
94
+
95
+ it("renders the description text", () => {
96
+ const el = mountLanding({ description: "line1\nline2" });
97
+ expect(el.querySelector(".hk-error-landing__desc")!.textContent).toBe("line1\nline2");
98
+ });
99
+
100
+ it("attaches the overlay scrollbar chrome inside the details pane", () => {
101
+ const el = mountLanding({ details: () => h("pre", { class: "s-tool-json-tree" }, "raw") });
102
+ expect(el.querySelector(".hk-error-landing__details-pane .hk-scrollbar-track")).not.toBeNull();
103
+ expect(el.querySelector(".hk-error-landing__details-pane .hk-scrollbar-thumb")).not.toBeNull();
104
+ });
105
+
106
+ it("renders no scrollbar chrome without a details pane", () => {
107
+ const el = mountLanding({});
108
+ expect(el.querySelector(".hk-scrollbar-track")).toBeNull();
109
+ });
110
+
111
+ it("detaches the scrollbar chrome on unmount", () => {
112
+ const container = document.createElement("div");
113
+ document.body.appendChild(container);
114
+ const app = createApp({
115
+ render: () => h(HkErrorLanding, { title: "Boom" }, { default: () => h("pre", "raw") }),
116
+ });
117
+ app.mount(container);
118
+ expect(container.querySelector(".hk-scrollbar-track")).not.toBeNull();
119
+ app.unmount();
120
+ container.remove();
121
+ expect(container.querySelector(".hk-scrollbar-track")).toBeNull();
87
122
  });
88
123
 
89
124
  it("renders no details section without a default slot", () => {
@@ -1,6 +1,17 @@
1
- import { computed, defineComponent, ref, type PropType } from "vue";
2
- import { ChevronDown, ChevronRight, Info, TriangleAlert } from "lucide-vue-next";
1
+ import { Braces, Info, TriangleAlert } from "lucide-vue-next";
2
+ import {
3
+ computed,
4
+ defineComponent,
5
+ onBeforeUnmount,
6
+ onMounted,
7
+ onUpdated,
8
+ ref,
9
+ type PropType,
10
+ } from "vue";
11
+
12
+ import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
3
13
  import { useI18n } from "../i18n/context";
14
+ import HkBadge from "./HkBadge";
4
15
 
5
16
  import "./HkErrorLanding.scss";
6
17
 
@@ -21,14 +32,20 @@ export type HErrorLandingVariant = "page" | "inline";
21
32
  *
22
33
  * Login-page-like layout: a centered card over a full-viewport backdrop,
23
34
  * carrying a tone icon, a (pre-translated) title and description, the wire
24
- * error code / HTTP status as meta chips, a collapsible raw-details section
25
- * (the default slot — hosts render HkJsonTree there) and an actions slot.
35
+ * error code / HTTP status as HkBadge chips above the headline, an
36
+ * always-open raw-details pane (the default slot — hosts render HkJsonTree
37
+ * there) with a FIXED ~20vh footprint carried by the family overlay
38
+ * scrollbar, and an actions slot.
39
+ *
40
+ * The details pane deliberately never collapses and never grows past its
41
+ * frame: folding every JSON node still leaves the pane standing, and a
42
+ * long stack trace scrolls inside it instead of stretching the card.
26
43
  *
27
44
  * The component is presentation-only and route-agnostic: it never touches
28
45
  * the router and can be mounted by an SPA overlay, a modal, or a standalone
29
46
  * server-rendered error page alike. All host-facing copy (`title`,
30
47
  * `description`, action buttons) arrives pre-translated; the component only
31
- * translates its own two labels via `hikari::errors.*`.
48
+ * translates its own labels via `hikari::errors.*`.
32
49
  */
33
50
  export const HkErrorLanding = defineComponent({
34
51
  name: "HkErrorLanding",
@@ -44,54 +61,117 @@ export const HkErrorLanding = defineComponent({
44
61
  tone: { type: String as PropType<HErrorTone>, default: "error" },
45
62
  /** Layout variant: `page` (viewport backdrop) or `inline` (in-flow card). */
46
63
  variant: { type: String as PropType<HErrorLandingVariant>, default: "page" },
47
- /** Initial expansion of the raw-details section. */
48
- detailsOpen: { type: Boolean, default: true },
49
64
  },
50
65
  setup(props, { slots }) {
51
66
  const { t } = useI18n();
52
- const detailsExpanded = ref(props.detailsOpen);
53
67
 
54
68
  const titleText = computed(() => props.title || t("hikari::errors.defaultTitle", "Something went wrong"));
55
69
  const hasDetails = computed(() => slots.default != null);
56
70
 
57
- function toggleDetails() {
58
- detailsExpanded.value = !detailsExpanded.value;
71
+ // Badge variant follows the landing tone so the chip, the icon and the
72
+ // card wash always speak the same severity language.
73
+ const codeBadgeVariant = computed(() =>
74
+ props.tone === "warning" ? "warning" : props.tone === "info" ? "info" : "error",
75
+ );
76
+
77
+ const detailsBodyRef = ref<HTMLElement | null>(null);
78
+ let detailsScrollbars: OverlayScrollbarHandle | null = null;
79
+ // The pane's viewport box is fixed, so the composable's own viewport
80
+ // ResizeObserver never fires when the slot content changes size — and
81
+ // folding a JSON node re-renders HkJsonTree internally, so the landing
82
+ // itself does not re-render either. Observe the CONTENT element (the
83
+ // tree root) so every fold/expand re-reads the thumb geometry.
84
+ let contentResizeObserver: ResizeObserver | null = null;
85
+ let observedContent: Element | null = null;
86
+
87
+ function observeDetailsContent() {
88
+ const content = detailsBodyRef.value?.firstElementChild ?? null;
89
+ if (content === observedContent) return;
90
+ if (observedContent) contentResizeObserver?.unobserve(observedContent);
91
+ observedContent = content;
92
+ if (content) contentResizeObserver?.observe(content);
93
+ }
94
+
95
+ function ensureDetailsScrollbars() {
96
+ if (detailsScrollbars || !detailsBodyRef.value) return;
97
+ detailsScrollbars = attachOverlayScrollbars(detailsBodyRef.value);
98
+ contentResizeObserver = new ResizeObserver(() => detailsScrollbars?.update());
99
+ observeDetailsContent();
100
+ }
101
+
102
+ function releaseDetailsScrollbars() {
103
+ detailsScrollbars?.detach();
104
+ detailsScrollbars = null;
105
+ contentResizeObserver?.disconnect();
106
+ contentResizeObserver = null;
107
+ observedContent = null;
59
108
  }
60
109
 
110
+ onMounted(() => {
111
+ ensureDetailsScrollbars();
112
+ });
113
+
114
+ // Covers landing rerenders (thumb geometry re-read) plus the rare
115
+ // dynamic-slot cases: a slot appearing after mount attaches the
116
+ // chrome, a slot removed at runtime tears it down (element-identity
117
+ // check, not the frozen hasDetails computed).
118
+ onUpdated(() => {
119
+ if (detailsBodyRef.value) {
120
+ ensureDetailsScrollbars();
121
+ observeDetailsContent();
122
+ detailsScrollbars?.update();
123
+ } else if (detailsScrollbars) {
124
+ releaseDetailsScrollbars();
125
+ }
126
+ });
127
+
128
+ onBeforeUnmount(() => {
129
+ releaseDetailsScrollbars();
130
+ });
131
+
61
132
  return () => (
62
133
  <div class={`hk-error-landing is-${props.tone}${props.variant === "inline" ? " is-inline" : ""}`}>
63
134
  <div class="hk-error-landing__card">
64
135
  {slots.brand?.()}
65
136
 
66
137
  <div class="hk-error-landing__icon" aria-hidden="true">
67
- {props.tone === "info" ? <Info size={26} /> : <TriangleAlert size={26} />}
138
+ {props.tone === "info" ? <Info size={28} /> : <TriangleAlert size={28} />}
68
139
  </div>
69
140
 
70
- <h1 class="hk-error-landing__title">{titleText.value}</h1>
71
-
72
- {props.description && <p class="hk-error-landing__desc">{props.description}</p>}
73
-
74
141
  {(props.code || props.status != null) && (
75
142
  <div class="hk-error-landing__meta">
76
- {props.code && <code class="hk-error-landing__code">{props.code}</code>}
77
- {props.status != null && <span class="hk-error-landing__status">HTTP {props.status}</span>}
143
+ {/* The landing-scoped selectors (.hk-error-landing__code/
144
+ __status) and family tests rely on the class falling
145
+ through onto the badge root — HkBadge must stay
146
+ single-rooted for that contract to hold. */}
147
+ {props.code && (
148
+ <HkBadge class="hk-error-landing__code" variant={codeBadgeVariant.value} size="sm" mono>
149
+ {props.code}
150
+ </HkBadge>
151
+ )}
152
+ {props.status != null && (
153
+ <HkBadge class="hk-error-landing__status" variant="muted" size="sm" mono>
154
+ HTTP {props.status}
155
+ </HkBadge>
156
+ )}
78
157
  </div>
79
158
  )}
80
159
 
160
+ <h1 class="hk-error-landing__title">{titleText.value}</h1>
161
+
162
+ {props.description && <p class="hk-error-landing__desc">{props.description}</p>}
163
+
81
164
  {hasDetails.value && (
82
165
  <div class="hk-error-landing__details">
83
- <button
84
- type="button"
85
- class="hk-error-landing__details-toggle"
86
- aria-expanded={detailsExpanded.value}
87
- onClick={toggleDetails}
88
- >
89
- {detailsExpanded.value ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
166
+ <div class="hk-error-landing__details-label" aria-hidden="true">
167
+ <Braces size={11} />
90
168
  <span>{t("hikari::errors.rawDetails", "Raw error details")}</span>
91
- </button>
92
- {detailsExpanded.value && (
93
- <div class="hk-error-landing__details-body">{slots.default?.()}</div>
94
- )}
169
+ </div>
170
+ <div class="hk-error-landing__details-pane">
171
+ <div ref={detailsBodyRef} class="hk-error-landing__details-body">
172
+ {slots.default?.()}
173
+ </div>
174
+ </div>
95
175
  </div>
96
176
  )}
97
177
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Source contract for the modal body's default vertical rhythm (2026-09-08
3
+ * user direction: window bodies keep a little distance between their
4
+ * elements without every consumer hand-rolling a gap).
5
+ *
6
+ * `.hk-modal-body-inner` spaces its bare stacked children with a
7
+ * host-tunable custom property:
8
+ * --hk-modal-body-gap margin between adjacent direct children
9
+ * (block layout collapses it with the children's
10
+ * own margins, so it never doubles spacing)
11
+ *
12
+ * Pinned here so a refactor cannot silently regress to the
13
+ * padding-only body that left every unstyled window glued together —
14
+ * the same class of "looks fixed but never shipped" failure as the
15
+ * overflow-poll incident.
16
+ */
17
+ import { beforeAll, describe, expect, it } from "vitest";
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const src = readFileSync(join(here, "HkModal.scss"), "utf-8");
24
+
25
+ describe("HkModal body rhythm contract", () => {
26
+ let inner = "";
27
+ beforeAll(() => {
28
+ inner = src.match(/\.hk-modal-body-inner\s*{[\s\S]*?\n}/)?.[0] ?? "";
29
+ });
30
+
31
+ it("spaces bare stacked children with --hk-modal-body-gap", () => {
32
+ expect(inner).toContain("& > * + *");
33
+ expect(inner).toMatch(/--hk-modal-body-gap,\s*0\.75rem/);
34
+ expect(inner).toContain("margin-top: var(--hk-modal-body-gap");
35
+ });
36
+
37
+ it("keeps the body padding hook unchanged", () => {
38
+ expect(inner).toContain("padding: var(--hk-modal-padding-body");
39
+ });
40
+ });
@@ -228,6 +228,17 @@
228
228
 
229
229
  .hk-modal-body-inner {
230
230
  padding: var(--hk-modal-padding-body, 1.5rem);
231
+
232
+ // Default vertical rhythm between bare stacked children (user direction
233
+ // 2026-09-08: window bodies keep a little distance between their elements
234
+ // without every consumer hand-rolling a gap). Block layout collapses
235
+ // adjacent vertical margins, so this composes with — never doubles — a
236
+ // consumer's own sibling margins; windows that manage their own rhythm
237
+ // through a single root wrapper are unaffected, and hosts can flatten it
238
+ // per window with `--hk-modal-body-gap: 0`.
239
+ & > * + * {
240
+ margin-top: var(--hk-modal-body-gap, 0.75rem);
241
+ }
231
242
  }
232
243
 
233
244
  // ------
@@ -88,12 +88,16 @@ export const HkThemeToggle = defineComponent({
88
88
  const { t } = useI18n();
89
89
  const { currentTheme, currentMode, effectiveMode, geo, setTheme, setMode, toggleMode, allThemeList, addCustomTheme, removeCustomTheme, customThemes } = useTheme();
90
90
 
91
- /** Resolve a row's full definition for the item slots: the live
92
- * preset table first (stock + registered brand themes), then the
93
- * user's stored custom schemes. */
91
+ /** Resolve a row's full definition for the item slots: the user's
92
+ * stored custom schemes first, then the live preset table — the SAME
93
+ * precedence getAllThemePresets applies at apply time, so a row that
94
+ * shadows a builtin id (in-place preset override) shows the anatomy
95
+ * of the scheme that actually renders, not the shadowed factory. */
94
96
  function presetOf(id: ThemeId): ThemePreset | CustomThemePreset | undefined {
97
+ const custom = customThemes.value.find((c) => c.id === id);
98
+ if (custom) return custom;
95
99
  if (id in themePresets) return themePresets[id as keyof typeof themePresets];
96
- return customThemes.value.find((c) => c.id === id);
100
+ return undefined;
97
101
  }
98
102
 
99
103
  const menuOpen = ref(false);
@@ -17,8 +17,8 @@ import "./HkErrorReportingOverlay.scss";
17
17
  * Renders nothing until the error-reporting state is raised. The card is
18
18
  * the family-wide HkErrorLanding (same design language as every unified
19
19
  * error surface): tone icon, headline, the error name as the code chip,
20
- * the message as the description, the raw error record in a collapsible
21
- * JSON tree, and Home / Retry actions.
20
+ * the message as the description, the raw error record in the fixed-height
21
+ * raw-details pane, and Home / Retry actions.
22
22
  *
23
23
  * Mounted by `createErrorReporting` on a dedicated root appended to
24
24
  * `document.body` via its own tiny app instance, so it keeps working even
@@ -137,3 +137,92 @@ describe("useTheme lean cssvar injection", () => {
137
137
  expect(blocks[0].textContent).not.toBe(before);
138
138
  });
139
139
  });
140
+
141
+ describe("useTheme preset/custom shadowing", () => {
142
+ let theme: ThemeModule;
143
+ let presetModule: typeof import("./presets");
144
+
145
+ beforeEach(async () => {
146
+ vi.resetModules();
147
+ vi.unstubAllGlobals();
148
+ localStorage.clear();
149
+ document.documentElement.style.cssText = "";
150
+ document.documentElement.removeAttribute("data-theme");
151
+ document.documentElement.removeAttribute("data-mode");
152
+ document.head.querySelectorAll("style[data-hikari-theme-vars]").forEach((el) => el.remove());
153
+ vi.stubGlobal("fetch", vi.fn(async () => {
154
+ throw new Error("offline");
155
+ }));
156
+ // Same import context as useTheme so both share one presets instance.
157
+ presetModule = await import("./presets");
158
+ theme = await import("./useTheme");
159
+ });
160
+
161
+ afterEach(() => {
162
+ theme.stopThemeClock();
163
+ vi.unstubAllGlobals();
164
+ vi.restoreAllMocks();
165
+ });
166
+
167
+ // A custom whose id equals a builtin shadows it at apply time
168
+ // (getAllThemePresets). The picker list must agree: ONE row per id,
169
+ // flagged custom — the in-place preset override grammar. The primary
170
+ // shifts so an applied override is distinguishable from the factory.
171
+ function override(id: string) {
172
+ const nord = presetModule.themePresets.nord;
173
+ return {
174
+ id,
175
+ name: `${id} (edited)`,
176
+ dark: { ...nord.dark, primary: { r: 1, g: 2, b: 3 } },
177
+ light: { ...nord.light },
178
+ };
179
+ }
180
+
181
+ it("allThemeList dedupes a builtin id shadowed by a custom, flagging it custom", () => {
182
+ theme.initTheme();
183
+ const th = theme.useTheme();
184
+ th.addCustomTheme(override("nord"));
185
+ const rows = th.allThemeList.value.filter((r) => r.id === "nord");
186
+ expect(rows).toHaveLength(1);
187
+ expect(rows[0].isCustom).toBe(true);
188
+ expect(rows[0].name).toBe("nord (edited)");
189
+ // Pure custom ids stay listed as customs; untouched builtins stay builtin.
190
+ expect(th.allThemeList.value.some((r) => r.id === "gruvbox" && !r.isCustom)).toBe(true);
191
+ });
192
+
193
+ it("applyTheme renders the shadowing custom's tokens", () => {
194
+ theme.initTheme();
195
+ const th = theme.useTheme();
196
+ th.setTheme("nord");
197
+ const before = document.head.querySelector("style[data-hikari-theme-vars]")!.textContent;
198
+ th.addCustomTheme(override("nord"));
199
+ th.setTheme("nord");
200
+ const after = document.head.querySelector("style[data-hikari-theme-vars]")!.textContent;
201
+ expect(after).not.toBe(before);
202
+ });
203
+
204
+ it("removing a shadowed builtin id restores the factory preset selection", () => {
205
+ theme.initTheme();
206
+ const th = theme.useTheme();
207
+ th.addCustomTheme(override("nord"));
208
+ th.setTheme("nord");
209
+ expect(document.documentElement.getAttribute("data-theme")).toBe("nord");
210
+ th.removeCustomTheme("nord");
211
+ // Still on the id — now backed by the factory preset again.
212
+ expect(th.currentTheme.value).toBe("nord");
213
+ expect(th.allThemeList.value.find((r) => r.id === "nord")?.isCustom).toBe(false);
214
+ expect(th.customThemes.value.some((c) => c.id === "nord")).toBe(false);
215
+ });
216
+
217
+ it("removing a pure custom id resets the selection to the default theme", () => {
218
+ theme.initTheme();
219
+ const th = theme.useTheme();
220
+ th.setTheme("nord");
221
+ th.addCustomTheme({ ...override("nord"), id: "my-own" });
222
+ th.setTheme("my-own");
223
+ expect(document.documentElement.getAttribute("data-theme")).toBe("my-own");
224
+ th.removeCustomTheme("my-own");
225
+ expect(th.currentTheme.value).not.toBe("my-own");
226
+ expect(th.allThemeList.value.some((r) => r.id === "my-own")).toBe(false);
227
+ });
228
+ });
@@ -281,16 +281,24 @@ export function useTheme() {
281
281
  const effectiveMode = computed(() => resolveEffectiveMode(currentMode.value));
282
282
 
283
283
  const allThemeList = computed(() => {
284
- const builtIn = (Object.keys(themePresets) as string[]).map((id) => ({
285
- id,
286
- name: themePresets[id as keyof typeof themePresets].name,
287
- isCustom: false,
288
- }));
289
284
  const custom = customThemes.value.map((ct: CustomThemePreset) => ({
290
285
  id: ct.id,
291
286
  name: ct.name,
292
287
  isCustom: true,
293
288
  }));
289
+ // A custom overriding a builtin id SHADOWS it — the same rule
290
+ // getAllThemePresets applies at apply time — so the picker shows ONE
291
+ // row per id (a duplicate key row would be unselectable dead chrome),
292
+ // flagged custom so the delete affordance doubles as "restore the
293
+ // factory preset".
294
+ const customIds = new Set(custom.map((c) => c.id));
295
+ const builtIn = (Object.keys(themePresets) as string[])
296
+ .filter((id) => !customIds.has(id))
297
+ .map((id) => ({
298
+ id,
299
+ name: themePresets[id as keyof typeof themePresets].name,
300
+ isCustom: false,
301
+ }));
294
302
  return [...builtIn, ...custom];
295
303
  });
296
304
 
@@ -319,7 +327,15 @@ export function useTheme() {
319
327
  removeCustomThemeFromStorage(id);
320
328
  customThemes.value = loadCustomThemes();
321
329
  if (currentTheme.value === id) {
322
- setTheme(resolveDefaultTheme());
330
+ // A deleted custom whose id still resolves as a builtin preset falls
331
+ // back to the FACTORY preset (an in-place preset override restoring
332
+ // its shipped look); a truly custom id would no longer resolve, so
333
+ // the selection resets to the default theme.
334
+ if (themePresets[id as keyof typeof themePresets]) {
335
+ setTheme(id);
336
+ } else {
337
+ setTheme(resolveDefaultTheme());
338
+ }
323
339
  }
324
340
  }
325
341