@celestia-island/hikari 0.47.1 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.47.1",
3
+ "version": "0.49.0",
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();