@celestia-island/hikari 0.48.0 → 0.49.1
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/HkAboutModal.scss +70 -7
- package/src/components/HkAboutModal.styles.test.ts +136 -0
- package/src/components/HkAboutModal.test.tsx +171 -3
- package/src/components/HkAboutModal.tsx +114 -37
- package/src/components/HkBlockingToast.scss +1 -1
- package/src/components/HkDatePicker.test.ts +55 -17
- package/src/components/HkDateTimePicker.test.ts +66 -27
- package/src/components/HkToast.gutter.contract.test.ts +25 -0
- package/src/components/HkToast.scss +1 -1
- package/src/index.ts +2 -0
- package/src/runtime/historySafetyNet.test.ts +25 -0
- package/src/runtime/historySafetyNet.ts +14 -4
package/package.json
CHANGED
|
@@ -228,7 +228,7 @@
|
|
|
228
228
|
color: rgb(var(--color-warning));
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
/* ──
|
|
231
|
+
/* ── Links: licenses, external links, credits names and filings ───────── */
|
|
232
232
|
|
|
233
233
|
.s-about-modal-chips {
|
|
234
234
|
display: flex;
|
|
@@ -237,10 +237,10 @@
|
|
|
237
237
|
gap: var(--space-8, 0.5rem);
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
// One
|
|
240
|
+
// One link group (licenses / links). Must span the chips block — inside an
|
|
241
241
|
// `align-items: center` column a plain block would shrink to fit-content and
|
|
242
|
-
// a long
|
|
243
|
-
//
|
|
242
|
+
// a long list would then wrap against that collapsed width instead of the
|
|
243
|
+
// modal's.
|
|
244
244
|
.s-about-modal-links {
|
|
245
245
|
width: 100%;
|
|
246
246
|
}
|
|
@@ -250,10 +250,12 @@
|
|
|
250
250
|
flex-wrap: wrap;
|
|
251
251
|
justify-content: center;
|
|
252
252
|
gap: var(--space-6, 0.375rem);
|
|
253
|
+
// The row's own size, so it holds for either face: `plain` links inherit
|
|
254
|
+
// it (chips set their own).
|
|
255
|
+
font-size: var(--text-xs, 0.75rem);
|
|
253
256
|
}
|
|
254
257
|
|
|
255
|
-
//
|
|
256
|
-
// credits names, licenses, external links and the legal filings.
|
|
258
|
+
// Chip face (default) — the ghost pill.
|
|
257
259
|
.s-about-modal-link {
|
|
258
260
|
padding: var(--space-2, 0.125rem) var(--space-10, 0.625rem);
|
|
259
261
|
border: 1px solid var(--border-subtle, rgb(100 100 100 / 12%));
|
|
@@ -268,6 +270,55 @@
|
|
|
268
270
|
background var(--duration-short, 0.15s) ease;
|
|
269
271
|
}
|
|
270
272
|
|
|
273
|
+
// Plain face: a bare text link (user direction 2026-09-14 — the organization
|
|
274
|
+
// / author names, the site domains and the filing numbers read as ordinary
|
|
275
|
+
// sentence text, not as tags). No frame, no pill padding, no underline; the
|
|
276
|
+
// hover colour shift is the entire affordance. Colour and size are inherited
|
|
277
|
+
// so the link melts into the run it sits in — the credits sentence
|
|
278
|
+
// (full-contrast text) or a muted link row.
|
|
279
|
+
//
|
|
280
|
+
// The face never has to out-shout the base `:hover` rules below: this rule
|
|
281
|
+
// (0,2,0) beats the chip base (0,1,0), and the `[data-face="plain"]:hover` /
|
|
282
|
+
// `:active` rules (0,3,0) are what keep the hover surface-free and the colour
|
|
283
|
+
// shift alive. Drop those two and the plain face silently regains the chip's
|
|
284
|
+
// tinted hover box.
|
|
285
|
+
.s-about-modal-link[data-face="plain"] {
|
|
286
|
+
display: inline;
|
|
287
|
+
margin: 0;
|
|
288
|
+
padding: 0;
|
|
289
|
+
border: 0;
|
|
290
|
+
border-radius: 0;
|
|
291
|
+
background: none;
|
|
292
|
+
color: inherit;
|
|
293
|
+
font-size: inherit;
|
|
294
|
+
line-height: inherit;
|
|
295
|
+
vertical-align: baseline;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// A link carrying an icon is one aligned unit (the mark, then the optional
|
|
299
|
+
// label) — icon-only links are how the GitHub mark rides beside the domains.
|
|
300
|
+
// Same specificity as the `plain` block above, so source order decides: this
|
|
301
|
+
// one wins the tie, which is what turns an icon-only link into inline-flex
|
|
302
|
+
// instead of the plain `inline`.
|
|
303
|
+
.s-about-modal-link.s-about-modal-link-has-icon {
|
|
304
|
+
display: inline-flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: var(--space-4, 0.25rem);
|
|
307
|
+
vertical-align: middle;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.s-about-modal-link-icon {
|
|
311
|
+
flex-shrink: 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// An icon-only link carries no text to click, so the mark buys its target
|
|
315
|
+
// width here; its height is whatever the row's line box gives it (the mark is
|
|
316
|
+
// centred in that), which keeps a row of plain links exactly as tall as its
|
|
317
|
+
// text.
|
|
318
|
+
.s-about-modal-link[data-face="plain"].s-about-modal-link-has-icon {
|
|
319
|
+
padding: 0 var(--space-6, 0.375rem);
|
|
320
|
+
}
|
|
321
|
+
|
|
271
322
|
// Chips inside the credits sentence sit in a text run: keep them on the
|
|
272
323
|
// baseline and let the surrounding spaces do the spacing.
|
|
273
324
|
.s-about-modal-credit-link {
|
|
@@ -282,10 +333,21 @@
|
|
|
282
333
|
color: rgb(var(--color-primary));
|
|
283
334
|
}
|
|
284
335
|
|
|
336
|
+
// The plain face owns no surface to tint — only the glyph changes colour.
|
|
337
|
+
.s-about-modal-link[data-face="plain"]:hover {
|
|
338
|
+
background: none;
|
|
339
|
+
color: rgb(var(--color-primary));
|
|
340
|
+
}
|
|
341
|
+
|
|
285
342
|
.s-about-modal-link:active {
|
|
286
343
|
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
287
344
|
}
|
|
288
345
|
|
|
346
|
+
.s-about-modal-link[data-face="plain"]:active {
|
|
347
|
+
background: none;
|
|
348
|
+
color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
|
|
349
|
+
}
|
|
350
|
+
|
|
289
351
|
.s-about-modal-link:focus-visible {
|
|
290
352
|
outline: none;
|
|
291
353
|
box-shadow:
|
|
@@ -295,7 +357,7 @@
|
|
|
295
357
|
|
|
296
358
|
/* ── Legal footer: filing links + copyright ───────────────────────────── */
|
|
297
359
|
|
|
298
|
-
// Filing numbers are long, so the legal
|
|
360
|
+
// Filing numbers are long, so the legal row renders a notch smaller than
|
|
299
361
|
// the license / link rows.
|
|
300
362
|
.s-about-modal-footer-link {
|
|
301
363
|
font-size: var(--text-2xs, 0.625rem);
|
|
@@ -307,6 +369,7 @@
|
|
|
307
369
|
justify-content: center;
|
|
308
370
|
gap: var(--space-6, 0.375rem);
|
|
309
371
|
margin-top: calc(-1 * var(--space-4, 0.25rem));
|
|
372
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
310
373
|
}
|
|
311
374
|
|
|
312
375
|
.s-about-modal-footer {
|
|
@@ -55,4 +55,140 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
55
55
|
.sort();
|
|
56
56
|
expect(dead, "stylesheet selectors with no matching element").toEqual([]);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Declaration-level guards for the two link faces.
|
|
61
|
+
*
|
|
62
|
+
* The checks above only compare class *names*, so the whole
|
|
63
|
+
* `[data-face="plain"]` contract — the part the user actually asked for —
|
|
64
|
+
* was invisible to them: emptying that rule (Dart Sass then drops the
|
|
65
|
+
* selector entirely), re-adding a pill padding, or losing `font-size:
|
|
66
|
+
* inherit` all left the suite green while turning bare text links back
|
|
67
|
+
* into chips. These assertions read the compiled declarations instead.
|
|
68
|
+
*/
|
|
69
|
+
describe("link faces", () => {
|
|
70
|
+
/**
|
|
71
|
+
* Body of one compiled rule, by exact selector (Sass drops the quotes
|
|
72
|
+
* around an attribute value: `[data-face=plain]`).
|
|
73
|
+
*
|
|
74
|
+
* The selector must compile exactly once: a second copy — say inside a
|
|
75
|
+
* media query, or a later override — would otherwise stay invisible here
|
|
76
|
+
* while still winning the cascade.
|
|
77
|
+
*/
|
|
78
|
+
function ruleBody(sheet: string, selector: string): string {
|
|
79
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
+
const matches = [...sheet.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))];
|
|
81
|
+
expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
|
|
82
|
+
return matches[0]?.[1] ?? "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One declaration's value inside a rule body.
|
|
87
|
+
*
|
|
88
|
+
* A property declared twice is a failure rather than a coincidence: CSS
|
|
89
|
+
* resolves a repeat last-wins, so a first-match reader would report the
|
|
90
|
+
* stale value and happily accept the override.
|
|
91
|
+
*/
|
|
92
|
+
function declaration(body: string, property: string): string {
|
|
93
|
+
const values = [
|
|
94
|
+
...body.matchAll(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, "g")),
|
|
95
|
+
].map((match) => match[1]!.trim());
|
|
96
|
+
expect(values.length, `${property} is declared at most once`).toBeLessThanOrEqual(1);
|
|
97
|
+
return values[0] ?? "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PLAIN = ".s-about-modal-link[data-face=plain]";
|
|
101
|
+
|
|
102
|
+
it("keeps the plain face a bare text link", () => {
|
|
103
|
+
const plain = ruleBody(css, PLAIN);
|
|
104
|
+
expect(plain, "the plain-face rule must survive compilation").not.toBe("");
|
|
105
|
+
expect({
|
|
106
|
+
padding: declaration(plain, "padding"),
|
|
107
|
+
border: declaration(plain, "border"),
|
|
108
|
+
radius: declaration(plain, "border-radius"),
|
|
109
|
+
background: declaration(plain, "background"),
|
|
110
|
+
// Inherited colour and size are what let a name melt into the credits
|
|
111
|
+
// sentence (13px) or a domain into the 12px link row, instead of
|
|
112
|
+
// keeping the chip's own 12px scale.
|
|
113
|
+
color: declaration(plain, "color"),
|
|
114
|
+
fontSize: declaration(plain, "font-size"),
|
|
115
|
+
lineHeight: declaration(plain, "line-height"),
|
|
116
|
+
}).toEqual({
|
|
117
|
+
padding: "0",
|
|
118
|
+
border: "0",
|
|
119
|
+
radius: "0",
|
|
120
|
+
background: "none",
|
|
121
|
+
color: "inherit",
|
|
122
|
+
fontSize: "inherit",
|
|
123
|
+
lineHeight: "inherit",
|
|
124
|
+
});
|
|
125
|
+
// Both faces promise "no underline" (these rows are text with a click
|
|
126
|
+
// target, not document links): the base rule turns the UA underline off
|
|
127
|
+
// and the plain face must not bring one back.
|
|
128
|
+
expect(declaration(ruleBody(css, ".s-about-modal-link"), "text-decoration")).toBe("none");
|
|
129
|
+
expect(declaration(plain, "text-decoration")).not.toContain("underline");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps the plain hover a colour shift, not a tinted box", () => {
|
|
133
|
+
const hover = ruleBody(css, `${PLAIN}:hover`);
|
|
134
|
+
expect(declaration(hover, "background")).toBe("none");
|
|
135
|
+
expect(declaration(hover, "color")).toBe("rgb(var(--color-primary))");
|
|
136
|
+
expect(declaration(ruleBody(css, `${PLAIN}:active`), "background")).toBe("none");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps an icon link one aligned unit with a visible mark", () => {
|
|
140
|
+
const iconLink = ruleBody(css, ".s-about-modal-link.s-about-modal-link-has-icon");
|
|
141
|
+
expect(declaration(iconLink, "display")).toBe("inline-flex");
|
|
142
|
+
expect(declaration(iconLink, "align-items")).toBe("center");
|
|
143
|
+
// The wrapper is what the alignment hangs off — a hidden or collapsing
|
|
144
|
+
// one takes the mark with it, leaving an icon-only link as an invisible
|
|
145
|
+
// target (the failure the empty-entry filter exists to prevent).
|
|
146
|
+
const iconWrap = ruleBody(css, ".s-about-modal-link-icon");
|
|
147
|
+
expect(declaration(iconWrap, "flex-shrink")).toBe("0");
|
|
148
|
+
expect(declaration(iconWrap, "display")).not.toBe("none");
|
|
149
|
+
// Icon-only: the mark carries no text, so the padding is the target.
|
|
150
|
+
expect(declaration(ruleBody(css, `${PLAIN}.s-about-modal-link-has-icon`), "padding")).toBe(
|
|
151
|
+
"0 var(--space-6, 0.375rem)",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("sizes every row the plain face inherits from", () => {
|
|
156
|
+
// The plain face inherits, so the rows own the type scale the chips used
|
|
157
|
+
// to bring themselves (16px body text otherwise): the credits sentence
|
|
158
|
+
// supplies the names' 13px, the link row the domains' 12px, the legal
|
|
159
|
+
// row the filings' 10px.
|
|
160
|
+
expect(declaration(ruleBody(css, ".s-about-modal-credits-line"), "font-size")).toBe(
|
|
161
|
+
"var(--text-sm, 0.8125rem)",
|
|
162
|
+
);
|
|
163
|
+
expect(declaration(ruleBody(css, ".s-about-modal-links-list"), "font-size")).toBe(
|
|
164
|
+
"var(--text-xs, 0.75rem)",
|
|
165
|
+
);
|
|
166
|
+
expect(declaration(ruleBody(css, ".s-about-modal-footer-links"), "font-size")).toBe(
|
|
167
|
+
"var(--text-2xs, 0.625rem)",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("keeps the pill the hosts that ask for nothing already ship", () => {
|
|
172
|
+
// The plain face is additive: a consumer passing `{ label, href }` must
|
|
173
|
+
// keep exactly the tag it had, so the chip's geometry is pinned here
|
|
174
|
+
// instead of being assumed.
|
|
175
|
+
const chip = ruleBody(css, ".s-about-modal-link");
|
|
176
|
+
expect(declaration(chip, "padding")).toBe(
|
|
177
|
+
"var(--space-2, 0.125rem) var(--space-10, 0.625rem)",
|
|
178
|
+
);
|
|
179
|
+
expect(declaration(chip, "border")).toContain("1px solid var(--border-subtle");
|
|
180
|
+
expect(declaration(chip, "border-radius")).toBe("var(--radius-full, 9999px)");
|
|
181
|
+
expect(declaration(chip, "color")).toBe("rgb(var(--color-muted))");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps the icon rule after the plain rule it refines", () => {
|
|
185
|
+
// Both are (0,2,0), so source order is the only thing that turns an
|
|
186
|
+
// icon link into inline-flex rather than the plain `inline` — the
|
|
187
|
+
// tie-break is part of the contract, not an accident of file layout.
|
|
188
|
+
const plainAt = css.indexOf(`${PLAIN} {`);
|
|
189
|
+
const iconAt = css.indexOf(".s-about-modal-link.s-about-modal-link-has-icon {");
|
|
190
|
+
expect(plainAt).toBeGreaterThanOrEqual(0);
|
|
191
|
+
expect(iconAt).toBeGreaterThan(plainAt);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
58
194
|
});
|
|
@@ -16,6 +16,9 @@ import { setLocale } from "../i18n/context";
|
|
|
16
16
|
* - the backdrop factory renders inside the clipped backdrop layer
|
|
17
17
|
* - licenses and links render as chips under one centered block
|
|
18
18
|
* - footer (filing) links render as external links
|
|
19
|
+
* - `face: "plain"` turns any of them into a bare text link (still a link,
|
|
20
|
+
* still a new tab), and an entry carrying an `icon` renders the mark, with
|
|
21
|
+
* an accessible name when it has no label to be named by
|
|
19
22
|
*
|
|
20
23
|
* (Repo test convention: raw createApp + document queries, no
|
|
21
24
|
* @vue/test-utils dependency.)
|
|
@@ -104,25 +107,190 @@ describe("HkAboutModal branding", () => {
|
|
|
104
107
|
{ name: "Celestia Island", href: "https://github.com/celestia-island" },
|
|
105
108
|
{ text: ",由 " },
|
|
106
109
|
{ name: "伊欧", href: "https://github.com/langyo" },
|
|
107
|
-
{ text: "
|
|
110
|
+
{ text: " 主创" },
|
|
108
111
|
],
|
|
109
112
|
});
|
|
110
113
|
await flushModal();
|
|
111
114
|
const line = query<HTMLElement>(".s-about-modal-credits-line");
|
|
112
|
-
expect(line.textContent).toBe("来自 Celestia Island,由 伊欧
|
|
115
|
+
expect(line.textContent).toBe("来自 Celestia Island,由 伊欧 主创");
|
|
113
116
|
const chips = [
|
|
114
117
|
...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-credit-link"),
|
|
115
118
|
];
|
|
116
119
|
expect(chips.map((chip) => chip.textContent)).toEqual(["Celestia Island", "伊欧"]);
|
|
117
|
-
// Every link in the dialog
|
|
120
|
+
// Every link in the dialog opens in a new tab; the chip is the default
|
|
121
|
+
// face, so an entry that asks for nothing keeps the tag.
|
|
118
122
|
for (const chip of chips) {
|
|
119
123
|
expect(chip.classList.contains("s-about-modal-link")).toBe(true);
|
|
124
|
+
expect(chip.getAttribute("data-face")).toBe("chip");
|
|
120
125
|
expect(chip.getAttribute("target")).toBe("_blank");
|
|
121
126
|
expect(chip.getAttribute("rel")).toContain("noopener");
|
|
122
127
|
}
|
|
123
128
|
expect(chips[0]!.getAttribute("href")).toBe("https://github.com/celestia-island");
|
|
124
129
|
});
|
|
125
130
|
|
|
131
|
+
it("renders a credit name as a plain text link when the entry asks for it", async () => {
|
|
132
|
+
mountAbout({
|
|
133
|
+
credits: [
|
|
134
|
+
{ text: "来自 " },
|
|
135
|
+
{ name: "Celestia Island", href: "https://github.com/celestia-island", face: "plain" },
|
|
136
|
+
{ text: ",由 " },
|
|
137
|
+
{ name: "伊欧", href: "https://github.com/langyo", face: "plain" },
|
|
138
|
+
{ text: " 主创" },
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
await flushModal();
|
|
142
|
+
// The names stay inside the sentence — same text, same flow, no frame.
|
|
143
|
+
expect(query<HTMLElement>(".s-about-modal-credits-line").textContent).toBe(
|
|
144
|
+
"来自 Celestia Island,由 伊欧 主创",
|
|
145
|
+
);
|
|
146
|
+
const links = [
|
|
147
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-credit-link"),
|
|
148
|
+
];
|
|
149
|
+
expect(links.map((link) => link.textContent)).toEqual(["Celestia Island", "伊欧"]);
|
|
150
|
+
for (const link of links) {
|
|
151
|
+
expect(link.getAttribute("data-face")).toBe("plain");
|
|
152
|
+
expect(link.classList.contains("s-about-modal-link")).toBe(true);
|
|
153
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
154
|
+
// A text link is named by its own text — no redundant aria-label.
|
|
155
|
+
expect(link.getAttribute("aria-label")).toBeNull();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("renders plain faces across the link rows and the legal filings", async () => {
|
|
160
|
+
mountAbout({
|
|
161
|
+
links: [
|
|
162
|
+
{ label: "celestia.world", href: "https://celestia.world", face: "plain" },
|
|
163
|
+
{ label: "celestia.ac.cn", href: "https://celestia.ac.cn", face: "plain" },
|
|
164
|
+
],
|
|
165
|
+
footerLinks: [
|
|
166
|
+
{ label: "苏ICP备2025155733号-2", href: "https://beian.miit.gov.cn/", face: "plain" },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
await flushModal();
|
|
170
|
+
const links = [
|
|
171
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(
|
|
172
|
+
".s-about-modal-links-list .s-about-modal-link",
|
|
173
|
+
),
|
|
174
|
+
];
|
|
175
|
+
expect(links.map((link) => link.textContent)).toEqual(["celestia.world", "celestia.ac.cn"]);
|
|
176
|
+
expect(links.map((link) => link.getAttribute("data-face"))).toEqual(["plain", "plain"]);
|
|
177
|
+
expect(
|
|
178
|
+
query<HTMLElement>(".s-about-modal-footer-link").getAttribute("data-face"),
|
|
179
|
+
).toBe("plain");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("renders an icon-only link with an accessible name beside the text links", async () => {
|
|
183
|
+
mountAbout({
|
|
184
|
+
links: [
|
|
185
|
+
{ label: "celestia.world", href: "https://celestia.world", face: "plain" },
|
|
186
|
+
{
|
|
187
|
+
icon: "github",
|
|
188
|
+
href: "https://github.com/celestia-island/shittim-chest",
|
|
189
|
+
face: "plain",
|
|
190
|
+
ariaLabel: "GitHub",
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
await flushModal();
|
|
195
|
+
const links = [
|
|
196
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(
|
|
197
|
+
".s-about-modal-links-list .s-about-modal-link",
|
|
198
|
+
),
|
|
199
|
+
];
|
|
200
|
+
expect(links.length).toBe(2);
|
|
201
|
+
const mark = links[1]!;
|
|
202
|
+
expect(mark.textContent).toBe("");
|
|
203
|
+
expect(mark.classList.contains("s-about-modal-link-has-icon")).toBe(true);
|
|
204
|
+
expect(mark.getAttribute("aria-label")).toBe("GitHub");
|
|
205
|
+
expect(mark.getAttribute("href")).toBe("https://github.com/celestia-island/shittim-chest");
|
|
206
|
+
expect(mark.getAttribute("target")).toBe("_blank");
|
|
207
|
+
expect(mark.querySelector(".s-about-modal-link-icon svg")).toBeTruthy();
|
|
208
|
+
// …and it is the GitHub mark, not just any glyph.
|
|
209
|
+
expect(
|
|
210
|
+
mark
|
|
211
|
+
.querySelector(".s-about-modal-link-icon svg")!
|
|
212
|
+
.classList.contains("lucide-github-icon"),
|
|
213
|
+
).toBe(true);
|
|
214
|
+
// The mark is decoration: it must not join the link's accessible name.
|
|
215
|
+
expect(
|
|
216
|
+
mark.querySelector(".s-about-modal-link-icon")!.getAttribute("aria-hidden"),
|
|
217
|
+
).toBe("true");
|
|
218
|
+
// The text link next to it stays text: no mark, no extra naming.
|
|
219
|
+
expect(links[0]!.querySelector("svg")).toBeNull();
|
|
220
|
+
expect(links[0]!.getAttribute("aria-label")).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("names an icon-only link from the icon when none is given", async () => {
|
|
224
|
+
mountAbout({ links: [{ icon: "github", href: "https://example.test/repo", face: "plain" }] });
|
|
225
|
+
await flushModal();
|
|
226
|
+
const mark = query<HTMLElement>(".s-about-modal-link");
|
|
227
|
+
expect(mark.getAttribute("aria-label")).toBe("GitHub");
|
|
228
|
+
expect(mark.getAttribute("data-face")).toBe("plain");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("lets a host name a text link explicitly", async () => {
|
|
232
|
+
mountAbout({
|
|
233
|
+
links: [
|
|
234
|
+
{
|
|
235
|
+
label: "celestia.world",
|
|
236
|
+
href: "https://celestia.world",
|
|
237
|
+
ariaLabel: "Celestia World 官网",
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
await flushModal();
|
|
242
|
+
// An explicit name outranks the visible text (and the visible text stays).
|
|
243
|
+
const link = query<HTMLAnchorElement>(".s-about-modal-link");
|
|
244
|
+
expect(link.getAttribute("aria-label")).toBe("Celestia World 官网");
|
|
245
|
+
expect(link.textContent).toBe("celestia.world");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("drops a link entry with neither a label nor an icon", async () => {
|
|
249
|
+
mountAbout({
|
|
250
|
+
links: [
|
|
251
|
+
{ label: "celestia.world", href: "https://celestia.world", face: "plain" },
|
|
252
|
+
// No text and no mark: an invisible but focusable link named by its
|
|
253
|
+
// raw URL is worse than no link.
|
|
254
|
+
{ href: "https://example.test/blank" },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
await flushModal();
|
|
258
|
+
const links = [
|
|
259
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(
|
|
260
|
+
".s-about-modal-links-list .s-about-modal-link",
|
|
261
|
+
),
|
|
262
|
+
];
|
|
263
|
+
expect(links.map((link) => link.textContent)).toEqual(["celestia.world"]);
|
|
264
|
+
expect(document.body.textContent).not.toContain("example.test/blank");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("renders no link row when every entry is empty", async () => {
|
|
268
|
+
mountAbout({ links: [{ href: "https://example.test/blank" }] });
|
|
269
|
+
await flushModal();
|
|
270
|
+
expect(document.body.querySelector(".s-about-modal-links")).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("drops an empty legal entry too", async () => {
|
|
274
|
+
mountAbout({
|
|
275
|
+
footerLinks: [
|
|
276
|
+
{ label: "苏ICP备2025155733号-2", href: "https://beian.miit.gov.cn/" },
|
|
277
|
+
{ href: "https://example.test/blank-filing" },
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
await flushModal();
|
|
281
|
+
const links = [
|
|
282
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-footer-link"),
|
|
283
|
+
];
|
|
284
|
+
expect(links.map((link) => link.textContent)).toEqual(["苏ICP备2025155733号-2"]);
|
|
285
|
+
expect(document.body.textContent).not.toContain("example.test/blank-filing");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("renders no legal row when every filing is empty", async () => {
|
|
289
|
+
mountAbout({ footerLinks: [{ href: "https://example.test/blank-filing" }] });
|
|
290
|
+
await flushModal();
|
|
291
|
+
expect(document.body.querySelector(".s-about-modal-footer-links")).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
126
294
|
it("keeps a credit name as plain text when no href is given", async () => {
|
|
127
295
|
mountAbout({ credits: [{ text: "由 " }, { name: "伊欧" }, { text: " 主创" }] });
|
|
128
296
|
await flushModal();
|
|
@@ -1,13 +1,45 @@
|
|
|
1
1
|
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
import { Github } from "lucide-vue-next";
|
|
2
3
|
import { HBadge, HModal } from "@celestia-island/hikari";
|
|
3
4
|
|
|
4
5
|
import { useI18n } from "../i18n/context";
|
|
5
6
|
|
|
6
7
|
import "./HkAboutModal.scss";
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How a link presents itself.
|
|
11
|
+
*
|
|
12
|
+
* `chip` is the dialog's original face — a ghost tag. `plain` is bare text:
|
|
13
|
+
* no frame, no underline, so the hover colour shift is the whole
|
|
14
|
+
* affordance. Chosen per link, because one dialog can want both (the
|
|
15
|
+
* credits names / URLs / filings read as sentence text, the licenses stay
|
|
16
|
+
* tags).
|
|
17
|
+
*/
|
|
18
|
+
export type HAboutLinkFace = "chip" | "plain";
|
|
19
|
+
|
|
20
|
+
/** Leading icon a link can carry; an icon-only link shows it alone. */
|
|
21
|
+
export type HAboutLinkIcon = "github";
|
|
22
|
+
|
|
23
|
+
// The mark behind each icon key. Brand names are locale-invariant, so they
|
|
24
|
+
// double as the accessible name of an icon-only link.
|
|
25
|
+
const ICONS: Record<HAboutLinkIcon, typeof Github> = { github: Github };
|
|
26
|
+
const ICON_NAMES: Record<HAboutLinkIcon, string> = { github: "GitHub" };
|
|
27
|
+
|
|
8
28
|
export interface HAboutLink {
|
|
9
|
-
|
|
29
|
+
/** Visible text. Omit for an icon-only link — then name it with
|
|
30
|
+
* `ariaLabel`. */
|
|
31
|
+
label?: string;
|
|
10
32
|
href: string;
|
|
33
|
+
/** Optional leading icon (e.g. the project's GitHub home). */
|
|
34
|
+
icon?: HAboutLinkIcon;
|
|
35
|
+
/** Link face; defaults to `chip`. */
|
|
36
|
+
face?: HAboutLinkFace;
|
|
37
|
+
/**
|
|
38
|
+
* Accessible name for an icon-only link — falls back to `label`, then to
|
|
39
|
+
* the icon's own name (brand names are locale-invariant, so they need no
|
|
40
|
+
* i18n key of their own).
|
|
41
|
+
*/
|
|
42
|
+
ariaLabel?: string;
|
|
11
43
|
}
|
|
12
44
|
|
|
13
45
|
/**
|
|
@@ -15,17 +47,19 @@ export interface HAboutLink {
|
|
|
15
47
|
*
|
|
16
48
|
* The line is a sentence assembled from parts so a host can phrase (and
|
|
17
49
|
* order) it freely per locale: literal `text` runs sit between linked
|
|
18
|
-
* `name`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
50
|
+
* `name` runs (organization first, author second, …). Each linked name
|
|
51
|
+
* carries its own `face`, so one dialog can mix a chip here with bare text
|
|
52
|
+
* there (and an unlinked name is always plain text).
|
|
21
53
|
*/
|
|
22
54
|
export interface HAboutCredit {
|
|
23
55
|
/** Literal sentence fragment (mutually exclusive with `name`). */
|
|
24
56
|
text?: string;
|
|
25
|
-
/** Linked
|
|
57
|
+
/** Linked name (mutually exclusive with `text`). */
|
|
26
58
|
name?: string;
|
|
27
|
-
/** Target for a `name
|
|
59
|
+
/** Target for a `name`; opens in a new tab. */
|
|
28
60
|
href?: string;
|
|
61
|
+
/** Face for a linked `name` (an unlinked name stays plain text). */
|
|
62
|
+
face?: HAboutLinkFace;
|
|
29
63
|
}
|
|
30
64
|
|
|
31
65
|
/**
|
|
@@ -51,12 +85,14 @@ export interface HAboutComponentVersion {
|
|
|
51
85
|
*
|
|
52
86
|
* Layout (2026-09 redesign): a centered identity hero (haloed logo, name,
|
|
53
87
|
* then version + tagline on one compact line), an optional credits block
|
|
54
|
-
* (a sentence built from text runs and linked
|
|
88
|
+
* (a sentence built from text runs and linked names, plus the
|
|
55
89
|
* organization blurb), a
|
|
56
90
|
* bordered spec card holding the software-component versions, and the
|
|
57
|
-
* link rows — licenses, external links
|
|
58
|
-
*
|
|
59
|
-
*
|
|
91
|
+
* link rows — licenses, external links and legal filings. Every link opens
|
|
92
|
+
* in a new tab and renders in the face its entry asks for: the ghost chip,
|
|
93
|
+
* or bare text (`plain`) for names / URLs / filings that should read as
|
|
94
|
+
* ordinary sentence text. Every branding prop is optional — the modal
|
|
95
|
+
* degrades to the plain identity card when none are given.
|
|
60
96
|
*/
|
|
61
97
|
export const HkAboutModal = defineComponent({
|
|
62
98
|
name: "HkAboutModal",
|
|
@@ -73,12 +109,12 @@ export const HkAboutModal = defineComponent({
|
|
|
73
109
|
/** Optional centered small line under the credits (organization blurb). */
|
|
74
110
|
description: { type: String, default: undefined },
|
|
75
111
|
/**
|
|
76
|
-
* Credits sentence, assembled from text runs and linked
|
|
77
|
-
* (e.g. 来自 <Celestia Island>,由 <伊欧>
|
|
78
|
-
*
|
|
112
|
+
* Credits sentence, assembled from text runs and linked names
|
|
113
|
+
* (e.g. 来自 <Celestia Island>,由 <伊欧> 主创). Each name renders in the
|
|
114
|
+
* face its entry asks for — `chip`, or bare text with `plain`.
|
|
79
115
|
*/
|
|
80
116
|
credits: { type: Array as PropType<HAboutCredit[]>, default: () => [] },
|
|
81
|
-
/** License
|
|
117
|
+
/** License links (e.g. SySL-1.0 / BUSL-1.1), rendered centered. */
|
|
82
118
|
licenses: { type: Array as PropType<HAboutLink[]>, default: () => [] },
|
|
83
119
|
/** Software-component version rows (WebUI / engines), label + value. */
|
|
84
120
|
componentVersions: {
|
|
@@ -87,7 +123,11 @@ export const HkAboutModal = defineComponent({
|
|
|
87
123
|
},
|
|
88
124
|
/** Optional copyright holder in the footer (defaults to the app name). */
|
|
89
125
|
copyright: { type: String, default: undefined },
|
|
90
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Optional external links (e.g. the site domains, the project's GitHub
|
|
128
|
+
* home). An entry may carry an `icon` instead of a `label` — that is how
|
|
129
|
+
* the GitHub mark rides at the end of the domain row.
|
|
130
|
+
*/
|
|
91
131
|
links: { type: Array as PropType<HAboutLink[]>, default: () => [] },
|
|
92
132
|
/** Optional centered legal links above the copyright (ICP filings). */
|
|
93
133
|
footerLinks: { type: Array as PropType<HAboutLink[]>, default: () => [] },
|
|
@@ -105,19 +145,45 @@ export const HkAboutModal = defineComponent({
|
|
|
105
145
|
setup(props, { emit }) {
|
|
106
146
|
const { t } = useI18n();
|
|
107
147
|
|
|
108
|
-
// One
|
|
109
|
-
// licenses, external links and the legal filings
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
148
|
+
// One link renderer for every link in the dialog: credits names,
|
|
149
|
+
// licenses, external links and the legal filings. `face` picks the tag
|
|
150
|
+
// or the bare-text face; an `icon` may replace or lead the label.
|
|
151
|
+
const renderLink = (
|
|
152
|
+
item: HAboutLink,
|
|
153
|
+
extraClass: string,
|
|
154
|
+
key: string,
|
|
155
|
+
) => {
|
|
156
|
+
const label = item.label ?? "";
|
|
157
|
+
const Icon = item.icon ? ICONS[item.icon] : undefined;
|
|
158
|
+
const classes = ["s-about-modal-link", extraClass];
|
|
159
|
+
if (Icon) classes.push("s-about-modal-link-has-icon");
|
|
160
|
+
return (
|
|
161
|
+
<a
|
|
162
|
+
key={key}
|
|
163
|
+
class={classes}
|
|
164
|
+
data-face={item.face ?? "chip"}
|
|
165
|
+
href={item.href}
|
|
166
|
+
target="_blank"
|
|
167
|
+
rel="noopener noreferrer"
|
|
168
|
+
// A text link is named by its own label unless the host names it
|
|
169
|
+
// explicitly; an icon-only link has no text to be named by, so it
|
|
170
|
+
// always needs a name of its own.
|
|
171
|
+
aria-label={
|
|
172
|
+
item.ariaLabel ?? (label ? undefined : item.icon ? ICON_NAMES[item.icon] : item.href)
|
|
173
|
+
}
|
|
174
|
+
>
|
|
175
|
+
{Icon && (
|
|
176
|
+
// Wrapped rather than classed on the lucide component: lucide
|
|
177
|
+
// merges its own classes with the passed ones and emits the token
|
|
178
|
+
// twice.
|
|
179
|
+
<span class="s-about-modal-link-icon" aria-hidden="true">
|
|
180
|
+
<Icon size={14} />
|
|
181
|
+
</span>
|
|
182
|
+
)}
|
|
183
|
+
{label}
|
|
184
|
+
</a>
|
|
185
|
+
);
|
|
186
|
+
};
|
|
121
187
|
|
|
122
188
|
const renderCredits = () => {
|
|
123
189
|
const parts = props.credits.filter((part) => part.text || part.name);
|
|
@@ -127,7 +193,11 @@ export const HkAboutModal = defineComponent({
|
|
|
127
193
|
{parts.map((part, index) =>
|
|
128
194
|
part.name ? (
|
|
129
195
|
part.href ? (
|
|
130
|
-
|
|
196
|
+
renderLink(
|
|
197
|
+
{ label: part.name, href: part.href, face: part.face },
|
|
198
|
+
"s-about-modal-credit-link",
|
|
199
|
+
`name:${index}`,
|
|
200
|
+
)
|
|
131
201
|
) : (
|
|
132
202
|
<span key={`name:${index}`} class="s-about-modal-credit-name">
|
|
133
203
|
{part.name}
|
|
@@ -141,12 +211,19 @@ export const HkAboutModal = defineComponent({
|
|
|
141
211
|
);
|
|
142
212
|
};
|
|
143
213
|
|
|
144
|
-
|
|
145
|
-
|
|
214
|
+
// An entry with neither text nor a mark would render an invisible but
|
|
215
|
+
// focusable link whose only name is its raw URL — drop it rather than
|
|
216
|
+
// ship a blank target. Every row filters through here.
|
|
217
|
+
const visibleLinks = (items: HAboutLink[]) =>
|
|
218
|
+
items.filter((item) => item.label || item.icon);
|
|
219
|
+
|
|
220
|
+
const renderLinks = (items: HAboutLink[], slot: string) => {
|
|
221
|
+
const visible = visibleLinks(items);
|
|
222
|
+
if (visible.length === 0) return null;
|
|
146
223
|
return (
|
|
147
224
|
<div class="s-about-modal-links" data-slot={slot}>
|
|
148
225
|
<div class="s-about-modal-links-list">
|
|
149
|
-
{
|
|
226
|
+
{visible.map((item, index) => renderLink(item, "", `${slot}:${index}`))}
|
|
150
227
|
</div>
|
|
151
228
|
</div>
|
|
152
229
|
);
|
|
@@ -229,15 +306,15 @@ export const HkAboutModal = defineComponent({
|
|
|
229
306
|
|
|
230
307
|
{(props.licenses.length > 0 || props.links.length > 0) && (
|
|
231
308
|
<div class="s-about-modal-chips">
|
|
232
|
-
{
|
|
233
|
-
{
|
|
309
|
+
{renderLinks(props.licenses, "licenses")}
|
|
310
|
+
{renderLinks(props.links, "links")}
|
|
234
311
|
</div>
|
|
235
312
|
)}
|
|
236
313
|
|
|
237
|
-
{props.footerLinks.length > 0 && (
|
|
314
|
+
{visibleLinks(props.footerLinks).length > 0 && (
|
|
238
315
|
<div class="s-about-modal-footer-links">
|
|
239
|
-
{props.footerLinks.map((link) =>
|
|
240
|
-
|
|
316
|
+
{visibleLinks(props.footerLinks).map((link, index) =>
|
|
317
|
+
renderLink(link, "s-about-modal-footer-link", `legal:${index}`),
|
|
241
318
|
)}
|
|
242
319
|
</div>
|
|
243
320
|
)}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
top: 4rem;
|
|
12
12
|
/* Same viewport gutter the anchored popups clamp to (16px desktop /
|
|
13
13
|
* 8px mobile) — the toast stack is a floating layer too. */
|
|
14
|
-
inset-inline-end: var(--viewport-gutter,
|
|
14
|
+
inset-inline-end: var(--viewport-gutter, 16px);
|
|
15
15
|
z-index: var(--hk-z-toast, 4000);
|
|
16
16
|
max-width: 24rem;
|
|
17
17
|
pointer-events: none;
|
|
@@ -48,6 +48,43 @@ async function settle() {
|
|
|
48
48
|
await nextTick();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Poll until the drilled view's title button reads `expected`. The drill
|
|
52
|
+
* transition is frame/timeout based and a fixed 20 ms settle raced it on
|
|
53
|
+
* the CI runner (the year-grid test once read the previous view's title),
|
|
54
|
+
* so the view STATE — not a sleep — is what this waits on. */
|
|
55
|
+
function titleText(): string {
|
|
56
|
+
return panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.textContent ?? "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitForTitle(expected: string): Promise<void> {
|
|
60
|
+
const deadline = Date.now() + 2000;
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (titleText() === expected) return;
|
|
63
|
+
if (Date.now() > deadline) {
|
|
64
|
+
throw new Error(`title never became ${JSON.stringify(expected)} (last: ${JSON.stringify(titleText())})`);
|
|
65
|
+
}
|
|
66
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Generic state poll over the same drill race: wait until `probe` holds. */
|
|
71
|
+
async function waitForView(desc: string, probe: () => boolean): Promise<void> {
|
|
72
|
+
const deadline = Date.now() + 2000;
|
|
73
|
+
while (!probe()) {
|
|
74
|
+
if (Date.now() > deadline) throw new Error(`view never reached: ${desc}`);
|
|
75
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function titleBtn(): HTMLButtonElement | null | undefined {
|
|
80
|
+
return panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The pick cells of the CURRENT view (months or years, both carry 12). */
|
|
84
|
+
function pickCells(): HTMLButtonElement[] {
|
|
85
|
+
return Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
|
|
86
|
+
}
|
|
87
|
+
|
|
51
88
|
function openViaEnter(harness: PickerHarness) {
|
|
52
89
|
const trigger = harness.container.querySelector<HTMLElement>(".hk-dp-trigger");
|
|
53
90
|
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
@@ -326,23 +363,24 @@ describe("HkDatePicker", () => {
|
|
|
326
363
|
const p = mountPicker({ modelValue: "2026-08-16" });
|
|
327
364
|
openViaEnter(p);
|
|
328
365
|
await nextTick();
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
366
|
+
// Every drill below waits for the DESTINATION view's state, not a
|
|
367
|
+
// fixed settle: the transition is frame/timeout based and the CI
|
|
368
|
+
// runner raced both the days→months click (the second click landed
|
|
369
|
+
// mid-transition and was swallowed) and the months→years read.
|
|
370
|
+
// days → months: the title button becomes the view year.
|
|
371
|
+
titleBtn()?.click();
|
|
372
|
+
await waitForView("the months grid of 2026", () => titleText() === "2026");
|
|
373
|
+
// months → years (the year button is the months-view title): the
|
|
374
|
+
// 2016–2027 block of the 2026 view year holds a 2027 cell.
|
|
375
|
+
titleBtn()?.click();
|
|
376
|
+
await waitForView("the year grid showing 2027", () =>
|
|
377
|
+
pickCells().some((c) => c.textContent === "2027"));
|
|
378
|
+
pickCells().find((c) => c.textContent === "2027")?.click();
|
|
379
|
+
// Picking a year lands on the months grid of that year.
|
|
380
|
+
await waitForTitle("2027");
|
|
381
|
+
pickCells()[6]?.click(); // July
|
|
382
|
+
// ...and picking a month lands back on the days grid.
|
|
383
|
+
await waitForView("the days grid", () => panel()?.querySelectorAll(".hk-dp-cell").length === 42);
|
|
346
384
|
const fmt = new Intl.DateTimeFormat("en", { year: "numeric", month: "long" });
|
|
347
385
|
expect(panel()?.querySelector<HTMLElement>(".hk-dp-title")?.textContent).toContain(
|
|
348
386
|
fmt.format(new Date(2027, 6, 1)),
|
|
@@ -8,6 +8,29 @@ const mounts: ReturnType<typeof createApp>[] = [];
|
|
|
8
8
|
const containers: HTMLElement[] = [];
|
|
9
9
|
const originalWidth = window.innerWidth;
|
|
10
10
|
|
|
11
|
+
/** The fixed model the suite mounts with. Every expected year/month/day
|
|
12
|
+
* in the assertions derives from THIS constant — never from a bare
|
|
13
|
+
* literal, and never from Date.now() — so the file cannot rot when a
|
|
14
|
+
* calendar year rolls over. */
|
|
15
|
+
const BASE = new Date(2026, 7, 16, 9, 30);
|
|
16
|
+
|
|
17
|
+
/** Local-ISO day (`YYYY-MM-DD`) — the native date input's wire format. */
|
|
18
|
+
function isoDay(d: Date): string {
|
|
19
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Local-ISO minute (`YYYY-MM-DDTHH:mm`) — datetime-local's wire format. */
|
|
23
|
+
function isoMinute(d: Date): string {
|
|
24
|
+
return `${isoDay(d)}T${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A day inside the mounted (BASE) month, optionally with a clock time. */
|
|
28
|
+
function dayOf(day: number, hours?: number, minutes?: number): Date {
|
|
29
|
+
return hours === undefined
|
|
30
|
+
? new Date(BASE.getFullYear(), BASE.getMonth(), day)
|
|
31
|
+
: new Date(BASE.getFullYear(), BASE.getMonth(), day, hours, minutes ?? 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
interface PickerHarness {
|
|
12
35
|
container: HTMLElement;
|
|
13
36
|
emitted: Date[];
|
|
@@ -18,7 +41,7 @@ function mountPicker(props: Record<string, unknown> = {}): PickerHarness {
|
|
|
18
41
|
document.body.appendChild(container);
|
|
19
42
|
containers.push(container);
|
|
20
43
|
|
|
21
|
-
const value = ref(props.modelValue as Date ??
|
|
44
|
+
const value = ref(props.modelValue as Date ?? BASE);
|
|
22
45
|
const emitted: Date[] = [];
|
|
23
46
|
const Wrapper = defineComponent({
|
|
24
47
|
setup() {
|
|
@@ -44,6 +67,22 @@ async function settle() {
|
|
|
44
67
|
await nextTick();
|
|
45
68
|
}
|
|
46
69
|
|
|
70
|
+
/** Poll until the drilled view's title button reads `expected`. The drill
|
|
71
|
+
* transition is frame/timeout based and a fixed 20 ms settle raced it
|
|
72
|
+
* under full-suite load (the assertion once read the days-view title),
|
|
73
|
+
* so the view STATE — not a sleep — is what this waits on. */
|
|
74
|
+
async function waitForTitle(expected: string): Promise<void> {
|
|
75
|
+
const deadline = Date.now() + 2000;
|
|
76
|
+
for (;;) {
|
|
77
|
+
const title = picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent ?? "";
|
|
78
|
+
if (title === expected) return;
|
|
79
|
+
if (Date.now() > deadline) {
|
|
80
|
+
throw new Error(`title never became ${JSON.stringify(expected)} (last: ${JSON.stringify(title)})`);
|
|
81
|
+
}
|
|
82
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
function picker(): HTMLElement | null {
|
|
48
87
|
return document.querySelector<HTMLElement>(".hk-dtp");
|
|
49
88
|
}
|
|
@@ -124,7 +163,9 @@ describe("HkDateTimePicker", () => {
|
|
|
124
163
|
|
|
125
164
|
it("re-derives month and weekday labels after a locale switch", async () => {
|
|
126
165
|
mountPicker();
|
|
127
|
-
const zhMonth = new Intl.DateTimeFormat("zh-Hans", { month: "long" }).format(
|
|
166
|
+
const zhMonth = new Intl.DateTimeFormat("zh-Hans", { month: "long" }).format(
|
|
167
|
+
new Date(BASE.getFullYear(), BASE.getMonth(), 1),
|
|
168
|
+
);
|
|
128
169
|
await setLocale("zh-Hans");
|
|
129
170
|
await nextTick();
|
|
130
171
|
const title = picker()?.querySelector<HTMLElement>(".hk-dtp-title-btn")?.textContent ?? "";
|
|
@@ -173,14 +214,14 @@ describe("HkDateTimePicker", () => {
|
|
|
173
214
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
174
215
|
monthBtn?.click();
|
|
175
216
|
await settle();
|
|
176
|
-
|
|
217
|
+
await waitForTitle(String(BASE.getFullYear()));
|
|
177
218
|
const navs = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-nav");
|
|
178
219
|
navs?.[1].click();
|
|
179
220
|
await nextTick();
|
|
180
|
-
|
|
221
|
+
await waitForTitle(String(BASE.getFullYear() + 1));
|
|
181
222
|
navs?.[0].click();
|
|
182
223
|
await nextTick();
|
|
183
|
-
|
|
224
|
+
await waitForTitle(String(BASE.getFullYear()));
|
|
184
225
|
});
|
|
185
226
|
|
|
186
227
|
it("time stepper bumps keep the drilled month view instead of snapping back", async () => {
|
|
@@ -205,14 +246,14 @@ describe("HkDateTimePicker", () => {
|
|
|
205
246
|
expect(p.emitted.length).toBe(1);
|
|
206
247
|
const d = p.emitted[0];
|
|
207
248
|
expect(d instanceof Date).toBe(true);
|
|
208
|
-
expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([
|
|
249
|
+
expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([BASE.getFullYear(), BASE.getMonth(), 20]);
|
|
209
250
|
expect([d.getHours(), d.getMinutes()]).toEqual([9, 30]);
|
|
210
251
|
});
|
|
211
252
|
|
|
212
253
|
it("disables days outside the inclusive min/max bounds", () => {
|
|
213
254
|
mountPicker({
|
|
214
|
-
min: new Date(
|
|
215
|
-
max: new Date(
|
|
255
|
+
min: new Date(BASE.getFullYear(), BASE.getMonth(), 10, 0, 0),
|
|
256
|
+
max: new Date(BASE.getFullYear(), BASE.getMonth(), 20, 23, 59),
|
|
216
257
|
});
|
|
217
258
|
const byDay = new Map(
|
|
218
259
|
dayCells().filter((c) => !c.classList.contains("is-out")).map((c) => [c.textContent ?? "", c]),
|
|
@@ -224,14 +265,14 @@ describe("HkDateTimePicker", () => {
|
|
|
224
265
|
});
|
|
225
266
|
|
|
226
267
|
it("blocked days never emit an update when clicked", async () => {
|
|
227
|
-
const p = mountPicker({ max:
|
|
268
|
+
const p = mountPicker({ max: dayOf(20, 23, 59) });
|
|
228
269
|
clickDay(25);
|
|
229
270
|
await nextTick();
|
|
230
271
|
expect(p.emitted).toEqual([]);
|
|
231
272
|
});
|
|
232
273
|
|
|
233
274
|
it("marks days present in markedDays with a dot", () => {
|
|
234
|
-
mountPicker({ markedDays: new Set([
|
|
275
|
+
mountPicker({ markedDays: new Set([isoDay(dayOf(18))]) });
|
|
235
276
|
const marked = Array.from(picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-cell") ?? [])
|
|
236
277
|
.filter((c) => c.querySelector(".hk-dtp-cell-dot"));
|
|
237
278
|
expect(marked.length).toBe(1);
|
|
@@ -257,7 +298,7 @@ describe("HkDateTimePicker", () => {
|
|
|
257
298
|
const input = nativeInput(p.container);
|
|
258
299
|
expect(input).not.toBeNull();
|
|
259
300
|
expect(input?.type).toBe("datetime-local");
|
|
260
|
-
expect(input?.value).toBe(
|
|
301
|
+
expect(input?.value).toBe(isoMinute(BASE));
|
|
261
302
|
expect(p.container.querySelector(".hk-dtp")).toBeNull();
|
|
262
303
|
expect(p.container.querySelector(".hk-dtp-grid")).toBeNull();
|
|
263
304
|
});
|
|
@@ -267,18 +308,15 @@ describe("HkDateTimePicker", () => {
|
|
|
267
308
|
const p = mountPicker({ showTime: false });
|
|
268
309
|
const input = nativeInput(p.container);
|
|
269
310
|
expect(input?.type).toBe("date");
|
|
270
|
-
expect(input?.value).toBe(
|
|
311
|
+
expect(input?.value).toBe(isoDay(BASE));
|
|
271
312
|
});
|
|
272
313
|
|
|
273
314
|
it("passes min/max through to the native input in its wire format", () => {
|
|
274
315
|
useMobileViewport();
|
|
275
|
-
const p = mountPicker({
|
|
276
|
-
min: new Date(2026, 7, 10, 8, 0),
|
|
277
|
-
max: new Date(2026, 7, 20, 18, 30),
|
|
278
|
-
});
|
|
316
|
+
const p = mountPicker({ min: dayOf(10, 8, 0), max: dayOf(20, 18, 30) });
|
|
279
317
|
const input = nativeInput(p.container);
|
|
280
|
-
expect(input?.min).toBe(
|
|
281
|
-
expect(input?.max).toBe(
|
|
318
|
+
expect(input?.min).toBe(isoMinute(dayOf(10, 8, 0)));
|
|
319
|
+
expect(input?.max).toBe(isoMinute(dayOf(20, 18, 30)));
|
|
282
320
|
});
|
|
283
321
|
|
|
284
322
|
it("keeps the custom calendar on mobile when nativeOnMobile is false", () => {
|
|
@@ -295,33 +333,34 @@ describe("HkDateTimePicker", () => {
|
|
|
295
333
|
useMobileViewport();
|
|
296
334
|
const p = mountPicker();
|
|
297
335
|
const input = nativeInput(p.container);
|
|
298
|
-
input!.value =
|
|
336
|
+
input!.value = isoMinute(dayOf(20, 14, 5));
|
|
299
337
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
300
338
|
await nextTick();
|
|
301
339
|
expect(p.emitted.length).toBe(1);
|
|
302
|
-
expect(p.emitted[0].getTime()).toBe(
|
|
303
|
-
expect(input?.value).toBe(
|
|
340
|
+
expect(p.emitted[0].getTime()).toBe(dayOf(20, 14, 5).getTime());
|
|
341
|
+
expect(input?.value).toBe(isoMinute(dayOf(20, 14, 5)));
|
|
304
342
|
});
|
|
305
343
|
|
|
306
344
|
it("native date edits preserve the clock time when showTime is false", async () => {
|
|
307
345
|
useMobileViewport();
|
|
308
346
|
const p = mountPicker({ showTime: false });
|
|
309
347
|
const input = nativeInput(p.container);
|
|
310
|
-
input!.value =
|
|
348
|
+
input!.value = isoDay(dayOf(20));
|
|
311
349
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
312
350
|
await nextTick();
|
|
313
|
-
expect(p.emitted[0].getTime()).toBe(
|
|
351
|
+
expect(p.emitted[0].getTime()).toBe(dayOf(20, 9, 30).getTime());
|
|
314
352
|
});
|
|
315
353
|
|
|
316
354
|
it("native input edits outside the bounds are rejected and re-synced", async () => {
|
|
317
355
|
useMobileViewport();
|
|
318
|
-
const p = mountPicker({ max:
|
|
356
|
+
const p = mountPicker({ max: dayOf(20, 23, 59) });
|
|
319
357
|
const input = nativeInput(p.container);
|
|
320
|
-
|
|
358
|
+
// Next month, past the max bound.
|
|
359
|
+
input!.value = isoMinute(new Date(BASE.getFullYear(), BASE.getMonth() + 1, 1, 10, 0));
|
|
321
360
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
322
361
|
await nextTick();
|
|
323
362
|
expect(p.emitted).toEqual([]);
|
|
324
|
-
expect(input?.value).toBe(
|
|
363
|
+
expect(input?.value).toBe(isoMinute(BASE));
|
|
325
364
|
});
|
|
326
365
|
|
|
327
366
|
it("clearing the native input falls back to the model value", async () => {
|
|
@@ -332,7 +371,7 @@ describe("HkDateTimePicker", () => {
|
|
|
332
371
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
333
372
|
await nextTick();
|
|
334
373
|
expect(p.emitted).toEqual([]);
|
|
335
|
-
expect(input?.value).toBe(
|
|
374
|
+
expect(input?.value).toBe(isoMinute(BASE));
|
|
336
375
|
});
|
|
337
376
|
|
|
338
377
|
it("native input replaces even the popup chrome on mobile", () => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract: the toast stacks are floating layers too, so their
|
|
3
|
+
* screen-edge inset rides the same shared --viewport-gutter token as the
|
|
4
|
+
* anchored popups (16px desktop / 8px mobile, hikari #501). Pinned here
|
|
5
|
+
* so a refactor cannot quietly re-hardcode a pixel value or drift the
|
|
6
|
+
* fallback away from the token's declared desktop magnitude.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
describe("toast container viewport gutter contract", () => {
|
|
16
|
+
for (const sheet of ["HkToast.scss", "HkBlockingToast.scss"]) {
|
|
17
|
+
it(`${sheet} pins the container inset to the shared gutter token`, () => {
|
|
18
|
+
const css = readFileSync(join(here, sheet), "utf-8");
|
|
19
|
+
// EVERY inset-inline-end declaration in the sheet must be the
|
|
20
|
+
// gutter var — no re-hardcoded pixel sibling may creep in.
|
|
21
|
+
const decls = css.match(/inset-inline-end:[^;]+;/g) ?? [];
|
|
22
|
+
expect(decls).toEqual(["inset-inline-end: var(--viewport-gutter, 16px);"]);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
top: 4rem;
|
|
4
4
|
/* Same viewport gutter the anchored popups clamp to (16px desktop /
|
|
5
5
|
* 8px mobile) — the toast stack is a floating layer too. */
|
|
6
|
-
inset-inline-end: var(--viewport-gutter,
|
|
6
|
+
inset-inline-end: var(--viewport-gutter, 16px);
|
|
7
7
|
/* Toast band — the topmost popup z band (POPUP_Z_BANDS.toast; mirror in
|
|
8
8
|
theme.scss). The live value arrives inline from the popup manager. */
|
|
9
9
|
z-index: var(--hk-z-toast, 4000);
|
package/src/index.ts
CHANGED
|
@@ -380,6 +380,8 @@ export {
|
|
|
380
380
|
type HAboutComponentVersion,
|
|
381
381
|
type HAboutCredit,
|
|
382
382
|
type HAboutLink,
|
|
383
|
+
type HAboutLinkFace,
|
|
384
|
+
type HAboutLinkIcon,
|
|
383
385
|
} from "./components/HkAboutModal";
|
|
384
386
|
export { HkLogWindow as HLogWindow, type HLogTab } from "./components/HkLogWindow";
|
|
385
387
|
export { HkCookieConsent as HCookieConsent } from "./components/HkCookieConsent";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { installHistorySafetyNet, sanitizeHistoryUrl } from "./historySafetyNet";
|
|
4
|
+
import { getHkRuntimeEntry } from "./registry";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The field bug this net closes (chest #754 lineage): some producer
|
|
@@ -136,4 +137,28 @@ describe("installHistorySafetyNet", () => {
|
|
|
136
137
|
expect(sessionStorage.getItem("hikari:historyNet")).toBeNull();
|
|
137
138
|
vi.restoreAllMocks();
|
|
138
139
|
});
|
|
140
|
+
|
|
141
|
+
it("keeps the first install's fold target when a HALF-overwritten net re-patches", () => {
|
|
142
|
+
// The recorded half-self-heal edge: pushState keeps our patch while an
|
|
143
|
+
// external lib restores replaceState to native. A re-install with new
|
|
144
|
+
// options must NOT silently adopt them for the re-patched method —
|
|
145
|
+
// the still-installed first capture wins for behavior AND meta, or a
|
|
146
|
+
// per-method option split ships to production unnoticed.
|
|
147
|
+
installHistorySafetyNet({ fallback: "/" });
|
|
148
|
+
// Another library restores replaceState (pushState stays ours).
|
|
149
|
+
History.prototype.replaceState = nativeReplace;
|
|
150
|
+
// Re-install with a different fallback: the pushState marker makes
|
|
151
|
+
// this a no-op claim, so "/" must stay the active fold target.
|
|
152
|
+
installHistorySafetyNet({ fallback: "/landing" });
|
|
153
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
154
|
+
history.replaceState(null, "", "https://evil.example/x");
|
|
155
|
+
// Folded to the FIRST install's "/" — never the re-install's
|
|
156
|
+
// "/landing" (which, being same-origin, would have navigated).
|
|
157
|
+
expect(window.location.pathname).toBe("/");
|
|
158
|
+
expect(sessionStorage.getItem("hikari:historyNet")).toContain("evil.example/x");
|
|
159
|
+
// ...and the registry meta reports the option set that is actually
|
|
160
|
+
// wired into the prototype, not the newest call's.
|
|
161
|
+
expect(getHkRuntimeEntry("historySafetyNet")?.meta?.fallback).toBe("/");
|
|
162
|
+
warn.mockRestore();
|
|
163
|
+
});
|
|
139
164
|
});
|
|
@@ -95,9 +95,15 @@ let historyNetRuntime: ReturnType<typeof reportHkRuntime> | null = null;
|
|
|
95
95
|
* our patch is still on the prototype skips re-patching (the `continue`
|
|
96
96
|
* below), so the first install's fallback/evidence stay in effect — the
|
|
97
97
|
* registry meta must report THOSE, not the newest call's options, or it
|
|
98
|
-
* would misreport the active fold target.
|
|
98
|
+
* would misreport the active fold target. The re-patch closure reads
|
|
99
|
+
* THESE module captures, never the invocation locals: when a net is
|
|
100
|
+
* HALF overwritten (say replaceState restored to native while pushState
|
|
101
|
+
* is still ours) a re-install patches the foreign method with the
|
|
102
|
+
* still-active capture, so behavior and meta can never diverge into
|
|
103
|
+
* per-method options. */
|
|
99
104
|
let activeFallback = "/";
|
|
100
105
|
let activeEvidenceKey: string | false = DEFAULT_EVIDENCE_KEY;
|
|
106
|
+
let activeOptions: HistorySafetyNetOptions = {};
|
|
101
107
|
function ensureHistoryNetRuntime() {
|
|
102
108
|
return (historyNetRuntime ??= reportHkRuntime("historySafetyNet", {
|
|
103
109
|
kind: "hook",
|
|
@@ -120,6 +126,7 @@ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}):
|
|
|
120
126
|
// active; a no-op re-install keeps the first install's capture.
|
|
121
127
|
activeFallback = fallback;
|
|
122
128
|
activeEvidenceKey = evidenceKey;
|
|
129
|
+
activeOptions = options;
|
|
123
130
|
}
|
|
124
131
|
ensureHistoryNetRuntime().setMeta({ fallback: activeFallback, evidenceKey: activeEvidenceKey === false ? "off" : activeEvidenceKey });
|
|
125
132
|
ensureHistoryNetRuntime().pulse();
|
|
@@ -133,13 +140,16 @@ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}):
|
|
|
133
140
|
}
|
|
134
141
|
const native = current as (this: History, state: unknown, title: string, url?: string | URL | null) => unknown;
|
|
135
142
|
if (typeof native !== "function") continue;
|
|
143
|
+
// The closure reads the ACTIVE capture, never this call's locals —
|
|
144
|
+
// the half-overwrite path above re-patches a foreign method while
|
|
145
|
+
// the first install's options stay in effect.
|
|
136
146
|
const patched = function (this: History, state: unknown, title: string, url?: string | URL | null) {
|
|
137
|
-
const safe = sanitizeHistoryUrl(url,
|
|
147
|
+
const safe = sanitizeHistoryUrl(url, activeFallback);
|
|
138
148
|
if (safe !== url) {
|
|
139
|
-
const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo:
|
|
149
|
+
const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: activeFallback };
|
|
140
150
|
coercions += 1;
|
|
141
151
|
historyNetRuntime?.pulse({ coercions });
|
|
142
|
-
reportCoercion(info,
|
|
152
|
+
reportCoercion(info, activeEvidenceKey, activeOptions);
|
|
143
153
|
}
|
|
144
154
|
return native.call(this, state as never, title, safe as never);
|
|
145
155
|
};
|