@celestia-island/hikari 0.55.287 → 0.55.288

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.287",
3
+ "version": "0.55.288",
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",
@@ -1,11 +1,18 @@
1
1
  /**
2
- * HkCornerBadge — the generic corner mark (dot / count / label) pinned
3
- * to one corner of the default-slot anchor (see HkCornerBadge.tsx for
4
- * the contract). Geometry follows the house pattern the trigger dots
5
- * and the Rust family's .hk-badge-wrapper established: the anchor is a
6
- * relative inline-flex wrapper (the badge escapes nothing — hosts wrap
7
- * ANY child), the mark centers ON the corner via translate(50%,-50%)
8
- * with an optional --hk-corner-badge-offset clearance.
2
+ * HkCornerBadge — the generic anchored mark (dot / count / label) for the
3
+ * default-slot anchor (see HkCornerBadge.tsx for the contract). Geometry
4
+ * has TWO placement families, selected by `data-placement`:
5
+ *
6
+ * corners (bottom-right / …) OVERLAY the anchor: the anchor is a
7
+ * relative inline-flex wrapper (the badge escapes nothing), the mark
8
+ * centers ON the corner via translate(±50%) with an optional
9
+ * --hk-corner-badge-offset clearance — the subscript mark;
10
+ *
11
+ * sides (`right` / `left`) put the mark in the wrapper's FLOW beside the
12
+ * anchor, vertically centered, so the anchor's own box (and text) is
13
+ * never covered. The wrapper is already a row; the mark spaces ITSELF
14
+ * (margin = --hk-corner-badge-gap + --hk-corner-badge-offset, default
15
+ * 6px + 0), so a multi-child anchor's own layout is never touched.
9
16
  */
10
17
 
