@celestia-island/hikari 0.32.0 → 0.32.2

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.32.0",
3
+ "version": "0.32.2",
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",
@@ -32,6 +32,7 @@
32
32
  "import": "./src/styles/index.scss"
33
33
  },
34
34
  "./styles/*": "./src/styles/*",
35
+ "./theme/*": "./src/theme/*",
35
36
  "./components/*": "./src/components/*",
36
37
  "./plugins": {
37
38
  "import": "./src/plugins/index.ts",
@@ -35,10 +35,12 @@
35
35
  centered hk-btn-block. HkButton has a fragment root, so inbound classes
36
36
  don't fall through — scope everything off the `.s-auth-methods`
37
37
  container (descendant match: the component wrapper is display:contents).
38
- The label column width is MEASURED by the component (widest label text,
39
- published as `--auth-methods-label-width` on its wrapper, inherited into
40
- the rows); the 8em fallback only covers the pre-measure frame, and apps
41
- may still override the var explicitly. */
38
+ The label column width is MEASURED by the component (widest label text
39
+ + 1px headroom, published as `--auth-methods-label-width` on its
40
+ wrapper, inherited into the rows); the `max-content` fallback only
41
+ covers the pre-measure / un-measurable frame, and apps may still
42
+ override the var explicitly. Labels are centered within the shared
43
+ column so every row's text sits on the same vertical center line. */
42
44
  .s-auth-methods .hk-btn .s-auth-methods-icon {
43
45
  width: 20px;
44
46
  flex: none;
@@ -48,9 +50,15 @@
48
50
  }
49
51
 
50
52
  .s-auth-methods .hk-btn .s-auth-methods-label {
51
- width: var(--auth-methods-label-width, 8em);
53
+ width: var(--auth-methods-label-width, max-content);
54
+ /* Hardened floor: even if the published column were ever stale or too
55
+ narrow (engine DPR / zoom mismatch), the label can never be clipped —
56
+ the row just widens to its own text. When the measurement is correct
57
+ the var already exceeds every label's natural width, so the floor is
58
+ inert and alignment is preserved. */
59
+ min-width: max-content;
52
60
  flex: none;
53
- text-align: start;
61
+ text-align: center;
54
62
  white-space: nowrap;
55
63
  overflow: hidden;
56
64
  text-overflow: ellipsis;
@@ -64,35 +64,36 @@ describe("HkAuthMethodList", () => {
64
64
  expect(picked).toBe("linuxdo");
65
65
  });
66
66
 
67
- it("publishes the widest label text as the label-column custom property", async () => {
67
+ it("publishes the widest label text plus a 1px headroom as the label-column custom property", async () => {
68
68
  const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
69
69
  await nextTick();
70
70
  const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
71
71
  expect(wrapper).toBeTruthy();
72
- // Layout engines in test DOM report zero text metrics; stub scrollWidth
73
- // on the label spans to a deterministic widest-wins pair (LinuxDo wider).
72
+ // Layout engines in test DOM report zero text metrics; stub a fractional
73
+ // getBoundingClientRect width on the label spans so the widest-wins pair
74
+ // (LinuxDo wider) exercises the ceil+1 headroom instead of the integer
75
+ // scrollWidth that would previously round a fraction and clip the widest.
74
76
  const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
75
77
  expect(spans.length).toBe(2);
76
- Object.defineProperty(spans[0]!, "scrollWidth", { configurable: true, value: 61 });
77
- Object.defineProperty(spans[1]!, "scrollWidth", { configurable: true, value: 59 });
78
- // Trigger the measurement through a fresh label change (watch path).
78
+ // 61.4 ceil→62 (+1→63) and a shorter 58.0: the published column must
79
+ // exceed BOTH so the widest label can never sit on the clip boundary.
80
+ Object.defineProperty(spans[0]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 58 }) });
81
+ Object.defineProperty(spans[1]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 61.4 }) });
79
82
  await nextTick();
80
- const { app } = mounts[mounts.length - 1]!;
81
83
  // Re-run measurement via a re-mount-less route: the component exposes
82
84
  // measure() — reach it through the rendered component instance.
