@celestia-island/hikari 0.55.11 → 0.55.13

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.11",
3
+ "version": "0.55.13",
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",
package/src/DemoApp.tsx CHANGED
@@ -537,13 +537,14 @@ export default defineComponent({
537
537
  <h2>HEmptyState</h2>
538
538
  <div class="col">
539
539
  <HEmptyState
540
+ boxed
540
541
  title="No items"
541
542
  description="Nothing to show here yet."
542
543
  v-slots={{ action: () => <HButton size="sm">Create item</HButton> }}
543
544
  />
544
545
  <HEmptyState
545
546
  title="Nothing found"
546
- description="Search returned no matches — try a different query."
547
+ description="Bare by default — the host (card/modal/panel) provides the surface; pass boxed when the empty state stands on the page alone."
547
548
  icon={PackageOpen}
548
549
  />
549
550
  <HEmptyState
@@ -187,6 +187,10 @@ export const HkAdminHeader = defineComponent({
187
187
  aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
188
188
  onClick={onAvatarClick}
189
189
  >
190
+ {/* Deliberately NO hover veil here: the trigger opens the user
191
+ * MENU (the avatar edit lives one row down as the Camera
192
+ * item), so a camera overlay would promise click-to-change
193
+ * and lie (user report 2026-09-16). */}
190
194
  {props.avatarUrl && !avatarFailed.value ? (
191
195
  <img
192
196
  src={props.avatarUrl}
@@ -204,11 +208,6 @@ export const HkAdminHeader = defineComponent({
204
208
  {props.username?.charAt(0).toUpperCase() || "?"}
205
209
  </span>
206
210
  )}
207
- {props.avatarAction === "menu" && (
208
- <div class="s-admin-header-avatar-veil">
209
- <Camera size={10} class="s-admin-header-veil-icon" />
210
- </div>
211
- )}
212
211
  </button>
213
212
  {/* WHERE am I — the open view's title, not the nickname. The
214
213
  1.5 line-height (in the class) keeps descenders (g, y, p)
@@ -53,7 +53,7 @@ export const HkAdminTablePage = defineComponent({
53
53
  ) : props.loading && !props.rows.length ? (
54
54
  <HSpinner center />
55
55
  ) : !props.rows.length ? (
56
- <HEmptyState title={emptyTitle} description={props.emptyDescription} />
56
+ <HEmptyState boxed title={emptyTitle} description={props.emptyDescription} />
57
57
  ) : (
58
58
  <HCard padded={false}>
59
59
  <HTable columns={props.columns} rows={props.rows} rowKey={props.rowKey}>
@@ -104,7 +104,7 @@ describe("HkButton click guard", () => {
104
104
 
105
105
  it("clears the loading state back to a clickable button (aria-busy drops off)", () => {
106
106
  const busy = mount(h(HkButton, { loading: true }, () => "x"));
107
- expect(busy.querySelector(".hk-spinner")).not.toBeNull();
107
+ expect(busy.querySelector(".hk-btn-ring")).not.toBeNull();
108
108
  const idle = mount(h(HkButton, () => "x"));
109
109
  const btn = button(idle);
110
110
  expect(idle.querySelector(".hk-spinner")).toBeNull();
@@ -123,7 +123,7 @@ describe("HkButton content slots", () => {
123
123
  // Loading swaps the icon out for the spinner — never both.
124
124
  const busy = mount(h(HkButton, { icon: "close", loading: true }, () => "x"));
125
125
  expect(busy.querySelector(".hk-btn-icon")).toBeNull();
126
- expect(busy.querySelector(".hk-spinner")).not.toBeNull();
126
+ expect(busy.querySelector(".hk-btn-ring")).not.toBeNull();
127
127
  });
128
128
 
129
129
  it("renders the suffix icon slot", () => {
@@ -66,7 +66,51 @@ export default defineComponent({
66
66
  aria-busy={props.loading || undefined}
67
67
  onClick={(e) => emit("click", e)}
68
68
  >
69
- {props.loading ? <HSpinner size="xs" tone="current" /> : null}
69
+ {props.loading ? (
70
+ /* Loading ring as inline SVG + SMIL: renders identically in
71
+ WebView2 hosts where CSS-border rings got lost to border-
72
+ color pipelines or where the animation context suspends CSS
73
+ animations in rAF-starved background windows (user report
74
+ 2026-09-16: an empty gap where the login ring should be).
75
+ Stroke follows currentcolor; SMIL rotates independently of
76
+ the CSS animation suspension switch. */
77
+ <svg
78
+ class="hk-btn-ring"
79
+ viewBox="0 0 16 16"
80
+ width="14"
81
+ height="14"
82
+ aria-hidden="true"
83
+ >
84
+ <circle
85
+ cx="8"
86
+ cy="8"
87
+ r="6.5"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ stroke-opacity="0.3"
91
+ stroke-width="1.8"
92
+ />
93
+ <circle
94
+ cx="8"
95
+ cy="8"
96
+ r="6.5"
97
+ fill="none"
98
+ stroke="currentColor"
99
+ stroke-width="1.8"
100
+ stroke-linecap="round"
101
+ stroke-dasharray="30 11"
102
+ >
103
+ <animateTransform
104
+ attributeName="transform"
105
+ type="rotate"
106
+ from="0 8 8"
107
+ to="360 8 8"
108
+ dur="0.7s"
109
+ repeatCount="indefinite"
110
+ />
111
+ </circle>
112
+ </svg>
113
+ ) : null}
70
114
  {!props.loading && props.icon ? (
71
115
  <span class="hk-btn-icon">
72
116
  <HIcon name={props.icon} size={16} />
@@ -5,11 +5,17 @@
5
5
  justify-content: center;
6
6
  padding: var(--space-48, 3rem) var(--space-16, 1rem);
7
7
  text-align: center;
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ /* The component's own surface. Opt-in (`boxed`): the host — card, modal,
12
+ panel — usually already provides a surface, and an always-on box turned
13
+ every in-card empty state into a nested placeholder layer. */
14
+ .hk-empty-state--boxed {
8
15
  background: var(--hk-empty-bg, var(--hi-color-surface, rgba(240,244,248,0.95)));
9
16
  backdrop-filter: blur(var(--blur-sm, 8px));
10
17
  border: 1px solid var(--hi-color-border, rgba(100,100,100,0.06));
11
18
  border-radius: var(--radius-md, 0.5rem);
12
- box-sizing: border-box;
13
19
  }
14
20
 
15
21
  .hk-empty-icon {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Source contract: the empty state's surface is OPT-IN (`boxed`). The
3
+ * base `.hk-empty-state` class must stay a bare layout block — the four
4
+ * surface declarations (background, blur, border, radius) live only in
5
+ * `.hk-empty-state--boxed`. Pinned because every in-card empty state in
6
+ * the family reads as a second nested placeholder layer the moment the
7
+ * surface creeps back into the base class (hikari #567); a class-name
8
+ * test alone cannot catch that, the compiled contract lives here.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+
17
+ const SURFACE_PROPERTIES = [
18
+ "background:",
19
+ "backdrop-filter:",
20
+ "border:",
21
+ "border-radius:",
22
+ ] as const;
23
+
24
+ /** Extract one top-level rule block's body (`{ ... }`) by selector. */
25
+ function ruleBody(css: string, selector: string): string {
26
+ const start = css.indexOf(`${selector} {`);
27
+ expect(start, `rule ${selector} present`).toBeGreaterThanOrEqual(0);
28
+ const open = css.indexOf("{", start);
29
+ const close = css.indexOf("}", open);
30
+ return css.slice(open + 1, close);
31
+ }
32
+
33
+ describe("HkEmptyState surface contract", () => {
34
+ const css = readFileSync(join(here, "HkEmptyState.scss"), "utf-8");
35
+
36
+ it("keeps the base class free of surface declarations", () => {
37
+ const base = ruleBody(css, ".hk-empty-state");
38
+ for (const property of SURFACE_PROPERTIES) {
39
+ expect(base).not.toContain(property);
40
+ }
41
+ });
42
+
43
+ it("declares the full surface on the opt-in --boxed rule only", () => {
44
+ const boxed = ruleBody(css, ".hk-empty-state--boxed");
45
+ for (const property of SURFACE_PROPERTIES) {
46
+ expect(boxed).toContain(property);
47
+ }
48
+ // The surface is the ONLY rule allowed to paint: with the --boxed rule
49
+ // (and comments, whose prose may name the properties) removed, no
50
+ // declaration of a surface property may remain anywhere in the sheet.
51
+ // (The sr-only reset's `border: 0` and box-sizing's `border-box` are
52
+ // not surfaces — the assertions match the painted forms.)
53
+ const commentFree = css.replace(/\/\*[\s\S]*?\*\//g, "");
54
+ const withoutBoxed = commentFree.replace(
55
+ /\.hk-empty-state--boxed\s*\{[^}]*\}/g,
56
+ "",
57
+ );
58
+ expect(withoutBoxed).not.toContain("background:");
59
+ expect(withoutBoxed).not.toContain("backdrop-filter:");
60
+ expect(withoutBoxed).not.toContain("border: 1px");
61
+ expect(withoutBoxed).not.toContain("border-radius:");
62
+ });
63
+ });
@@ -135,4 +135,16 @@ describe("HkEmptyState", () => {
135
135
  expect(cls).toContain("hk-empty-state--fill");
136
136
  expect(cls).not.toContain("hk-empty-state--page");
137
137
  });
138
+
139
+ it("does not draw its own surface by default (the host provides it)", () => {
140
+ const c = mount(h(HkEmptyState, { title: "x" }));
141
+ const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
142
+ expect(cls).not.toContain("hk-empty-state--boxed");
143
+ });
144
+
145
+ it("opt-in boxed restores the surface modifier", () => {
146
+ const c = mount(h(HkEmptyState, { title: "x", boxed: true }));
147
+ const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
148
+ expect(cls).toContain("hk-empty-state--boxed");
149
+ });
138
150
  });
@@ -19,6 +19,12 @@ export default defineComponent({
19
19
  type: String as PropType<"page" | "fill">,
20
20
  default: "page",
21
21
  },
22
+ /** Draw the component's own surface (background, border, blur). Off by
23
+ * default: most hosts (cards, modals, panels) already provide a
24
+ * surface, and a boxed empty state inside them reads as a second
25
+ * nested placeholder layer. Set this only when the empty state stands
26
+ * directly on the page with no outer frame of its own. */
27
+ boxed: { type: Boolean, default: false },
22
28
  },
23
29
  setup(props, { slots }) {
24
30
  const { t } = useI18n();
@@ -28,6 +34,7 @@ export default defineComponent({
28
34
  "hk-empty-state",
29
35
  props.fit === "fill" ? "hk-empty-state--fill" : "hk-empty-state--page",
30
36
  props.loading && "hk-empty-state--loading",
37
+ props.boxed && "hk-empty-state--boxed",
31
38
  ]}
32
39
  role={props.loading ? "status" : undefined}
33
40
  aria-busy={props.loading ? "true" : undefined}