@celestia-island/hikari 0.41.8 → 0.41.9

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.41.8",
3
+ "version": "0.41.9",
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",
@@ -64,6 +64,62 @@
64
64
  min-height: 2.75rem;
65
65
  }
66
66
 
67
+ /* ---- Prefix / suffix icon boxes ---- */
68
+ /* Standard-part contract (2026-09-10 user direction): a button glyph
69
+ renders inside a FIXED SQUARE box — flex:none so host flex rows can
70
+ never squeeze it into a squat sliver, centered on both axes so the
71
+ icon rides the button's vertical midline at any button height, and
72
+ the box (not the bare svg) owns the rhythm so the glyph keeps a
73
+ consistent margin from the adjacent label via the button's own gap.
74
+ The 1rem box matches the 16px glyph HkButton renders for the
75
+ icon/suffix props. */
76
+ .hk-btn-icon,
77
+ .hk-btn-suffix {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ flex: none;
82
+ inline-size: 1rem;
83
+ block-size: 1rem;
84
+ line-height: 0;
85
+
86
+ svg {
87
+ display: block;
88
+ }
89
+ }
90
+
91
+ /* ---- Icon-only square ---- */
92
+ /* An icon-only HkButton (glyph, no label) occupies a SQUARE footprint
93
+ by default: padding collapses fully and width locks to the size
94
+ variant's min-height, so it reads as the same standard square the
95
+ HIconButton family guarantees. box-sizing is self-declared (house
96
+ convention — geometry rules never rely on a host-side reset reaching
97
+ the component): with zero padding the min-height governs the height
98
+ on every size, an exact square under border-box AND content-box.
99
+ Hosts may still override with an explicit width when a design
100
+ genuinely calls for another shape. */
101
+ .hk-btn-icon-only {
102
+ padding: 0;
103
+ box-sizing: border-box;
104
+ }
105
+
106
+ .hk-btn-sm.hk-btn-icon-only {
107
+ width: 1.75rem;
108
+ }
109
+
110
+ .hk-btn-md.hk-btn-icon-only {
111
+ width: 2.5rem;
112
+ }
113
+
114
+ .hk-btn-lg.hk-btn-icon-only {
115
+ width: 2.75rem;
116
+ }
117
+
118
+ /* The first-class block prop wins over the square width. */
119
+ .hk-btn-block.hk-btn-icon-only {
120
+ width: 100%;
121
+ }
122
+
67
123
  /* ---- Primary ---- */
68
124
 
69
125
  .hk-btn-primary {
@@ -1,8 +1,13 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, h } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
 
4
7
  import HkButton from "./HkButton";
