@celestia-island/hikari 0.45.6 → 0.45.8

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.45.6",
3
+ "version": "0.45.8",
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",
@@ -126,7 +126,7 @@
126
126
  color: rgb(var(--color-muted));
127
127
  }
128
128
 
129
- /* ── Credits: made-by sentence + organization blurb ────────────────────── */
129
+ /* ── Credits: credits sentence + organization blurb ────────────────────── */
130
130
 
131
131
  .s-about-modal-credits {
132
132
  display: flex;
@@ -136,24 +136,19 @@
136
136
  margin-top: calc(-1 * var(--space-4, 0.25rem));
137
137
  }
138
138
 
139
- .s-about-modal-made-by {
139
+ // Inline flow (not flex): the sentence is assembled from text runs and
140
+ // chips, and the runs carry their own spaces around the chips.
141
+ .s-about-modal-credits-line {
140
142
  margin: 0;
141
143
  font-size: var(--text-sm, 0.8125rem);
144
+ line-height: 2;
142
145
  text-align: center;
146
+ text-wrap: pretty;
143
147
  color: rgb(var(--color-text));
144
148
  }
145
149
 
146
- .s-about-modal-made-by .s-about-modal-row-link {
147
- margin: 0 0.15em;
148
- }
149
-
150
- .s-about-modal-row-link {
151
- color: rgb(var(--color-primary));
152
- text-decoration: none;
153
- }
154
-
155
- .s-about-modal-row-link:hover {
156
- text-decoration: underline;
150
+ .s-about-modal-credit-name {
151
+ color: rgb(var(--color-text));
157
152
  }
158
153
 
159
154
  .s-about-modal-description {
@@ -242,6 +237,14 @@
242
237
  gap: var(--space-8, 0.5rem);
243
238
  }
244
239
 
240
+ // One chip group (licenses / links). Must span the chips block — inside an
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.
244
+ .s-about-modal-links {
245
+ width: 100%;
246
+ }
247
+
245
248
  .s-about-modal-links-list {
246
249
  display: flex;
247
250
  flex-wrap: wrap;
@@ -249,8 +252,8 @@
249
252
  gap: var(--space-6, 0.375rem);
250
253
  }
251
254
 
252
- // Ghost pills: the chips are secondary navigation, so they carry a hairline
253
- // instead of a filled brand block.
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.
254
257
  .s-about-modal-link {
255
258
  padding: var(--space-2, 0.125rem) var(--space-10, 0.625rem);
256
259
  border: 1px solid var(--border-subtle, rgb(100 100 100 / 12%));
@@ -265,6 +268,14 @@
265
268
  background var(--duration-short, 0.15s) ease;
266
269
  }
267
270
 
271
+ // Chips inside the credits sentence sit in a text run: keep them on the
272
+ // baseline and let the surrounding spaces do the spacing.
273
+ .s-about-modal-credit-link {
274
+ display: inline-block;
275
+ vertical-align: middle;
276
+ margin: 0 0.1em;
277
+ }
278
+
268
279
  .s-about-modal-link:hover {
269
280
  border-color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
270
281
  background: var(--c-primary-faint, rgb(var(--color-primary) / 5%));
@@ -284,25 +295,20 @@
284
295
 
285
296
  /* ── Legal footer: filing links + copyright ───────────────────────────── */
286
297
 
298
+ // Filing numbers are long, so the legal chips render a notch smaller than
299
+ // the license / link rows.
300
+ .s-about-modal-footer-link {
301
+ font-size: var(--text-2xs, 0.625rem);
302
+ }
303
+
287
304
  .s-about-modal-footer-links {
288
305
  display: flex;
289
306
  flex-wrap: wrap;
290
307
  justify-content: center;
291
- gap: var(--space-16, 1rem);
308
+ gap: var(--space-6, 0.375rem);
292
309
  margin-top: calc(-1 * var(--space-4, 0.25rem));
293
310
  }
294
311
 
295
- .s-about-modal-footer-link {
296
- font-size: var(--text-xs, 0.75rem);
297
- color: rgb(var(--color-muted));
298
- text-decoration: none;
299
- }
300
-
301
- .s-about-modal-footer-link:hover {
302
- color: rgb(var(--color-primary));
303
- text-decoration: underline;
304
- }
305
-
306
312
  .s-about-modal-footer {
307
313
  display: flex;
308
314
  justify-content: center;
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Compiled-stylesheet guard for HkAboutModal.
10
+ *
11
+ * Vitest stubs CSS (`test.css` is off), so the runtime suite can never catch
12
+ * a class the component emits that lost its rule — the #493 redesign dropped
13
+ * the `.s-about-modal-links` rule while keeping the element and every test
14
+ * stayed green. This test compiles the component sheet directly and asserts
15
+ * both directions of the class contract:
16
+ *
17
+ * 1. every `s-about-modal-*` class the TSX renders has a rule, and
18
+ * 2. every `s-about-modal-*` rule in the sheet is rendered by the TSX
19
+ * (no dead selectors).
20
+ */
21
+
22
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
23
+
24
+ function classesFrom(source: string): Set<string> {
25
+ // The component only uses static class strings; collect them from the
26
+ // `class="…"` attributes plus any bare `s-about-modal-*` token.
27
+ const found = new Set<string>();
28
+ for (const match of source.matchAll(/s-about-modal[\w-]*/g)) found.add(match[0]);
29
+ return found;
30
+ }
31
+
32
+ describe("HkAboutModal stylesheet contract", () => {
33
+ const tsx = readFileSync(resolve(componentDir, "HkAboutModal.tsx"), "utf8");
34
+ const scss = readFileSync(resolve(componentDir, "HkAboutModal.scss"), "utf8");
35
+ const css = compile(resolve(componentDir, "HkAboutModal.scss"), {
36
+ style: "expanded",
37
+ }).css;
38
+
39
+ it("compiles without warnings", () => {
40
+ expect(css).toContain(".s-about-modal");
41
+ });
42
+
43
+ it("gives every emitted class a rule", () => {
44
+ // Selector-side scan of the compiled sheet (not the SCSS source): a rule
45
+ // that failed to compile would otherwise still count as "present".
46
+ const declared = new Set([...css.matchAll(/\.(s-about-modal[\w-]*)/g)].map((m) => m[1]!));
47
+ const missing = [...classesFrom(tsx)].filter((name) => !declared.has(name)).sort();
48
+ expect(missing, "classes rendered without a stylesheet rule").toEqual([]);
49
+ });
50
+
51
+ it("declares no rule the component never renders", () => {
52
+ const rendered = classesFrom(tsx);
53
+ const dead = [...new Set([...scss.matchAll(/\.(s-about-modal[\w-]*)/g)].map((m) => m[1]!))]
54
+ .filter((name) => !rendered.has(name))
55
+ .sort();
56
+ expect(dead, "stylesheet selectors with no matching element").toEqual([]);
57
+ });
58
+ });
@@ -9,8 +9,9 @@ import { setLocale } from "../i18n/context";
9
9
  * - the first-letter tile is the default identity and `logoSrc` swaps it
10
10
  * for an image inside the identity frame
11
11
  * - version and tagline share one subtitle line
12
- * - description / made-by / license / copyright render the given strings,
13
- * madeByNameHref turns the author name into an external link
12
+ * - the credits sentence assembles text runs and linked name chips, and a
13
+ * name without an href stays plain text
14
+ * - description / license / copyright render the given strings
14
15
  * - component rows split the machine value from an optional meta pill
15
16
  * - the backdrop factory renders inside the clipped backdrop layer
16
17
  * - licenses and links render as chips under one centered block
@@ -96,27 +97,44 @@ describe("HkAboutModal branding", () => {
96
97
  expect(query<HTMLElement>(".s-about-modal-description").textContent).toBe("Save the world.");
97
98
  });
98
99
 
99
- it("renders the made-by sentence with a linked author name", async () => {
100
+ it("renders the credits sentence with linked name chips", async () => {
100
101
  mountAbout({
101
- madeByPrefix: "由",
102
- madeByName: "伊欧",
103
- madeByNameHref: "https://github.com/langyo",
104
- madeBySuffix: " 主创,来自 Celestia Island",
102
+ credits: [
103
+ { text: "来自 " },
104
+ { name: "Celestia Island", href: "https://github.com/celestia-island" },
105
+ { text: ",由 " },
106
+ { name: "伊欧", href: "https://github.com/langyo" },
107
+ { text: " 倾力设计" },
108
+ ],
105
109
  });
106
110
  await flushModal();
107
- const line = query<HTMLElement>(".s-about-modal-made-by");
108
- expect(line.textContent).toBe("由伊欧 主创,来自 Celestia Island");
109
- const link = query<HTMLAnchorElement>(".s-about-modal-row-link");
110
- expect(link.textContent).toBe("伊欧");
111
- expect(link.getAttribute("href")).toBe("https://github.com/langyo");
112
- expect(link.getAttribute("target")).toBe("_blank");
111
+ const line = query<HTMLElement>(".s-about-modal-credits-line");
112
+ expect(line.textContent).toBe("来自 Celestia Island,由 伊欧 倾力设计");
113
+ const chips = [
114
+ ...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-credit-link"),
115
+ ];
116
+ expect(chips.map((chip) => chip.textContent)).toEqual(["Celestia Island", "伊欧"]);
117
+ // Every link in the dialog is a tag and opens in a new tab.
118
+ for (const chip of chips) {
119
+ expect(chip.classList.contains("s-about-modal-link")).toBe(true);
120
+ expect(chip.getAttribute("target")).toBe("_blank");
121
+ expect(chip.getAttribute("rel")).toContain("noopener");
122
+ }
123
+ expect(chips[0]!.getAttribute("href")).toBe("https://github.com/celestia-island");
124
+ });
125
+
126
+ it("keeps a credit name as plain text when no href is given", async () => {
127
+ mountAbout({ credits: [{ text: "由 " }, { name: "伊欧" }, { text: " 主创" }] });
128
+ await flushModal();
129
+ expect(query<HTMLElement>(".s-about-modal-credits-line").textContent).toBe("由 伊欧 主创");
130
+ expect(query<HTMLElement>(".s-about-modal-credit-name").textContent).toBe("伊欧");
131
+ expect(document.body.querySelector(".s-about-modal-credit-link")).toBeNull();
113
132
  });
114
133
 
115
- it("renders the made-by sentence without a link when no href", async () => {
116
- mountAbout({ madeByPrefix: "由", madeByName: "伊欧", madeBySuffix: " 主创" });
134
+ it("skips empty credit parts", async () => {
135
+ mountAbout({ credits: [{ text: "" }, { name: "" }, { text: "仅此一句。" }] });
117
136
  await flushModal();
118
- expect(query<HTMLElement>(".s-about-modal-made-by").textContent).toBe("由伊欧 主创");
119
- expect(document.body.querySelector(".s-about-modal-row-link")).toBeNull();
137
+ expect(query<HTMLElement>(".s-about-modal-credits-line").textContent).toBe("仅此一句。");
120
138
  });
121
139
 
122
140
  it("renders software-component version rows with a meta pill and tone", async () => {
@@ -151,6 +169,18 @@ describe("HkAboutModal branding", () => {
151
169
  );
152
170
  });
153
171
 
172
+ it("carries the caution tone through to the pill", async () => {
173
+ mountAbout({
174
+ componentVersions: [
175
+ { label: "云模型调度引擎版本", value: "0.1.26", meta: "预发", metaTone: "caution" },
176
+ ],
177
+ });
178
+ await flushModal();
179
+ expect(query<HTMLElement>(".s-about-modal-row-meta").getAttribute("data-tone")).toBe(
180
+ "caution",
181
+ );
182
+ });
183
+
154
184
  it("renders centered license chips", async () => {
155
185
  mountAbout({
156
186
  licenses: [
@@ -222,7 +252,7 @@ describe("HkAboutModal branding", () => {
222
252
  mountAbout();
223
253
  await flushModal();
224
254
  expect(document.body.querySelectorAll(".s-about-modal-row").length).toBe(0);
225
- expect(document.body.querySelector(".s-about-modal-made-by")).toBeNull();
255
+ expect(document.body.querySelector(".s-about-modal-credits-line")).toBeNull();
226
256
  expect(document.body.querySelector(".s-about-modal-links")).toBeNull();
227
257
  expect(document.body.querySelector(".s-about-modal-chips")).toBeNull();
228
258
  expect(document.body.querySelector(".s-about-modal-credits")).toBeNull();
@@ -10,6 +10,24 @@ export interface HAboutLink {
10
10
  href: string;
11
11
  }
12
12
 
13
+ /**
14
+ * One piece of the credits line.
15
+ *
16
+ * The line is a sentence assembled from parts so a host can phrase (and
17
+ * 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.
21
+ */
22
+ export interface HAboutCredit {
23
+ /** Literal sentence fragment (mutually exclusive with `name`). */
24
+ text?: string;
25
+ /** Linked chip label (mutually exclusive with `text`). */
26
+ name?: string;
27
+ /** Target for a `name` chip; opens in a new tab. */
28
+ href?: string;
29
+ }
30
+
13
31
  /**
14
32
  * One software-component version row.
15
33
  *
@@ -33,10 +51,11 @@ export interface HAboutComponentVersion {
33
51
  *
34
52
  * Layout (2026-09 redesign): a centered identity hero (haloed logo, name,
35
53
  * then version + tagline on one compact line), an optional credits block
36
- * (made-by sentence with a linked author name + organization blurb), a
37
- * bordered spec card holding the software-component versions, ghost chip
38
- * rows for licenses / external links, and a muted legal footer (filing
39
- * links + copyright). Every branding prop is optional — the modal degrades
54
+ * (a sentence built from text runs and linked name chips, plus the
55
+ * organization blurb), a
56
+ * 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
40
59
  * to the plain identity card when none are given.
41
60
  */
42
61
  export const HkAboutModal = defineComponent({
@@ -53,14 +72,12 @@ export const HkAboutModal = defineComponent({
53
72
  tagline: { type: String, default: undefined },
54
73
  /** Optional centered small line under the credits (organization blurb). */
55
74
  description: { type: String, default: undefined },
56
- /** Made-by sentence: text before the linked author name (e.g. "由"). */
57
- madeByPrefix: { type: String, default: undefined },
58
- /** Made-by sentence: the linked author name (e.g. "伊欧"). */
59
- madeByName: { type: String, default: undefined },
60
- /** Link applied to the author name (e.g. the GitHub profile). */
61
- madeByNameHref: { type: String, default: undefined },
62
- /** Made-by sentence: text after the name (e.g. " 主创,来自 …"). */
63
- madeBySuffix: { type: String, default: undefined },
75
+ /**
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.
79
+ */
80
+ credits: { type: Array as PropType<HAboutCredit[]>, default: () => [] },
64
81
  /** License chips (e.g. SySL-1.0 / BUSL-1.1), rendered centered. */
65
82
  licenses: { type: Array as PropType<HAboutLink[]>, default: () => [] },
66
83
  /** Software-component version rows (WebUI / engines), label + value. */
@@ -88,25 +105,38 @@ export const HkAboutModal = defineComponent({
88
105
  setup(props, { emit }) {
89
106
  const { t } = useI18n();
90
107
 
91
- const renderMadeBy = () => {
92
- if (!props.madeByName) return null;
93
- const name = props.madeByNameHref ? (
94
- <a
95
- class="s-about-modal-row-link"
96
- href={props.madeByNameHref}
97
- target="_blank"
98
- rel="noopener noreferrer"
99
- >
100
- {props.madeByName}
101
- </a>
102
- ) : (
103
- <span>{props.madeByName}</span>
104
- );
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
+ );
121
+
122
+ const renderCredits = () => {
123
+ const parts = props.credits.filter((part) => part.text || part.name);
124
+ if (parts.length === 0) return null;
105
125
  return (
106
- <p class="s-about-modal-made-by">
107
- {props.madeByPrefix}
108
- {name}
109
- {props.madeBySuffix}
126
+ <p class="s-about-modal-credits-line">
127
+ {parts.map((part, index) =>
128
+ part.name ? (
129
+ part.href ? (
130
+ renderChip(part.name, part.href, "s-about-modal-credit-link")
131
+ ) : (
132
+ <span key={`name:${index}`} class="s-about-modal-credit-name">
133
+ {part.name}
134
+ </span>
135
+ )
136
+ ) : (
137
+ <span key={`text:${index}`}>{part.text}</span>
138
+ ),
139
+ )}
110
140
  </p>
111
141
  );
112
142
  };
@@ -116,17 +146,7 @@ export const HkAboutModal = defineComponent({
116
146
  return (
117
147
  <div class="s-about-modal-links" data-slot={slot}>
118
148
  <div class="s-about-modal-links-list">
119
- {items.map((item) => (
120
- <a
121
- key={item.href}
122
- href={item.href}
123
- target="_blank"
124
- rel="noopener noreferrer"
125
- class="s-about-modal-link"
126
- >
127
- {item.label}
128
- </a>
129
- ))}
149
+ {items.map((item) => renderChip(item.label, item.href, ""))}
130
150
  </div>
131
151
  </div>
132
152
  );
@@ -177,9 +197,9 @@ export const HkAboutModal = defineComponent({
177
197
  </div>
178
198
  </header>
179
199
 
180
- {(props.madeByName || props.description) && (
200
+ {(props.credits.length > 0 || props.description) && (
181
201
  <div class="s-about-modal-credits">
182
- {renderMadeBy()}
202
+ {renderCredits()}
183
203
  {props.description && (
184
204
  <p class="s-about-modal-description">{props.description}</p>
185
205
  )}
@@ -216,17 +236,9 @@ export const HkAboutModal = defineComponent({
216
236
 
217
237
  {props.footerLinks.length > 0 && (
218
238
  <div class="s-about-modal-footer-links">
219
- {props.footerLinks.map((link) => (
220
- <a
221
- key={link.href}
222
- href={link.href}
223
- target="_blank"
224
- rel="noopener noreferrer"
225
- class="s-about-modal-footer-link"
226
- >
227
- {link.label}
228
- </a>
229
- ))}
239
+ {props.footerLinks.map((link) =>
240
+ renderChip(link.label, link.href, "s-about-modal-footer-link"),
241
+ )}
230
242
  </div>
231
243
  )}
232
244
 
package/src/index.ts CHANGED
@@ -356,6 +356,7 @@ export { HkProtocolModal as HProtocolModal } from "./components/HkProtocolModal"
356
356
  export {
357
357
  HkAboutModal as HAboutModal,
358
358
  type HAboutComponentVersion,
359
+ type HAboutCredit,
359
360
  type HAboutLink,
360
361
  } from "./components/HkAboutModal";
361
362
  export { HkLogWindow as HLogWindow, type HLogTab } from "./components/HkLogWindow";