@celestia-island/hikari 0.55.163 → 0.55.165

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.163",
3
+ "version": "0.55.165",
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",
@@ -0,0 +1,132 @@
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.
9
+ */
10
+
11
+ .hk-corner-badge-anchor {
12
+ position: relative;
13
+ display: inline-flex;
14
+ }
15
+
16
+ .hk-corner-badge {
17
+ 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. */
20
+ transform: translate(var(--hk-corner-badge-tx, 50%), var(--hk-corner-badge-ty, -50%));
21
+ pointer-events: none;
22
+ white-space: nowrap;
23
+ }
24
+
25
+ .hk-corner-badge[data-corner="top-right"] {
26
+ top: var(--hk-corner-badge-offset, 0px);
27
+ right: var(--hk-corner-badge-offset, 0px);
28
+ --hk-corner-badge-tx: 50%;
29
+ --hk-corner-badge-ty: -50%;
30
+ }
31
+
32
+ .hk-corner-badge[data-corner="top-left"] {
33
+ top: var(--hk-corner-badge-offset, 0px);
34
+ left: var(--hk-corner-badge-offset, 0px);
35
+ --hk-corner-badge-tx: -50%;
36
+ --hk-corner-badge-ty: -50%;
37
+ }
38
+
39
+ .hk-corner-badge[data-corner="bottom-right"] {
40
+ bottom: var(--hk-corner-badge-offset, 0px);
41
+ right: var(--hk-corner-badge-offset, 0px);
42
+ --hk-corner-badge-tx: 50%;
43
+ --hk-corner-badge-ty: 50%;
44
+ }
45
+
46
+ .hk-corner-badge[data-corner="bottom-left"] {
47
+ bottom: var(--hk-corner-badge-offset, 0px);
48
+ left: var(--hk-corner-badge-offset, 0px);
49
+ --hk-corner-badge-tx: -50%;
50
+ --hk-corner-badge-ty: 50%;
51
+ }
52
+
53
+ /* Dot form: a filled circle in the variant's tone (same token trio
54
+ * HkBadge uses — variant classes below set all three channels). */
55
+ .hk-corner-badge--dot {
56
+ width: 8px;
57
+ height: 8px;
58
+ border-radius: 50%;
59
+ background: var(--hk-badge-bg);
60
+ box-shadow: 0 0 0 1.5px rgb(var(--color-surface) / 85%);
61
+ }
62
+
63
+ /* Pill form: the compact count/label chip (18px min, text-4xs mono
64
+ * digits — notification-badge geometry across the family). */
65
+ .hk-corner-badge--pill {
66
+ min-width: 18px;
67
+ height: 18px;
68
+ padding: 0 5px;
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ border-radius: var(--radius-full, 999px);
73
+ background: var(--hk-badge-bg);
74
+ color: var(--hk-badge-text);
75
+ border: 1px solid var(--hk-badge-border);
76
+ font-size: var(--text-4xs, 0.625rem);
77
+ font-weight: 700;
78
+ line-height: 1;
79
+ font-variant-numeric: tabular-nums;
80
+ }
81
+
82
+ /* Variant tones — the same channel triplets HkBadge.scss ships; kept
83
+ * literal here so the corner badge owns its own (tiny) sheet. */
84
+ .hk-corner-badge-default {
85
+ --hk-badge-bg: rgb(var(--color-primary) / 12%);
86
+ --hk-badge-text: rgb(var(--color-text));
87
+ --hk-badge-border: rgb(var(--color-primary) / 35%);
88
+ }
89
+
90
+ .hk-corner-badge-primary {
91
+ --hk-badge-bg: rgb(var(--color-primary));
92
+ --hk-badge-text: rgb(var(--color-on-primary, 255 255 255));
93
+ --hk-badge-border: transparent;
94
+ }
95
+
96
+ .hk-corner-badge-success {
97
+ --hk-badge-bg: rgb(var(--color-success));
98
+ --hk-badge-text: rgb(var(--color-on-primary, 255 255 255));
99
+ --hk-corner-badge-ring: rgb(var(--color-surface) / 85%);
100
+ --hk-badge-border: transparent;
101
+ }
102
+
103
+ .hk-corner-badge-error {
104
+ --hk-badge-bg: rgb(var(--color-error));
105
+ --hk-badge-text: rgb(var(--color-on-primary, 255 255 255));
106
+ --hk-badge-border: transparent;
107
+ }
108
+
109
+ .hk-corner-badge-warning {
110
+ --hk-badge-bg: rgb(var(--color-warning));
111
+ --hk-badge-text: rgb(var(--color-text));
112
+ --hk-badge-border: transparent;
113
+ }
114
+
115
+ .hk-corner-badge-info {
116
+ --hk-badge-bg: rgb(var(--color-info, var(--color-primary)));
117
+ --hk-badge-text: rgb(var(--color-on-primary, 255 255 255));
118
+ --hk-badge-border: transparent;
119
+ }
120
+
121
+ .hk-corner-badge-muted {
122
+ --hk-badge-bg: rgb(var(--color-text) / 55%);
123
+ --hk-badge-text: rgb(var(--color-surface));
124
+ --hk-badge-border: transparent;
125
+ }
126
+
127
+ /* The success dot's ring token feeds the dot form's surface cutout so
128
+ * the mark reads ON the anchor, not under it (the icon-group marker's
129
+ * trick, generalized). */
130
+ .hk-corner-badge-success.hk-corner-badge--dot {
131
+ box-shadow: 0 0 0 1.5px var(--hk-corner-badge-ring, rgb(var(--color-surface) / 85%));
132
+ }
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
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.
13
+ * Vitest stubs CSS, so the runtime suite can never catch a lost rule.
14
+ */
15
+
16
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
17
+
18
+ const css = compile(resolve(componentDir, "HkCornerBadge.scss"), {
19
+ style: "expanded",
20
+ }).css;
21
+
22
+ function ruleFor(selector: string): string {
23
+ const start = css.indexOf(selector);
24
+ expect(start, `${selector} present in the compiled sheet`).toBeGreaterThanOrEqual(0);
25
+ const open = css.indexOf("{", start);
26
+ const close = css.indexOf("}", open);
27
+ return css.slice(start, close + 1);
28
+ }
29
+
30
+ describe("HkCornerBadge stylesheet contract", () => {
31
+ it("compiles", () => {
32
+ expect(css).toContain(".hk-corner-badge");
33
+ });
34
+
35
+ it("the anchor is a relative inline-flex wrapper (any child carries the mark)", () => {
36
+ const rule = ruleFor(".hk-corner-badge-anchor {");
37
+ expect(rule).toContain("position: relative");
38
+ expect(rule).toContain("display: inline-flex");
39
+ });
40
+
41
+ it("the mark never takes pointer events and never wraps", () => {
42
+ const rule = ruleFor(".hk-corner-badge {");
43
+ expect(rule).toContain("position: absolute");
44
+ expect(rule).toContain("pointer-events: none");
45
+ expect(rule).toContain("white-space: nowrap");
46
+ });
47
+
48
+ 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%");
53
+ });
54
+
55
+ it("the dot is round and the pill is a tabular-numbers chip", () => {
56
+ const dot = ruleFor(".hk-corner-badge--dot {");
57
+ expect(dot).toContain("border-radius: 50%");
58
+ const pill = ruleFor(".hk-corner-badge--pill {");
59
+ expect(pill).toContain("font-variant-numeric: tabular-nums");
60
+ });
61
+ });
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkCornerBadge from "./HkCornerBadge";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mount(node: ReturnType<typeof h>) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({ render: () => node });
12
+ app.mount(container);
13
+ mounts.push({ app, container });
14
+ return container;
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const { app, container } of mounts.splice(0)) {
19
+ app.unmount();
20
+ container.remove();
21
+ }
22
+ });
23
+
24
+ describe("HkCornerBadge — the generic corner mark (2026-10-15)", () => {
25
+ it("anchors the default slot in a relative wrapper with no mark by default", () => {
26
+ const c = mount(h(HkCornerBadge, null, () => h("button", { class: "probe" }, "x")));
27
+ const anchor = c.querySelector(".hk-corner-badge-anchor")!;
28
+ expect(anchor, "anchor wrapper present").toBeTruthy();
29
+ // (The relative/inline-flex anchoring is pinned by the compiled-css
30
+ // contract test — vitest stubs styles, so no computed assertions.)
31
+ expect(c.querySelector(".probe"), "slot content rendered").toBeTruthy();
32
+ expect(c.querySelector(".hk-corner-badge")).toBeNull();
33
+ });
34
+
35
+ it("dot form: a variant-colored dot at the requested corner, decorative by default", () => {
36
+ const c = mount(
37
+ h(HkCornerBadge, { dot: true, variant: "success", corner: "bottom-right" }, () => h("i")),
38
+ );
39
+ const badge = c.querySelector(".hk-corner-badge")!;
40
+ expect(badge).toBeTruthy();
41
+ expect(badge.className).toContain("hk-corner-badge--dot");
42
+ expect(badge.className).toContain("hk-corner-badge-success");
43
+ expect(badge.getAttribute("data-corner")).toBe("bottom-right");
44
+ expect(badge.getAttribute("aria-hidden")).toBe("true");
45
+ expect(badge.getAttribute("role")).toBeNull();
46
+ });
47
+
48
+ it("count form: renders the number, caps past max, hides zero unless showZero", () => {
49
+ const c = mount(h(HkCornerBadge, { count: 7 }, () => h("i")));
50
+ expect(c.querySelector(".hk-corner-badge")!.textContent).toBe("7");
51
+ const capped = mount(h(HkCornerBadge, { count: 130, max: 99 }, () => h("i")));
52
+ expect(capped.querySelector(".hk-corner-badge")!.textContent).toBe("99+");
53
+ const zero = mount(h(HkCornerBadge, { count: 0 }, () => h("i")));
54
+ expect(zero.querySelector(".hk-corner-badge")).toBeNull();
55
+ const zeroShown = mount(h(HkCornerBadge, { count: 0, showZero: true }, () => h("i")));
56
+ expect(zeroShown.querySelector(".hk-corner-badge")!.textContent).toBe("0");
57
+ });
58
+
59
+ it("custom label form: a text pill from the default slot", () => {
60
+ const c = mount(h(HkCornerBadge, { variant: "primary", text: "5h" }, () => h("i")));
61
+ const badge = c.querySelector(".hk-corner-badge")!;
62
+ expect(badge.textContent).toBe("5h");
63
+ expect(badge.className).toContain("hk-corner-badge--pill");
64
+ });
65
+
66
+ it("accessible mode: role=img + aria-label instead of aria-hidden", () => {
67
+ const c = mount(h(HkCornerBadge, { dot: true, label: "3 active filters" }, () => h("i")));
68
+ const badge = c.querySelector(".hk-corner-badge")!;
69
+ expect(badge.getAttribute("role")).toBe("img");
70
+ expect(badge.getAttribute("aria-label")).toBe("3 active filters");
71
+ expect(badge.getAttribute("aria-hidden")).toBeNull();
72
+ });
73
+
74
+ it("dot wins when both dot and count/text are set", () => {
75
+ const c = mount(h(HkCornerBadge, { dot: true, count: 9 }, () => h("i")));
76
+ const badge = c.querySelector(".hk-corner-badge")!;
77
+ expect(badge.className).toContain("hk-corner-badge--dot");
78
+ expect(badge.textContent).toBe("");
79
+ });
80
+
81
+ it("offset writes the clearance custom property", () => {
82
+ const c = mount(h(HkCornerBadge, { dot: true, offset: 2 }, () => h("i")));
83
+ const badge = c.querySelector(".hk-corner-badge") as HTMLElement;
84
+ expect(badge.style.getPropertyValue("--hk-corner-badge-offset")).toBe("2px");
85
+ });
86
+ });
@@ -0,0 +1,91 @@
1
+ import { computed, defineComponent, type PropType } from "vue";
2
+
3
+ import "./HkCornerBadge.scss";
4
+
5
+ /** The color channels HkBadge already standardizes (same variant names,
6
+ * same override escape hatch — kept in lockstep with HkBadge.tsx). */
7
+ export type CornerBadgeVariant =
8
+ | "default"
9
+ | "primary"
10
+ | "success"
11
+ | "error"
12
+ | "warning"
13
+ | "info"
14
+ | "muted";
15
+
16
+ /**
17
+ * HkCornerBadge — the generic corner mark for ANY anchor (user
18
+ * direction 2026-10-15): a green/red/traffic dot, a notification count
19
+ * ("9+"), or a custom text label pinned to one corner of whatever sits
20
+ * in the default slot. The Vue package had every consumer hand-rolling
21
+ * its own absolute span (the filter/search trigger dots, the icon-group
22
+ * marker, chest's dock time-range pill); this is the one component they
23
+ * converge on — API parity with the Rust family's badge.rs (dot / count
24
+ * / max "N+" / showZero), plus the corner + custom-label the web needs.
25
+ *
26
+ * Decorative by default (aria-hidden): the mark VISUALIZES state the
27
+ * anchor already exposes (a filtered trigger, a selected range); pass
28
+ * `label` when it is the only place a count is announced.
29
+ */
30
+ export default defineComponent({
31
+ name: "HkCornerBadge",
32
+ props: {
33
+ variant: { type: String as PropType<CornerBadgeVariant>, default: "primary" },
34
+ /** Bare dot form (the green/red/traffic light). */
35
+ dot: { type: Boolean, default: false },
36
+ /** Count form: renders the number, or "max+" past `max`. Hidden at
37
+ * 0 unless showZero (notification semantics). */
38
+ count: { type: Number, default: undefined },
39
+ max: { type: Number, default: undefined },
40
+ showZero: { type: Boolean, default: false },
41
+ /** Custom label text (the pill form). The default slot is the
42
+ * ANCHOR, never the label — overloading it made every anchored
43
+ * child's own text leak into the badge. */
44
+ text: { type: String, default: undefined },
45
+ /** Which corner of the anchor. */
46
+ corner: {
47
+ type: String as PropType<"top-right" | "top-left" | "bottom-right" | "bottom-left">,
48
+ default: "top-right",
49
+ },
50
+ /** Extra clearance from the corner in px (0 = sit ON the corner,
51
+ * the translate(50%,-50%) centering). */
52
+ offset: { type: Number, default: 0 },
53
+ /** Accessible name for the mark (without it the badge is
54
+ * aria-hidden decoration). */
55
+ label: { type: String, default: undefined },
56
+ },
57
+ setup(props, { slots }) {
58
+ const text = computed<string | null>(() => {
59
+ if (props.count !== undefined) {
60
+ if (props.count === 0 && !props.showZero) return null;
61
+ if (props.max !== undefined && props.count > props.max) return `${props.max}+`;
62
+ return String(props.count);
63
+ }
64
+ return props.text && props.text.length > 0 ? props.text : null;
65
+ });
66
+
67
+ const show = computed(() => props.dot || text.value !== null);
68
+
69
+ return () => (
70
+ <span class="hk-corner-badge-anchor">
71
+ {slots.default?.()}
72
+ {show.value && (
73
+ <span
74
+ class={[
75
+ "hk-corner-badge",
76
+ `hk-corner-badge-${props.variant}`,
77
+ props.dot ? "hk-corner-badge--dot" : "hk-corner-badge--pill",
78
+ ]}
79
+ data-corner={props.corner}
80
+ style={props.offset ? { "--hk-corner-badge-offset": `${props.offset}px` } : undefined}
81
+ aria-hidden={props.label ? undefined : "true"}
82
+ role={props.label ? "img" : undefined}
83
+ aria-label={props.label}
84
+ >
85
+ {props.dot ? null : text.value}
86
+ </span>
87
+ )}
88
+ </span>
89
+ );
90
+ },
91
+ });
@@ -0,0 +1,69 @@
1
+ import { defineComponent } from "vue";
2
+ import { Equal, EqualApproximately, EqualNot } from "lucide-vue-next";
3
+
4
+ import type { HkFilterOperator } from "./HkFilterPanel";
5
+
6
+ /**
7
+ * The operator glyph as an ICON, never a text character (user direction
8
+ * 2026-10-15: font stacks render U+2249 ≉ unevenly — the operator
9
+ * buttons are UI chrome, so they draw like every other icon in the
10
+ * family). Lucide ships =, ≠ and ≈; the ordered four and ≉ are
11
+ * hikari-authored in lucide's exact stroke grammar (24×24, stroke 2,
12
+ * currentColor) — ≉ is lucide's own ≈ path pair with EqualNot's slash
13
+ * composed in, so the set reads as one family.
14
+ */
15
+
16
+ // Lucide's EqualApproximately path pair (ISC, verbatim) + its EqualNot
17
+ // slash — composed rather than reinvented.
18
+ const NAPPROX_PATHS = [
19
+ "M5 15a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
20
+ "M5 9a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
21
+ "m5 19 14-14",
22
+ ];
23
+
24
+ const CUSTOM: Partial<Record<HkFilterOperator, string[]>> = {
25
+ napprox: NAPPROX_PATHS,
26
+ lt: ["m19 6-11 6 11 6"],
27
+ lte: ["m19 5-11 6 11 6", "M8 20h11"],
28
+ gt: ["m5 6 11 6-11 6"],
29
+ gte: ["m5 5 11 6-11 6", "M5 20h11"],
30
+ };
31
+
32
+ const LUCIDE: Partial<Record<HkFilterOperator, typeof Equal>> = {
33
+ eq: Equal,
34
+ ne: EqualNot,
35
+ approx: EqualApproximately,
36
+ };
37
+
38
+ export default defineComponent({
39
+ name: "HkFilterOperatorIcon",
40
+ props: {
41
+ op: { type: String as () => HkFilterOperator, required: true },
42
+ size: { type: Number, default: 14 },
43
+ },
44
+ setup(props) {
45
+ return () => {
46
+ const Lucide = LUCIDE[props.op];
47
+ if (Lucide) return <Lucide size={props.size} aria-hidden="true" />;
48
+ const paths = CUSTOM[props.op]!;
49
+ return (
50
+ <svg
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ width={props.size}
53
+ height={props.size}
54
+ viewBox="0 0 24 24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ stroke-width="2"
58
+ stroke-linecap="round"
59
+ stroke-linejoin="round"
60
+ aria-hidden="true"
61
+ >
62
+ {paths.map((d) => (
63
+ <path key={d} d={d} />
64
+ ))}
65
+ </svg>
66
+ );
67
+ };
68
+ },
69
+ });
@@ -42,6 +42,14 @@
42
42
  /* Minimum outer margins for every column (user direction
43
43
  * 2026-10-12): the rows never touch the panel edge. */
