@celestia-island/hikari 0.48.0 → 0.49.0
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
|
@@ -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
|
)}
|
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";
|