@celestia-island/hikari 0.55.147 → 0.55.149

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.147",
3
+ "version": "0.55.149",
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,204 @@
1
+ // HkDiffViewer — grouped, collapsible unified-diff rendering.
2
+ // Sibling of the markdown code-block chrome: same subtle surface, same
3
+ // mono stack, same hidden native scrollbars (overlay rails are attached
4
+ // by the consumer's scroll container).
5
+
6
+ @use "./hikari-vars" as vars;
7
+
8
+ .hk-diff-viewer {
9
+ border: 1px solid var(--hi-color-border, #646464);
10
+ border-radius: var(--radius-sm);
11
+ background: var(--hi-color-bg-subtle, #21262d);
12
+ overflow: hidden; // clip hunk/gap bands to the rounded corners
13
+ }
14
+
15
+ .hk-diff-viewer__body {
16
+ overflow-x: auto;
17
+ font-size: var(--text-xs);
18
+ line-height: 1.55;
19
+ scrollbar-width: none;
20
+
21
+ &::-webkit-scrollbar {
22
+ display: none;
23
+ }
24
+ }
25
+
26
+ // One diff row: old number | new number | sign | content. The row is at
27
+ // least the container width (so tints span edge to edge) and grows with
28
+ // the longest content (so the container can scroll horizontally).
29
+ .hk-diff-viewer__line {
30
+ display: grid;
31
+ grid-template-columns: 4.2ch 4.2ch 2ch 1fr;
32
+ width: 100%;
33
+ min-width: max-content;
34
+ font-family: var(--font-mono, vars.$hikari-font-family-mono);
35
+ white-space: pre;
36
+ color: var(--hi-color-text-primary, #1e1e1e);
37
+
38
+ &[data-kind="add"] {
39
+ background: rgb(var(--color-success) / 12%);
40
+
41
+ .hk-diff-viewer__sign {
42
+ color: rgb(var(--color-success));
43
+ }
44
+ }
45
+
46
+ &[data-kind="del"] {
47
+ background: rgb(var(--color-error) / 12%);
48
+
49
+ .hk-diff-viewer__sign {
50
+ color: rgb(var(--color-error));
51
+ }
52
+ }
53
+ }
54
+
55
+ .hk-diff-viewer__no {
56
+ padding: 0 0.8ch;
57
+ text-align: end;
58
+ color: var(--hi-color-text-tertiary, #8b949e);
59
+ opacity: 0.75;
60
+ user-select: none;
61
+ }
62
+
63
+ .hk-diff-viewer__sign {
64
+ user-select: none;
65
+ text-align: center;
66
+ font-weight: 600;
67
+ }
68
+
69
+ .hk-diff-viewer__code {
70
+ padding-inline-end: 1ch;
71
+ min-width: 0;
72
+ }
73
+
74
+ // ── hunk groups ────────────────────────────────────────────────────────────
75
+ .hk-diff-viewer__hunk + .hk-diff-viewer__hunk,
76
+ .hk-diff-viewer__hunk-body {
77
+ border-top: 1px solid var(--hi-color-border, #646464);
78
+ }
79
+
80
+ .hk-diff-viewer__hunk-head {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 0.5em;
84
+ width: 100%;
85
+ padding: 0.3em 0.8em;
86
+ border: 0;
87
+ background: var(--hi-color-bg-subtle, #21262d);
88
+ color: var(--hi-color-text-secondary, #666666);
89
+ font: inherit;
90
+ text-align: start;
91
+ cursor: pointer;
92
+ user-select: none;
93
+
94
+ &:hover {
95
+ color: var(--hi-color-text-primary, #1e1e1e);
96
+ }
97
+
98
+ &:focus-visible {
99
+ outline: 2px solid var(--hi-color-primary, #7aa2f7);
100
+ outline-offset: -2px;
101
+ }
102
+ }
103
+
104
+ .hk-diff-viewer__hunk-range {
105
+ font-weight: 600;
106
+ letter-spacing: 0.01em;
107
+ }
108
+
109
+ .hk-diff-viewer__hunk-section {
110
+ color: var(--hi-color-text-tertiary, #8b949e);
111
+ overflow: hidden;
112
+ text-overflow: ellipsis;
113
+ white-space: nowrap;
114
+ min-width: 0;
115
+ flex: 1;
116
+ }
117
+
118
+ .hk-diff-viewer__hunk-stats {
119
+ display: inline-flex;
120
+ gap: 0.6em;
121
+ margin-inline-start: auto;
122
+ font-style: normal;
123
+
124
+ em {
125
+ font-style: normal;
126
+ font-weight: 600;
127
+ }
128
+
129
+ em[data-kind="add"] {
130
+ color: rgb(var(--color-success));
131
+ }
132
+
133
+ em[data-kind="del"] {
134
+ color: rgb(var(--color-error));
135
+ }
136
+ }
137
+
138
+ // ── collapsed unchanged regions ────────────────────────────────────────────
139
+ .hk-diff-viewer__gap {
140
+ display: flex;
141
+ align-items: center;
142
+ justify-content: center;
143
+ gap: 0.5em;
144
+ width: 100%;
145
+ padding: 0.35em 0.8em;
146
+ border: 0;
147
+ border-top: 1px dashed var(--hi-color-border, #646464);
148
+ background: rgb(var(--color-primary) / 5%);
149
+ color: var(--hi-color-text-tertiary, #8b949e);
150
+ font: inherit;
151
+ cursor: pointer;
152
+ user-select: none;
153
+
154
+ &:hover:not(:disabled) {
155
+ background: rgb(var(--color-primary) / 10%);
156
+ color: var(--hi-color-text-primary, #1e1e1e);
157
+ }
158
+
159
+ &:focus-visible {
160
+ outline: 2px solid var(--hi-color-primary, #7aa2f7);
161
+ outline-offset: -2px;
162
+ }
163
+
164
+ &:disabled {
165
+ cursor: progress;
166
+ }
167
+
168
+ &[data-state="failed"] {
169
+ background: rgb(var(--color-error) / 8%);
170
+ color: rgb(var(--color-error));
171
+ }
172
+
173
+ // Without a loadContext fetcher the row is a passive divider.
174
+ &[data-state="static"] {
175
+ cursor: default;
176
+
177
+ &:hover {
178
+ background: rgb(var(--color-primary) / 5%);
179
+ color: var(--hi-color-text-tertiary, #8b949e);
180
+ }
181
+ }
182
+ }
183
+
184
+ .hk-diff-viewer__gap-label {
185
+ font-size: 0.95em;
186
+ }
187
+
188
+ // A gap directly after the last hunk still gets its dashed separator.
189
+ .hk-diff-viewer__hunk + .hk-diff-viewer__gap,
190
+ .hk-diff-viewer__gap + .hk-diff-viewer__hunk .hk-diff-viewer__hunk-head {
191
+ border-top: 1px dashed var(--hi-color-border, #646464);
192
+ }
193
+
194
+ // ── raw fallback (unparseable input) ───────────────────────────────────────
195
+ .hk-diff-viewer__raw {
196
+ margin: 0;
197
+ padding: 0.6em 0.8em;
198
+ font-family: var(--font-mono, vars.$hikari-font-family-mono);
199
+ font-size: var(--text-xs);
200
+ line-height: 1.55;
201
+ white-space: pre-wrap;
202
+ word-break: break-word;
203
+ color: var(--hi-color-text-primary, #1e1e1e);
204
+ }
@@ -0,0 +1,240 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
3
+
4
+ import HkDiffViewer from "./HkDiffViewer";
5
+
6
+ /**
7
+ * HkDiffViewer — the rendered half: hunk grouping, collapsed unchanged
8
+ * regions and the async expander contract (loadContext round-trips,
9
+ * anchor policy, failure/retry, reset on diff change).
10
+ */
11
+
12
+ const apps: App[] = [];
13
+ afterEach(() => {
14
+ for (const app of apps.splice(0)) app.unmount();
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ function mount(props: Record<string, unknown>) {
19
+ const container = document.createElement("div");
20
+ document.body.appendChild(container);
21
+ const Root = defineComponent({
22
+ setup() {
23
+ return () => h(HkDiffViewer, props as never);
24
+ },
25
+ });
26
+ const app = createApp(Root);
27
+ app.mount(container);
28
+ apps.push(app);
29
+ return container;
30
+ }
31
+
32
+ /** Diff with a 9-line leading gap, one mixed hunk, and a 5-line trailing gap. */
33
+ const DIFF = [
34
+ "--- a/a.toml",
35
+ "+++ b/a.toml",
36
+ "@@ -10,4 +10,5 @@",
37
+ " ctx-a",
38
+ "-old",
39
+ "+new-1",
40
+ "+new-2",
41
+ " ctx-b",
42
+ " ctx-c",
43
+ "",
44
+ ].join("\n");
45
+
46
+ const tick = () => new Promise((r) => setTimeout(r, 0));
47
+
48
+ describe("HkDiffViewer", () => {
49
+ it("groups the diff into hunk cards with per-hunk stats and both-side numbers", async () => {
50
+ const root = mount({ diff: DIFF, totalLines: 19 });
51
+ await nextTick();
52
+ const head = root.querySelector(".hk-diff-viewer__hunk-head")!;
53
+ expect(head.textContent).toContain("@@ -10,4 +10,5 @@");
54
+ expect(head.querySelector('[data-kind="add"]')!.textContent).toBe("+2");
55
+ expect(head.querySelector('[data-kind="del"]')!.textContent).toBe("−1");
56
+ const lines = [...root.querySelectorAll(".hk-diff-viewer__line")];
57
+ // 6 hunk lines (3 context + 1 del + 2 add); gap rows are not .line
58
+ expect(lines).toHaveLength(6);
59
+ const kinds = lines.map((l) => l.getAttribute("data-kind"));
60
+ expect(kinds).toEqual(["context", "del", "add", "add", "context", "context"]);
61
+ const delLine = lines[1];
62
+ expect(delLine.querySelectorAll(".hk-diff-viewer__no")[0].textContent).toBe("11");
63
+ expect(delLine.querySelectorAll(".hk-diff-viewer__no")[1].textContent).toBe("");
64
+ });
65
+
66
+ it("renders unchanged regions as static dividers without a loadContext fetcher", async () => {
67
+ const root = mount({ diff: DIFF, totalLines: 19 });
68
+ await nextTick();
69
+ const gaps = [...root.querySelectorAll(".hk-diff-viewer__gap")];
70
+ expect(gaps).toHaveLength(2); // leading 9 + trailing 5
71
+ expect(gaps[0].tagName).toBe("DIV"); // not a button — nothing to fetch with
72
+ expect(gaps[0].textContent).toContain("9");
73
+ expect(gaps[1].textContent).toContain("5");
74
+ });
75
+
76
+ it("expands an unchanged region asynchronously via loadContext", async () => {
77
+ const seen: Array<[number, number]> = [];
78
+ const loadContext = vi.fn(async (start: number, count: number) => {
79
+ seen.push([start, count]);
80
+ return Array.from({ length: count }, (_, i) => `line-${start + i}`);
81
+ });
82
+ const root = mount({ diff: DIFF, totalLines: 19, loadContext, contextChunk: 4 });
83
+ await nextTick();
84
+
85
+ // Trailing gap [15..19] (5 lines, anchor start): first click fetches 15..18.
86
+ const trailing = root.querySelectorAll(".hk-diff-viewer__gap")[1] as HTMLButtonElement;
87
+ expect(trailing.tagName).toBe("BUTTON");
88
+ trailing.click();
89
+ await tick();
90
+ await nextTick();
91
+ expect(seen).toEqual([[15, 4]]);
92
+ let text = root.textContent!;
93
+ expect(text).toContain("line-15");
94
+ expect(text).toContain("line-18");
95
+ // One line remains hidden → the expander stays, now counting 1.
96
+ const after = root.querySelectorAll(".hk-diff-viewer__gap")[1] as HTMLButtonElement;
97
+ expect(after.textContent).toContain("1");
98
+
99
+ // Second click completes the region; the divider disappears entirely.
100
+ after.click();
101
+ await tick();
102
+ await nextTick();
103
+ expect(seen[1]).toEqual([19, 1]);
104
+ expect(root.querySelectorAll(".hk-diff-viewer__gap")).toHaveLength(1); // only the leading gap
105
+ text = root.textContent!;
106
+ expect(text).toContain("line-19");
107
+ // Revealed context lines carry both-side numbers (shift = 0 here).
108
+ const revealed = [...root.querySelectorAll(".hk-diff-viewer__line[data-kind='context']")];
109
+ expect(revealed.length).toBeGreaterThan(4);
110
+ });
111
+
112
+ it("expands the LEADING gap bottom-up (lines nearest the hunk first)", async () => {
113
+ const seen: Array<[number, number]> = [];
114
+ const loadContext = vi.fn(async (start: number, count: number) => {
115
+ seen.push([start, count]);
116
+ return Array.from({ length: count }, (_, i) => `L${start + i}`);
117
+ });
118
+ const root = mount({ diff: DIFF, totalLines: 19, loadContext, contextChunk: 4 });
119
+ await nextTick();
120
+ const leading = root.querySelectorAll(".hk-diff-viewer__gap")[0] as HTMLButtonElement;
121
+ leading.click();
122
+ await tick();
123
+ await nextTick();
124
+ expect(seen).toEqual([[6, 4]]); // bottom 4 of the 9-line leading gap
125
+ const text = root.textContent!;
126
+ expect(text).toContain("L6");
127
+ expect(text).toContain("L9");
128
+ expect(text).not.toContain("L5");
129
+ });
130
+
131
+ it("marks a failed fetch inline and retries on the next click", async () => {
132
+ let calls = 0;
133
+ const loadContext = vi.fn(async () => {
134
+ calls += 1;
135
+ if (calls === 1) return null; // transport failure
136
+ return ["recovered"];
137
+ });
138
+ const root = mount({
139
+ diff: DIFF,
140
+ totalLines: 19,
141
+ loadContext,
142
+ contextChunk: 1, // trailing gap is 5 lines; irrelevant — leading used
143
+ });
144
+ await nextTick();
145
+ const gap = root.querySelectorAll(".hk-diff-viewer__gap")[1] as HTMLButtonElement;
146
+ gap.click();
147
+ await tick();
148
+ await nextTick();
149
+ expect(root.querySelector(".hk-diff-viewer__gap[data-state='failed']")).toBeTruthy();
150
+ expect(loadContext).toHaveBeenCalledTimes(1);
151
+
152
+ (root.querySelector(".hk-diff-viewer__gap[data-state='failed']") as HTMLButtonElement).click();
153
+ await tick();
154
+ await nextTick();
155
+ expect(loadContext).toHaveBeenCalledTimes(2);
156
+ expect(root.querySelector(".hk-diff-viewer__gap[data-state='failed']")).toBeNull();
157
+ expect(root.textContent).toContain("recovered");
158
+ });
159
+
160
+ it("bottom-aligns a SHORT response in the leading gap (R1)", async () => {
161
+ // A clipping host answers fewer lines than requested: for the
162
+ // bottom-anchored leading gap the arrived lines are the ones NEAREST
163
+ // the hunk — storing them top-aligned would permanently hide those.
164
+ const seen: Array<[number, number]> = [];
165
+ const loadContext = vi.fn(async (start: number, count: number) => {
166
+ seen.push([start, count]);
167
+ // Clips to 2 lines regardless of the requested window.
168
+ return ["L8", "L9"].slice(0, Math.min(2, count));
169
+ });
170
+ const root = mount({ diff: DIFF, totalLines: 19, loadContext, contextChunk: 4 });
171
+ await nextTick();
172
+ const leading = root.querySelectorAll(".hk-diff-viewer__gap")[0] as HTMLButtonElement;
173
+ leading.click();
174
+ await tick();
175
+ await nextTick();
176
+ expect(seen).toEqual([[6, 4]]); // bottom 4 of the 9-line gap requested…
177
+ const text = root.textContent!;
178
+ expect(text).toContain("L8"); // …but the 2 that arrived render…
179
+ expect(text).toContain("L9"); // …nearest the hunk (bottom-aligned)
180
+ expect(text).not.toContain("L6");
181
+ // 7 lines remain hidden; the expander persists with the new count.
182
+ const after = root.querySelectorAll(".hk-diff-viewer__gap")[0] as HTMLButtonElement;
183
+ expect(after.textContent).toContain("7");
184
+ });
185
+
186
+ it("collapses and re-expands a hunk via its header", async () => {
187
+ const root = mount({ diff: DIFF, totalLines: 19 });
188
+ await nextTick();
189
+ const head = root.querySelector(".hk-diff-viewer__hunk-head") as HTMLButtonElement;
190
+ expect(root.querySelectorAll(".hk-diff-viewer__line")).toHaveLength(6);
191
+ head.click();
192
+ await nextTick();
193
+ expect(root.querySelectorAll(".hk-diff-viewer__line")).toHaveLength(0);
194
+ head.click();
195
+ await nextTick();
196
+ expect(root.querySelectorAll(".hk-diff-viewer__line")).toHaveLength(6);
197
+ });
198
+
199
+ it("resets gap state when the diff prop changes", async () => {
200
+ const loadContext = vi.fn(async (start: number, count: number) =>
201
+ Array.from({ length: count }, (_, i) => `line-${start + i}`),
202
+ );
203
+ const container = document.createElement("div");
204
+ document.body.appendChild(container);
205
+ const diffRef = ref(DIFF);
206
+ const Root = defineComponent({
207
+ setup() {
208
+ return () =>
209
+ h(HkDiffViewer, {
210
+ diff: diffRef.value,
211
+ totalLines: 19,
212
+ loadContext,
213
+ contextChunk: 100,
214
+ } as never);
215
+ },
216
+ });
217
+ const app = createApp(Root);
218
+ app.mount(container);
219
+ apps.push(app);
220
+ await nextTick();
221
+
222
+ (container.querySelector(".hk-diff-viewer__gap") as HTMLButtonElement).click();
223
+ await tick();
224
+ await nextTick();
225
+ expect(container.textContent).toContain("line-1");
226
+
227
+ // A different diff must drop the fetched lines and recompute gaps.
228
+ diffRef.value = "@@ -3,2 +3,2 @@\n-a\n+b\n";
229
+ await nextTick();
230
+ expect(container.textContent).not.toContain("line-1");
231
+ expect(container.textContent).toContain("15"); // trailing gap of the new diff
232
+ });
233
+
234
+ it("degrades unparseable input to a raw <pre>", async () => {
235
+ const root = mount({ diff: "not a diff\nat all" });
236
+ await nextTick();
237
+ expect(root.querySelector(".hk-diff-viewer__raw")!.textContent).toContain("not a diff");
238
+ expect(root.querySelector(".hk-diff-viewer__hunk")).toBeNull();
239
+ });
240
+ });