@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 +1 -1
- package/src/components/HkErrorBoundary.tsx +3 -2
- package/src/components/HkErrorLanding.scss +138 -61
- package/src/components/HkErrorLanding.test.tsx +49 -14
- package/src/components/HkErrorLanding.tsx +108 -28
- package/src/components/HkModal.bodyrhythm.test.ts +40 -0
- package/src/components/HkModal.scss +11 -0
- package/src/components/HkThemeToggle.tsx +8 -4
- package/src/errorReporting/HkErrorReportingOverlay.tsx +2 -2
- package/src/theme/useTheme.test.ts +89 -0
- package/src/theme/useTheme.ts +22 -6
package/package.json
CHANGED
|
@@ -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
|
|
34
|
-
* name/message/stack in
|
|
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
|
-
|
|
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-
|
|
44
|
+
border-radius: var(--radius-xl, 20px);
|
|
43
45
|
box-shadow: var(--shadow-lg, 0 18px 48px rgb(0 0 0 / 8%));
|
|
44
|
-
padding:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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) /
|
|
73
|
-
border: 1px solid rgb(var(--hel-error) /
|
|
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) /
|
|
83
|
-
border-color: rgb(var(--hel-warning) /
|
|
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) /
|
|
93
|
-
border-color: rgb(var(--hel-primary) /
|
|
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-
|
|
99
|
-
font-weight:
|
|
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-
|
|
148
|
-
display:
|
|
194
|
+
.hk-error-landing__details-label {
|
|
195
|
+
display: flex;
|
|
149
196
|
align-items: center;
|
|
150
197
|
gap: 4px;
|
|
151
|
-
|
|
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
|
-
|
|
158
|
-
|
|
203
|
+
user-select: none;
|
|
204
|
+
}
|
|
159
205
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
76
|
-
expect(
|
|
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
|
|
85
|
-
const el = mountLanding({
|
|
86
|
-
expect(el.querySelector(".hk-error-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
25
|
-
* (the default slot — hosts render HkJsonTree
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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={
|
|
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
|
-
{
|
|
77
|
-
|
|
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
|
-
<
|
|
84
|
-
|
|
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
|
-
</
|
|
92
|
-
|
|
93
|
-
<div class="hk-error-landing__details-body">
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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
|
|
21
|
-
*
|
|
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
|
+
});
|
package/src/theme/useTheme.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|