83
85
  const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
84
86
  .__vueParentComponent?.exposed;
85
87
  expect(typeof instance?.measure).toBe("function");
86
88
  (instance!.measure as () => void)();
87
- expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("61px");
88
- void app;
89
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("63px");
89
90
  });
90
91
 
91
92
  it("leaves the column fallback intact when no text metrics are available", async () => {
92
93
  const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
93
94
  await nextTick();
94
95
  const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
95
- // Zero scrollWidth (no layout) must not publish a 0px column.
96
+ // Zero text metrics (no layout) must not publish a 0px or a 1px column.
96
97
  expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("");
97
98
  });
98
99
  });
@@ -12,11 +12,16 @@ import "./HkAuthMethodList.scss";
12
12
  * then the same width, the row can stay centered like any HkButton
13
13
  * without the columns drifting.
14
14
  *
15
- * The label column is auto-sized: after mount (and whenever the label set
16
- * or the document fonts change) the component measures the widest label
17
- * text and publishes it as `--auth-methods-label-width` on its wrapper,
18
- * so the aligned text region hugs the longest label instead of a blind
19
- * hardcoded width. Consumers can still override the var explicitly.
15
+ * The label column is auto-sized: after mount (and whenever the label set,
16
+ * the document fonts, the browser zoom, or the container size change) the
17
+ * component measures the widest label text and publishes it as
18
+ * `--auth-methods-label-width` on its wrapper, so the aligned text region
19
+ * hugs the longest label instead of a blind hardcoded width. The measured
20
+ * fraction is rounded UP with a 1px margin — the column is always strictly
21
+ * wider than the longest label, so the text can never overflow into an
22
+ * ellipsis. Labels are centered within that shared column, keeping every
23
+ * row's text on the same vertical center line. Consumers can still
24
+ * override the var explicitly.
20
25
  *
21
26
  * The wrapper is `display: contents`: the component keeps a DOM node for
22
27
  * measurement and CSS-var inheritance while the buttons remain direct
