@celestia-island/hikari 0.45.2 → 0.45.4

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.2",
3
+ "version": "0.45.4",
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",
@@ -72,6 +72,14 @@
72
72
  color: rgb(var(--color-muted));
73
73
  }
74
74
 
75
+ .s-about-modal-slogan {
76
+ margin: 0;
77
+ font-size: var(--text-base, 1rem);
78
+ font-weight: 600;
79
+ text-align: center;
80
+ color: rgb(var(--color-text));
81
+ }
82
+
75
83
  .s-about-modal-tagline {
76
84
  margin: var(--space-2, 0.125rem) 0 0;
77
85
  font-size: var(--text-xs, 0.75rem);
@@ -80,8 +88,9 @@
80
88
 
81
89
  .s-about-modal-description {
82
90
  margin: 0;
83
- font-size: var(--text-sm, 0.8125rem);
84
- line-height: 1.55;
91
+ font-size: var(--text-xs, 0.75rem);
92
+ line-height: 1.6;
93
+ text-align: center;
85
94
  color: rgb(var(--color-muted));
86
95
  }
87
96
 
@@ -127,6 +136,7 @@
127
136
  .s-about-modal-links-list {
128
137
  display: flex;
129
138
  flex-wrap: wrap;
139
+ justify-content: center;
130
140
  gap: var(--space-8, 0.5rem);
131
141
  }
132
142
 
@@ -154,7 +164,25 @@
154
164
  0 0 0 4px rgb(var(--color-primary));
155
165
  }
156
166
 
167
+ .s-about-modal-footer-links {
168
+ display: flex;
169
+ flex-wrap: wrap;
170
+ justify-content: center;
171
+ gap: var(--space-12, 0.75rem);
172
+ }
173
+
174
+ .s-about-modal-footer-link {
175
+ font-size: var(--text-xs, 0.75rem);
176
+ color: rgb(var(--color-muted));
177
+ text-decoration: none;
178
+ }
179
+
180
+ .s-about-modal-footer-link:hover {
181
+ color: rgb(var(--color-primary));
182
+ text-decoration: underline;
183
+ }
184
+
157
185
  .s-about-modal-footer {
158
186
  display: flex;
159
- justify-content: flex-end;
187
+ justify-content: center;
160
188
  }
@@ -94,15 +94,18 @@ describe("HkAboutModal branding", () => {
94
94
  expect(query<HTMLElement>(".s-about-modal-description").textContent).toBe("Save the world.");
95
95
  });
96
96
 
