@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.48.0",
3
+ "version": "0.49.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -228,7 +228,7 @@
228
228
  color: rgb(var(--color-warning));
229
229
  }
230
230
 
231
- /* ── Chips: licenses and external links ───────────────────────────────── */
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 chip group (licenses / links). Must span the chips block — inside an
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 chip list would then wrap against that collapsed width instead of
243
- // the modal's.
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
- // Ghost pills: every link in this dialog is a tag, so they share one face —
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 chips render a notch smaller than
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 is a tag and opens in a new tab.
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
- label: string;
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` chips (organization first, author second, …). The names render as
19
- * the same chips as the license / link rows — every link in this dialog is
20
- * a tag.
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 chip label (mutually exclusive with `text`). */
57
+ /** Linked name (mutually exclusive with `text`). */
26
58
  name?: string;
27
- /** Target for a `name` chip; opens in a new tab. */
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 name chips, plus the
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, legal filings and the credits
58
- * names all render as the same ghost tag. Every one opens in a new tab. Every branding prop is optional — the modal degrades
59
- * to the plain identity card when none are given.
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 name chips
77
- * (e.g. 来自 <Celestia Island>,由 <伊欧> 倾力设计). Names render as the
78
- * same chips as the license / link rows.
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 chips (e.g. SySL-1.0 / BUSL-1.1), rendered centered. */
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
- /** Optional external links (e.g. GitHub, docs). */
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 chip renderer for every link in the dialog: credits names,
109
- // licenses, external links and the legal filings all render as tags.
110
- const renderChip = (label: string, href: string, extraClass: string) => (
111
- <a
112
- key={`${extraClass}:${href}`}
113
- class={`s-about-modal-link ${extraClass}`}
114
- href={href}
115
- target="_blank"
116
- rel="noopener noreferrer"
117
- >
118
- {label}
119
- </a>
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
- renderChip(part.name, part.href, "s-about-modal-credit-link")
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
- const renderChips = (items: HAboutLink[], slot: string) => {
145
- if (items.length === 0) return null;
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
- {items.map((item) => renderChip(item.label, item.href, ""))}
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
- {renderChips(props.licenses, "licenses")}
233
- {renderChips(props.links, "links")}
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
- renderChip(link.label, link.href, "s-about-modal-footer-link"),
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, 1rem);
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
- // days → months
330
- panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
331
- await settle();
332
- // months → years (the year button is the months-view title)
333
- panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
334
- await settle();
335
- const yearCells = Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
336
- expect(yearCells.length).toBe(12);
337
- // 2027 sits inside the 2016–2027 block of the 2026 view year.
338
- const t2027 = yearCells.find((c) => c.textContent === "2027");
339
- t2027?.click();
340
- await settle();
341
- expect(panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.textContent).toBe("2027");
342
- const months = Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
343
- months[6]?.click(); // July
344
- await settle();
345
- expect(panel()?.querySelectorAll(".hk-dp-cell").length).toBe(42);
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 ?? new Date(2026, 7, 16, 9, 30));
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(new Date(2026, 7, 1));
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
- expect(picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent).toBe("2026");
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
- expect(picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent).toBe("2027");
221
+ await waitForTitle(String(BASE.getFullYear() + 1));
181
222
  navs?.[0].click();
182
223
  await nextTick();
183
- expect(picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent).toBe("2026");
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([2026, 7, 20]);
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(2026, 7, 10, 0, 0),
215
- max: new Date(2026, 7, 20, 23, 59),
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: new Date(2026, 7, 20, 23, 59) });
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(["2026-08-18"]) });
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("2026-08-16T09:30");
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("2026-08-16");
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("2026-08-10T08:00");
281
- expect(input?.max).toBe("2026-08-20T18:30");
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 = "2026-08-20T14:05";
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(new Date(2026, 7, 20, 14, 5).getTime());
303
- expect(input?.value).toBe("2026-08-20T14:05");
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 = "2026-08-20";
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(new Date(2026, 7, 20, 9, 30).getTime());
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: new Date(2026, 7, 20, 23, 59) });
356
+ const p = mountPicker({ max: dayOf(20, 23, 59) });
319
357
  const input = nativeInput(p.container);
320
- input!.value = "2026-09-01T10:00";
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("2026-08-16T09:30");
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("2026-08-16T09:30");
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, 1rem);
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, fallback);
147
+ const safe = sanitizeHistoryUrl(url, activeFallback);
138
148
  if (safe !== url) {
139
- const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: fallback };
149
+ const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: activeFallback };
140
150
  coercions += 1;
141
151
  historyNetRuntime?.pulse({ coercions });
142
- reportCoercion(info, evidenceKey, options);
152
+ reportCoercion(info, activeEvidenceKey, activeOptions);
143
153
  }
144
154
  return native.call(this, state as never, title, safe as never);
145
155
  };