44
44
  padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
45
+ /* Empty panel collapses to the add verb's own width (user direction
46
+ * 2026-10-15): no rows means nothing to align columns against, so the
47
+ * popover hugs the 添加条件 button instead of holding the full-row
48
+ * width. Desktop only — the phone branch below re-widens to 100%. */
49
+ &[data-empty] {
50
+ width: max-content;
51
+ min-width: 0;
52
+ }
45
53
  }
46
54
 
47
55
  /* A row is not a box — it donates its three cells to the panel grid so
@@ -73,6 +73,12 @@ describe("HkFilterPanel stylesheet contract", () => {
73
73
  expect(rule).toContain("justify-self: start");
74
74
  });
75
75
 
76
+ it("the empty panel collapses to the add verb's own width", () => {
77
+ // 2026-10-15: no rows = nothing to align columns against — the
78
+ // desktop popover hugs the 添加条件 button (data-empty marker).
79
+ expect(css).toMatch(/\.hk-filter-panel\[data-empty\]\s*\{[^}]*width:\s*max-content/);
80
+ });
81
+
76
82
  it("the phone operator column keeps its widened cap (≥ the old 4.25rem)", () => {
77
83
  expect(css).toContain("@media (max-width: 767px)");
78
84
  const media = css.slice(css.indexOf("@media (max-width: 767px)"));