5
8
 
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+
6
11
  /**
7
12
  * HkButton contract tests:
8
13
  * - variant / size / block / loading class mapping on the root <button>
@@ -142,6 +147,82 @@ describe("HkButton content slots", () => {
142
147
  });
143
148
  });
144
149
 
150
+ describe("HkButton icon-only square contract", () => {
151
+ it("flags the icon-only square class for a glyph with no label", () => {
152
+ const iconOnly = mount(h(HkButton, { icon: "close", ariaLabel: "Close" }));
153
+ const btn = button(iconOnly);
154
+ expect(btn.className).toContain("hk-btn-icon-only");
155
+ expect(iconOnly.querySelector(".hk-btn-icon")).not.toBeNull();
156
+
157
+ const suffixOnly = mount(h(HkButton, { suffix: "close", ariaLabel: "Next" }));
158
+ expect(button(suffixOnly).className).toContain("hk-btn-icon-only");
159
+
160
+ // Glyph + text stays a normal text button — no square collapse.
161
+ const withText = mount(h(HkButton, { icon: "close" }, () => "Close"));
162
+ expect(button(withText).className).not.toContain("hk-btn-icon-only");
163
+ });
164
+
165
+ it("keeps the square footprint while an icon-only button loads", () => {
166
+ const c = mount(h(HkButton, { icon: "close", loading: true, ariaLabel: "Busy" }));
167
+ // The spinner replaces the glyph, but the button still carries no
168
+ // label — the square footprint must hold through the swap.
169
+ expect(button(c).className).toContain("hk-btn-icon-only");
170
+ });
171
+
172
+ it("keeps the block class alongside the icon-only square class", () => {
173
+ // CSS precedence lives in the SCSS contract test below; this pins the
174
+ // class composition so both rules can actually apply.
175
+ const c = mount(h(HkButton, { icon: "close", block: true, ariaLabel: "Go" }));
176
+ const cls = button(c).className;
177
+ expect(cls).toContain("hk-btn-block");
178
+ expect(cls).toContain("hk-btn-icon-only");
179
+ });
180
+
181
+ it("does not square-collapse when a shortcut chip rides along", () => {
182
+ // The HKbd chip is an extra flex child — the fixed square width would
183
+ // visibly clip it, so icon + shortcut stays a padded button.
184
+ const c = mount(h(HkButton, { icon: "close", shortcut: "Ctrl+W", ariaLabel: "Close" }));
185
+ expect(button(c).className).not.toContain("hk-btn-icon-only");
186
+ });
187
+ });
188
+
189
+ // SCSS geometry contract (house pattern, cf. HkNumberInput.affix.test.ts):
190
+ // the square is a CSS fact — padding fully collapsed + self-declared
191
+ // border-box (survives hosts without a reset) + the block prop winning
192
+ // over the square width. Textual assertions because happy-dom does not
193
+ // lay out.
194
+ describe("HkButton icon-only SCSS contract", () => {
195
+ const scss = readFileSync(join(here, "HkButton.scss"), "utf-8");
196
+
197
+ it("self-declares the collapsed square geometry", () => {
198
+ const rule = scss.match(/\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
199
+ expect(rule, "icon-only rule must exist").toBeTruthy();
200
+ expect(rule).toContain("padding: 0");
201
+ expect(rule).toContain("box-sizing: border-box");
202
+ });
203
+
204
+ it("lets the block prop win over the square width", () => {
205
+ const rule = scss.match(/\.hk-btn-block\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
206
+ expect(rule, "block override rule must exist").toBeTruthy();
207
+ expect(rule).toContain("width: 100%");
208
+ });
209
+
210
+ it("locks each size variant width to its min-height", () => {
211
+ const cases: Array<[string, string]> = [
212
+ ["sm", "1.75rem"],
213
+ ["md", "2.5rem"],
214
+ ["lg", "2.75rem"],
215
+ ];
216
+ for (const [size, width] of cases) {
217
+ const rule = scss.match(new RegExp(`\\.hk-btn-${size}\\.hk-btn-icon-only\\s*{[^}]*}`))?.[0] ?? "";
218
+ expect(rule, `${size} icon-only rule must exist`).toBeTruthy();
219
+ expect(rule).toContain(`width: ${width}`);
220
+ const minH = scss.match(new RegExp(`\\.hk-btn-${size}\\s*{[^}]*}`))?.[0] ?? "";
221
+ expect(minH).toContain(`min-height: ${width}`);
222
+ }
223
+ });
224
+ });
225
+
145
226
  describe("HkButton attr fallthrough (inheritAttrs: false)", () => {
146
227
  it("spreads extra attrs and honors an explicit type", () => {
147
228
  const c = mount(h(HkButton, { type: "submit", "data-test": "probe" }, () => "Save"));
@@ -42,33 +42,47 @@ export default defineComponent({
42
42
  props.shortcut ? "hk-btn-has-shortcut" : "",
43
43
  ]);
44
44
 
45
- return () => (
46
- <button
47
- {...attrs}
48
- type={(attrs.type as "button" | "submit" | "reset") || "button"}
49
- disabled={props.disabled || props.loading}
50
- class={[buttonClass.value, attrs.class]}
51
- style={attrs.style || undefined}
52
- aria-label={props.ariaLabel}
53
- aria-busy={props.loading || undefined}
54
- onClick={(e) => emit("click", e)}
55
- >
56
- {props.loading ? <HSpinner size="xs" tone="current" /> : null}
57
- {!props.loading && props.icon ? (
58
- <span class="hk-btn-icon">
59
- <HIcon name={props.icon} size={16} />
60
- </span>
61
- ) : null}
62
- {slots.default?.()}
63
- {props.suffix ? (
64
- <span class="hk-btn-suffix">
65
- <HIcon name={props.suffix} size={16} />
66
- </span>
67
- ) : null}
68
- {props.shortcut ? (
69
- <HKbd keys={props.shortcut!} size="sm" />
70
- ) : null}
71
- </button>
72
- );
45
+ return () => {
46
+ // Icon-only contract (2026-09-10 user direction): a button carrying
47
+ // just a prefix/suffix glyph and no text collapses to the same
48
+ // square footprint the HIconButton family guarantees — a bare icon
49
+ // in a padded text button reads as a squat rectangle otherwise.
50
+ // Re-evaluated per render, NOT a computed: slots.default is not
51
+ // reactive, so a cached flag would go stale when a parent toggles
52
+ // the label slot without touching the icon props. A shortcut chip
53
+ // is excluded: it renders as an extra flex child the fixed square
54
+ // width would visibly clip.
55
+ const iconOnly =
56
+ !slots.default && Boolean(props.icon || props.suffix) && !props.shortcut;
57
+
58
+ return (
59
+ <button
60
+ {...attrs}
61
+ type={(attrs.type as "button" | "submit" | "reset") || "button"}
62
+ disabled={props.disabled || props.loading}
63
+ class={[buttonClass.value, iconOnly ? "hk-btn-icon-only" : "", attrs.class]}
64
+ style={attrs.style || undefined}
65
+ aria-label={props.ariaLabel}
66
+ aria-busy={props.loading || undefined}
67
+ onClick={(e) => emit("click", e)}
68
+ >
69
+ {props.loading ? <HSpinner size="xs" tone="current" /> : null}
70
+ {!props.loading && props.icon ? (
71
+ <span class="hk-btn-icon">
72
+ <HIcon name={props.icon} size={16} />
73
+ </span>
74
+ ) : null}
75
+ {slots.default?.()}
76
+ {props.suffix ? (
77
+ <span class="hk-btn-suffix">
78
+ <HIcon name={props.suffix} size={16} />
79
+ </span>
80
+ ) : null}
81
+ {props.shortcut ? (
82
+ <HKbd keys={props.shortcut!} size="sm" />
83
+ ) : null}
84
+ </button>
85
+ );
86
+ };
73
87
  },
74
88
  });
@@ -102,4 +102,19 @@ describe("HkIconButton", () => {
102
102
  const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
103
103
  expect(c.querySelector("circle")).not.toBeNull();
104
104
  });
105
+
106
+ // Named-icon contract (2026-09-10): the icon prop resolves through HIcon
107
+ // (functional keys ride the host material pack) — slot content still
108
+ // wins over it, and the placeholder circle only shows with neither.
109
+ it("renders the icon prop through HIcon, below slot precedence", () => {
110
+ const propOnly = mountComp(() => h(HkIconButton, { icon: "close", "aria-label": "close" }));
111
+ expect(propOnly.querySelector(".hk-icon")).not.toBeNull();
112
+ expect(propOnly.querySelector("circle")).toBeNull();
113
+
114
+ const propAndSlot = mountComp(() =>
115
+ h(HkIconButton, { icon: "close", "aria-label": "close" }, { icon: () => h("span", { class: "slot-probe" }) }),
116
+ );
117
+ expect(propAndSlot.querySelector(".slot-probe")).not.toBeNull();
118
+ expect(propAndSlot.querySelector(".hk-icon")).toBeNull();
119
+ });
105
120
  });
@@ -1,6 +1,7 @@
1
1
  import { computed, defineComponent, type PropType } from "vue";
2
2
  import "./HkIconButton.scss";
3
3
  import "./HkIconButtonVars.scss";
4
+ import HIcon from "./HkIcon";
4
5
 
5
6
  export default defineComponent({
6
7
  name: "HkIconButton",
@@ -21,6 +22,14 @@ export default defineComponent({
21
22
  `hk-icon-button-${props.variant}`,
22
23
  ]);
23
24
 
25
+ // Named-icon contract (2026-09-10 user direction): the icon prop is a
26
+ // first-class way to draw the glyph — it resolves through HIcon so
27
+ // functional keys ("close", "back") keep riding the host material-pack
28
+ // family exactly like the slot composition does. The rendered size is
29
+ // pinned by --hi-icon-button-icon-size; the HIcon size prop only picks
30
+ // the matching box class. Slot content still wins over the prop.
31
+ const glyphSize = computed(() => (props.size >= 32 ? 20 : 16));
32
+
24
33
  return () => (
25
34
  <button
26
35
  class={cls.value}
@@ -35,11 +44,15 @@ export default defineComponent({
35
44
  ? // Natural children usage: <HIconButton><HIcon .../></HIconButton>
36
45
  // renders the child as the icon (unified window-close wave).
37
46
  slots.default()
38
- : (
39
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
40
- <circle cx="12" cy="12" r="10" />
41
- </svg>
42
- )}
47
+ : props.icon
48
+ ? (
49
+ <HIcon name={props.icon} size={glyphSize.value} />
50
+ )
51
+ : (
52
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
53
+ <circle cx="12" cy="12" r="10" />
54
+ </svg>
55
+ )}
43
56
  </span>
44
57
  </button>
45
58
  );
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Info } from "lucide-vue-next";
3
+
4
+ import { iconByName } from "./iconRegistry";
5
+
6
+ // Glyph-expansion contract (2026-09-10): downstream audits (chest / e.cw /
7
+ // erp.cw icon waves) found these icon-prop names silently rendering the
8
+ // `Info` fallback because the lucide exports were missing from the
9
+ // tree-shakeable registry. `minus` and `copy` are chest adversarial-verifier
10
+ // finds that shipped as Info-fallback regressions. Each entry must resolve
11
+ // to its real glyph — asserted against the unknown-name fallback, not just
12
+ // against `Info`, so a future fallback swap cannot silently pass this suite.
13
+ const NEW_GLYPH_KEBAB = [
14
+ "log-out", "sparkles", "upload", "camera", "flag", "download", "image",
15
+ "video", "save", "braces", "history", "rotate-cw", "key-round",
16
+ "scan-search", "ban", "filter", "minus", "copy",
17
+ ] as const;
18
+
19
+ const NEW_GLYPH_PASCAL = [
20
+ "LogOut", "Sparkles", "Upload", "Camera", "Flag", "Download", "Image",
21
+ "Video", "Save", "Braces", "History", "RotateCw", "KeyRound",
22
+ "ScanSearch", "Ban", "Filter", "Minus", "Copy",
23
+ ] as const;
24
+
25
+ describe("icon registry glyph expansion", () => {
26
+ const fallback = iconByName("totally-unknown-name");
27
+
28
+ it("resolves unknown names to the Info fallback", () => {
29
+ expect(fallback).toBe(Info);
30
+ });
31
+
32
+ it("keeps the kebab-case lookup distinct from the fallback for every new glyph", () => {
33
+ for (const kebab of NEW_GLYPH_KEBAB) {
34
+ const resolved = iconByName(kebab);
35
+ expect(resolved, `iconByName(${kebab}) hit the fallback`).not.toBe(fallback);
36
+ expect(resolved).not.toBe(Info);
37
+ }
38
+ });
39
+
40
+ it("resolves the PascalCase export names directly", () => {
41
+ for (const pascal of NEW_GLYPH_PASCAL) {
42
+ const resolved = iconByName(pascal);
43
+ expect(resolved, `iconByName(${pascal}) hit the fallback`).not.toBe(fallback);
44
+ expect(resolved).not.toBe(Info);
45
+ }
46
+ });
47
+
48
+ it("maps kebab-case and PascalCase spellings to the same glyph", () => {
49
+ for (const [kebab, pascal] of NEW_GLYPH_KEBAB.map((k, i) => [k, NEW_GLYPH_PASCAL[i]] as const)) {
50
+ expect(iconByName(kebab)).toBe(iconByName(pascal));
51
+ }
52
+ });
53
+
54
+ it("still resolves the Info fallback for unknown names after the expansion", () => {
55
+ expect(iconByName("no-such-glyph-at-all")).toBe(fallback);
56
+ });
57
+ });
@@ -8,7 +8,7 @@
8
8
  // badges, nav tags) resolve without the wildcard import. Unknown names fall
9
9
  // back to `Info`, matching the previous `|| LucideIcons.Info` behavior.
10
10
 
11
- import { Activity, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Battery, Bell, Bot, Box, Brain, Cable, Calendar, ChartBarBig as BarChart3, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleAlert as AlertCircle, CircleCheck as CheckCircle, CircleCheckBig as CheckCircle2, CircleDot, Clock, Cpu, Database, Dot, Ellipsis as MoreHorizontal, ExternalLink, Eye, FileArchive, FileCode, FileImage, FileText, Flame, FolderOpen, Gauge, Globe, HardDrive, Info, Kanban, Key, Layers, LayoutDashboard, LoaderCircle as Loader2, Lock, Maximize, Maximize2, MessageSquare, Mic, MicOff, Minimize, Monitor, Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Share2, Shield, Star, Table, Tag, Thermometer, Trash2, TriangleAlert as AlertTriangle, Volume2, VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut } from "lucide-vue-next";
11
+ import { Activity, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Ban, Battery, Bell, Bot, Box, Braces, Brain, Cable, Calendar, Camera, ChartBarBig as BarChart3, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleAlert as AlertCircle, CircleCheck as CheckCircle, CircleCheckBig as CheckCircle2, CircleDot, Clock, Copy, Cpu, Database, Dot, Download, Ellipsis as MoreHorizontal, ExternalLink, Eye, FileArchive, FileCode, FileImage, FileText, Filter, Flag, Flame, FolderOpen, Gauge, Globe, HardDrive, History, Image, Info, Kanban, Key, KeyRound, Layers, LayoutDashboard, LoaderCircle as Loader2, LogOut, Lock, Maximize, Maximize2, MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw, RotateCw, ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield, Sparkles, Star, Table, Tag, Thermometer, Trash2, TriangleAlert as AlertTriangle, Upload, Video, Volume2, VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut } from "lucide-vue-next";
12
12
  import type { Component } from "vue";
13
13
 
14
14
 
@@ -98,16 +98,19 @@ import type { Component } from "vue";
98
98
 
99
99
  const REGISTRY: Record<string, Component> = {
100
100
  Activity, AlertCircle, AlertTriangle, ArrowDown, ArrowLeft, ArrowRight,
101
- ArrowUp, BarChart3, Battery, Bell, Bot, Box, Brain, Cable, Calendar,
102
- Check, CheckCircle, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight,
103
- ChevronUp, Circle, CircleDot, Clock, Cpu, Database, Dot, ExternalLink, Eye,
104
- FileArchive, FileCode, FileImage, FileText, Flame,
105
- FolderOpen, Gauge, Globe, HardDrive, Info, Kanban, Key, Layers,
106
- LayoutDashboard, Loader2, Lock, Maximize, Maximize2, MessageSquare, Mic,
107
- MicOff, Minimize, Monitor, MoreHorizontal, Network, Package, Pause, Pencil,
108
- Play, Plug, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Share2,
109
- Shield, Star, Table, Tag, Thermometer, Trash2, Volume2, VolumeX, Webhook,
110
- Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut,
101
+ ArrowUp, Ban, BarChart3, Battery, Bell, Bot, Box, Braces, Brain, Cable,
102
+ Calendar, Camera, Check, CheckCircle, CheckCircle2, ChevronDown,
103
+ ChevronLeft, ChevronRight, ChevronUp, Circle, CircleDot, Clock, Copy, Cpu,
104
+ Database, Dot, Download, ExternalLink, Eye, FileArchive, FileCode,
105
+ FileImage, FileText, Filter, Flag, Flame, FolderOpen, Gauge, Globe,
106
+ HardDrive, History, Image, Info, Kanban, Key, KeyRound, Layers,
107
+ LayoutDashboard, Loader2, LogOut, Lock, Maximize, Maximize2,
108
+ MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, MoreHorizontal,
109
+ Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw,
110
+ RotateCw,
111
+ ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield,
112
+ Sparkles, Star, Table, Tag, Thermometer, Trash2, Upload, Video, Volume2,
113
+ VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut,
111
114
  };
112
115
 
113
116
  /**