97
- it("renders author and license rows with an linked author", async () => {
97
+ it("renders the made-by row with a linked origin", async () => {
98
98
  mountAbout({
99
- author: "Celestia Island",
100
- authorHref: "https://github.com/celestia-island",
99
+ author: "由 伊欧 主创",
100
+ origin: "来自 Celestia Island",
101
+ originHref: "https://github.com/celestia-island",
101
102
  license: "BUSL-1.1",
102
103
  });
103
104
  await flushModal();
105
+ const row = query<HTMLElement>(".s-about-modal-row");
106
+ expect(row.querySelector(".s-about-modal-row-label")?.textContent).toBe("由 伊欧 主创");
104
107
  const link = query<HTMLAnchorElement>(".s-about-modal-row-link");
105
- expect(link.textContent).toBe("Celestia Island");
108
+ expect(link.textContent).toBe("来自 Celestia Island");
106
109
  expect(link.getAttribute("href")).toBe("https://github.com/celestia-island");
107
110
  expect(link.getAttribute("target")).toBe("_blank");
108
111
  const rows = [...document.body.querySelectorAll<HTMLElement>(".s-about-modal-row")];
@@ -111,13 +114,28 @@ describe("HkAboutModal branding", () => {
111
114
  expect(licenseRow!.textContent).toContain("License");
112
115
  });
113
116
 
114
- it("renders the author row as plain text without authorHref", async () => {
115
- mountAbout({ author: "Celestia Island" });
117
+ it("renders the made-by row as plain text without originHref", async () => {
118
+ mountAbout({ author: "由 伊欧 主创", origin: "来自 Celestia Island" });
116
119
  await flushModal();
117
- expect(query<HTMLElement>(".s-about-modal-row").textContent).toContain("Celestia Island");
120
+ expect(query<HTMLElement>(".s-about-modal-row").textContent).toContain("来自 Celestia Island");
118
121
  expect(document.body.querySelector(".s-about-modal-row-link")).toBeNull();
119
122
  });
120
123
 
124
+ it("renders the centered slogan and centered legal footer links", async () => {
125
+ mountAbout({
126
+ slogan: "技术宅拯救世界。",
127
+ footerLinks: [{ label: "京ICP备2026xxxx号", href: "https://beian.miit.gov.cn/" }],
128
+ });
129
+ await flushModal();
130
+ expect(query<HTMLElement>(".s-about-modal-slogan").textContent).toBe("技术宅拯救世界。");
131
+ const links = [
132
+ ...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-footer-link"),
133
+ ];
134
+ expect(links.length).toBe(1);
135
+ expect(links[0]!.textContent).toBe("京ICP备2026xxxx号");
136
+ expect(links[0]!.getAttribute("href")).toBe("https://beian.miit.gov.cn/");
137
+ });
138
+
121
139
  it("renders the backdrop factory inside the clipped layer", async () => {
122
140
  mountAbout({ backdrop: () => h("div", { class: "test-backdrop-marker" }) });
123
141
  await flushModal();
@@ -159,23 +177,19 @@ describe("HkAboutModal branding", () => {
159
177
  expect(badge.textContent).toContain(`© ${year} Test App`);
160
178
  });
161
179
 
162
- it("localizes the author and license labels", async () => {
163
- mountAbout({ author: "Celestia Island", license: "BUSL-1.1" });
180
+ it("localizes the license label", async () => {
181
+ mountAbout({ license: "BUSL-1.1" });
164
182
  await flushModal();
165
- const rows = () => [
183
+ const labels = () => [
166
184
  ...document.body.querySelectorAll<HTMLElement>(".s-about-modal-row-label"),
167
185
  ];
168
186
 
169
187
  setLocale("zh-Hans");
170
188
  await flushModal();
171
- let text = rows().map((el) => el.textContent);
172
- expect(text.some((t) => t === "作者")).toBe(true);
173
- expect(text.some((t) => t === "许可证")).toBe(true);
189
+ expect(labels().some((el) => el.textContent === "许可证")).toBe(true);
174
190
 
175
191
  setLocale("en");
176
192
  await flushModal();
177
- text = rows().map((el) => el.textContent);
178
- expect(text.some((t) => t === "Author")).toBe(true);
179
- expect(text.some((t) => t === "License")).toBe(true);
193
+ expect(labels().some((el) => el.textContent === "License")).toBe(true);
180
194
  });
181
195
  });
@@ -15,12 +15,13 @@ export interface HAboutLink {
15
15
  * (Upstreamed from shittim-chest's plana-legacy layer.)
16
16
  *
17
17
  * Shows the app identity and version metadata plus optional branding: a
18
- * logo image, tagline, organization blurb, author / license rows, a
19
- * decorative backdrop layer (canvas or anything else, rendered behind the
20
- * content and pointer-inert) and external links. Version/build hashes
21
- * render as short hashes when longer than 12 chars (full value in `title`
22
- * tooltip). Every branding prop is optional — the modal degrades to the
23
- * plain identity + version card when none are given.
18
+ * logo image, tagline, centered slogan + organization line, a made-by /
19
+ * origin row, license row, a decorative backdrop layer (canvas or anything
20
+ * else, rendered behind the content and pointer-inert), external link
21
+ * chips and centered legal footer links (e.g. ICP filings). Version/build
22
+ * hashes render as short hashes when longer than 12 chars (full value in
23
+ * `title` tooltip). Every branding prop is optional — the modal degrades
24
+ * to the plain identity + version card when none are given.
24
25
  */
25
26
  export const HkAboutModal = defineComponent({
26
27
  name: "HkAboutModal",
@@ -40,18 +41,24 @@ export const HkAboutModal = defineComponent({
40
41
  logoSrc: { type: String, default: undefined },
41
42
  /** Optional one-liner under the version (e.g. the app tagline). */
42
43
  tagline: { type: String, default: undefined },
43
- /** Optional paragraph below the header (vision / organization blurb). */
44
+ /** Optional centered slogan below the header (the app's one-liner). */
45
+ slogan: { type: String, default: undefined },
46
+ /** Optional centered small line under the slogan (organization blurb). */
44
47
  description: { type: String, default: undefined },
45
- /** Optional author / organization row. */
48
+ /** Optional made-by row: left label (e.g. "由 伊欧 主创"). */
46
49
  author: { type: String, default: undefined },
47
- /** Optional link applied to the author value. */
48
- authorHref: { type: String, default: undefined },
50
+ /** Optional right value of the made-by row (e.g. "来自 Celestia Island"). */
51
+ origin: { type: String, default: undefined },
52
+ /** Optional link applied to the origin value. */
53
+ originHref: { type: String, default: undefined },
49
54
  /** Optional license identifier row (e.g. "BUSL-1.1"). */
50
55
  license: { type: String, default: undefined },
51
56
  /** Optional copyright holder in the footer (defaults to the app name). */
52
57
  copyright: { type: String, default: undefined },
53
58
  /** Optional external links (e.g. GitHub, docs). */
54
59
  links: { type: Array as PropType<HAboutLink[]>, default: () => [] },
60
+ /** Optional centered legal links above the copyright (ICP filings). */
61
+ footerLinks: { type: Array as PropType<HAboutLink[]>, default: () => [] },
55
62
  /**
56
63
  * Optional decorative backdrop factory, rendered behind the content
57
64
  * inside a clipped, pointer-inert layer. The modal owns only the layer —
@@ -70,16 +77,16 @@ export const HkAboutModal = defineComponent({
70
77
  return hash.length > 12 ? `${hash.slice(0, 12)}…` : hash;
71
78
  }
72
79
 
73
- const renderAuthorValue = () => {
74
- if (!props.authorHref) return props.author;
80
+ const renderOriginValue = () => {
81
+ if (!props.originHref) return props.origin;
75
82
  return (
76
83
  <a
77
84
  class="s-about-modal-row-link"
78
- href={props.authorHref}
85
+ href={props.originHref}
79
86
  target="_blank"
80
87
  rel="noopener noreferrer"
81
88
  >
82
- {props.author}
89
+ {props.origin}
83
90
  </a>
84
91
  );
85
92
  };
@@ -113,13 +120,14 @@ export const HkAboutModal = defineComponent({
113
120
  </div>
114
121
  </header>
115
122
 
123
+ {props.slogan && <p class="s-about-modal-slogan">{props.slogan}</p>}
116
124
  {props.description && <p class="s-about-modal-description">{props.description}</p>}
117
125
 
118
126
  <div class="s-about-modal-rows">
119
- {props.author && (
127
+ {(props.author || props.origin) && (
120
128
  <div class="s-about-modal-row">
121
- <span class="s-about-modal-row-label">{t("hikari::about.author", "Author")}</span>
122
- <span class="s-about-modal-row-value">{renderAuthorValue()}</span>
129
+ <span class="s-about-modal-row-label">{props.author}</span>
130
+ <span class="s-about-modal-row-value">{renderOriginValue()}</span>
123
131
  </div>
124
132
  )}
125
133
  {props.license && (
@@ -176,6 +184,22 @@ export const HkAboutModal = defineComponent({
176
184
  </div>
177
185
  )}
178
186
 
187
+ {props.footerLinks.length > 0 && (
188
+ <div class="s-about-modal-footer-links">
189
+ {props.footerLinks.map((link) => (
190
+ <a
191
+ key={link.href}
192
+ href={link.href}
193
+ target="_blank"
194
+ rel="noopener noreferrer"
195
+ class="s-about-modal-footer-link"
196
+ >
197
+ {link.label}
198
+ </a>
199
+ ))}
200
+ </div>
201
+ )}
202
+
179
203
  <footer class="s-about-modal-footer">
180
204
  <HBadge variant="muted">
181
205
  © {new Date().getFullYear()} {props.copyright ?? props.appName}
@@ -0,0 +1,123 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+
6
+ /**
7
+ * HkModal footerLead slot contract tests:
8
+ * - the named slot renders inside .hk-modal-footer BEFORE the action buttons,
9
+ * wrapped in .hk-modal-footer-lead, with the band marked .hk-modal-footer--lead
10
+ * - the band still renders with ONLY a footerLead (no footerActions) — a lead
11
+ * alone must not vanish with the actions
12
+ * - absent the slot, no new DOM appears (backward compat)
13
+ * - the explicit `footer` slot keeps precedence over footerLead + footerActions
14
+ * (existing full-custom contract unchanged)
15
+ */
16
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
17
+
18
+ afterEach(() => {
19
+ for (const { app, container } of mounts.splice(0)) {
20
+ app.unmount();
21
+ container.remove();
22
+ }
23
+ });
24
+
25
+ interface Harness {
26
+ container: HTMLElement;
27
+ footer: HTMLElement | null;
28
+ }
29
+
30
+ async function mountModal(
31
+ props: Record<string, unknown>,
32
+ slots: Record<string, unknown> = {},
33
+ ): Promise<Harness> {
34
+ const container = document.createElement("div");
35
+ document.body.appendChild(container);
36
+ const open = ref(true);
37
+ const Wrapper = defineComponent({
38
+ setup() {
39
+ // A FRESH slots object per Wrapper render: updateSlots mutates the
40
+ // slots object it is handed (deleting keys absent from a later
41
+ // sibling mount's shape), and a shared literal would lose footerLead
42
+ // on re-render.
43
+ return () =>
44
+ h(HkModal, {
45
+ ...props,
46
+ modelValue: open.value,
47
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
48
+ }, { ...slots });
49
+ },
50
+ });
51
+ const app = createApp(Wrapper);
52
+ app.mount(container);
53
+ mounts.push({ app, container });
54
+ await nextTick();
55
+ await new Promise((resolve) => setTimeout(resolve, 0));
56
+ await nextTick();
57
+ // HkModal teleports to body and sibling test files in this worker can
58
+ // leave their own modals behind — scope to the NEWEST mounted footer
59
+ // instead of the document-first match.
60
+ const footers = document.querySelectorAll(".hk-modal-footer");
61
+ return {
62
+ container,
63
+ footer: footers.length ? (footers[footers.length - 1] as HTMLElement) : null,
64
+ };
65
+ }
66
+
67
+ describe("HkModal footerLead slot", () => {
68
+ it("renders the lead before the action buttons inside the band", async () => {
69
+ const { footer } = await mountModal(
70
+ {
71
+ title: "Config",
72
+ footerActions: [{ label: "Add", variant: "primary" }],
73
+ },
74
+ { footerLead: () => h("button", { class: "lead-jump", "data-test": "jump" }, "JUMP") },
75
+ );
76
+ expect(footer).not.toBeNull();
77
+ expect(footer?.classList.contains("hk-modal-footer--lead")).toBe(true);
78
+ const lead = footer?.querySelector(".hk-modal-footer-lead");
79
+ expect(lead).not.toBeNull();
80
+ expect(lead?.querySelector(".lead-jump")?.textContent).toBe("JUMP");
81
+
82
+ const children = footer ? Array.from(footer.children) : [];
83
+ expect(children[0]?.classList.contains("hk-modal-footer-lead")).toBe(true);
84
+ expect(children.length).toBe(2);
85
+ expect(children[1]?.textContent).toBe("Add");
86
+ });
87
+
88
+ it("keeps the band alive with ONLY a footerLead and no actions", async () => {
89
+ const { footer } = await mountModal(
90
+ { title: "Config" },
91
+ { footerLead: () => h("button", { class: "lead-jump" }, "JUMP") },
92
+ );
93
+ expect(footer).not.toBeNull();
94
+ expect(footer?.classList.contains("hk-modal-footer--lead")).toBe(true);
95
+ expect(footer?.querySelector(".hk-modal-footer-lead")).not.toBeNull();
96
+ // No action buttons — the only children are the lead wrapper.
97
+ const children = footer ? Array.from(footer.children) : [];
98
+ expect(children).toHaveLength(1);
99
+ });
100
+
101
+ it("adds no lead DOM and no band when both lead and actions are absent", async () => {
102
+ const { footer } = await mountModal({ title: "Config" });
103
+ expect(footer).toBeNull();
104
+ });
105
+
106
+ it("the explicit footer slot keeps precedence over footerLead and actions", async () => {
107
+ const { footer } = await mountModal(
108
+ {
109
+ title: "Config",
110
+ footerActions: [{ label: "Add" }],
111
+ },
112
+ {
113
+ footer: () => h("div", { class: "custom-footer" }, "CUSTOM"),
114
+ footerLead: () => h("button", { class: "lead-jump" }, "JUMP"),
115
+ },
116
+ );
117
+ expect(footer).not.toBeNull();
118
+ expect(footer?.classList.contains("hk-modal-footer--lead")).toBe(false);
119
+ expect(footer?.querySelector(".custom-footer")?.textContent).toBe("CUSTOM");
120
+ expect(footer?.querySelector(".hk-modal-footer-lead")).toBeNull();
121
+ expect(footer?.textContent).not.toContain("Add");
122
+ });
123
+ });
@@ -298,6 +298,21 @@
298
298
  }
299
299
  }
300
300
 
301
+ // Lead content (e.g. a ghost jump link into the admin console) rendered at
302
+ // the footer's inline-start with the action buttons still pinned at
303
+ // inline-end — the footer counterpart of .hk-modal-header-lead. The lead's
304
+ // auto inline-end margin absorbs the slack (same distribution trick the
305
+ // header uses), so the layout mirrors correctly in RTL without a
306
+ // space-between override.
307
+ .hk-modal-footer--lead {
308
+ .hk-modal-footer-lead {
309
+ display: flex;
310
+ align-items: center;
311
+ flex-shrink: 0;
312
+ margin-inline-end: auto;
313
+ }
314
+ }
315
+
301
316
  // ------
302
317
  // Responsive — full-screen on narrow viewports
303
318
  // ------
@@ -695,10 +695,13 @@ export default defineComponent({
695
695
  if (slots.footer) {
696
696
  return <div class="hk-modal-footer">{slots.footer()}</div>;
697
697
  }
698
- if (props.footerActions && props.footerActions.length > 0) {
698
+ const hasLead = Boolean(slots.footerLead);
699
+ const hasActions = Boolean(props.footerActions && props.footerActions.length > 0);
700
+ if (hasLead || hasActions) {
699
701
  return (
700
- <div class="hk-modal-footer">
701
- {props.footerActions.map((action, i) => (
702
+ <div class={["hk-modal-footer", hasLead ? "hk-modal-footer--lead" : ""]}>
703
+ {hasLead && <div class="hk-modal-footer-lead">{slots.footerLead?.()}</div>}
704
+ {props.footerActions?.map((action, i) => (
702
705
  <HButton
703
706
  key={i}
704
707
  variant={action.variant ?? "secondary"}
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "البناء",
18
18
  "hikari::about.engineVersion": "إصدار المحرك",
19
19
  "hikari::about.engineBuildHash": "بناء المحرك",
20
- "hikari::about.author": "المؤلف",
21
20
  "hikari::about.license": "الترخيص",
22
21
  "hikari::theme.mode": "وضع السمة",
23
22
  "hikari::theme.modeLight": "فاتح",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "Build",
18
18
  "hikari::about.engineVersion": "Engine-Version",
19
19
  "hikari::about.engineBuildHash": "Engine-Build",
20
- "hikari::about.author": "Autor",
21
20
  "hikari::about.license": "Lizenz",
22
21
  "hikari::theme.mode": "Design-Modus",
23
22
  "hikari::theme.modeLight": "Hell",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "Build",
18
18
  "hikari::about.engineVersion": "Engine version",
19
19
  "hikari::about.engineBuildHash": "Engine build",
20
- "hikari::about.author": "Author",
21
20
  "hikari::about.license": "License",
22
21
  "hikari::theme.mode": "Theme mode",
23
22
  "hikari::theme.modeLight": "Light",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "Compilación",
18
18
  "hikari::about.engineVersion": "Versión del motor",
19
19
  "hikari::about.engineBuildHash": "Compilación del motor",
20
- "hikari::about.author": "Autor",
21
20
  "hikari::about.license": "Licencia",
22
21
  "hikari::theme.mode": "Modo de tema",
23
22
  "hikari::theme.modeLight": "Claro",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "Build",
18
18
  "hikari::about.engineVersion": "Version du moteur",
19
19
  "hikari::about.engineBuildHash": "Build du moteur",
20
- "hikari::about.author": "Auteur",
21
20
  "hikari::about.license": "Licence",
22
21
  "hikari::theme.mode": "Mode du thème",
23
22
  "hikari::theme.modeLight": "Clair",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "ビルド",
18
18
  "hikari::about.engineVersion": "エンジンバージョン",
19
19
  "hikari::about.engineBuildHash": "エンジンビルド",
20
- "hikari::about.author": "作者",
21
20
  "hikari::about.license": "ライセンス",
22
21
  "hikari::theme.mode": "テーマモード",
23
22
  "hikari::theme.modeLight": "ライト",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "빌드",
18
18
  "hikari::about.engineVersion": "엔진 버전",
19
19
  "hikari::about.engineBuildHash": "엔진 빌드",
20
- "hikari::about.author": "제작자",
21
20
  "hikari::about.license": "라이선스",
22
21
  "hikari::theme.mode": "테마 모드",
23
22
  "hikari::theme.modeLight": "라이트",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "Build",
18
18
  "hikari::about.engineVersion": "Versão do motor",
19
19
  "hikari::about.engineBuildHash": "Build do motor",
20
- "hikari::about.author": "Autor",
21
20
  "hikari::about.license": "Licença",
22
21
  "hikari::theme.mode": "Modo do tema",
23
22
  "hikari::theme.modeLight": "Claro",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "Сборка",
18
18
  "hikari::about.engineVersion": "Версия движка",
19
19
  "hikari::about.engineBuildHash": "Сборка движка",
20
- "hikari::about.author": "Автор",
21
20
  "hikari::about.license": "Лицензия",
22
21
  "hikari::theme.mode": "Режим темы",
23
22
  "hikari::theme.modeLight": "Светлая",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "构建",
18
18
  "hikari::about.engineVersion": "引擎版本",
19
19
  "hikari::about.engineBuildHash": "引擎构建",
20
- "hikari::about.author": "作者",
21
20
  "hikari::about.license": "许可证",
22
21
  "hikari::theme.mode": "主题模式",
23
22
  "hikari::theme.modeLight": "浅色",
@@ -17,7 +17,6 @@
17
17
  "hikari::about.buildHash": "建置",
18
18
  "hikari::about.engineVersion": "引擎版本",
19
19
  "hikari::about.engineBuildHash": "引擎建置",
20
- "hikari::about.author": "作者",
21
20
  "hikari::about.license": "授權條款",
22
21
  "hikari::theme.mode": "主題模式",
23
22
  "hikari::theme.modeLight": "淺色",