@celestia-island/hikari 0.55.225 → 0.55.227

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.55.225",
3
+ "version": "0.55.227",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -0,0 +1,124 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick } from "vue";
3
+
4
+ import { HkAboutBody } from "./HkAboutModal";
5
+
6
+ /**
7
+ * HkAboutBody contract tests for the extraction wave (2026-10-29): the
8
+ * body is the modal's whole layout mountable OUTSIDE a modal — the desktop
9
+ * settings' About pane renders it directly, and a second hand-copied body
10
+ * is exactly how the credits links drifted. These tests pin the two
11
+ * body-level knobs the modal never needed:
12
+ * - `filingsAsChips` renders the legal filings as their own chip group
13
+ * (`data-slot="filings"`) and NO bare footer-links row
14
+ * - default (modal) behavior keeps the bare footer-links row and no filings
15
+ * group
16
+ * - the default slot appends host content after the copyright footer
17
+ * - without a backdrop factory no backdrop layer renders (a body host may
18
+ * not want the decorative canvas at all)
19
+ *
20
+ * (Repo test convention: raw createApp + document queries, no
21
+ * @vue/test-utils dependency.)
22
+ */
23
+
24
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
25
+
26
+ function mountBody(props: Record<string, unknown> = {}, slot?: () => unknown): void {
27
+ const container = document.createElement("div");
28
+ document.body.appendChild(container);
29
+
30
+ const Wrapper = defineComponent({
31
+ setup() {
32
+ return () =>
33
+ h(HkAboutBody, { appName: "Test App", version: "1.2.3", ...props }, slot ? { default: slot } : undefined);
34
+ },
35
+ });
36
+ const app = createApp(Wrapper);
37
+ app.mount(container);
38
+ mounts.push({ app, container });
39
+ }
40
+
41
+ function query<T extends Element>(selector: string): T {
42
+ const el = document.body.querySelector<T>(selector);
43
+ expect(el, `${selector} renders`).toBeTruthy();
44
+ return el!;
45
+ }
46
+
47
+ async function flush() {
48
+ await nextTick();
49
+ await nextTick();
50
+ }
51
+
52
+ afterEach(() => {
53
+ for (const { app, container } of mounts.splice(0)) {
54
+ app.unmount();
55
+ container.remove();
56
+ }
57
+ });
58
+
59
+ const FILINGS = [
60
+ { label: "苏ICP备2025155733号-2", href: "https://beian.miit.gov.cn/", face: "chip" as const },
61
+ ];
62
+
63
+ describe("HkAboutBody extraction", () => {
64
+ it("mounts the full body outside a modal", async () => {
65
+ mountBody({
66
+ tagline: "Chat with your AI agents",
67
+ credits: [
68
+ { text: "来自 " },
69
+ { name: "Celestia Island", href: "https://celestia.world", face: "accent" },
70
+ { text: ",由 " },
71
+ { name: "伊欧", href: "https://github.com/langyo", face: "accent" },
72
+ { text: " 主创" },
73
+ ],
74
+ componentVersions: [{ label: "WebUI 版本", value: "0.1.0 master:abc1234" }],
75
+ });
76
+ await flush();
77
+
78
+ expect(query(".s-about-modal")).toBeTruthy();
79
+ // No HModal shell around it: no dialog surface, no overlay.
80
+ expect(document.body.querySelector(".hk-modal")).toBeNull();
81
+ expect(query(".s-about-modal-name").textContent).toContain("Test App");
82
+ expect(document.body.querySelectorAll(".s-about-modal-credit-link")).toHaveLength(2);
83
+ expect(query(".s-about-modal-row-number").textContent).toContain("master:abc1234");
84
+ });
85
+
86
+ it("filingsAsChips renders the filings group and no footer-links row", async () => {
87
+ mountBody({ footerLinks: FILINGS, filingsAsChips: true });
88
+ await flush();
89
+
90
+ const group = query('.s-about-modal-links[data-slot="filings"]');
91
+ const link = group.querySelector<HTMLAnchorElement>(".s-about-modal-link");
92
+ expect(link?.getAttribute("data-face")).toBe("chip");
93
+ expect(link?.getAttribute("href")).toBe("https://beian.miit.gov.cn/");
94
+ // The bare row is the modal face; chips mode must not render both.
95
+ expect(document.body.querySelector(".s-about-modal-footer-links")).toBeNull();
96
+ });
97
+
98
+ it("default filings keep the bare footer-links row with no group", async () => {
99
+ mountBody({ footerLinks: [{ label: "ICP", href: "https://beian.miit.gov.cn/" }] });
100
+ await flush();
101
+
102
+ expect(query(".s-about-modal-footer-links")).toBeTruthy();
103
+ expect(document.body.querySelector('.s-about-modal-links[data-slot="filings"]')).toBeNull();
104
+ });
105
+
106
+ it("appends default-slot content after the copyright footer", async () => {
107
+ mountBody({}, () => h("button", { class: "body-slot-row" }, "Check update"));
108
+ await flush();
109
+
110
+ const body = query(".s-about-modal-body");
111
+ const footer = body.querySelector(".s-about-modal-footer")!;
112
+ const button = body.querySelector(".body-slot-row")!;
113
+ expect(button).toBeTruthy();
114
+ // After the footer in document order: the slot rides at the body's end.
115
+ expect(footer.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
116
+ });
117
+
118
+ it("renders no backdrop layer when the host passes no factory", async () => {
119
+ mountBody();
120
+ await flush();
121
+
122
+ expect(document.body.querySelector(".s-about-modal-backdrop")).toBeNull();
123
+ });
124
+ });
@@ -82,24 +82,27 @@ export interface HAboutComponentVersion {
82
82
  }
83
83
 
84
84
  /**
85
- * HkAboutModal — version / about dialog.
86
- * (Upstreamed from shittim-chest's plana-legacy layer.)
85
+ * HkAboutBody — the About dialog's body, mountable OUTSIDE a modal.
87
86
  *
88
- * Layout (2026-09 redesign): a centered identity hero (haloed logo, name,
89
- * then version + tagline on one compact line), an optional credits block
90
- * (a sentence built from text runs and linked names, plus the
91
- * organization blurb), a
92
- * bordered spec card holding the software-component versions, and the
93
- * link rows — licenses, external links and legal filings. Every link opens
94
- * in a new tab and renders in the face its entry asks for: the ghost chip,
95
- * or bare text (`plain` / tinted `accent`) for names / URLs / filings that
96
- * should read as ordinary sentence text. Every branding prop is optional — the modal
97
- * degrades to the plain identity card when none are given.
87
+ * Extracted verbatim from HkAboutModal (2026-10-29): the desktop settings'
88
+ * About section renders this same body inside its own pane, so every
89
+ * surface shares one implementation of the identity hero, the credits
90
+ * sentence, the spec card and the link rows — the hand-copied second body
91
+ * is exactly how the credits links drifted (both names pointing at the
92
+ * organization's URL, the trailing role word lost).
93
+ *
94
+ * Two body-level knobs exist for hosts that are not a modal:
95
+ * - `filingsAsChips` renders the legal filings as a chip group
96
+ * (`data-slot="filings"`) instead of the bare footer-text row — the
97
+ * desktop surface's standing direction (2026-10-23: no bare text
98
+ * hyperlinks on that surface) while the modal keeps the plain row
99
+ * (2026-09-14).
100
+ * - the default slot appends host content after the copyright footer
101
+ * (the desktop's update-check row), still inside the body's flow.
98
102
  */
99
- export const HkAboutModal = defineComponent({
100
- name: "HkAboutModal",
103
+ export const HkAboutBody = defineComponent({
104
+ name: "HkAboutBody",
101
105
  props: {
102
- modelValue: { type: Boolean, default: false },
103
106
  /** Application display name. */
104
107
  appName: { type: String, required: true },
105
108
  /** Application version (e.g. "0.1.4"). */
@@ -134,18 +137,17 @@ export const HkAboutModal = defineComponent({
134
137
  links: { type: Array as PropType<HAboutLink[]>, default: () => [] },
135
138
  /** Optional centered legal links above the copyright (ICP filings). */
136
139
  footerLinks: { type: Array as PropType<HAboutLink[]>, default: () => [] },
140
+ /** Render the legal filings as a chip group instead of the bare
141
+ * footer-text row (the desktop surface's standing direction). */
142
+ filingsAsChips: { type: Boolean, default: false },
137
143
  /**
138
144
  * Optional decorative backdrop factory, rendered behind the content
139
- * inside a clipped, pointer-inert layer. The modal owns only the layer —
145
+ * inside a clipped, pointer-inert layer. The body owns only the layer —
140
146
  * sizing, theming and the render loop belong to the returned subtree.
141
147
  */
142
148
  backdrop: { type: Function as PropType<() => unknown>, default: undefined },
143
- title: { type: String, default: undefined },
144
- },
145
- emits: {
146
- "update:modelValue": (_v: boolean) => true,
147
149
  },
148
- setup(props, { emit }) {
150
+ setup(props, { slots }) {
149
151
  const { t } = useI18n();
150
152
 
151
153
  // One link renderer for every link in the dialog: credits names,
@@ -233,102 +235,195 @@ export const HkAboutModal = defineComponent({
233
235
  };
234
236
 
235
237
  return () => (
236
- <HModal
237
- modelValue={props.modelValue}
238
- onUpdate:modelValue={(v: boolean) => emit("update:modelValue", v)}
239
- title={props.title ?? t("hikari::about.title", "About")}
240
- width="30rem"
241
- >
242
- <div class="s-about-modal">
243
- {props.backdrop && (
244
- <div class="s-about-modal-backdrop" aria-hidden="true">
245
- {props.backdrop()}
238
+ <div class="s-about-modal">
239
+ {props.backdrop && (
240
+ <div class="s-about-modal-backdrop" aria-hidden="true">
241
+ {props.backdrop()}
242
+ </div>
243
+ )}
244
+ <div class="s-about-modal-body">
245
+ <header class="s-about-modal-header">
246
+ <div class="s-about-modal-logo-frame">
247
+ {props.logoSrc ? (
248
+ <img
249
+ class="s-about-modal-logo-img"
250
+ src={props.logoSrc}
251
+ alt=""
252
+ draggable={false}
253
+ />
254
+ ) : (
255
+ <div class="s-about-modal-logo">{props.appName.slice(0, 1).toUpperCase()}</div>
256
+ )}
246
257
  </div>
247
- )}
248
- <div class="s-about-modal-body">
249
- <header class="s-about-modal-header">
250
- <div class="s-about-modal-logo-frame">
251
- {props.logoSrc ? (
252
- <img
253
- class="s-about-modal-logo-img"
254
- src={props.logoSrc}
255
- alt=""
256
- draggable={false}
257
- />
258
- ) : (
259
- <div class="s-about-modal-logo">{props.appName.slice(0, 1).toUpperCase()}</div>
258
+ <div class="s-about-modal-identity">
259
+ <h2 class="s-about-modal-name">{props.appName}</h2>
260
+ <p class="s-about-modal-subtitle">
261
+ <span class="s-about-modal-version">
262
+ {t("hikari::about.version", "Version")} {props.version}
263
+ </span>
264
+ {props.tagline && (
265
+ <>
266
+ <span class="s-about-modal-subtitle-sep" aria-hidden="true">
267
+ ·
268
+ </span>
269
+ <span class="s-about-modal-tagline">{props.tagline}</span>
270
+ </>
260
271
  )}
261
- </div>
262
- <div class="s-about-modal-identity">
263
- <h2 class="s-about-modal-name">{props.appName}</h2>
264
- <p class="s-about-modal-subtitle">
265
- <span class="s-about-modal-version">
266
- {t("hikari::about.version", "Version")} {props.version}
267
- </span>
268
- {props.tagline && (
269
- <>
270
- <span class="s-about-modal-subtitle-sep" aria-hidden="true">
271
- ·
272
- </span>
273
- <span class="s-about-modal-tagline">{props.tagline}</span>
274
- </>
275
- )}
276
- </p>
277
- </div>
278
- </header>
272
+ </p>
273
+ </div>
274
+ </header>
279
275
 
280
- {(props.credits.length > 0 || props.description) && (
281
- <div class="s-about-modal-credits">
282
- {renderCredits()}
283
- {props.description && (
284
- <p class="s-about-modal-description">{props.description}</p>
285
- )}
286
- </div>
287
- )}
276
+ {(props.credits.length > 0 || props.description) && (
277
+ <div class="s-about-modal-credits">
278
+ {renderCredits()}
279
+ {props.description && (
280
+ <p class="s-about-modal-description">{props.description}</p>
281
+ )}
282
+ </div>
283
+ )}
288
284
 
289
- {props.componentVersions.length > 0 && (
290
- <div class="s-about-modal-rows">
291
- {props.componentVersions.map((component) => (
292
- <div class="s-about-modal-row" key={component.label}>
293
- <span class="s-about-modal-row-label">{component.label}</span>
294
- <span class="s-about-modal-row-value">
295
- <span class="s-about-modal-row-number">{component.value}</span>
296
- {component.meta && (
297
- <span
298
- class="s-about-modal-row-meta"
299
- data-tone={component.metaTone ?? "neutral"}
300
- >
301
- {component.meta}
302
- </span>
303
- )}
304
- </span>
305
- </div>
306
- ))}
307
- </div>
308
- )}
285
+ {props.componentVersions.length > 0 && (
286
+ <div class="s-about-modal-rows">
287
+ {props.componentVersions.map((component) => (
288
+ <div class="s-about-modal-row" key={component.label}>
289
+ <span class="s-about-modal-row-label">{component.label}</span>
290
+ <span class="s-about-modal-row-value">
291
+ <span class="s-about-modal-row-number">{component.value}</span>
292
+ {component.meta && (
293
+ <span
294
+ class="s-about-modal-row-meta"
295
+ data-tone={component.metaTone ?? "neutral"}
296
+ >
297
+ {component.meta}
298
+ </span>
299
+ )}
300
+ </span>
301
+ </div>
302
+ ))}
303
+ </div>
304
+ )}
309
305
 
310
- {(props.licenses.length > 0 || props.links.length > 0) && (
311
- <div class="s-about-modal-chips">
312
- {renderLinks(props.licenses, "licenses")}
313
- {renderLinks(props.links, "links")}
314
- </div>
315
- )}
306
+ {(props.licenses.length > 0 || props.links.length > 0) && (
307
+ <div class="s-about-modal-chips">
308
+ {renderLinks(props.licenses, "licenses")}
309
+ {renderLinks(props.links, "links")}
310
+ </div>
311
+ )}
316
312
 
317
- {visibleLinks(props.footerLinks).length > 0 && (
318
- <div class="s-about-modal-footer-links">
319
- {visibleLinks(props.footerLinks).map((link, index) =>
320
- renderLink(link, "s-about-modal-footer-link", `legal:${index}`),
321
- )}
322
- </div>
323
- )}
313
+ {props.filingsAsChips
314
+ ? // The desktop surface's filings: chips in their own group,
315
+ // in the body flow (2026-10-23 direction).
316
+ renderLinks(props.footerLinks, "filings")
317
+ : visibleLinks(props.footerLinks).length > 0 && (
318
+ <div class="s-about-modal-footer-links">
319
+ {visibleLinks(props.footerLinks).map((link, index) =>
320
+ renderLink(link, "s-about-modal-footer-link", `legal:${index}`),
321
+ )}
322
+ </div>
323
+ )}
324
324
 
325
- <footer class="s-about-modal-footer">
326
- <HBadge variant="muted">
327
- © {new Date().getFullYear()} {props.copyright ?? props.appName}
328
- </HBadge>
329
- </footer>
330
- </div>
325
+ <footer class="s-about-modal-footer">
326
+ <HBadge variant="muted">
327
+ © {new Date().getFullYear()} {props.copyright ?? props.appName}
328
+ </HBadge>
329
+ </footer>
330
+
331
+ {slots.default?.()}
331
332
  </div>
333
+ </div>
334
+ );
335
+ },
336
+ });
337
+
338
+ /**
339
+ * HkAboutModal — version / about dialog.
340
+ * (Upstreamed from shittim-chest's plana-legacy layer.)
341
+ *
342
+ * A centered HModal wrapping {@link HkAboutBody} — the body carries the
343
+ * whole layout (identity hero, credits block, spec card, link rows) and is
344
+ * the piece other surfaces mount directly, so the modal and the settings
345
+ * pane cannot drift. Every link opens in a new tab and renders in the face
346
+ * its entry asks for: the ghost chip, or bare text (`plain` / tinted
347
+ * `accent`) for names / URLs / filings that should read as ordinary
348
+ * sentence text. Every branding prop is optional — the modal degrades to
349
+ * the plain identity card when none are given.
350
+ */
351
+ export const HkAboutModal = defineComponent({
352
+ name: "HkAboutModal",
353
+ props: {
354
+ modelValue: { type: Boolean, default: false },
355
+ /** Application display name. */
356
+ appName: { type: String, required: true },
357
+ /** Application version (e.g. "0.1.4"). */
358
+ version: { type: String, required: true },
359
+ /** Optional logo image URL — replaces the first-letter tile. */
360
+ logoSrc: { type: String, default: undefined },
361
+ /** Optional one-liner shown beside the version (e.g. the app tagline). */
362
+ tagline: { type: String, default: undefined },
363
+ /** Optional centered small line under the credits (organization blurb). */
364
+ description: { type: String, default: undefined },
365
+ /**
366
+ * Credits sentence, assembled from text runs and linked names
367
+ * (e.g. 来自 <Celestia Island>,由 <伊欧> 主创). Each name renders in the
368
+ * face its entry asks for — `chip`, bare text with `plain`, or bare
369
+ * text tinted in the primary colour with `accent`.
370
+ */
371
+ credits: { type: Array as PropType<HAboutCredit[]>, default: () => [] },
372
+ /** License links (e.g. SySL-1.0 / BUSL-1.1), rendered centered. */
373
+ licenses: { type: Array as PropType<HAboutLink[]>, default: () => [] },
374
+ /** Software-component version rows (WebUI / engines), label + value. */
375
+ componentVersions: {
376
+ type: Array as PropType<HAboutComponentVersion[]>,
377
+ default: () => [],
378
+ },
379
+ /** Optional copyright holder in the footer (defaults to the app name). */
380
+ copyright: { type: String, default: undefined },
381
+ /**
382
+ * Optional external links (e.g. the site domains, the project's GitHub
383
+ * home). An entry may carry an `icon` instead of a `label` — that is how
384
+ * the GitHub mark rides at the end of the domain row.
385
+ */
386
+ links: { type: Array as PropType<HAboutLink[]>, default: () => [] },
387
+ /** Optional centered legal links above the copyright (ICP filings). */
388
+ footerLinks: { type: Array as PropType<HAboutLink[]>, default: () => [] },
389
+ /** Render the legal filings as a chip group (see {@link HkAboutBody}). */
390
+ filingsAsChips: { type: Boolean, default: false },
391
+ /**
392
+ * Optional decorative backdrop factory, rendered behind the content
393
+ * inside a clipped, pointer-inert layer. The modal owns only the layer —
394
+ * sizing, theming and the render loop belong to the returned subtree.
395
+ */
396
+ backdrop: { type: Function as PropType<() => unknown>, default: undefined },
397
+ title: { type: String, default: undefined },
398
+ },
399
+ emits: {
400
+ "update:modelValue": (_v: boolean) => true,
401
+ },
402
+ setup(props, { emit }) {
403
+ const { t } = useI18n();
404
+
405
+ return () => (
406
+ <HModal
407
+ modelValue={props.modelValue}
408
+ onUpdate:modelValue={(v: boolean) => emit("update:modelValue", v)}
409
+ title={props.title ?? t("hikari::about.title", "About")}
410
+ width="30rem"
411
+ >
412
+ <HkAboutBody
413
+ appName={props.appName}
414
+ version={props.version}
415
+ logoSrc={props.logoSrc}
416
+ tagline={props.tagline}
417
+ description={props.description}
418
+ credits={props.credits}
419
+ licenses={props.licenses}
420
+ componentVersions={props.componentVersions}
421
+ copyright={props.copyright}
422
+ links={props.links}
423
+ footerLinks={props.footerLinks}
424
+ filingsAsChips={props.filingsAsChips}
425
+ backdrop={props.backdrop}
426
+ />
332
427
  </HModal>
333
428
  );
334
429
  },
@@ -31,6 +31,10 @@ afterEach(() => {
31
31
 
32
32
  const OPTIONS = [{ value: "a1", label: "Agent One" }];
33
33
 
34
+ const DESCRIBED_OPTIONS = [
35
+ { value: "a1", label: "Agent One", description: "Personal · /srv/one" },
36
+ ];
37
+
34
38
  describe("HkPopupSelect (mobile sheet)", () => {
35
39
  it("hideLabel keeps the sheet's breadcrumb title on the field's name", async () => {
36
40
  // hideLabel only suppresses the INLINE caption; the sheet header
@@ -47,4 +51,18 @@ describe("HkPopupSelect (mobile sheet)", () => {
47
51
  expect(title, "the sheet renders a title band").toBeTruthy();
48
52
  expect(title!.textContent).toBe("Agent");
49
53
  });
54
+
55
+ it("described options keep their second line inside the sheet", async () => {
56
+ const c = mount(
57
+ h(HkPopupSelect, { modelValue: "", options: DESCRIBED_OPTIONS }),
58
+ );
59
+ const trigger = c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!;
60
+ trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
61
+ await Promise.resolve();
62
+ await Promise.resolve();
63
+ const desc = document.body.querySelector(
64
+ ".hk-popover-panel .hk-popup-select-item-desc",
65
+ );
66
+ expect(desc?.textContent).toBe("Personal · /srv/one");
67
+ });
50
68
  });
@@ -149,6 +149,38 @@
149
149
  @include mi.item;
150
150
  }
151
151
 
152
+ /* Two-line option rows, rendered ONLY for options that carry a
153
+ * description — a stock row keeps the bare label span (wrap allowed, no
154
+ * optical shift) so existing consumers see byte-identical geometry (R2).
155
+ * The body owns the row's flex growth so both lines ellipsis against the
156
+ * same edge. */
157
+ .hk-popup-select-item-body {
158
+ flex: 1;
159
+ min-width: 0;
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: var(--space-2);
163
+ }
164
+
165
+ .hk-popup-select-item-label {
166
+ @include mi.label;
167
+ }
168
+
169
+ /* The secondary line inherits the row color (hover/checked tints flow
170
+ * through) and steps down via opacity + the caption size, so it stays
171
+ * clearly meta against both the plain and the primary checked face. */
172
+ .hk-popup-select-item-desc {
173
+ overflow: hidden;
174
+ text-overflow: ellipsis;
175
+ white-space: nowrap;
176
+ font-size: var(--text-xs);
177
+ font-weight: 400;
178
+ color: inherit;
179
+ opacity: 0.62;
180
+ /* Same italic-overhang clipping room the label mixin reserves. */
181
+ padding-inline-end: var(--hk-menu-item-label-cushion);
182
+ }
183
+
152
184
  /* Keyboard highlight face yields to checked/active; the hover face comes
153
185
  * from mi.item's own guarded :hover (identical values, higher specificity). */
154
186
  .hk-popup-select-item[data-highlighted]:not([data-checked]):not([data-active]) {
@@ -81,4 +81,51 @@ describe("HkPopupSelect", () => {
81
81
  const c = mount(h(HkPopupSelect, { modelValue: "a1", label: "Agent", options: OPTIONS }));
82
82
  expect(c.querySelector(".hk-popup-select-trigger")!.getAttribute("aria-label")).toBeNull();
83
83
  });
84
+
85
+ it("an option description renders its own muted line under the label", async () => {
86
+ const c = mount(
87
+ h(HkPopupSelect, {
88
+ modelValue: "",
89
+ options: [{ value: "a1", label: "Agent One", description: "Personal · /srv/one" }],
90
+ }),
91
+ );
92
+ // Open the popout — option rows live in the anchored panel, which
93
+ // mounts across a microtask (the mobile sheet tests await the same).
94
+ c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!
95
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
96
+ await Promise.resolve();
97
+ await Promise.resolve();
98
+ const item = document.body.querySelector(".hk-popup-select-item")!;
99
+ expect(item.textContent).toContain("Agent One");
100
+ const desc = item.querySelector(".hk-popup-select-item-desc");
101
+ expect(desc?.textContent).toBe("Personal · /srv/one");
102
+ // The label sits in its own span so the two lines never merge.
103
+ expect(item.querySelector(".hk-popup-select-item-label")?.textContent).toBe("Agent One");
104
+ });
105
+
106
+ it("no description → the exact stock DOM: bare label span, no body wrapper", async () => {
107
+ const c = mount(h(HkPopupSelect, { modelValue: "", options: OPTIONS }));
108
+ c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!
109
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
110
+ await Promise.resolve();
111
+ await Promise.resolve();
112
+ const item = document.body.querySelector(".hk-popup-select-item")!;
113
+ // R2 contract: description-less rows render the pre-description DOM —
114
+ // no two-line anatomy, so no mi.label ellipsis/optical spec applies.
115
+ expect(item.querySelector(".hk-popup-select-item-body")).toBeNull();
116
+ expect(item.querySelector(".hk-popup-select-item-label")).toBeNull();
117
+ expect(item.querySelector("span")?.textContent).toBe("Agent One");
118
+ });
119
+
120
+ it("the collapsed trigger shows the label only, never the description", () => {
121
+ const c = mount(
122
+ h(HkPopupSelect, {
123
+ modelValue: "a1",
124
+ options: [{ value: "a1", label: "Agent One", description: "secret-meta" }],
125
+ }),
126
+ );
127
+ const value = c.querySelector(".hk-popup-select-value")!;
128
+ expect(value.textContent).toBe("Agent One");
129
+ expect(value.textContent).not.toContain("secret-meta");
130
+ });
84
131
  });
@@ -9,6 +9,15 @@ import "./HkPopupSelect.scss";
9
9
  export interface HkPopupSelectOption {
10
10
  value: string;
11
11
  label: string;
12
+ /** Secondary line under the label, inside the same option row — a
13
+ * muted, single-line ellipsis strip for meta text (a row's owner,
14
+ * address, type) that must stay visible without leaking into the
15
+ * collapsed trigger, which keeps showing `label` alone. Absent → the
16
+ * row renders the EXACT stock DOM (bare label span, wrap allowed, no
17
+ * optical shift): the two-line anatomy exists only when there is a
18
+ * second line to show (R2: the mi.label ellipsis + 0.5px spec is a
19
+ * behavior change stock rows must not inherit silently). */
20
+ description?: string;
12
21
  }
13
22
 
14
23
  const activePopupId = ref<string | null>(null);
@@ -232,7 +241,16 @@ export default defineComponent({
232
241
  onClick={() => select(opt.value)}
233
242
  onPointerenter={() => { highlightedIndex.value = i; }}
234
243
  >
235
- <span>{opt.label}</span>
244
+ {opt.description ? (
245
+ <span class="hk-popup-select-item-body">
246
+ <span class="hk-popup-select-item-label">{opt.label}</span>
247
+ <span class="hk-popup-select-item-desc">{opt.description}</span>
248
+ </span>
249
+ ) : (
250
+ // Stock rows keep the exact pre-description DOM: no
251
+ // body wrapper, no mi.label spec — the R2 contract.
252
+ <span>{opt.label}</span>
253
+ )}
236
254
  </div>
237
255
  ))}
238
256
  </HkScrollContainer>
package/src/index.ts CHANGED
@@ -534,6 +534,7 @@ export { HkCaptchaModal as HCaptchaModal } from "./components/HkCaptchaModal";
534
534
  export { HkProtocolModal as HProtocolModal } from "./components/HkProtocolModal";
535
535
  export {
536
536
  HkAboutModal as HAboutModal,
537
+ HkAboutBody as HAboutBody,
537
538
  type HAboutComponentVersion,
538
539
  type HAboutCredit,
539
540
  type HAboutLink,
@@ -806,6 +807,7 @@ export { HkCaptchaWidget as HkCaptchaWidget } from "./components/HkCaptchaWidget
806
807
  export { HkCaptchaModal as HkCaptchaModal } from "./components/HkCaptchaModal";
807
808
  export { HkProtocolModal as HkProtocolModal } from "./components/HkProtocolModal";
808
809
  export { HkAboutModal as HkAboutModal } from "./components/HkAboutModal";
810
+ export { HkAboutBody as HkAboutBody } from "./components/HkAboutModal";
809
811
  export { HkLogWindow as HkLogWindow } from "./components/HkLogWindow";
810
812
  export { HkSettingsBody as HkSettingsBody } from "./components/HkSettingsDialog";
811
813
  export { HkSettingsDialog as HkSettingsDialog } from "./components/HkSettingsDialog";