@celestia-island/hikari 0.40.28 → 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/HkDatePicker.scss +12 -0
- package/src/components/HkDatePicker.sheet.test.ts +56 -0
- package/src/components/HkDatePicker.tsx +4 -0
- package/src/components/HkDateTimePicker.scss +17 -0
- package/src/components/HkDateTimePicker.tsx +4 -0
- 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
|
@@ -136,6 +136,18 @@
|
|
|
136
136
|
color: var(--hi-color-text-primary);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/* Mobile sheet context (#424 centered-cap pattern): the calendar keeps its
|
|
140
|
+
* designed measure inside the viewport-wide sheet instead of left-gluing
|
|
141
|
+
* at the anchored popover's min-width. Safe to stretch: the stage pins
|
|
142
|
+
* only its height (HkPickerPane.scss) and the day/month grids use 1fr
|
|
143
|
+
* tracks, so nothing fixed fights width: 100%. */
|
|
144
|
+
.hk-popover-panel.hk-is-sheet .hk-dp-panel {
|
|
145
|
+
min-width: 0;
|
|
146
|
+
max-width: min(22rem, 100%);
|
|
147
|
+
margin-inline: auto;
|
|
148
|
+
width: 100%;
|
|
149
|
+
}
|
|
150
|
+
|
|
139
151
|
.hk-dp-header {
|
|
140
152
|
display: grid;
|
|
141
153
|
grid-template-columns: 1fr auto 1fr;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the date/datetime picker mobile sheet docking
|
|
3
|
+
* (2026-09-08 mobile audit deferred item, family contract completion):
|
|
4
|
+
* HkDatePicker and HkDateTimePicker were the last anchored surfaces that
|
|
5
|
+
* never docked as bottom sheets on phones — on non-native touch hosts
|
|
6
|
+
* (nativeOnMobile=false) their calendars opened as tiny trigger-anchored
|
|
7
|
+
* popovers while every other popover-family window docked. Both pickers
|
|
8
|
+
* must pass sheetOnMobile to HPopover (the native path returns before the
|
|
9
|
+
* popover renders, so this is inherently non-native-touch-only) and cap
|
|
10
|
+
* their panels with the #424 centered-cap pattern inside the sheet.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const read = (name: string) => readFileSync(join(here, name), "utf-8");
|
|
19
|
+
/* Fixed window from the opening tag — a non-greedy `[\s\S]*?>` would stop
|
|
20
|
+
* at the `=>` inside the onUpdate prop's inline arrow. */
|
|
21
|
+
const popoverTag = (src: string) => {
|
|
22
|
+
const at = src.indexOf("<HPopover");
|
|
23
|
+
return at < 0 ? "" : src.slice(at, at + 700);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("date picker mobile sheet family contract", () => {
|
|
27
|
+
it("HkDatePicker opts its calendar popover into mobile sheet docking", () => {
|
|
28
|
+
const popover = popoverTag(read("HkDatePicker.tsx"));
|
|
29
|
+
expect(popover).toContain("sheetOnMobile");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("HkDateTimePicker opts its popup popover into mobile sheet docking", () => {
|
|
33
|
+
const popover = popoverTag(read("HkDateTimePicker.tsx"));
|
|
34
|
+
expect(popover).toContain("sheetOnMobile");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("HkDatePicker caps the calendar panel with the #424 centered-cap pattern", () => {
|
|
38
|
+
const src = read("HkDatePicker.scss");
|
|
39
|
+
const block = src.match(/\.hk-popover-panel\.hk-is-sheet \.hk-dp-panel\s*\{[^}]*\}/)?.[0] ?? "";
|
|
40
|
+
expect(block).toContain("min-width: 0");
|
|
41
|
+
expect(block).toContain("max-width: min(22rem, 100%)");
|
|
42
|
+
expect(block).toContain("margin-inline: auto");
|
|
43
|
+
expect(block).toContain("width: 100%");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("HkDateTimePicker caps the popup panel and lifts the body floor in the sheet", () => {
|
|
47
|
+
const src = read("HkDateTimePicker.scss");
|
|
48
|
+
const popup = src.match(/\.hk-popover-panel\.hk-is-sheet \.hk-dtp-popup\s*\{[^}]*\}/)?.[0] ?? "";
|
|
49
|
+
expect(popup).toContain("min-width: 0");
|
|
50
|
+
expect(popup).toContain("max-width: min(22rem, 100%)");
|
|
51
|
+
expect(popup).toContain("margin-inline: auto");
|
|
52
|
+
expect(popup).toContain("width: 100%");
|
|
53
|
+
const body = src.match(/\.hk-popover-panel\.hk-is-sheet \.hk-dtp\s*\{[^}]*\}/)?.[0] ?? "";
|
|
54
|
+
expect(body).toContain("min-width: 0");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -539,6 +539,10 @@ export default defineComponent({
|
|
|
539
539
|
placement="bottom-start"
|
|
540
540
|
offset={6}
|
|
541
541
|
backdrop={false}
|
|
542
|
+
sheetOnMobile
|
|
543
|
+
// The calendar docks as a bottom sheet on non-native touch
|
|
544
|
+
// hosts (nativeOnMobile=false); the native path returns
|
|
545
|
+
// before this popover ever renders.
|
|
542
546
|
title={t("hikari::datePicker.pickDate")}
|
|
543
547
|
class="hk-dp-popover"
|
|
544
548
|
>
|
|
@@ -331,6 +331,23 @@
|
|
|
331
331
|
min-width: 260px;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/* Mobile sheet context (#424 centered-cap pattern): the popup wrapper is
|
|
335
|
+
* the sheet's direct content child, so it takes the centered cap; the
|
|
336
|
+
* .hk-dtp body root already fills via width: 100% and only needs its
|
|
337
|
+
* 17.5rem floor lifted so narrow sheets never overflow. The stage pins
|
|
338
|
+
* height only (HkPickerPane.scss) and the grids use 1fr tracks, so
|
|
339
|
+
* stretching fights no fixed geometry. */
|
|
340
|
+
.hk-popover-panel.hk-is-sheet .hk-dtp-popup {
|
|
341
|
+
min-width: 0;
|
|
342
|
+
max-width: min(22rem, 100%);
|
|
343
|
+
margin-inline: auto;
|
|
344
|
+
width: 100%;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.hk-popover-panel.hk-is-sheet .hk-dtp {
|
|
348
|
+
min-width: 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
334
351
|
.hk-dtp-popup-foot {
|
|
335
352
|
display: flex;
|
|
336
353
|
justify-content: flex-end;
|
|
@@ -578,6 +578,10 @@ export default defineComponent({
|
|
|
578
578
|
anchorRef={triggerWrapRef.value ?? null}
|
|
579
579
|
placement={props.placement}
|
|
580
580
|
offset={props.offset}
|
|
581
|
+
sheetOnMobile
|
|
582
|
+
// The popup docks as a bottom sheet on non-native touch hosts
|
|
583
|
+
// (nativeOnMobile=false); the native path returns before this
|
|
584
|
+
// popover ever renders.
|
|
581
585
|
title={t("hikari::dateTimePicker.pickDate")}
|
|
582
586
|
>
|
|
583
587
|
<div class="hk-dtp-popup">
|
|
@@ -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
|
|