@@ -53,12 +58,25 @@ export default defineComponent({
53
58
  setup(props, { emit, expose }) {
54
59
  const listRef = ref<HTMLDivElement | null>(null);
55
60
 
61
+ let resizeObserver: ResizeObserver | undefined;
62
+ const onResize = () => void nextTick(measure);
63
+ const onFontsReady = () => measure();
64
+
56
65
  /** Measure the widest label text and publish it as the label-column
57
66
  * width custom property. Each span is momentarily set to
58
67
  * `max-content` while reading: a plain `scrollWidth` on the fixed
59
68
  * column would report `max(textWidth, columnWidth)` and the column
60
69
  * could never shrink below its fallback. All reads happen inside one
61
- * synchronous task, so no intermediate paint is observable. */
70
+ * synchronous task, so no intermediate paint is observable.
71
+ *
72
+ * Precision: the widest width is read as a fractional
73
+ * `getBoundingClientRect()` (not the integer `scrollWidth`) and
74
+ * rounded UP with a 1px margin before publishing. An integer read can
75
+ * round the longest label DOWN (some engines floor it), pinning its
76
+ * column exactly on the glyph boundary — then any sub-pixel
77
+ * font-metric difference (zoom, DPR, engine) makes that one label
78
+ * overflow and render an ellipsis while the shorter rows stay fine.
79
+ * The headroom keeps the longest label strictly inside its column. */
62
80
  function measure() {
63
81
  const root = listRef.value;
64
82
  if (!root) return;
@@ -66,22 +84,55 @@ export default defineComponent({
66
84
  for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
67
85
  const previous = label.style.width;
68
86
  label.style.width = "max-content";
69
- widest = Math.max(widest, label.scrollWidth);
87
+ widest = Math.max(widest, label.getBoundingClientRect().width);
70
88
  label.style.width = previous;
71
89
  }
72
- if (widest > 0) root.style.setProperty("--auth-methods-label-width", `${widest}px`);
90
+ // Guard: only publish when real text metrics were observed, so a
91
+ // hidden / zero-size frame keeps the CSS `max-content` fallback
92
+ // instead of collapsing the column to a clipping 1px.
93
+ if (widest > 0) {
94
+ root.style.setProperty("--auth-methods-label-width", `${Math.ceil(widest) + 1}px`);
95
+ }
96
+ // Keep observing every rendered row so a layout change (the panel
97
+ // becoming visible, a container width change) re-measures. Rows are
98
+ // full-width `.hk-btn-block`, so publishing the column width never
99
+ // resizes them (no feedback loop into the observer).
100
+ if (resizeObserver) {
101
+ for (const button of root.querySelectorAll<HTMLElement>(".hk-btn")) {
102
+ resizeObserver.observe(button);
103
+ }
104
+ }
73
105
  }
74
106
 
75
107
  onMounted(() => {
76
108
  void nextTick(measure);
77
109
  // Webfonts change text metrics after first paint; re-measure when
78
110
  // they finish loading (no-op in environments without document.fonts).
79
- if (typeof document !== "undefined" && document.fonts?.ready) {
111
+ if (typeof document !== "undefined" && document.fonts) {
80
112
  void document.fonts.ready.then(measure).catch(() => {});
113
+ document.fonts.addEventListener?.("loadingdone", onFontsReady);
114
+ }
115
+ // Browser zoom and container-width changes reflow the text; without a
116
+ // re-measure the published column goes stale and the longest label
117
+ // overflows into the ellipsis. `resize` covers page zoom; the
118
+ // ResizeObserver covers the panel appearing / the container resizing.
119
+ if (typeof window !== "undefined") {
120
+ window.addEventListener("resize", onResize);
121
+ if (!resizeObserver && typeof ResizeObserver !== "undefined") {
122
+ resizeObserver = new ResizeObserver(onResize);
123
+ }
81
124
  }
82
125
  });
83
126
  onBeforeUnmount(() => {
84
127
  listRef.value?.style.removeProperty("--auth-methods-label-width");
128
+ resizeObserver?.disconnect();
129
+ resizeObserver = undefined;
130
+ if (typeof window !== "undefined") {
131
+ window.removeEventListener("resize", onResize);
132
+ }
133
+ if (typeof document !== "undefined" && document.fonts) {
134
+ document.fonts.removeEventListener?.("loadingdone", onFontsReady);
135
+ }
85
136
  });
86
137
  watch(
87
138
  () => props.methods.map((method) => method.label).join("\u0000"),
@@ -35,6 +35,7 @@
35
35
  .s-token-usage-arrow {
36
36
  &[data-direction="in"] { color: rgb(var(--color-warning)); }
37
37
  &[data-direction="out"] { color: rgb(var(--color-success)); }
38
+ &[data-direction="tool"] { color: rgb(var(--color-muted)); }
38
39
  }
39
40
 
40
41
  .s-token-usage-val {
@@ -43,10 +44,6 @@
43
44
  font-variant-numeric: tabular-nums;
44
45
  }
45
46
 
46
- .s-token-usage-icon-tool {
47
- color: rgb(var(--color-muted));
48
- }
49
-
50
47
  .s-token-usage-duration {
51
48
  color: rgb(var(--color-muted));
52
49
  opacity: 0.8;
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, type PropType } from "vue";
2
- import { ArrowDown, ArrowUp } from "lucide-vue-next";
2
+ import { ArrowDown, ArrowLeftRight, ArrowUp } from "lucide-vue-next";
3
3
  import { HRollingNumber } from "@celestia-island/hikari";
4
4
 
5
5
  import { formatTokenCount } from "../utils/format";
@@ -39,7 +39,7 @@ export const HkTokenUsageBadge = defineComponent({
39
39
  </span>
40
40
  {props.toolCount != null && (
41
41
  <span class="s-token-usage-stat">
42
- <span class="s-token-usage-icon-tool">⇄</span>
42
+ <ArrowLeftRight size={props.compact ? 9 : 10} class="s-token-usage-arrow" data-direction="tool" />
43
43
  <span class="s-token-usage-val"><HRollingNumber value={props.toolCount} /></span>
44
44
  </span>
45
45
  )}