@celestia-island/hikari 0.55.340 → 0.55.342

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.340",
3
+ "version": "0.55.342",
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",
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Source contract: HkAddTile's base class stays NEUTRAL — faint dashed
3
+ * border, muted ink, no surface, no primary tint. Color belongs to the
4
+ * drag-over state only (`--drag-over`), which carries the dashed
5
+ * primary + primary-wash language the product's hand-drawn drag
6
+ * overlays already use. Pinned because the tile invites every host to
7
+ * "just tint it a little" — a class-name test cannot catch the base
8
+ * picking up color, the source 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
+ /** Extract one top-level rule block's body (`{ ... }`) by selector,
18
+ * counting braces so nested `&:hover` blocks stay inside the slice. */
19
+ function fullRule(css: string, selector: string): string {
20
+ const start = css.indexOf(`${selector} {`);
21
+ expect(start, `rule ${selector} present`).toBeGreaterThanOrEqual(0);
22
+ const open = css.indexOf("{", start);
23
+ let depth = 0;
24
+ for (let i = open; i < css.length; i++) {
25
+ if (css[i] === "{") depth++;
26
+ if (css[i] === "}") {
27
+ depth--;
28
+ if (depth === 0) return css.slice(open + 1, i);
29
+ }
30
+ }
31
+ throw new Error(`unbalanced braces in ${selector}`);
32
+ }
33
+
34
+ /** The rule's DIRECT declarations only — up to the first nested block
35
+ * (`&:hover` and friends). Nested states may tint by design; the
36
+ * neutrality discipline is about what the resting state paints. */
37
+ function directDecls(css: string, selector: string): string {
38
+ const body = fullRule(css, selector);
39
+ const nested = body.indexOf("&:");
40
+ return nested === -1 ? body : body.slice(0, nested);
41
+ }
42
+
43
+ describe("HkAddTile neutrality contract", () => {
44
+ const css = readFileSync(join(here, "HkAddTile.scss"), "utf-8");
45
+
46
+ it("resting state: dashed neutral border, no surface, no wash", () => {
47
+ const base = directDecls(css, ".hk-add-tile");
48
+ expect(base).toContain("border: 1px dashed");
49
+ expect(base).toContain("background: transparent");
50
+ expect(base).not.toContain("rgb(var(--color-primary)");
51
+ });
52
+
53
+ it("compact stays a plain row form: no tint in direct declarations", () => {
54
+ const compact = directDecls(css, ".hk-add-tile--compact");
55
+ expect(compact).toContain("flex-direction: row");
56
+ expect(compact).not.toContain("rgb(var(--color-primary)");
57
+ });
58
+
59
+ it("the drag-over wash lives exclusively in --drag-over", () => {
60
+ const drag = fullRule(css, ".hk-add-tile--drag-over");
61
+ expect(drag).toContain("rgb(var(--color-primary) / 8%)");
62
+ // Whole-file sweep: exactly one top-level rule chunk paints the
63
+ // wash, and it is the drag-over rule.
64
+ const chunks = css.split(/(?=\.hk-add-tile)/g).filter((r) => r.includes("/ 8%)"));
65
+ expect(chunks.length).toBe(1);
66
+ expect(chunks[0].startsWith(".hk-add-tile--drag-over")).toBe(true);
67
+ });
68
+
69
+ it("disabled resets to neutral (no primary anywhere in the rule)", () => {
70
+ const disabled = fullRule(css, ".hk-add-tile--disabled");
71
+ expect(disabled).toContain("cursor: default");
72
+ expect(disabled).not.toContain("primary");
73
+ });
74
+ });
@@ -0,0 +1,71 @@
1
+ @use "../styles/polyfill" as *;
2
+
3
+ /* HkAddTile — the dashed add / empty affordance tile. The tile is
4
+ * neutral AT REST: faint dashed border, muted ink, no surface and no
5
+ * wash. Primary ink comes in through the states that invite (the
6
+ * hover border/ink tint below, and the drag-over wash) — the primary
7
+ * SURFACE wash lives exclusively in the --drag-over rule (pinned by
8
+ * HkAddTile.contract.test.ts, do not creep a wash into any other
9
+ * rule). */
10
+ .hk-add-tile {
11
+ display: flex;
12
+ flex-direction: column;
13
+ align-items: center;
14
+ justify-content: center;
15
+ gap: var(--space-8, 0.5rem);
16
+ width: 100%;
17
+ box-sizing: border-box;
18
+ padding: var(--space-16, 1rem) var(--space-12, 0.75rem);
19
+ border: 1px dashed var(--border-faint);
20
+ border-radius: var(--radius-md, 8px);
21
+ background: transparent;
22
+ color: rgb(var(--color-muted));
23
+ font: inherit;
24
+ font-size: var(--text-xs, 0.75rem);
25
+ text-align: center;
26
+ cursor: pointer;
27
+ @include user-select(none);
28
+ transition:
29
+ border-color var(--duration-fast, 0.15s) ease,
30
+ background-color var(--duration-fast, 0.15s) ease,
31
+ color var(--duration-fast, 0.15s) ease;
32
+
33
+ &:hover {
34
+ border-color: rgb(var(--color-primary) / 45%);
35
+ color: rgb(var(--color-primary));
36
+ }
37
+
38
+ &:focus-visible {
39
+ outline: 2px solid rgb(var(--color-primary));
40
+ outline-offset: 2px;
41
+ }
42
+ }
43
+
44
+ /* List-end form: a slim left-aligned row ("+ add one more") that rides
45
+ * the end of a row list instead of floating under it as a button. */
46
+ .hk-add-tile--compact {
47
+ flex-direction: row;
48
+ justify-content: flex-start;
49
+ gap: var(--space-8, 0.5rem);
50
+ padding: var(--space-8, 0.5rem) var(--space-10, 0.625rem);
51
+ text-align: start;
52
+ }
53
+
54
+ /* Drag-over wash — dashed primary over a faint primary floor, matching
55
+ * the drag overlays the product already draws by hand. */
56
+ .hk-add-tile--drag-over {
57
+ border-color: rgb(var(--color-primary) / 45%);
58
+ background: rgb(var(--color-primary) / 8%);
59
+ color: rgb(var(--color-primary));
60
+ }
61
+
62
+ .hk-add-tile--disabled {
63
+ cursor: default;
64
+ opacity: 0.55;
65
+
66
+ &:hover {
67
+ border-color: var(--border-faint);
68
+ color: rgb(var(--color-muted));
69
+ background: transparent;
70
+ }
71
+ }
@@ -0,0 +1,204 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick } from "vue";
3
+
4
+ import HkAddTile from "./HkAddTile";
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
+ /* Functional icon stub with a distinguishing class, same recipe as the
25
+ HkEmptyState tests, so the icon prop / slot override can be asserted
26
+ to have actually mounted. */
27
+ const StubIcon = defineComponent({
28
+ name: "StubIcon",
29
+ setup() {
30
+ return () => h("i", { class: "stub-add-icon", "data-stub-icon": "true" });
31
+ },
32
+ });
33
+
34
+ /** A drop event carrying the given files — plain Event + a defined
35
+ dataTransfer, because happy-dom has no real DataTransfer. */
36
+ function dropEvent(files: File[]): Event {
37
+ const ev = new Event("drop", { bubbles: true, cancelable: true });
38
+ Object.defineProperty(ev, "dataTransfer", { value: { files } });
39
+ return ev;
40
+ }
41
+
42
+ function file(name: string): File {
43
+ return new File(["x"], name, { type: "text/plain" });
44
+ }
45
+
46
+ function tileEl(c: HTMLElement): HTMLElement {
47
+ return c.querySelector(".hk-add-tile") as HTMLElement;
48
+ }
49
+
50
+ describe("HkAddTile", () => {
51
+ it("renders the label and the default Plus icon", () => {
52
+ const c = mount(h(HkAddTile, null, { default: () => "Add one" }));
53
+ const el = tileEl(c);
54
+ expect(el.tagName).toBe("BUTTON");
55
+ expect(el.textContent).toContain("Add one");
56
+ expect(el.querySelector("svg")).not.toBeNull();
57
+ expect(el.classList.contains("hk-add-tile--compact")).toBe(false);
58
+ });
59
+
60
+ it("compact form carries the compact modifier", () => {
61
+ const c = mount(h(HkAddTile, { compact: true }, { default: () => "Add" }));
62
+ expect(tileEl(c).classList.contains("hk-add-tile--compact")).toBe(true);
63
+ });
64
+
65
+ it("icon prop replaces the default glyph", () => {
66
+ const c = mount(h(HkAddTile, { icon: StubIcon }, { default: () => "Add" }));
67
+ expect(c.querySelector(".stub-add-icon")).not.toBeNull();
68
+ });
69
+
70
+ it("icon slot wins over both the default and the icon prop", () => {
71
+ const c = mount(
72
+ h(HkAddTile, { icon: StubIcon }, { icon: () => h("i", { class: "slot-icon" }), default: () => "Add" }),
73
+ );
74
+ expect(c.querySelector(".slot-icon")).not.toBeNull();
75
+ expect(c.querySelector(".stub-add-icon")).toBeNull();
76
+ // The default Plus must not ride along either.
77
+ expect(tileEl(c).querySelectorAll("svg").length).toBe(0);
78
+ });
79
+
80
+ it("emits click for an enabled tile", () => {
81
+ const onClick = vi.fn();
82
+ const c = mount(h(HkAddTile, { onClick }, { default: () => "Add" }));
83
+ tileEl(c).dispatchEvent(new MouseEvent("click", { bubbles: true }));
84
+ expect(onClick).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it("does not emit click when disabled", () => {
88
+ const onClick = vi.fn();
89
+ const c = mount(h(HkAddTile, { disabled: true, onClick }, { default: () => "Add" }));
90
+ const el = tileEl(c);
91
+ expect(el.classList.contains("hk-add-tile--disabled")).toBe(true);
92
+ // The handler-level guard (a disabled button never receives the
93
+ // native click in a real browser, but dispatchEvent bypasses that).
94
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
95
+ expect(onClick).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("emits drop-files with the dropped files", () => {
99
+ const onDropFiles = vi.fn();
100
+ const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
101
+ const ev = dropEvent([file("a.toml"), file("b.toml")]);
102
+ tileEl(c).dispatchEvent(ev);
103
+ expect(onDropFiles).toHaveBeenCalledTimes(1);
104
+ const files = onDropFiles.mock.calls[0][0] as File[];
105
+ expect(files.map((f) => f.name)).toEqual(["a.toml", "b.toml"]);
106
+ });
107
+
108
+ it("ignores drops without files (the gesture contract is files)", () => {
109
+ const onDropFiles = vi.fn();
110
+ const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
111
+ tileEl(c).dispatchEvent(dropEvent([]));
112
+ expect(onDropFiles).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("ignores drops when not droppable", () => {
116
+ const onDropFiles = vi.fn();
117
+ const c = mount(h(HkAddTile, { onDropFiles }, { default: () => "Nope" }));
118
+ tileEl(c).dispatchEvent(dropEvent([file("a.toml")]));
119
+ expect(onDropFiles).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it("ignores drops and drag styling when disabled", () => {
123
+ const onDropFiles = vi.fn();
124
+ const c = mount(
125
+ h(HkAddTile, { disabled: true, droppable: true, onDropFiles }, { default: () => "Off" }),
126
+ );
127
+ const el = tileEl(c);
128
+ el.dispatchEvent(new Event("dragenter", { bubbles: true }));
129
+ expect(el.classList.contains("hk-add-tile--drag-over")).toBe(false);
130
+ el.dispatchEvent(dropEvent([file("a.toml")]));
131
+ expect(onDropFiles).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("flips the drag-over class across child boundaries without flicker", async () => {
135
+ const c = mount(h(HkAddTile, { droppable: true }, { default: () => "Drop" }));
136
+ const el = tileEl(c);
137
+ // The default slot text is a direct child; enter the tile, then its
138
+ // label span boundary, then back out of the span only.
139
+ el.dispatchEvent(new Event("dragenter", { bubbles: true }));
140
+ await nextTick();
141
+ expect(el.classList.contains("hk-add-tile--drag-over")).toBe(true);
142
+ const inner = el.querySelector("span") ?? el.firstElementChild ?? el;
143
+ inner.dispatchEvent(new Event("dragenter", { bubbles: true })); // depth 2
144
+ inner.dispatchEvent(new Event("dragleave", { bubbles: true })); // depth 1 — still over
145
+ await nextTick();
146
+ expect(el.classList.contains("hk-add-tile--drag-over")).toBe(true);
147
+ el.dispatchEvent(new Event("dragleave", { bubbles: true })); // depth 0
148
+ await nextTick();
149
+ expect(el.classList.contains("hk-add-tile--drag-over")).toBe(false);
150
+ });
151
+
152
+ it("cancels dragenter/dragover exactly when it accepts the drag", () => {
153
+ // The browser only DELIVERS a drop onto a target whose last dragover
154
+ // was not cancelled — these defaultPrevented pins ARE the drop-target
155
+ // contract; deleting either preventDefault keeps every emit-level
156
+ // test green while killing the feature in real browsers (R2-S2).
157
+ const c = mount(h(HkAddTile, { droppable: true }, { default: () => "Drop" }));
158
+ const el = tileEl(c);
159
+ const enter = new Event("dragenter", { cancelable: true });
160
+ el.dispatchEvent(enter);
161
+ expect(enter.defaultPrevented).toBe(true);
162
+ const over = new Event("dragover", { cancelable: true });
163
+ el.dispatchEvent(over);
164
+ expect(over.defaultPrevented).toBe(true);
165
+ });
166
+
167
+ it("never cancels drag events when not droppable or disabled (refusal = no preventDefault)", () => {
168
+ const c = mount(h(HkAddTile, {}, { default: () => "No" }));
169
+ const over = new Event("dragover", { cancelable: true });
170
+ tileEl(c).dispatchEvent(over);
171
+ expect(over.defaultPrevented).toBe(false);
172
+ const off = mount(
173
+ h(HkAddTile, { droppable: true, disabled: true }, { default: () => "Off" }),
174
+ );
175
+ const over2 = new Event("dragover", { cancelable: true });
176
+ tileEl(off).dispatchEvent(over2);
177
+ expect(over2.defaultPrevented).toBe(false);
178
+ });
179
+
180
+ it("hands the native drop event as the second drop-files argument", () => {
181
+ const onDropFiles = vi.fn();
182
+ const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
183
+ const ev = dropEvent([file("a.toml")]);
184
+ tileEl(c).dispatchEvent(ev);
185
+ expect(onDropFiles.mock.calls[0][1]).toBe(ev);
186
+ });
187
+
188
+ it("clears the drag-over state on drop", async () => {
189
+ const onDropFiles = vi.fn();
190
+ const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
191
+ const el = tileEl(c);
192
+ el.dispatchEvent(new Event("dragenter", { bubbles: true }));
193
+ await nextTick();
194
+ expect(el.classList.contains("hk-add-tile--drag-over")).toBe(true);
195
+ el.dispatchEvent(dropEvent([file("a.toml")]));
196
+ await nextTick();
197
+ expect(el.classList.contains("hk-add-tile--drag-over")).toBe(false);
198
+ // A second drop after the depth reset must still emit (no stale
199
+ // depth leaking into the next drag session).
200
+ el.dispatchEvent(new Event("dragenter", { bubbles: true }));
201
+ el.dispatchEvent(dropEvent([file("b.toml")]));
202
+ expect(onDropFiles).toHaveBeenCalledTimes(2);
203
+ });
204
+ });
@@ -0,0 +1,138 @@
1
+ import { computed, defineComponent, h, ref, type Component, type PropType } from "vue";
2
+ import { Plus } from "lucide-vue-next";
3
+
4
+ import "./HkAddTile.scss";
5
+
6
+ /**
7
+ * HkAddTile — the dashed add / empty affordance tile.
8
+ *
9
+ * One standard element for the "dashed placeholder that invites an
10
+ * action" pattern that product surfaces kept re-drawing by hand: the
11
+ * empty-list tile, the "add one more" row at the end of a staged list,
12
+ * and the pick-a-file / drop-files zone. Three blessed forms, one
13
+ * anatomy:
14
+ *
15
+ * - **default** — a centered vertical block (icon above the label):
16
+ * the empty state of a list or panel ("no sources yet — add one to
17
+ * begin"). The whole tile is the click target; hosts no longer pair
18
+ * a passive dashed box with a separate button below it.
19
+ * - **compact** — a slim left-aligned row ("+ Add node / repo"): the
20
+ * tile that rides the END of a list of rows and grows the list. It
21
+ * replaces the loose secondary button, so the add affordance reads
22
+ * as part of the list instead of floating under it.
23
+ * - **droppable** — either form additionally accepts file drags:
24
+ * dragover/dragenter flip the tile into the drag-over wash (same
25
+ * visual language as the established drag overlays — dashed primary
26
+ * on a faint primary floor), and a drop with a non-empty `FileList`
27
+ * is handed UP as `drop-files`. The host decides what files mean;
28
+ * the tile only standardizes the gesture. Drops of anything but
29
+ * files (text, URLs) are ignored — the drag-files-in gesture is the
30
+ * contract, so a host can rely on `drop-files` always carrying ≥1
31
+ * file.
32
+ *
33
+ * Accessibility: a real `<button type="button">` — keyboard focus and
34
+ * Enter/Space activation come free; `disabled` renders the native
35
+ * attribute (and a disabled tile refuses drags entirely: it never calls
36
+ * `preventDefault` on dragover, so the browser disallows the drop).
37
+ */
38
+ export default defineComponent({
39
+ name: "HkAddTile",
40
+ props: {
41
+ /** Off switches the tile off entirely: no click, no drag acceptance. */
42
+ disabled: { type: Boolean, default: false },
43
+ /** Accept file drags; non-empty drops surface as `drop-files`. */
44
+ droppable: { type: Boolean, default: false },
45
+ /** List-end form: slim horizontal row instead of the centered block. */
46
+ compact: { type: Boolean, default: false },
47
+ /** Icon component (lucide-vue-next style); defaults to Plus. */
48
+ icon: { type: [Object, Function] as PropType<Component>, default: undefined },
49
+ },
50
+ emits: {
51
+ click: (_e: MouseEvent) => true,
52
+ /** A drop carrying at least one file: `(files, nativeEvent)`. */
53
+ "drop-files": (_files: File[], _e: DragEvent) => true,
54
+ },
55
+ setup(props, { slots, emit }) {
56
+ // dragenter/dragleave fire in pairs across EVERY child boundary, so
57
+ // a boolean flips off the moment the pointer crosses the label —
58
+ // track depth instead and clear it on drop.
59
+ const dragDepth = ref(0);
60
+ const dragOver = computed(
61
+ () => props.droppable && !props.disabled && dragDepth.value > 0,
62
+ );
63
+
64
+ /** Whether this tile accepts the drag at all. Refusing means NEVER
65
+ * calling preventDefault: the browser only permits a drop onto a
66
+ * target whose last dragover was not cancelled, so a disabled (or
67
+ * non-droppable) tile cannot receive one by construction. */
68
+ function acceptsDrag(): boolean {
69
+ return props.droppable && !props.disabled;
70
+ }
71
+
72
+ function onDragenter(e: DragEvent) {
73
+ if (!acceptsDrag()) return;
74
+ e.preventDefault();
75
+ dragDepth.value += 1;
76
+ }
77
+
78
+ function onDragover(e: DragEvent) {
79
+ if (!acceptsDrag()) return;
80
+ // preventDefault on EVERY dragover (not just the first) — the
81
+ // drop allowance expires continuously; missing one cancels the
82
+ // drop for the whole hover.
83
+ e.preventDefault();
84
+ }
85
+
86
+ function onDragleave() {
87
+ if (dragDepth.value > 0) dragDepth.value -= 1;
88
+ }
89
+
90
+ function onDrop(e: DragEvent) {
91
+ dragDepth.value = 0;
92
+ if (!acceptsDrag()) return;
93
+ e.preventDefault();
94
+ const files = Array.from(e.dataTransfer?.files ?? []);
95
+ if (files.length === 0) return;
96
+ emit("drop-files", files, e);
97
+ }
98
+
99
+ function onClick(e: MouseEvent) {
100
+ if (props.disabled) return;
101
+ emit("click", e);
102
+ }
103
+
104
+ return () => {
105
+ // Icon resolution reads `slots` INSIDE the render function — the
106
+ // slots object is not reactive, so a computed here would cache the
107
+ // choice across slot add/remove (R2-S1: a parent that adds an icon
108
+ // slot later rendered the stale default Plus ALONGSIDE the slot;
109
+ // removing the slot left the tile iconless until an unrelated prop
110
+ // churned the cache). House style: HkEmptyState reads slots.icon
111
+ // per render for the same reason. h() rather than JSX: the icon
112
+ // prop is a Component (object | function) union, which TS cannot
113
+ // expand as a JSX element type.
114
+ const Icon = slots.icon ? null : (props.icon ?? Plus);
115
+ return (
116
+ <button
117
+ type="button"
118
+ class={[
119
+ "hk-add-tile",
120
+ props.compact && "hk-add-tile--compact",
121
+ props.disabled && "hk-add-tile--disabled",
122
+ dragOver.value && "hk-add-tile--drag-over",
123
+ ]}
124
+ disabled={props.disabled}
125
+ onClick={onClick}
126
+ onDragenter={onDragenter}
127
+ onDragover={onDragover}
128
+ onDragleave={onDragleave}
129
+ onDrop={onDrop}
130
+ >
131
+ {Icon ? h(Icon, { size: props.compact ? 14 : 18, "aria-hidden": true }) : null}
132
+ {slots.icon?.()}
133
+ {slots.default?.()}
134
+ </button>
135
+ );
136
+ };
137
+ },
138
+ });
@@ -163,6 +163,20 @@ function boxOf(element: HTMLElement): { left: number; width: number } {
163
163
  return { left: parseFloat(element.style.left), width: parseFloat(element.style.width) };
164
164
  }
165
165
 
166
+ /** Inline box assertions at the geometry tolerance. `percent()` rounds to four
167
+ * decimals OF A PERCENT (~1e-6 fraction), so a four-decimal `toBeCloseTo`
168
+ * sits exactly on that rounding boundary; 3 keeps a margin while still
169
+ * pinning well below a percent. */
170
+ function expectBox(
171
+ element: HTMLElement | null,
172
+ expected: { left: number; width: number },
173
+ label: string,
174
+ ): void {
175
+ expect(element, label).toBeTruthy();
176
+ expect(boxOf(element!).left, `${label} left`).toBeCloseTo(expected.left, 3);
177
+ expect(boxOf(element!).width, `${label} width`).toBeCloseTo(expected.width, 3);
178
+ }
179
+
166
180
  function lastWindow(mock: ReturnType<typeof vi.fn>): [number, number] | null {
167
181
  const calls = mock.mock.calls;
168
182
  expect(calls.length, "windowChange was emitted").toBeGreaterThan(0);
@@ -304,9 +318,11 @@ describe("HkRangeRibbon full domain", () => {
304
318
  // …and a segment can never invent an undeclared lane.
305
319
  expect(segmentById(root, "orphan")).toBeNull();
306
320
  expect(root.querySelector('[data-segment-id="orphan"]')).toBeNull();
307
- // Content wholly outside the axis paints NOTHING: the domain is a
308
- // clipping rule, not a clamp to an edge tick (pre-domain content would
309
- // have collapsed [1.2, 1.4] onto 100% and drawn a tick there).
321
+ // Content wholly outside the axis paints NOTHING on either layer: the
322
+ // domain is a clipping rule, not a clamp. (Pre-domain, `fullDomainBox`
323
+ // emitted the raw fractions — left 120%, width 20% for this bar — which
324
+ // the overview's `overflow: hidden` cut and the detail track painted
325
+ // overflowing; nothing ever clamped it to an edge.)
310
326
  expect(segmentById(root, "outside")).toBeNull();
311
327
  expect(root.querySelector('[data-segment-id="outside"]')).toBeNull();
312
328
  });
@@ -429,6 +445,38 @@ describe("HkRangeRibbon windowed view", () => {
429
445
  expect(laneSegments(root)).toHaveLength(1);
430
446
  });
431
447
 
448
+ it("paints a domain-edge point in the full view but not through a window edge", () => {
449
+ // DELIBERATE asymmetry: the DOMAIN edges are INCLUSIVE (a zero-length
450
+ // point on the axis paints as a tick), while the WINDOW is strict
451
+ // half-open (`end > win[0] && start < win[1]`), so the same point sitting
452
+ // exactly on the window end vanishes in the detail. Chest's
453
+ // `traceRecordsInRange` mirrors that half-open rule verbatim — do NOT
454
+ // make the window inclusive to "fix" this; it would fork the consumer's
455
+ // mirrored filter.
456
+ const segments: RibbonSegment[] = [
457
+ { id: "edgePoint", lane: "cpu", start: 2, end: 2, label: "edge point" },
458
+ ];
459
+ const full = mountRibbon({ window: null, domain: [0, 2], segments });
460
+ expectBox(
461
+ full.querySelector<HTMLElement>(
462
+ '.hk-range-ribbon-overview-segment[data-segment-id="edgePoint"]',
463
+ ),
464
+ { left: 100, width: 0 },
465
+ "the inclusive domain end keeps the point as a tick",
466
+ );
467
+ expect(segmentById(full, "edgePoint"), "the no-window detail paints it too").toBeTruthy();
468
+
469
+ const windowed = mountRibbon({ window: [1.98, 2], domain: [0, 2], segments });
470
+ expect(
471
+ segmentById(windowed, "edgePoint"),
472
+ "a point ON the window end does not intersect the half-open window",
473
+ ).toBeNull();
474
+ expect(
475
+ windowed.querySelector('.hk-range-ribbon-overview-segment[data-segment-id="edgePoint"]'),
476
+ "the minimap keeps painting the domain box",
477
+ ).toBeTruthy();
478
+ });
479
+
432
480
  it("normalizes inverted, out-of-domain and degenerate windows; NaN means full", () => {
433
481
  // Inverted [0.8, 0.2] → [0.2, 0.8].
434
482
  const inverted = mountRibbon({ window: [0.8, 0.2] as unknown as [number, number] });
@@ -902,6 +950,52 @@ describe("HkRangeRibbon reset", () => {
902
950
  expect(host.root.querySelector(".hk-range-ribbon-reset")).toBeNull();
903
951
  });
904
952
 
953
+ it("scopes the full-view epsilon to the axis span", () => {
954
+ // On a 1e-12 axis the window [0, 5e-13] is a REAL sub-selection half the
955
+ // axis wide, but an absolute 1e-9 slack would call it "full". The
956
+ // span-relative slack (1e-9 * span) keeps the distinction; the unit axis
957
+ // still uses the historical 1e-9.
958
+ const tiny = mountRibbon({ domain: [0, 1e-12], minWindow: 1e-13, window: [0, 5e-13] });
959
+ expect(
960
+ tiny.querySelector(".hk-range-ribbon-reset"),
961
+ "a half-axis window on a tiny axis is a real sub-selection",
962
+ ).toBeTruthy();
963
+ const full = mountRibbon({ domain: [0, 1e-12], minWindow: 1e-13, window: [0, 1e-12] });
964
+ expect(
965
+ full.querySelector(".hk-range-ribbon-reset"),
966
+ "the whole tiny axis needs no reset",
967
+ ).toBeNull();
968
+
969
+ // A NEGATIVE-ORIGIN axis catches a slack scaled by the wrong end: with
970
+ // `eps = WINDOW_EPSILON * d1` (~ -1e-9 here) the FULL view would become a
971
+ // phantom sub-selection. The shipped slack (1e-9 * span) keeps the full
972
+ // axis full and half the axis a real selection.
973
+ const negativeDomain: [number, number] = [-1, -1 + 1e-12];
974
+ const negativeFull = mountRibbon({
975
+ domain: negativeDomain,
976
+ minWindow: 1e-13,
977
+ window: [-1, -1 + 1e-12],
978
+ });
979
+ expect(
980
+ negativeFull.querySelector(".hk-range-ribbon-reset"),
981
+ "the full negative-origin axis needs no reset",
982
+ ).toBeNull();
983
+ expect(
984
+ keydown(negativeFull, "Escape").defaultPrevented,
985
+ "a full-axis Escape passes through to the host",
986
+ ).toBe(false);
987
+
988
+ const negativeHalf = mountRibbon({
989
+ domain: negativeDomain,
990
+ minWindow: 1e-13,
991
+ window: [-1, -1 + 5e-13],
992
+ });
993
+ expect(
994
+ negativeHalf.querySelector(".hk-range-ribbon-reset"),
995
+ "half of the negative-origin axis is a real sub-selection",
996
+ ).toBeTruthy();
997
+ });
998
+
905
999
  it("cancels an in-flight brush with Escape and consumes the key", async () => {
906
1000
  const host = mountHost(null);
907
1001
  const { strip, x } = stubStrip(host.root);
@@ -1165,6 +1259,52 @@ describe("HkRangeRibbon tip card", () => {
1165
1259
  expect(host.root.querySelector(".hk-range-ribbon-tip")).toBeNull();
1166
1260
  });
1167
1261
 
1262
+ it("does not resurrect a tip for a segment that left the axis", async () => {
1263
+ // The no-window tip gate: when the domain re-widens, a stale tip id must
1264
+ // not re-open a card for a segment that had left the axis.
1265
+ const container = document.createElement("div");
1266
+ document.body.appendChild(container);
1267
+ const liveDomain = ref<[number, number]>([0, 1]);
1268
+ const Wrapper = defineComponent({
1269
+ setup() {
1270
+ return () =>
1271
+ h(HkRangeRibbon, {
1272
+ lanes: LANES,
1273
+ segments: SEGMENTS,
1274
+ domain: liveDomain.value,
1275
+ window: null,
1276
+ });
1277
+ },
1278
+ });
1279
+ const app = createApp(Wrapper);
1280
+ app.mount(container);
1281
+ mounts.push({ app, container });
1282
+ const root = container.querySelector<HTMLElement>(".hk-range-ribbon")!;
1283
+
1284
+ // Hover flush (0.95..1) so its tip card floats.
1285
+ const bar = segmentById(root, "flush")!;
1286
+ expect(bar, "flush paints on the unit axis").toBeTruthy();
1287
+ bar.dispatchEvent(new MouseEvent("mouseenter"));
1288
+ await nextTick();
1289
+ expect(bar.querySelector(".hk-range-ribbon-tip"), "the tip floats on hover").toBeTruthy();
1290
+
1291
+ // Narrow the axis so flush leaves it: the bar unmounts, so the watch has
1292
+ // to drop the tip id (a removed element never fires mouseleave).
1293
+ liveDomain.value = [0.2, 0.8];
1294
+ await nextTick();
1295
+ expect(segmentById(root, "flush"), "flush left the axis").toBeNull();
1296
+ expect(root.querySelector(".hk-range-ribbon-tip"), "no tip without a mounted bar").toBeNull();
1297
+
1298
+ // Widen back: flush returns, but nothing hovered or focused it again.
1299
+ liveDomain.value = [0, 1];
1300
+ await nextTick();
1301
+ expect(segmentById(root, "flush"), "flush is back").toBeTruthy();
1302
+ expect(
1303
+ root.querySelector(".hk-range-ribbon-tip"),
1304
+ "the stale tip must not resurrect without a pointer or focus",
1305
+ ).toBeNull();
1306
+ });
1307
+
1168
1308
  it("anchors the tip inward at the track edges", async () => {
1169
1309
  const segments: RibbonSegment[] = [
1170
1310
  { id: "left", lane: "cpu", start: 0.02, end: 0.12, label: "left", tip: { title: "Left" } },
@@ -1422,29 +1562,6 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
1422
1562
  return (fraction / (1 + headroom)) * 100;
1423
1563
  }
1424
1564
 
1425
- /** The minimap's x of a DOMAIN coordinate / of a domain LENGTH, in percent. */
1426
- function domainPercent(value: number, domain: [number, number]): number {
1427
- return ((value - domain[0]) / (domain[1] - domain[0])) * 100;
1428
- }
1429
-
1430
- function domainWidthPercent(length: number, domain: [number, number]): number {
1431
- return (length / (domain[1] - domain[0])) * 100;
1432
- }
1433
-
1434
- /** Inline box assertions at the geometry tolerance. `percent()` rounds to
1435
- * four decimals OF A PERCENT (~1e-6 fraction), so a four-decimal
1436
- * `toBeCloseTo` sits exactly on that rounding boundary; 3 keeps a margin
1437
- * while still pinning well below a percent. */
1438
- function expectBox(
1439
- element: HTMLElement | null,
1440
- expected: { left: number; width: number },
1441
- label: string,
1442
- ): void {
1443
- expect(element, label).toBeTruthy();
1444
- expect(boxOf(element!).left, `${label} left`).toBeCloseTo(expected.left, 3);
1445
- expect(boxOf(element!).width, `${label} width`).toBeCloseTo(expected.width, 3);
1446
- }
1447
-
1448
1565
  it("marks both layers explicitly and keeps the lane markup intact", () => {
1449
1566
  const root = mountRibbon({ window: [0.35, 0.67] });
1450
1567
  const minimap = root.querySelector<HTMLElement>(".hk-range-ribbon-overview");
@@ -107,14 +107,21 @@ type WindowRange = [number, number];
107
107
  const MIN_WINDOW_FALLBACK = 0.02;
108
108
  /** Keyboard nudge as a fraction of the AXIS SPAN for move and resize. */
109
109
  const KEYBOARD_STEP = 0.05;
110
- /** Slack for "did this interaction change anything" comparisons. */
110
+ /** Slack for "did this interaction change anything" comparisons. Window to
111
+ * window (`windowsMatch`) is absolute — both sides live on the same axis —
112
+ * while the full-domain test scales it by the axis SPAN, so a tiny axis can
113
+ * still tell its full view from a hairline window. */
111
114
  const WINDOW_EPSILON = 1e-9;
112
115
 
113
- /** An ordered pair of caller coordinates: the axis everything is drawn on. */
114
- type DomainRange = [number, number];
116
+ /** An ordered pair of caller coordinates: the axis everything is drawn on.
117
+ * Read-only by contract: the same pair is handed out to every consumer. */
118
+ type DomainRange = readonly [number, number];
115
119
 
116
- /** The fallback axis: the familiar 0%..100% reading. */
117
- const UNIT_DOMAIN: DomainRange = [0, 1];
120
+ /** The fallback axis: the familiar 0%..100% reading. FROZEN because this
121
+ * exact array is handed out by `resolveDomain`, stored in the `domain`
122
+ * computed and used as the normalizer's default — an in-place edit would
123
+ * corrupt every ribbon on the page. */
124
+ const UNIT_DOMAIN: DomainRange = Object.freeze([0, 1] as const);
118
125
 
119
126
  function clampTo(value: number, lo: number, hi: number): number {
120
127
  return value < lo ? lo : value > hi ? hi : value;
@@ -550,7 +557,10 @@ export default defineComponent({
550
557
  * close while the bar held focus). */
551
558
  function isFullDomain(win: WindowRange | null): boolean {
552
559
  const [d0, d1] = domain.value;
553
- return win !== null && win[0] <= d0 + WINDOW_EPSILON && win[1] >= d1 - WINDOW_EPSILON;
560
+ // Span-relative: an absolute slack would call every window "full" on an
561
+ // axis shorter than ~2e-9. On the unit axis this is the historical 1e-9.
562
+ const eps = WINDOW_EPSILON * (d1 - d0);
563
+ return win !== null && win[0] <= d0 + eps && win[1] >= d1 - eps;
554
564
  }
555
565
 
556
566
  /** Returns true when a window actually existed AND was narrower than the
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  type HkDockBarAnchor as HDockBarAnchor,
27
27
  type HkDockBarSurface as HDockBarSurface,
28
28
  } from "./components/HkDockBar";
29
+ export { default as HAddTile } from "./components/HkAddTile";
29
30
  export { default as HDrawer } from "./components/HkDrawer";
30
31
  export { default as HEdgeGlow } from "./components/HkEdgeGlow";
31
32
  export { default as HEmptyState } from "./components/HkEmptyState";
@@ -814,6 +815,7 @@ export { default as HkNavItem } from "./components/HkNavItem";
814
815
  export { default as HkNumberInput } from "./components/HkNumberInput";
815
816
  export { default as HkPhoneInput } from "./components/HkPhoneInput";
816
817
  export { default as HkAffixPicker } from "./components/HkAffixPicker";
818
+ export { default as HkAddTile } from "./components/HkAddTile";
817
819
  export { default as HkUserPicker, type HkUserOption } from "./components/HkUserPicker";
818
820
  export { default as HkPhaseTransition } from "./components/HkPhaseTransition";
819
821
  export { default as HkGaugeRing } from "./components/HkGaugeRing";