11
18
  .hk-corner-badge-anchor {
@@ -15,41 +22,70 @@
15
22
 
16
23
  .hk-corner-badge {
17
24
  position: absolute;
18
- /* Centered on the corner; the corner data-attribute flips the axes
19
- * and the offset var adds clearance without re-deriving transforms. */
25
+ /* Corner placements center ON the corner; the side placement rules
26
+ * below return the mark to the flow (static, no transform). */
20
27
  transform: translate(var(--hk-corner-badge-tx, 50%), var(--hk-corner-badge-ty, -50%));
21
28
  pointer-events: none;
22
29
  white-space: nowrap;
23
30
  }
24
31
 
25
- .hk-corner-badge[data-corner="top-right"] {
32
+ .hk-corner-badge[data-placement="top-right"] {
26
33
  top: var(--hk-corner-badge-offset, 0px);
27
34
  right: var(--hk-corner-badge-offset, 0px);
28
35
  --hk-corner-badge-tx: 50%;
29
36
  --hk-corner-badge-ty: -50%;
30
37
  }
31
38
 
32
- .hk-corner-badge[data-corner="top-left"] {
39
+ .hk-corner-badge[data-placement="top-left"] {
33
40
  top: var(--hk-corner-badge-offset, 0px);
34
41
  left: var(--hk-corner-badge-offset, 0px);
35
42
  --hk-corner-badge-tx: -50%;
36
43
  --hk-corner-badge-ty: -50%;
37
44
  }
38
45
 
39
- .hk-corner-badge[data-corner="bottom-right"] {
46
+ .hk-corner-badge[data-placement="bottom-right"] {
40
47
  bottom: var(--hk-corner-badge-offset, 0px);
41
48
  right: var(--hk-corner-badge-offset, 0px);
42
49
  --hk-corner-badge-tx: 50%;
43
50
  --hk-corner-badge-ty: 50%;
44
51
  }
45
52
 
46
- .hk-corner-badge[data-corner="bottom-left"] {
53
+ .hk-corner-badge[data-placement="bottom-left"] {
47
54
  bottom: var(--hk-corner-badge-offset, 0px);
48
55
  left: var(--hk-corner-badge-offset, 0px);
49
56
  --hk-corner-badge-tx: -50%;
50
57
  --hk-corner-badge-ty: 50%;
51
58
  }
52
59
 
60
+ /* Side placements: the mark rides the wrapper's row. `right` stays after
61
+ * the anchor (DOM order), `left` flips ahead of it; `align-self` centers
62
+ * the mark on the row and the mark's own margin spaces it from the anchor,
63
+ * so the anchor's other children keep their default alignment. The flex
64
+ * row follows the writing direction, so an RTL host mirrors the side for
65
+ * free (the house flex-row convention, see HkNumberSteppers). The two
66
+ * sides are written out separately (rather than one shared rule) so each
67
+ * side's block stays a single, greppable declaration. */
68
+ .hk-corner-badge[data-placement="right"] {
69
+ position: static;
70
+ transform: none;
71
+ align-self: center;
72
+ margin-inline-start: calc(
73
+ var(--hk-corner-badge-gap, var(--space-6, 6px)) +
74
+ var(--hk-corner-badge-offset, 0px)
75
+ );
76
+ }
77
+
78
+ .hk-corner-badge[data-placement="left"] {
79
+ position: static;
80
+ transform: none;
81
+ align-self: center;
82
+ order: -1;
83
+ margin-inline-end: calc(
84
+ var(--hk-corner-badge-gap, var(--space-6, 6px)) +
85
+ var(--hk-corner-badge-offset, 0px)
86
+ );
87
+ }
88
+
53
89
  /* Dot form: a filled circle in the variant's tone (same token trio
54
90
  * HkBadge uses — variant classes below set all three channels). */
55
91
  .hk-corner-badge--dot {
@@ -7,9 +7,14 @@ import { describe, expect, it } from "vitest";
7
7
 
8
8
  /**
9
9
  * Compiled-stylesheet guard for HkCornerBadge — the anchoring contract
10
- * (2026-10-15): the anchor wrapper is a relative inline-flex host (any
11
- * child can carry the mark), the mark centers ON its corner via
12
- * translate(±50%,±50%), and each data-corner flips the right axes.
10
+ * (2026-10-15, two placement families since 2026-11-05): the anchor
11
+ * wrapper is a relative inline-flex host (any child can carry the mark);
12
+ * the four CORNER placements overlay it, centering ON the corner via
13
+ * translate(±50%,±50%) with each data-placement flipping the right axes;
14
+ * the SIDE placements (`right` / `left`) return the mark to the flow,
15
+ * centered on the wrapper's row by `align-self: center`, and the mark's
16
+ * own margin spaces it from the anchor (gap + offset), so the anchor's
17
+ * own box — and its other children's alignment — is never touched.
13
18
  * Vitest stubs CSS, so the runtime suite can never catch a lost rule.
14
19
  */
15
20
 
@@ -46,10 +51,23 @@ describe("HkCornerBadge stylesheet contract", () => {
46
51
  });
47
52
 
48
53
  it("each corner flips the translate axes", () => {
49
- expect(ruleFor('.hk-corner-badge[data-corner=top-right] {')).toContain("--hk-corner-badge-tx: 50%");
50
- expect(ruleFor('.hk-corner-badge[data-corner=top-left] {')).toContain("--hk-corner-badge-tx: -50%");
51
- expect(ruleFor('.hk-corner-badge[data-corner=bottom-right] {')).toContain("--hk-corner-badge-ty: 50%");
52
- expect(ruleFor('.hk-corner-badge[data-corner=bottom-left] {')).toContain("--hk-corner-badge-tx: -50%");
54
+ expect(ruleFor('.hk-corner-badge[data-placement=top-right] {')).toContain("--hk-corner-badge-tx: 50%");
55
+ expect(ruleFor('.hk-corner-badge[data-placement=top-left] {')).toContain("--hk-corner-badge-tx: -50%");
56
+ expect(ruleFor('.hk-corner-badge[data-placement=bottom-right] {')).toContain("--hk-corner-badge-ty: 50%");
57
+ expect(ruleFor('.hk-corner-badge[data-placement=bottom-left] {')).toContain("--hk-corner-badge-tx: -50%");
58
+ });
59
+
60
+ it("side placements return to the flow and space themselves from the anchor", () => {
61
+ const right = ruleFor('.hk-corner-badge[data-placement=right] {');
62
+ expect(right).toContain("position: static");
63
+ expect(right).toContain("transform: none");
64
+ expect(right).toContain("align-self: center");
65
+ expect(right).toContain("margin-inline-start: calc(var(--hk-corner-badge-gap");
66
+ const left = ruleFor('.hk-corner-badge[data-placement=left] {');
67
+ expect(left).toContain("position: static");
68
+ expect(left).toContain("align-self: center");
69
+ expect(left).toContain("order: -1");
70
+ expect(left).toContain("margin-inline-end: calc(var(--hk-corner-badge-gap");
53
71
  });
54
72
 
55
73
  it("the dot is round and the pill is a tabular-numbers chip", () => {
@@ -21,7 +21,7 @@ afterEach(() => {
21
21
  }
22
22
  });
23
23
 
24
- describe("HkCornerBadge — the generic corner mark (2026-10-15)", () => {
24
+ describe("HkCornerBadge — the generic anchored mark (2026-10-15)", () => {
25
25
  it("anchors the default slot in a relative wrapper with no mark by default", () => {
26
26
  const c = mount(h(HkCornerBadge, null, () => h("button", { class: "probe" }, "x")));
27
27
  const anchor = c.querySelector(".hk-corner-badge-anchor")!;
@@ -32,19 +32,34 @@ describe("HkCornerBadge — the generic corner mark (2026-10-15)", () => {
32
32
  expect(c.querySelector(".hk-corner-badge")).toBeNull();
33
33
  });
34
34
 
35
+ it("defaults to the subscript overlay (bottom-right) exactly like an avatar mark", () => {
36
+ const c = mount(h(HkCornerBadge, { count: 2 }, () => h("i")));
37
+ const anchor = c.querySelector(".hk-corner-badge-anchor")!;
38
+ expect(anchor.getAttribute("data-placement")).toBe("bottom-right");
39
+ expect(c.querySelector(".hk-corner-badge")!.getAttribute("data-placement")).toBe("bottom-right");
40
+ });
41
+
35
42
  it("dot form: a variant-colored dot at the requested corner, decorative by default", () => {
36
43
  const c = mount(
37
- h(HkCornerBadge, { dot: true, variant: "success", corner: "bottom-right" }, () => h("i")),
44
+ h(HkCornerBadge, { dot: true, variant: "success", placement: "top-right" }, () => h("i")),
38
45
  );
39
46
  const badge = c.querySelector(".hk-corner-badge")!;
40
47
  expect(badge).toBeTruthy();
41
48
  expect(badge.className).toContain("hk-corner-badge--dot");
42
49
  expect(badge.className).toContain("hk-corner-badge-success");
43
- expect(badge.getAttribute("data-corner")).toBe("bottom-right");
50
+ expect(badge.getAttribute("data-placement")).toBe("top-right");
44
51
  expect(badge.getAttribute("aria-hidden")).toBe("true");
45
52
  expect(badge.getAttribute("role")).toBeNull();
46
53
  });
47
54
 
55
+ it("side placements carry the same data attribute on anchor and mark (button-group default)", () => {
56
+ const c = mount(
57
+ h(HkCornerBadge, { count: 4, placement: "right" }, () => h("span", "Active")),
58
+ );
59
+ expect(c.querySelector(".hk-corner-badge-anchor")!.getAttribute("data-placement")).toBe("right");
60
+ expect(c.querySelector(".hk-corner-badge")!.getAttribute("data-placement")).toBe("right");
61
+ });
62
+
48
63
  it("count form: renders the number, caps past max, hides zero unless showZero", () => {
49
64
  const c = mount(h(HkCornerBadge, { count: 7 }, () => h("i")));
50
65
  expect(c.querySelector(".hk-corner-badge")!.textContent).toBe("7");
@@ -13,17 +13,41 @@ export type CornerBadgeVariant =
13
13
  | "info"
14
14
  | "muted";
15
15
 
16
- /** Which corner of the anchor the mark hangs on. Exported so hosts that
17
- * embed the shell (HkTabs' trigger badge) share the one vocabulary
18
- * instead of re-spelling the union; HkIconButton's dot borrows the tone
19
- * channels but is an inset span of its own, not a corner mark. */
20
- export type CornerBadgeCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left";
16
+ /**
17
+ * Where the mark sits relative to the anchor — TWO families, not just
18
+ * corners (user direction 2026-11-05: a corner overlay on every anchor
19
+ * was wrong; "(n)" replaced by a badge must not cover the trigger's own
20
+ * label).
21
+ *
22
+ * - `top-right` / `top-left` / `bottom-right` / `bottom-left` OVERLAY the
23
+ * anchor: the mark centers ON the anchor's edge (half of it straddles
24
+ * the corner) — the classic subscript / notification mark for a fixed
25
+ * box (an avatar, an icon button).
26
+ * - `right` / `left` place the mark BESIDE the anchor in the flow,
27
+ * vertically centered, so the anchor's own box (and text) is never
28
+ * covered; the wrapper grows to fit both — the button-group / tab
29
+ * trigger default.
30
+ *
31
+ * Scenario defaults follow the same line: an anchor that can grow (a
32
+ * trigger inside a button group / tab strip) wants a side placement, a
33
+ * fixed-box anchor (an avatar) wants the bottom-right overlay. The
34
+ * component default is `bottom-right` (the subscript mark); hosts whose
35
+ * scenario is a button group pass `right` as their own default (HkTabs).
36
+ */
37
+ export type CornerBadgePlacement =
38
+ | "top-right"
39
+ | "top-left"
40
+ | "bottom-right"
41
+ | "bottom-left"
42
+ | "right"
43
+ | "left";
21
44
 
22
45
  /**
23
- * HkCornerBadge — the generic corner mark for ANY anchor (user
46
+ * HkCornerBadge — the generic anchored mark for ANY anchor (user
24
47
  * direction 2026-10-15): a green/red/traffic dot, a notification count
25
- * ("9+"), or a custom text label pinned to one corner of whatever sits
26
- * in the default slot. The Vue package had every consumer hand-rolling
48
+ * ("9+"), or a custom text label pinned to a corner of — or beside —
49
+ * whatever sits in the default slot (see CornerBadgePlacement). The Vue
50
+ * package had every consumer hand-rolling
27
51
  * its own absolute span (the filter/search trigger dots, the icon-group
28
52
  * marker, chest's dock time-range pill); this is the one component they
29
53
  * converge on — API parity with the Rust family's badge.rs (dot / count
@@ -48,13 +72,18 @@ export default defineComponent({
48
72
  * ANCHOR, never the label — overloading it made every anchored
49
73
  * child's own text leak into the badge. */
50
74
  text: { type: String, default: undefined },
51
- /** Which corner of the anchor. */
52
- corner: {
53
- type: String as PropType<CornerBadgeCorner>,
54
- default: "top-right",
75
+ /** Where the mark hangs (see CornerBadgePlacement). The four corners
76
+ * overlay the anchor; `right` / `left` sit beside it, vertically
77
+ * centered and never covering it. Default `bottom-right` = the
78
+ * subscript mark (avatar / icon). Button-group hosts (HkTabs)
79
+ * default to `right` instead. */
80
+ placement: {
81
+ type: String as PropType<CornerBadgePlacement>,
82
+ default: "bottom-right",
55
83
  },
56
- /** Extra clearance from the corner in px (0 = sit ON the corner,
57
- * the translate(50%,-50%) centering). */
84
+ /** Extra clearance in px. Corner placements: clearance from the
85
+ * corner (0 = the mark centers ON it via translate(±50%,±50%));
86
+ * side placements: the gap beyond the shared --hk-corner-badge-gap. */
58
87
  offset: { type: Number, default: 0 },
59
88
  /** Accessible name for the mark (without it the badge is
60
89
  * aria-hidden decoration). */
@@ -73,7 +102,7 @@ export default defineComponent({
73
102
  const show = computed(() => props.dot || text.value !== null);
74
103
 
75
104
  return () => (
76
- <span class="hk-corner-badge-anchor">
105
+ <span class="hk-corner-badge-anchor" data-placement={props.placement}>
77
106
  {slots.default?.()}
78
107
  {show.value && (
79
108
  <span
@@ -82,7 +111,7 @@ export default defineComponent({
82
111
  `hk-corner-badge-${props.variant}`,
83
112
  props.dot ? "hk-corner-badge--dot" : "hk-corner-badge--pill",
84
113
  ]}
85
- data-corner={props.corner}
114
+ data-placement={props.placement}
86
115
  style={props.offset ? { "--hk-corner-badge-offset": `${props.offset}px` } : undefined}
87
116
  aria-hidden={props.label ? undefined : "true"}
88
117
  role={props.label ? "img" : undefined}
@@ -68,7 +68,7 @@ export default defineComponent({
68
68
  const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
69
69
  return (
70
70
  <span ref={anchorRef} class="hk-filter-trigger">
71
- <HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
71
+ <HkCornerBadge dot={active.value} variant="success" placement="bottom-right" offset={1}>
72
72
  <HkIconButton
73
73
  size={chromeSize.value}
74
74
  variant="ghost"
@@ -96,7 +96,7 @@ const HkSearchTrigger = defineComponent({
96
96
  {/* The active-keyword marker rides the generic corner badge —
97
97
  * the same green bottom-right dot the filter trigger wears
98
98
  * (2026-10-15: was a hand-rolled primary dot, top-right). */}
99
- <HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
99
+ <HkCornerBadge dot={active.value} variant="success" placement="bottom-right" offset={1}>
100
100
  <HkIconButton
101
101
  size={chromeSize.value}
102
102
  variant="ghost"
@@ -691,24 +691,47 @@ describe("HkTabs trigger corner badge", () => {
691
691
  return { container };
692
692
  }
693
693
 
694
- it("renders the count through HkCornerBadge inside the trigger (no hand-rolled suffix)", () => {
694
+ it("renders the count through HkCornerBadge BESIDE the label (side default, no hand-rolled suffix)", () => {
695
695
  const { container } = mountBadge({ count: 3, label: "3 items" });
696
696
  const trig = container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[0]!;
697
- // The mark is the family's corner-badge pill, anchored bottom-right
698
- // (the subscript corner) of the label, not a "(3)" text suffix.
697
+ // The mark is the family's pill, placed on the BUTTON-GROUP default:
698
+ // `right` — beside the label, vertically centered, never covering
699
+ // it (user direction 2026-11-05: the corner overlay read as "歪了/
700
+ // 盖住了" on a tab trigger). Not a "(3)" text suffix either.
699
701
  const mark = trig.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
700
702
  expect(mark).not.toBeNull();
701
703
  expect(mark.textContent).toBe("3");
702
- expect(mark.dataset.corner).toBe("bottom-right");
704
+ expect(mark.dataset.placement).toBe("right");
705
+ expect(trig.querySelector(".hk-corner-badge-anchor")!.getAttribute("data-placement")).toBe("right");
703
706
  expect(trig.textContent).not.toContain("(3)");
704
- // Default clearance: the straddling pill must stay inside the
705
- // scroller's 6px vertical clearance below the label (see
706
- // TabItem.badge.offset) — the offset var pins that contract.
707
- expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("4px");
707
+ // Side placement needs no corner clearance: the wrapper gap is the
708
+ // spacing, so the tab default writes no offset var.
709
+ expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("");
710
+ // Side flow order: the label comes first, the mark after it.
711
+ const anchor = trig.querySelector(".hk-corner-badge-anchor")!;
712
+ expect(anchor.firstElementChild!.textContent).toBe("Alpha");
713
+ expect(anchor.lastElementChild).toBe(mark);
708
714
  // The sibling trigger stays badge-free.
709
715
  expect(container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[1]!.querySelector(".hk-corner-badge")).toBeNull();
710
716
  });
711
717
 
718
+ it("keeps the explicit corner override, with the corner clearance default", () => {
719
+ // A host that opts back into the overlay must still get the 4px
720
+ // clearance the scroller viewport needs (the placement-aware default),
721
+ // without restating it.
722
+ const { container } = mountBadge({ count: 3, label: "3 items", placement: "bottom-right" });
723
+ const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
724
+ expect(mark.dataset.placement).toBe("bottom-right");
725
+ expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("4px");
726
+ });
727
+
728
+ it("honours an explicit offset in either placement family", () => {
729
+ const side = mountBadge({ count: 3, label: "3 items", offset: 2 });
730
+ const mark = side.container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
731
+ expect(mark.dataset.placement).toBe("right");
732
+ expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("2px");
733
+ });
734
+
712
735
  it("keeps zero visible (presence count, not notification noise)", () => {
713
736
  const { container } = mountBadge({ count: 0, label: "0 items" });
714
737
  const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
@@ -6,7 +6,7 @@ import HkHoverRevealAction from "./HkHoverRevealAction";
6
6
  import HkIconButton from "./HkIconButton";
7
7
  import HkTransitionContainer from "./HkTransitionContainer";
8
8
  import HkCornerBadge from "./HkCornerBadge";
9
- import type { CornerBadgeCorner, CornerBadgeVariant } from "./HkCornerBadge";
9
+ import type { CornerBadgePlacement, CornerBadgeVariant } from "./HkCornerBadge";
10
10
  import { iconByName } from "../composables/iconRegistry";
11
11
  import { useI18n } from "../i18n/context";
12
12
  import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
@@ -20,34 +20,34 @@ export interface TabItem {
20
20
  /** Optional icon vnode rendered before the label (the `icon-${key}`
21
21
  * slot wins when provided). */
22
22
  icon?: unknown;
23
- /** Corner-count badge rendered on the trigger through HkCornerBadge —
24
- * the ONE sup/sub decoration shell (user direction 2026-11-05:
25
- * hand-rolled "(n)" count suffixes baked into labels are being
26
- * replaced by the standard corner mark, and triggers are badge hosts
27
- * like every other button). Zero stays visible: a tab count is
28
- * presence information (the "(0)" the inline suffix carried), not
29
- * notification noise — pass no badge to hide it entirely. The badge
30
- * anchors the LABEL, so a tab with an empty label renders no badge
31
- * either; pass `label` (an accessible name like "{n} active") or the
32
- * mark is aria-hidden decoration and the count is announced nowhere
33
- * (a DEV warning fires — a silent a11y regression against the inline
34
- * suffix it replaces must at least be loud). */
23
+ /** Count badge rendered on the trigger through HkCornerBadge — the ONE
24
+ * sup/sub decoration shell (user direction 2026-11-05: hand-rolled
25
+ * "(n)" count suffixes baked into labels are replaced by the standard
26
+ * mark, and triggers are badge hosts like every other button). Zero
27
+ * stays visible: a tab count is presence information (the "(0)" the
28
+ * inline suffix carried), not notification noise — pass no badge to
29
+ * hide it entirely. The badge anchors the LABEL, so a tab with an
30
+ * empty label renders no badge either; pass `label` (an accessible
31
+ * name like "{n} active") or the mark is aria-hidden decoration and
32
+ * the count is announced nowhere (a DEV warning fires — a silent a11y
33
+ * regression must at least be loud). */
35
34
  badge?: {
36
35
  count: number;
37
36
  /** "N+" ceiling (HkCornerBadge.max). */
38
37
  max?: number;
39
- /** Which corner of the LABEL the mark hangs on — the subscript
40
- * corner (bottom-right) by default, per the same direction. */
41
- corner?: CornerBadgeCorner;
38
+ /** Where the mark hangs (HkCornerBadge.placement). Default `right`:
39
+ * a tab trigger is a BUTTON-GROUP member, so the mark sits BESIDE
40
+ * the label, vertically centered and never covering it (user
41
+ * direction 2026-11-05 — the subscript corner overlay read as
42
+ * "歪了/盖住了"). Pass a corner only when the host really wants an
43
+ * overlay mark on the label. */
44
+ placement?: CornerBadgePlacement;
42
45
  /** Tone channel (HkCornerBadge.variant names, default "primary"). */
43
46
  variant?: CornerBadgeVariant;
44
- /** Corner clearance in px (HkCornerBadge.offset). Default 4: the
45
- * pill protrudes half its 18px height past the label box, and the
46
- * strip's vertical clearance below the label is trigger padding
47
- * (4px) + list padding (2px) = 6px before the scroller viewport
48
- * clips — offset 4 keeps the protrusion at 5px, inside that
49
- * clearance. 0 sits ON the corner and clips ~3px on the default
50
- * scrollable strip; raise it if your host adds no extra padding. */
47
+ /** Extra clearance in px (HkCornerBadge.offset). Placement-aware
48
+ * default: side marks are already spaced by the wrapper gap, so they
49
+ * default to 0; corner marks keep the historical 4 (the pill
50
+ * straddles the label and the scroller clips ~6px below it). */
51
51
  offset?: number;
52
52
  /** Accessible name for the mark — strongly expected (DEV-warned
53
53
  * when missing). */
@@ -569,6 +569,13 @@ export default defineComponent({
569
569
  }
570
570
  const active = tab.key === props.modelValue;
571
571
  const disabled = tab.disabled;
572
+ // The badge default is placement-aware: a side mark is spaced by
573
+ // the wrapper (offset 0), a corner mark keeps the clearance that
574
+ // keeps the straddling pill inside the scroller viewport.
575
+ const badgePlacement = tab.badge?.placement ?? "right";
576
+ const badgeOffset =
577
+ tab.badge?.offset ??
578
+ (badgePlacement === "right" || badgePlacement === "left" ? 0 : 4);
572
579
  // `icon` is a deliberately loose `unknown` on the public item type
573
580
  // (hosts may pass vnodes materialized against another vue store);
574
581
  // at the render site its documented contract is a render child.
@@ -599,18 +606,20 @@ export default defineComponent({
599
606
  {icon}
600
607
  {/* The badge anchors the LABEL (inside the trigger's own
601
608
  * padding box) rather than the button box: the mark reads
602
- * as part of the control's silhouette (the HkIconButton
603
- * dot's 2026-10-28 lesson, count form). Clipping is kept
604
- * honest by the offset default: the pill straddles the
605
- * label's corner and the scroller viewport clips at
606
- * 6px below the label — see TabItem.badge.offset. */}
609
+ * as part of the control's silhouette. A tab is a
610
+ * BUTTON-GROUP member, so its default is the side placement
611
+ * (`right`, vertically centered): the label keeps its full
612
+ * box and the strip grows by the mark + gap — a corner
613
+ * overlay straddled the label and read as covering it (user
614
+ * report 2026-11-05). Corner placements stay available for
615
+ * hosts that want the overlay on purpose. */}
607
616
  {tab.label && (tab.badge ? (
608
617
  <HkCornerBadge
609
618
  count={tab.badge.count}
610
619
  max={tab.badge.max}
611
620
  showZero
612
- corner={tab.badge.corner ?? "bottom-right"}
613
- offset={tab.badge.offset ?? 4}
621
+ placement={badgePlacement}
622
+ offset={badgeOffset}
614
623
  variant={tab.badge.variant ?? "primary"}
615
624
  label={tab.badge.label}
616
625
  >
package/src/index.ts CHANGED
@@ -201,7 +201,7 @@ export { default as HLogo } from "./components/HkLogo";
201
201
 
202
202
  // Component types
203
203
  export { type BadgeVariant } from "./components/HkBadge";
204
- export { type CornerBadgeVariant } from "./components/HkCornerBadge";
204
+ export { type CornerBadgePlacement, type CornerBadgeVariant } from "./components/HkCornerBadge";
205
205
  export { type BoardNodeInput, type BoardEdgeInput } from "./components/HkBoard";
206
206
  export { isSafeHref, safeHref } from "./utils/safeHref";
207
207
  export { type BoardAnchorMode, type BoardEdgeStyle, type BoardPoint } from "./utils/boardEdges";
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import * as THREE from "three";
3
3
 
4
4
  import { fakeAs } from "../test-utils/fakes";
5
+ import { HIKARI_FONT_SANS } from "../theme/fontContext";
5
6
  import {
6
7
  hologramHudText,
7
8
  makeFleetHologram,
@@ -13,24 +14,81 @@ import {
13
14
  const INPUT: FleetHologramInput = {
14
15
  id: "node-1",
15
16
  label: "node-1 engine stack",
16
- status: "Online",
17
17
  detail: "3 endpoints",
18
18
  role: "planet",
19
19
  radius: 1,
20
20
  };
21
21
 
22
- function mockCanvas(): ReturnType<typeof vi.fn> {
22
+ interface CanvasSpy {
23
+ fillText: ReturnType<typeof vi.fn>;
24
+ /** The font currently on the most recently created 2D context. */
25
+ font: () => string;
26
+ }
27
+
28
+ /** Font metrics scale with the requested size, like a real canvas: this
29
+ * keeps the fit/ellipsis paths testable instead of always fitting at 64px. */
30
+ function mockFontPx(font: string): number {
31
+ const match = /(\d+)px/.exec(font);
32
+ return match ? Number(match[1]) : 64;
33
+ }
34
+
35
+ function mockCanvas(): CanvasSpy {
23
36
  const fillText = vi.fn();
24
- const gradient = fakeAs<CanvasGradient>({ addColorStop: vi.fn() });
25
- const context = fakeAs<CanvasRenderingContext2D>({
26
- clearRect: vi.fn(),
27
- fillRect: vi.fn(),
28
- fillText,
29
- measureText: (text: string) => fakeAs<TextMetrics>({ width: text.length * 24 }),
30
- createRadialGradient: () => gradient,
37
+ const stateByCanvas = new WeakMap<HTMLCanvasElement, { font: string }>();
38
+ let current: { font: string } | null = null;
39
+ const widthDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "width");
40
+ const heightDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "height");
41
+
42
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (
43
+ this: HTMLCanvasElement,
44
+ ) {
45
+ const state = { font: "" };
46
+ stateByCanvas.set(this, state);
47
+ current = state;
48
+ const gradient = fakeAs<CanvasGradient>({ addColorStop: vi.fn() });
49
+ return fakeAs<CanvasRenderingContext2D>({
50
+ clearRect: vi.fn(),
51
+ fillRect: vi.fn(),
52
+ fillText,
53
+ measureText: (text: string) =>
54
+ fakeAs<TextMetrics>({ width: text.length * mockFontPx(state.font) * 0.45 }),
55
+ createRadialGradient: () => gradient,
56
+ get font() {
57
+ return state.font;
58
+ },
59
+ set font(value: string) {
60
+ state.font = value;
61
+ },
62
+ });
31
63
  });
32
- vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context);
33
- return fillText;
64
+
65
+ // A real canvas resets its 2D state (including the font) whenever width
66
+ // or height changes. Model that here so "font applied AFTER the resize"
67
+ // is actually guarded, not just "font assigned once somewhere".
68
+ const resetState = (canvas: HTMLCanvasElement): void => {
69
+ const state = stateByCanvas.get(canvas);
70
+ if (state) state.font = "";
71
+ };
72
+ if (widthDescriptor?.set) {
73
+ vi.spyOn(HTMLCanvasElement.prototype, "width", "set").mockImplementation(function (
74
+ this: HTMLCanvasElement,
75
+ value: number,
76
+ ) {
77
+ widthDescriptor.set!.call(this, value);
78
+ resetState(this);
79
+ });
80
+ }
81
+ if (heightDescriptor?.set) {
82
+ vi.spyOn(HTMLCanvasElement.prototype, "height", "set").mockImplementation(function (
83
+ this: HTMLCanvasElement,
84
+ value: number,
85
+ ) {
86
+ heightDescriptor.set!.call(this, value);
87
+ resetState(this);
88
+ });
89
+ }
90
+
91
+ return { fillText, font: () => current?.font ?? "" };
34
92
  }
35
93
 
36
94
  afterEach(() => {
@@ -38,41 +96,55 @@ afterEach(() => {
38
96
  });
39
97
 
40
98
  describe("hologramHudText", () => {
41
- it("contains only the real label, current status and supplied detail", () => {
42
- expect(hologramHudText(INPUT)).toBe("node-1 engine stack · Online · 3 endpoints");
43
- expect(hologramHudText({ ...INPUT, status: "Offline", detail: "" }))
44
- .toBe("node-1 engine stack · Offline");
45
- expect(hologramHudText({ ...INPUT, label: "", status: "", detail: "" }))
46
- .toBe("node-1");
99
+ it("carries the label and detail, never a supplied liveness status", () => {
100
+ expect(hologramHudText(INPUT)).toBe("node-1 engine stack · 3 endpoints");
101
+ // `status` is no longer part of the HUD input type; a caller that
102
+ // still spreads the host body must not leak it into the ball.
103
+ const withStatus = { ...INPUT, status: "Offline", detail: "" };
104
+ expect(hologramHudText(withStatus)).toBe("node-1 engine stack");
105
+ expect(hologramHudText({ ...INPUT, label: "", detail: "" })).toBe("node-1");
47
106
  });
48
107
  });
49
108
 
50
109
  describe("makeFleetHologram", () => {
51
- it("pins the real-data ticker to the centre of the front dial", () => {
52
- const fillText = mockCanvas();
110
+ it("pins the real-data HUD line to the centre of the front dial", () => {
111
+ const { fillText, font } = mockCanvas();
53
112
  const hologram = makeFleetHologram(INPUT, new THREE.Color(0x7fb8dd), false);
54
113
 
55
114
  expect(hologram.hudTexture).not.toBeNull();
56
115
  expect(hologram.hudPanel).not.toBeNull();
57
116
  expect(hologram.hudPanel!.geometry).toBeInstanceOf(THREE.PlaneGeometry);
58
- // The ticker is a child of the camera-facing dial, AT its centre: the
117
+ // The HUD line is a child of the camera-facing dial, AT its centre: the
59
118
  // dial owns the plane, so facing the dial faces the text (user
60
119
  // direction 2026-10-31 — no wrapping ribbon).
61
120
  expect(hologram.frontDial.children).toContain(hologram.hudPanel);
62
121
  expect(hologram.hudPanel!.position.x).toBe(0);
63
122
  expect(hologram.hudPanel!.position.y).toBe(0);
64
- // Not vacuous: a blank ticker must fail here, not just render wrong
65
- // text when it renders anything at all.
123
+ // Not vacuous: a blank HUD line must fail here, not just render wrong
124
+ // text when it renders anything at all. The third argument is the
125
+ // only legal one — canvas's optional `maxWidth` condenses glyphs and
126
+ // is exactly the 2026-11-05 defect.
66
127
  expect(fillText.mock.calls.length).toBeGreaterThan(0);
67
128
  for (const call of fillText.mock.calls) {
68
- expect(call[0]).toBe("node-1 engine stack · Online · 3 endpoints");
129
+ expect(call[0]).toBe("node-1 engine stack · 3 endpoints");
130
+ expect(call).toHaveLength(3);
69
131
  }
70
132
 
71
- // The plane's aspect matches the texture's (1024×96): a mismatched
72
- // aspect compresses the glyphs horizontally (R2 S3-1 regression).
133
+ // The canvas is painted in the host's UI sans stack, not a mono face:
134
+ // the chip beside the ball and the in-ball line must read as one UI.
135
+ // The mock clears context state on every canvas resize, so this also
136
+ // proves the font is re-applied AFTER the dimensions are set (the real
137
+ // canvas trap), not merely assigned once before them.
138
+ expect(font()).toBe(`600 64px ${HIKARI_FONT_SANS}`);
139
+ expect(font()).toContain("sans-serif");
140
+ expect(font()).not.toContain("monospace");
141
+
142
+ // The plane's aspect matches the texture tile's own aspect: a
143
+ // mismatched aspect compresses the glyphs horizontally.
144
+ const image = hologram.hudTexture!.image as HTMLCanvasElement;
73
145
  const plane = hologram.hudPanel!.geometry as THREE.PlaneGeometry;
74
146
  const params = plane.parameters as { width: number; height: number };
75
- expect(params.width / params.height).toBeCloseTo(1024 / 96, 5);
147
+ expect(params.width / params.height).toBeCloseTo(image.width / image.height, 5);
76
148
 
77
149
  // The text plane never widens the body's pointer target.
78
150
  const hits: THREE.Intersection[] = [];
@@ -80,6 +152,52 @@ describe("makeFleetHologram", () => {
80
152
  expect(hits).toHaveLength(0);
81
153
  });
82
154
 
155
+ it("widens the tile for a long label instead of clipping or condensing", () => {
156
+ const label = "n".repeat(70);
157
+ const { fillText, font } = mockCanvas();
158
+ const hologram = makeFleetHologram(
159
+ { ...INPUT, label, detail: "" },
160
+ new THREE.Color(),
161
+ false,
162
+ );
163
+ const image = hologram.hudTexture!.image as HTMLCanvasElement;
164
+ // 70 glyphs at 46px is the largest uniform size whose natural width
165
+ // fits the 1504px text box; the tile adds the 96px seam gap.
166
+ expect(font()).toBe(`600 46px ${HIKARI_FONT_SANS}`);
167
+ expect(image.width).toBe(1545);
168
+ const rendered = fillText.mock.calls[0]?.[0] as string;
169
+ expect(rendered).toBe(label);
170
+ expect(fillText.mock.calls[0]).toHaveLength(3);
171
+ });
172
+
173
+ it("ellipsizes when even 40px cannot fit the label", () => {
174
+ const label = "n".repeat(120);
175
+ const { fillText, font } = mockCanvas();
176
+ const hologram = makeFleetHologram(
177
+ { ...INPUT, label, detail: "" },
178
+ new THREE.Color(),
179
+ false,
180
+ );
181
+ const image = hologram.hudTexture!.image as HTMLCanvasElement;
182
+ expect(font()).toBe(`600 40px ${HIKARI_FONT_SANS}`);
183
+ expect(image.width).toBe(1590);
184
+ const rendered = fillText.mock.calls[0]?.[0] as string;
185
+ expect(rendered.endsWith("…")).toBe(true);
186
+ expect(rendered.length).toBeLessThan(label.length);
187
+ expect(fillText.mock.calls[0]).toHaveLength(3);
188
+ });
189
+
190
+ it("uses a host-provided --font-sans stack for the canvas text", () => {
191
+ document.documentElement.style.setProperty("--font-sans", '"Host Sans", sans-serif');
192
+ try {
193
+ const { font } = mockCanvas();
194
+ makeFleetHologram(INPUT, new THREE.Color(), false);
195
+ expect(font()).toBe('600 64px "Host Sans", sans-serif');
196
+ } finally {
197
+ document.documentElement.style.removeProperty("--font-sans");
198
+ }
199
+ });
200
+
83
201
  it("gives main cores denser dial content than node cores", () => {
84
202
  mockCanvas();
85
203
  const hub = makeFleetHologram({ ...INPUT, role: "star" }, new THREE.Color(), false);
@@ -311,10 +429,11 @@ describe("makeFleetHologram", () => {
311
429
  expect(hologram.selectionRing.quaternion.angleTo(camera.quaternion)).toBeCloseTo(0, 6);
312
430
  // …the dial's ticks sweep…
313
431
  expect(hologram.frontSpinner.rotation.z).toBeCloseTo(0.55, 6);
314
- // …and the real-data ticker scrolls across the dial's centre.
315
- expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.09, 6);
432
+ // …and the measured HUD line stays put at the dial's centre: its
433
+ // band already fits the label, so a scroll would only slice it.
434
+ expect(hologram.hudTexture?.offset.x).toBe(0);
316
435
  poseFleetHologram(hologram, 10, 20, camera.quaternion);
317
- expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.18, 6);
436
+ expect(hologram.hudTexture?.offset.x).toBe(0);
318
437
  });
319
438
  });
320
439
 
@@ -323,7 +442,7 @@ describe("makeSatelliteHologram", () => {
323
442
  const satellite = makeSatelliteHologram(0.2, new THREE.Color(0x9fb8dd), false);
324
443
 
325
444
  // Endpoint tier: three crossed support dials plus a three-ring
326
- // spindle — the gateway's language, minimal content, no ticker.
445
+ // spindle — the gateway's language, minimal content, no HUD line.
327
446
  expect(satellite.getObjectsByProperty("name", "fleet-hologram-face-dial"))
328
447
  .toHaveLength(3);
329
448
  expect(satellite.getObjectsByProperty("name", "fleet-hologram-ring-spindle-circle"))
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * holographicFleetBody — one node's hologram: a spherical shell built from
3
3
  * six overlapping face dials plus a fat-centre ring spindle, with ONE
4
- * detailed camera-facing dial at the front carrying the real-data ticker.
4
+ * detailed camera-facing dial at the front carrying the real-data HUD line.
5
5
  *
6
6
  * User direction (2026-10-31): the body must read as a ball of circles
7
7
  * from every side — no polyhedron, no solid sphere, no shared wireframe,
@@ -10,15 +10,23 @@
10
10
  * get a miniature crossed-dial shell with no text. Every decorative line
11
11
  * stays out of raycast picking; the invisible centre proxy remains the
12
12
  * node's pointer target.
13
+ *
14
+ * User direction (2026-11-05): the HUD line inside the ball is set in the
15
+ * host's sans UI stack at natural glyph advances — never condensed by
16
+ * canvas — and carries descriptive data only (no liveness wording such as
17
+ * "Online"; that stays on the chip and the selection card). The band is
18
+ * measured from the line and sits still, so nothing is sliced at a wrap
19
+ * seam either.
13
20
  */
14
21
 
15
22
  import * as THREE from "three";
16
23
 
24
+ import { HIKARI_FONT_SANS } from "../theme/fontContext";
17
25
  import type { FleetBodySpec } from "./holographicFleetSpec";
18
26
 
19
27
  export type FleetHologramInput = Pick<
20
28
  FleetBodySpec,
21
- "id" | "label" | "status" | "detail" | "role" | "radius"
29
+ "id" | "label" | "detail" | "role" | "radius"
22
30
  >;
23
31
 
24
32
  export interface FleetHologram {
@@ -27,26 +35,115 @@ export interface FleetHologram {
27
35
  mesh: THREE.Mesh;
28
36
  /** Six faint overlapping face dials: the spherical shell. */
29
37
  shell: THREE.Group;
30
- /** The detailed camera-facing dial that carries the real-data ticker. */
38
+ /** The detailed camera-facing dial that carries the real-data HUD line. */
31
39
  frontDial: THREE.Group;
32
40
  /** Sweeping layer inside the front dial (ticks and arcs rotate here). */
33
41
  frontSpinner: THREE.Group;
34
42
  /** Circular focus mark, shown while the body is selected. */
35
43
  selectionRing: THREE.Group;
36
- /** The real-data ticker plane at the front dial's centre. */
44
+ /** The real-data HUD plane at the front dial's centre. */
37
45
  hudPanel: THREE.Mesh | null;
38
46
  /** Null only when the host cannot create a 2D canvas (SSR/test DOM). */
39
47
  hudTexture: THREE.CanvasTexture | null;
40
48
  }
41
49
 
42
- /** The single source of truth for the dial ticker's content. */
50
+ /** The single source of truth for the dial HUD line's content.
51
+ * Liveness (`status`) is deliberately absent (user direction 2026-11-05):
52
+ * the ball carries the node's descriptive data, while liveness already
53
+ * has a home on the chip and the selection card. */
43
54
  export function hologramHudText(input: FleetHologramInput): string {
44
- return [input.label, input.status, input.detail]
55
+ return [input.label, input.detail]
45
56
  .map((part) => part.trim())
46
57
  .filter((part) => part.length > 0)
47
58
  .join(" · ") || input.id;
48
59
  }
49
60
 
61
+ /** HUD tile geometry. The width is measured from the live line: a long
62
+ * label widens the strip instead of condensing the glyphs. */
63
+ const HUD_TILE_HEIGHT = 96;
64
+ /** The plane's scale reference: a 1024px tile maps to a 1.14×radius band,
65
+ * so the glyph height on the ball stays constant as the tile grows. */
66
+ const HUD_REFERENCE_WIDTH = 1024;
67
+ const HUD_MIN_TILE_WIDTH = 640;
68
+ const HUD_MAX_TILE_WIDTH = 1600;
69
+ const HUD_FONT_MAX_PX = 64;
70
+ const HUD_FONT_MIN_PX = 40;
71
+ const HUD_FONT_STEP_PX = 2;
72
+ const HUD_ITEM_GAP_PX = 96;
73
+
74
+ /** The host applies the canonical UI stack as `--font-sans` during
75
+ * bootstrap; reading it keeps canvas text in the same family as the DOM
76
+ * around it (and honours a host font override). The exported stack is
77
+ * only the fallback for SSR / test DOMs without computed styles. */
78
+ function hudFontFamily(): string {
79
+ if (typeof document !== "undefined") {
80
+ try {
81
+ const declared = getComputedStyle(document.documentElement)
82
+ .getPropertyValue("--font-sans")
83
+ .trim();
84
+ if (declared && !/[;{}]/.test(declared) && !/var\(/i.test(declared)) return declared;
85
+ } catch {
86
+ // No computed-style implementation: fall through to the canonical stack.
87
+ }
88
+ }
89
+ return HIKARI_FONT_SANS;
90
+ }
91
+
92
+ function hudFont(fontPx: number, family: string): string {
93
+ return `600 ${fontPx}px ${family}`;
94
+ }
95
+
96
+ /** Cut the line at a code-point boundary when even the smallest HUD size
97
+ * cannot hold it, so an emoji or astral character is never split into a
98
+ * lone surrogate. Never pass the result to a `maxWidth` argument: this
99
+ * function shortens the text, it does not scale glyphs horizontally. */
100
+ function ellipsizeHudText(
101
+ ctx: CanvasRenderingContext2D,
102
+ text: string,
103
+ maxWidth: number,
104
+ ): string {
105
+ if (ctx.measureText(text).width <= maxWidth) return text;
106
+ const characters = Array.from(text);
107
+ let low = 0;
108
+ let high = characters.length;
109
+ while (low < high) {
110
+ const mid = Math.ceil((low + high) / 2);
111
+ const candidate = `${characters.slice(0, mid).join("")}…`;
112
+ if (ctx.measureText(candidate).width <= maxWidth) low = mid;
113
+ else high = mid - 1;
114
+ }
115
+ return `${characters.slice(0, low).join("").trimEnd()}…`;
116
+ }
117
+
118
+ interface HudTextLayout {
119
+ fontPx: number;
120
+ text: string;
121
+ width: number;
122
+ }
123
+
124
+ /** Choose the largest uniform font size that fits the tile, then fall back
125
+ * to a code-point-boundary ellipsis. Uniform scaling preserves every
126
+ * glyph's aspect; only the horizontal `maxWidth` squeeze is banned. */
127
+ function layoutHudText(
128
+ ctx: CanvasRenderingContext2D,
129
+ line: string,
130
+ family: string,
131
+ ): HudTextLayout {
132
+ const maxTextWidth = HUD_MAX_TILE_WIDTH - HUD_ITEM_GAP_PX;
133
+ for (let px = HUD_FONT_MAX_PX; px >= HUD_FONT_MIN_PX; px -= HUD_FONT_STEP_PX) {
134
+ ctx.font = hudFont(px, family);
135
+ const width = ctx.measureText(line).width;
136
+ if (width <= maxTextWidth) return { fontPx: px, text: line, width };
137
+ }
138
+ ctx.font = hudFont(HUD_FONT_MIN_PX, family);
139
+ const text = ellipsizeHudText(ctx, line, maxTextWidth);
140
+ return {
141
+ fontPx: HUD_FONT_MIN_PX,
142
+ text,
143
+ width: ctx.measureText(text).width,
144
+ };
145
+ }
146
+
50
147
  /** Decorative geometry must not become a broad, invisible pointer target. */
51
148
  function unpickable<T extends THREE.Object3D>(object: T): T {
52
149
  object.raycast = () => undefined;
@@ -278,49 +375,58 @@ function makeRingSpindle(
278
375
 
279
376
  function makeHudTexture(input: FleetHologramInput): THREE.CanvasTexture | null {
280
377
  const canvas = document.createElement("canvas");
281
- canvas.width = 1024;
282
- canvas.height = 96;
283
378
  const ctx = canvas.getContext("2d");
284
379
  if (!ctx) return null;
285
380
 
381
+ const family = hudFontFamily();
286
382
  const line = hologramHudText(input);
287
- ctx.clearRect(0, 0, canvas.width, canvas.height);
288
- // 64px glyphs on the 96px strip: the plane's aspect is locked to the
289
- // canvas's below, so on-disc text height is fontPx/canvasW × planeW —
290
- // 64/1024 of the band, readable at fleet zoom.
291
- ctx.font = "600 64px ui-monospace, SFMono-Regular, Consolas, monospace";
383
+ const layout = layoutHudText(ctx, line, family);
384
+ const tileWidth = Math.min(
385
+ HUD_MAX_TILE_WIDTH,
386
+ Math.max(HUD_MIN_TILE_WIDTH, Math.ceil(layout.width + HUD_ITEM_GAP_PX)),
387
+ );
388
+
389
+ // Setting the canvas dimensions resets every 2D context state, so the
390
+ // font and paint state go on AFTER the final size is known.
391
+ canvas.width = tileWidth;
392
+ canvas.height = HUD_TILE_HEIGHT;
393
+ ctx.font = hudFont(layout.fontPx, family);
292
394
  ctx.textBaseline = "middle";
395
+ ctx.textAlign = "left";
293
396
  ctx.fillStyle = "#ffffff";
294
397
 
295
- // A clipped horizontal ticker for the dial's centre (user direction
296
- // 2026-10-31: the text belongs to the disc's middle, not a wrapping
297
- // ribbon). Long host labels are condensed by canvas rather than
298
- // replaced with fabricated data.
299
- const maxItemWidth = 610;
300
- const measured = ctx.measureText(line).width;
301
- const itemWidth = Math.min(maxItemWidth, Math.max(360, measured + 64));
302
- for (let x = 24; x < canvas.width; x += itemWidth) {
303
- ctx.fillText(line, x, canvas.height / 2, maxItemWidth - 48);
304
- }
398
+ // One natural-width line, centred in a tile that is exactly the band's
399
+ // width. The band sits still (no offset scroll), so the line is never
400
+ // sliced at a texture seam. There is deliberately NO `maxWidth`
401
+ // argument — that is the canvas feature which condensed long labels
402
+ // and made glyph advances unequal (user direction 2026-11-05).
403
+ ctx.fillText(
404
+ layout.text,
405
+ Math.max(0, (tileWidth - layout.width) / 2),
406
+ HUD_TILE_HEIGHT / 2,
407
+ );
305
408
 
306
409
  const texture = new THREE.CanvasTexture(canvas);
307
410
  texture.name = "fleet-hologram-hud-texture";
308
411
  texture.colorSpace = THREE.SRGBColorSpace;
309
- texture.wrapS = THREE.RepeatWrapping;
310
412
  return texture;
311
413
  }
312
414
 
313
- /** The real-data ticker plane, pinned to the front dial's centre. The
314
- * plane's aspect is locked to the texture's (1024:96) — a mismatched
315
- * aspect would compress the glyphs horizontally. */
316
- function makeTickerPlane(
415
+ /** The real-data HUD plane, pinned to the front dial's centre. The
416
+ * plane's aspect is locked to the texture tile's own aspect — the tile
417
+ * width is measured from the live label, so the band grows with long
418
+ * names and glyphs are never compressed horizontally. */
419
+ function makeHudPlane(
317
420
  bodyRadius: number,
318
421
  color: THREE.Color,
319
422
  offline: boolean,
320
423
  texture: THREE.CanvasTexture,
321
424
  ): THREE.Mesh {
322
- const width = bodyRadius * 1.14;
323
- const height = width * (96 / 1024);
425
+ const image = texture.image as HTMLCanvasElement | undefined;
426
+ const tileWidth = image?.width ?? HUD_REFERENCE_WIDTH;
427
+ const tileHeight = image?.height ?? HUD_TILE_HEIGHT;
428
+ const width = bodyRadius * 1.14 * (tileWidth / HUD_REFERENCE_WIDTH);
429
+ const height = width * (tileHeight / tileWidth);
324
430
  const mesh = unpickable(new THREE.Mesh(
325
431
  new THREE.PlaneGeometry(width, height),
326
432
  new THREE.MeshBasicMaterial({
@@ -405,7 +511,7 @@ export function makeFleetHologram(
405
511
  const hudTexture = makeHudTexture(input);
406
512
  let hudPanel: THREE.Mesh | null = null;
407
513
  if (hudTexture) {
408
- hudPanel = makeTickerPlane(input.radius, color, offline, hudTexture);
514
+ hudPanel = makeHudPlane(input.radius, color, offline, hudTexture);
409
515
  hudPanel.position.z = input.radius * 0.02;
410
516
  frontDial.add(hudPanel);
411
517
  }
@@ -458,8 +564,9 @@ export function makeSatelliteHologram(
458
564
  }
459
565
 
460
566
  /** Independent motion: the shell turns slowly, the front dial and the
461
- * selection ring face the camera, the dial's ticks sweep, and the
462
- * real-data ticker scrolls across the disc's centre. */
567
+ * selection ring face the camera, and the dial's ticks sweep. The HUD
568
+ * line itself sits still at the disc's centre: its band is measured from
569
+ * the label, so a scroll would only slice it at the texture seam. */
463
570
  export function poseFleetHologram(
464
571
  hologram: FleetHologram,
465
572
  elapsed: number,
@@ -473,7 +580,4 @@ export function poseFleetHologram(
473
580
  hologram.frontDial.quaternion.copy(cameraQuaternion);
474
581
  hologram.selectionRing.quaternion.copy(cameraQuaternion);
475
582
  }
476
- if (hologram.hudTexture) {
477
- hologram.hudTexture.offset.x = (elapsed * 0.018) % 1;
478
- }
479
583
  }
@@ -143,7 +143,7 @@ export function buildFleetScene(
143
143
  return;
144
144
  }
145
145
  // Everything else this builder puts in the scene that owns GPU
146
- // resources is a Mesh (pick proxies, dial cores, ticker planes),
146
+ // resources is a Mesh (pick proxies, dial cores, HUD planes),
147
147
  // a Line (dial rings, spindle circles, orbit rings) or Points —
148
148
  // all three carry `geometry` + `material`.
149
149
  if (