@kgdiff/viewer 0.0.0-stage → 0.1.0

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -2
  3. package/dist/compute-loader.d.ts +13 -0
  4. package/dist/compute-loader.d.ts.map +1 -0
  5. package/dist/compute-loader.js +46 -0
  6. package/dist/compute-loader.js.map +1 -0
  7. package/dist/compute.d.ts +8 -0
  8. package/dist/compute.d.ts.map +1 -0
  9. package/dist/compute.js +8 -0
  10. package/dist/compute.js.map +1 -0
  11. package/dist/controller.d.ts +199 -0
  12. package/dist/controller.d.ts.map +1 -0
  13. package/dist/controller.js +1254 -0
  14. package/dist/controller.js.map +1 -0
  15. package/dist/dom.d.ts +32 -0
  16. package/dist/dom.d.ts.map +1 -0
  17. package/dist/dom.js +105 -0
  18. package/dist/dom.js.map +1 -0
  19. package/dist/element.d.ts +43 -0
  20. package/dist/element.d.ts.map +1 -0
  21. package/dist/element.js +656 -0
  22. package/dist/element.js.map +1 -0
  23. package/dist/framing.d.ts +45 -0
  24. package/dist/framing.d.ts.map +1 -0
  25. package/dist/framing.js +107 -0
  26. package/dist/framing.js.map +1 -0
  27. package/dist/hash-sync.d.ts +18 -0
  28. package/dist/hash-sync.d.ts.map +1 -0
  29. package/dist/hash-sync.js +45 -0
  30. package/dist/hash-sync.js.map +1 -0
  31. package/dist/i18n.d.ts +223 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +436 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/icons.d.ts +37 -0
  36. package/dist/icons.d.ts.map +1 -0
  37. package/dist/icons.js +36 -0
  38. package/dist/icons.js.map +1 -0
  39. package/dist/index.d.ts +151 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +41 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/keyboard.d.ts +64 -0
  44. package/dist/keyboard.d.ts.map +1 -0
  45. package/dist/keyboard.js +95 -0
  46. package/dist/keyboard.js.map +1 -0
  47. package/dist/list-model.d.ts +35 -0
  48. package/dist/list-model.d.ts.map +1 -0
  49. package/dist/list-model.js +70 -0
  50. package/dist/list-model.js.map +1 -0
  51. package/dist/payload.d.ts +10 -0
  52. package/dist/payload.d.ts.map +1 -0
  53. package/dist/payload.js +138 -0
  54. package/dist/payload.js.map +1 -0
  55. package/dist/pipeline.d.ts +31 -0
  56. package/dist/pipeline.d.ts.map +1 -0
  57. package/dist/pipeline.js +122 -0
  58. package/dist/pipeline.js.map +1 -0
  59. package/dist/prefs.d.ts +18 -0
  60. package/dist/prefs.d.ts.map +1 -0
  61. package/dist/prefs.js +38 -0
  62. package/dist/prefs.js.map +1 -0
  63. package/dist/registry.d.ts +3 -0
  64. package/dist/registry.d.ts.map +1 -0
  65. package/dist/registry.js +3 -0
  66. package/dist/registry.js.map +1 -0
  67. package/dist/renderer-port.d.ts +14 -0
  68. package/dist/renderer-port.d.ts.map +1 -0
  69. package/dist/renderer-port.js +16 -0
  70. package/dist/renderer-port.js.map +1 -0
  71. package/dist/replay.d.ts +55 -0
  72. package/dist/replay.d.ts.map +1 -0
  73. package/dist/replay.js +147 -0
  74. package/dist/replay.js.map +1 -0
  75. package/dist/review-store.d.ts +30 -0
  76. package/dist/review-store.d.ts.map +1 -0
  77. package/dist/review-store.js +120 -0
  78. package/dist/review-store.js.map +1 -0
  79. package/dist/search.d.ts +25 -0
  80. package/dist/search.d.ts.map +1 -0
  81. package/dist/search.js +90 -0
  82. package/dist/search.js.map +1 -0
  83. package/dist/standalone/kgdiff-viewer-compute.js +1 -0
  84. package/dist/standalone/kgdiff-viewer.js +5 -0
  85. package/dist/standalone-compute.d.ts +2 -0
  86. package/dist/standalone-compute.d.ts.map +1 -0
  87. package/dist/standalone-compute.js +8 -0
  88. package/dist/standalone-compute.js.map +1 -0
  89. package/dist/standalone.d.ts +2 -0
  90. package/dist/standalone.d.ts.map +1 -0
  91. package/dist/standalone.js +18 -0
  92. package/dist/standalone.js.map +1 -0
  93. package/dist/styles.d.ts +11 -0
  94. package/dist/styles.d.ts.map +1 -0
  95. package/dist/styles.js +627 -0
  96. package/dist/styles.js.map +1 -0
  97. package/dist/text-diff.d.ts +32 -0
  98. package/dist/text-diff.d.ts.map +1 -0
  99. package/dist/text-diff.js +211 -0
  100. package/dist/text-diff.js.map +1 -0
  101. package/dist/ui/context.d.ts +32 -0
  102. package/dist/ui/context.d.ts.map +1 -0
  103. package/dist/ui/context.js +49 -0
  104. package/dist/ui/context.js.map +1 -0
  105. package/dist/ui/detail.d.ts +9 -0
  106. package/dist/ui/detail.d.ts.map +1 -0
  107. package/dist/ui/detail.js +566 -0
  108. package/dist/ui/detail.js.map +1 -0
  109. package/dist/ui/header.d.ts +14 -0
  110. package/dist/ui/header.d.ts.map +1 -0
  111. package/dist/ui/header.js +195 -0
  112. package/dist/ui/header.js.map +1 -0
  113. package/dist/ui/popovers.d.ts +20 -0
  114. package/dist/ui/popovers.d.ts.map +1 -0
  115. package/dist/ui/popovers.js +180 -0
  116. package/dist/ui/popovers.js.map +1 -0
  117. package/dist/ui/sidebar.d.ts +25 -0
  118. package/dist/ui/sidebar.d.ts.map +1 -0
  119. package/dist/ui/sidebar.js +609 -0
  120. package/dist/ui/sidebar.js.map +1 -0
  121. package/dist/ui/stage.d.ts +18 -0
  122. package/dist/ui/stage.d.ts.map +1 -0
  123. package/dist/ui/stage.js +664 -0
  124. package/dist/ui/stage.js.map +1 -0
  125. package/package.json +58 -3
  126. package/src/compute-loader.ts +58 -0
  127. package/src/compute.ts +7 -0
  128. package/src/controller.ts +1393 -0
  129. package/src/dom.ts +131 -0
  130. package/src/element.ts +697 -0
  131. package/src/framing.ts +150 -0
  132. package/src/hash-sync.ts +54 -0
  133. package/src/i18n.ts +469 -0
  134. package/src/icons.ts +48 -0
  135. package/src/index.ts +171 -0
  136. package/src/keyboard.ts +128 -0
  137. package/src/list-model.ts +86 -0
  138. package/src/payload.ts +165 -0
  139. package/src/pipeline.ts +179 -0
  140. package/src/prefs.ts +55 -0
  141. package/src/registry.ts +2 -0
  142. package/src/renderer-port.ts +31 -0
  143. package/src/replay.ts +163 -0
  144. package/src/review-store.ts +125 -0
  145. package/src/search.ts +113 -0
  146. package/src/standalone-compute.ts +8 -0
  147. package/src/standalone.ts +18 -0
  148. package/src/styles.ts +638 -0
  149. package/src/text-diff.ts +208 -0
  150. package/src/ui/context.ts +70 -0
  151. package/src/ui/detail.ts +921 -0
  152. package/src/ui/header.ts +278 -0
  153. package/src/ui/popovers.ts +251 -0
  154. package/src/ui/sidebar.ts +752 -0
  155. package/src/ui/stage.ts +853 -0
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Sidebar: "Changes" header with review progress, node search, family filter chips, the
3
+ * phase-grouped change list (virtualized, sticky group headers, `listbox` with
4
+ * `aria-activedescendant`) and the detail panel. Below 760 px it is a resizable bottom sheet.
5
+ */
6
+ import type { SceneHunk } from '@kgdiff/scene';
7
+ import type { Topic } from '../controller.js';
8
+ import { attr, h, icon, replace, setText } from '../dom.js';
9
+ import { familyCounts, type ListItem } from '../list-model.js';
10
+ import type { SearchHit } from '../search.js';
11
+ import { FAMILIES } from '../styles.js';
12
+ import { sceneHunkGlyph, type UiContext } from './context.js';
13
+ import { Detail } from './detail.js';
14
+
15
+ /** Row slot and group-header heights (px); rows are 52 px tall inside a 56 px slot. */
16
+ export const ROW_SLOT = 56;
17
+ export const HEADER_SLOT = 32;
18
+ const OVERSCAN = 320;
19
+ const SEARCH_LIMIT = 50;
20
+
21
+ function itemHeight(item: ListItem): number {
22
+ return item.kind === 'header' ? HEADER_SLOT : ROW_SLOT;
23
+ }
24
+
25
+ export class Sidebar {
26
+ readonly el: HTMLElement;
27
+ readonly detail: Detail;
28
+ readonly #title: HTMLElement;
29
+ readonly #count: HTMLElement;
30
+ readonly #progress: HTMLElement;
31
+ readonly #progressText: HTMLElement;
32
+ readonly #progressBar: HTMLElement;
33
+ readonly searchInput: HTMLInputElement;
34
+ readonly #searchKbd: HTMLElement;
35
+ readonly #searchClear: HTMLButtonElement;
36
+ readonly #filters: HTMLElement;
37
+ readonly #chips = new Map<string, HTMLButtonElement>();
38
+ readonly #showAll: HTMLButtonElement;
39
+ readonly list: HTMLElement;
40
+ readonly #listInner: HTMLElement;
41
+ /** Visually hidden group names the options are described by. */
42
+ readonly #phases: HTMLElement = h('div', { class: 'sr-only' });
43
+ readonly #sticky: HTMLElement;
44
+ readonly #results: HTMLElement;
45
+ readonly #handle: HTMLElement;
46
+ #offsets: number[] = [];
47
+ #rendered = new Map<number, HTMLElement>();
48
+ #scrollQueued = false;
49
+ #query = '';
50
+ #hits: SearchHit[] = [];
51
+ #activeHit = 0;
52
+
53
+ readonly #ctx: UiContext;
54
+
55
+ constructor(ctx: UiContext) {
56
+ this.#ctx = ctx;
57
+ const c = ctx.controller;
58
+ this.#title = h('h2', { id: 'kg-changes-title' });
59
+ this.#count = h('span', { class: 'count-badge' });
60
+ this.#progressText = h('span');
61
+ this.#progressBar = h('div');
62
+ this.#progress = h(
63
+ 'div',
64
+ { class: 'progress' },
65
+ this.#progressText,
66
+ h('span', { class: 'bar', 'aria-hidden': 'true' }, this.#progressBar),
67
+ );
68
+ this.searchInput = h('input', {
69
+ type: 'search',
70
+ autocomplete: 'off',
71
+ spellcheck: 'false',
72
+ role: 'combobox',
73
+ 'aria-autocomplete': 'list',
74
+ 'aria-expanded': 'false',
75
+ 'aria-controls': 'kg-results',
76
+ enterkeyhint: 'go',
77
+ });
78
+ this.#searchKbd = h('kbd', { 'aria-hidden': 'true', text: '/' });
79
+ this.#searchClear = h(
80
+ 'button',
81
+ { type: 'button', class: 'clear', hidden: true, onclick: () => this.#setQuery('', true) },
82
+ icon('close'),
83
+ );
84
+ this.searchInput.addEventListener('input', () => this.#setQuery(this.searchInput.value));
85
+ this.searchInput.addEventListener('keydown', (e) => this.#onSearchKey(e));
86
+ this.searchInput.addEventListener('focus', () => this.#updateSearchChrome());
87
+ this.searchInput.addEventListener('blur', () => this.#updateSearchChrome());
88
+ this.#filters = h('div', { class: 'filters', role: 'group' });
89
+ for (const f of FAMILIES) {
90
+ const chip = h(
91
+ 'button',
92
+ {
93
+ type: 'button',
94
+ class: 'fchip',
95
+ 'data-family': f,
96
+ 'aria-pressed': 'false',
97
+ onclick: () => c.toggleFamily(f),
98
+ },
99
+ h('span', { class: 'dot', 'aria-hidden': 'true' }),
100
+ h('span', { class: 'lbl' }),
101
+ h('span', { class: 'n' }),
102
+ );
103
+ this.#chips.set(f, chip);
104
+ this.#filters.appendChild(chip);
105
+ }
106
+ this.#showAll = h('button', {
107
+ type: 'button',
108
+ class: 'link-btn',
109
+ hidden: true,
110
+ onclick: () => c.setFamilies([]),
111
+ });
112
+ this.#filters.appendChild(this.#showAll);
113
+
114
+ this.#listInner = h('div', { class: 'list-inner', role: 'presentation' });
115
+ this.list = h(
116
+ 'div',
117
+ {
118
+ class: 'list scroll',
119
+ role: 'listbox',
120
+ tabindex: '0',
121
+ 'aria-labelledby': 'kg-changes-title',
122
+ 'aria-keyshortcuts': 'J K X Enter Home End',
123
+ },
124
+ this.#listInner,
125
+ );
126
+ this.list.addEventListener('scroll', () => this.#queueWindow(), { passive: true });
127
+ this.list.addEventListener('click', (e) => this.#onListClick(e));
128
+ this.list.addEventListener('dblclick', (e) => {
129
+ const row = (e.target as Element).closest('.row');
130
+ if (row) c.flyToSelection();
131
+ });
132
+ this.#sticky = h('div', { class: 'sticky', 'aria-hidden': 'true', hidden: true });
133
+ this.#results = h('div', {
134
+ class: 'results list scroll',
135
+ id: 'kg-results',
136
+ role: 'listbox',
137
+ hidden: true,
138
+ });
139
+ this.#handle = h('div', {
140
+ class: 'sheet-handle',
141
+ role: 'separator',
142
+ 'aria-orientation': 'horizontal',
143
+ tabindex: '0',
144
+ });
145
+ this.#bindSheet();
146
+ this.detail = new Detail(ctx);
147
+ const head = h(
148
+ 'div',
149
+ { class: 'sb-head' },
150
+ h('div', { class: 'sb-title' }, this.#title, this.#count, this.#progress),
151
+ h(
152
+ 'div',
153
+ { class: 'search' },
154
+ icon('search'),
155
+ this.searchInput,
156
+ this.#searchKbd,
157
+ this.#searchClear,
158
+ ),
159
+ this.#filters,
160
+ );
161
+ this.el = h(
162
+ 'aside',
163
+ { class: 'sidebar', 'data-open': 'true' },
164
+ h(
165
+ 'div',
166
+ { class: 'sidebar-inner' },
167
+ this.#handle,
168
+ head,
169
+ h('div', { class: 'list-wrap' }, this.#sticky, this.list, this.#results, this.#phases),
170
+ this.detail.el,
171
+ ),
172
+ );
173
+ }
174
+
175
+ setOpen(open: boolean): void {
176
+ attr(this.el, 'data-open', String(open));
177
+ this.el.inert = !open;
178
+ }
179
+
180
+ update(topics: ReadonlySet<Topic>): void {
181
+ const tr = this.#ctx.tr();
182
+ const all = topics.has('status') || topics.has('data') || topics.has('locale');
183
+ if (all) {
184
+ attr(this.el, 'aria-label', tr.t('sidebar.label'));
185
+ setText(this.#title, tr.t('sidebar.changes'));
186
+ this.searchInput.placeholder = tr.t('sidebar.searchPlaceholder');
187
+ attr(this.searchInput, 'aria-label', tr.t('sidebar.search'));
188
+ attr(this.#searchClear, 'aria-label', tr.t('sidebar.searchClear'));
189
+ attr(this.#filters, 'aria-label', tr.t('sidebar.filters'));
190
+ attr(this.#handle, 'aria-label', tr.t('sidebar.sheet'));
191
+ setText(this.#showAll, tr.t('sidebar.clearFilters'));
192
+ this.searchInput.disabled = this.#ctx.controller.status !== 'ready';
193
+ this.#renderChips();
194
+ if (this.#query) this.#runSearch();
195
+ }
196
+ if (all || topics.has('filters')) {
197
+ this.#renderChipState();
198
+ this.#layoutList();
199
+ }
200
+ if (all || topics.has('review')) {
201
+ this.#renderProgress();
202
+ this.#refreshRows();
203
+ }
204
+ if (topics.has('selection')) {
205
+ this.#refreshRows();
206
+ this.#revealSelected();
207
+ }
208
+ this.detail.update(topics);
209
+ }
210
+
211
+ /** Move keyboard focus into the list (after choosing a search result). */
212
+ focusList(): void {
213
+ this.list.focus({ preventScroll: true });
214
+ }
215
+
216
+ focusSearch(): void {
217
+ this.searchInput.focus();
218
+ this.searchInput.select();
219
+ }
220
+
221
+ /** Esc inside the sidebar: clear the search first. Returns whether it handled the key. */
222
+ escape(): boolean {
223
+ if (this.#query) {
224
+ this.#setQuery('', true);
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+
230
+ // ─── Header bits ─────────────────────────────────────────────────────────
231
+
232
+ #renderChips(): void {
233
+ const tr = this.#ctx.tr();
234
+ const scene = this.#ctx.controller.scene;
235
+ const counts = scene ? familyCounts(scene) : null;
236
+ setText(this.#count, tr.num(scene?.hunks.length ?? 0));
237
+ this.#count.hidden = !scene;
238
+ this.#filters.hidden = !scene || scene.hunks.length === 0;
239
+ for (const [f, chip] of this.#chips) {
240
+ const n = counts ? counts[f as keyof typeof counts] : 0;
241
+ setText(chip.querySelector('.lbl') as Element, tr.t(`family.${f as keyof typeof counts}`));
242
+ setText(chip.querySelector('.n') as Element, tr.num(n));
243
+ chip.hidden = n === 0;
244
+ attr(chip, 'title', tr.t(`familyHint.${f as keyof typeof counts}`));
245
+ }
246
+ }
247
+
248
+ #renderChipState(): void {
249
+ const fams = this.#ctx.controller.families;
250
+ for (const [f, chip] of this.#chips) {
251
+ attr(chip, 'aria-pressed', String(fams.has(f as never)));
252
+ }
253
+ this.#showAll.hidden = fams.size === 0;
254
+ }
255
+
256
+ #renderProgress(): void {
257
+ const tr = this.#ctx.tr();
258
+ const state = this.#ctx.controller.reviewState();
259
+ const done = state.reviewed.length;
260
+ const total = state.total;
261
+ this.#progress.hidden = total === 0;
262
+ const complete = total > 0 && done === total;
263
+ attr(this.#progress, 'data-complete', String(complete));
264
+ setText(
265
+ this.#progressText,
266
+ complete ? tr.t('review.complete') : tr.t('review.progress', { done, total }),
267
+ );
268
+ this.#progressBar.style.transform = `scaleX(${total > 0 ? done / total : 0})`;
269
+ }
270
+
271
+ // ─── Virtual list ────────────────────────────────────────────────────────
272
+
273
+ #layoutList(): void {
274
+ const c = this.#ctx.controller;
275
+ const tr = this.#ctx.tr();
276
+ replace(
277
+ this.#phases,
278
+ c.list.items.map((it) =>
279
+ it.kind === 'header'
280
+ ? h('span', {
281
+ id: `kg-phase-${it.phase}`,
282
+ text: `${tr.t(`phase.${it.phase}`)} (${tr.num(it.count)})`,
283
+ })
284
+ : null,
285
+ ),
286
+ );
287
+ for (const el of this.#rendered.values()) el.remove();
288
+ this.#rendered.clear();
289
+ const items = c.list.items;
290
+ this.#offsets = new Array(items.length);
291
+ let y = 0;
292
+ for (let i = 0; i < items.length; i++) {
293
+ this.#offsets[i] = y;
294
+ y += itemHeight(items[i] as ListItem);
295
+ }
296
+ this.#listInner.style.height = `${y + 8}px`;
297
+ this.#renderEmpty();
298
+ this.renderWindow();
299
+ }
300
+
301
+ #renderEmpty(): void {
302
+ const c = this.#ctx.controller;
303
+ const tr = this.#ctx.tr();
304
+ const existing = this.#listInner.querySelector('.list-empty, .skeleton');
305
+ existing?.remove();
306
+ if (c.status === 'loading' || c.status === 'idle') {
307
+ const sk = h('div', { class: 'skeleton', 'aria-hidden': 'true' });
308
+ if (c.status === 'loading') {
309
+ for (let i = 0; i < 6; i++) {
310
+ sk.appendChild(
311
+ h(
312
+ 'div',
313
+ { class: 'skeleton-row shimmer' },
314
+ h('span', { class: 'g' }),
315
+ h('span', { class: 'b' }),
316
+ ),
317
+ );
318
+ }
319
+ }
320
+ this.#listInner.appendChild(sk);
321
+ return;
322
+ }
323
+ if (c.status !== 'ready' || c.list.items.length > 0) return;
324
+ const filtered = c.families.size > 0 && c.hunkCount > 0;
325
+ this.#listInner.appendChild(
326
+ h(
327
+ 'div',
328
+ { class: 'list-empty' },
329
+ icon(filtered ? 'search' : 'check-circle'),
330
+ h('span', { text: tr.t(filtered ? 'sidebar.filteredEmpty' : 'sidebar.noChanges') }),
331
+ filtered
332
+ ? h('button', {
333
+ type: 'button',
334
+ class: 'btn',
335
+ text: tr.t('sidebar.clearFilters'),
336
+ onclick: () => c.setFamilies([]),
337
+ })
338
+ : null,
339
+ ),
340
+ );
341
+ }
342
+
343
+ #queueWindow(): void {
344
+ if (this.#scrollQueued) return;
345
+ this.#scrollQueued = true;
346
+ requestAnimationFrame(() => {
347
+ this.#scrollQueued = false;
348
+ this.renderWindow();
349
+ });
350
+ }
351
+
352
+ /** First item index whose bottom is below `y` (binary search over offsets). */
353
+ #indexAt(y: number): number {
354
+ let lo = 0;
355
+ let hi = this.#offsets.length;
356
+ while (lo < hi) {
357
+ const mid = (lo + hi) >> 1;
358
+ if ((this.#offsets[mid] as number) + ROW_SLOT <= y) lo = mid + 1;
359
+ else hi = mid;
360
+ }
361
+ return lo;
362
+ }
363
+
364
+ renderWindow(): void {
365
+ const items = this.#ctx.controller.list.items;
366
+ const top = this.list.scrollTop;
367
+ const height = this.list.clientHeight || 640;
368
+ const from = this.#indexAt(Math.max(0, top - OVERSCAN));
369
+ const limit = top + height + OVERSCAN;
370
+ const needed = new Set<number>();
371
+ for (let i = from; i < items.length && (this.#offsets[i] as number) < limit; i++) needed.add(i);
372
+ for (const [i, el] of this.#rendered) {
373
+ if (!needed.has(i)) {
374
+ el.remove();
375
+ this.#rendered.delete(i);
376
+ }
377
+ }
378
+ for (const i of needed) {
379
+ if (this.#rendered.has(i)) continue;
380
+ const el = this.#renderItem(items[i] as ListItem, this.#offsets[i] as number);
381
+ this.#rendered.set(i, el);
382
+ this.#listInner.appendChild(el);
383
+ }
384
+ this.#renderSticky(top);
385
+ }
386
+
387
+ #renderItem(item: ListItem, y: number): HTMLElement {
388
+ const tr = this.#ctx.tr();
389
+ const c = this.#ctx.controller;
390
+ if (item.kind === 'header') {
391
+ return h(
392
+ 'div',
393
+ { class: 'group', role: 'presentation', style: `top: ${y}px` },
394
+ h('span', { text: tr.t(`phase.${item.phase}`) }),
395
+ h('span', { class: 'n', text: tr.num(item.count) }),
396
+ );
397
+ }
398
+ const hunk = c.scene?.hunks[item.hunk] as SceneHunk;
399
+ const reviewed = c.isReviewed(hunk.id);
400
+ // Pointer shortcut only: options hold no interactive children. Keyboard users press `x` or
401
+ // use the detail panel's "Reviewed" button.
402
+ const rv = h(
403
+ 'span',
404
+ {
405
+ class: 'rv',
406
+ 'aria-hidden': 'true',
407
+ 'data-pressed': String(reviewed),
408
+ title: tr.t(reviewed ? 'review.unmark' : 'review.mark'),
409
+ 'data-hunk': hunk.id,
410
+ },
411
+ h('span', { class: 'ring' }, icon('check')),
412
+ );
413
+ const rank = c.list.rank.get(item.hunk) ?? 0;
414
+ const row = h(
415
+ 'div',
416
+ {
417
+ class: 'row',
418
+ role: 'option',
419
+ id: `kg-opt-${item.hunk}`,
420
+ 'data-index': String(item.hunk),
421
+ 'data-family': hunk.family,
422
+ 'data-reviewed': String(reviewed),
423
+ 'aria-selected': String(c.selectedHunk === item.hunk),
424
+ 'aria-setsize': String(c.list.order.length),
425
+ 'aria-posinset': String(rank + 1),
426
+ 'aria-describedby': `kg-phase-${hunk.phase}`,
427
+ style: `top: ${y + 2}px`,
428
+ },
429
+ h('span', { class: 'glyph-box', 'aria-hidden': 'true', text: sceneHunkGlyph(hunk) }),
430
+ h(
431
+ 'span',
432
+ { class: 'txt' },
433
+ h('span', { class: 'title', title: hunk.title, text: hunk.title }),
434
+ h('span', { class: 'sub', text: hunk.subtitle }),
435
+ ),
436
+ rv,
437
+ );
438
+ this.#labelRow(row, hunk, reviewed);
439
+ return row;
440
+ }
441
+
442
+ #labelRow(row: HTMLElement, hunk: SceneHunk, reviewed: boolean): void {
443
+ const tr = this.#ctx.tr();
444
+ const parts = [hunk.title];
445
+ if (hunk.subtitle) parts.push(hunk.subtitle);
446
+ if (reviewed) parts.push(tr.t('review.toggle'));
447
+ attr(row, 'aria-label', parts.join('. '));
448
+ }
449
+
450
+ /** Update selection / reviewed state of the rendered rows in place. */
451
+ #refreshRows(): void {
452
+ const c = this.#ctx.controller;
453
+ const tr = this.#ctx.tr();
454
+ for (const el of this.#rendered.values()) {
455
+ if (!el.classList.contains('row')) continue;
456
+ const index = Number(el.dataset.index);
457
+ const hunk = c.scene?.hunks[index];
458
+ if (!hunk) continue;
459
+ const reviewed = c.isReviewed(hunk.id);
460
+ attr(el, 'aria-selected', String(index === c.selectedHunk));
461
+ attr(el, 'data-reviewed', String(reviewed));
462
+ const rv = el.querySelector('.rv') as HTMLElement;
463
+ attr(rv, 'data-pressed', String(reviewed));
464
+ attr(rv, 'title', tr.t(reviewed ? 'review.unmark' : 'review.mark'));
465
+ this.#labelRow(el, hunk, reviewed);
466
+ }
467
+ const sel = c.selectedHunk;
468
+ attr(
469
+ this.list,
470
+ 'aria-activedescendant',
471
+ sel >= 0 && c.list.rank.has(sel) ? `kg-opt-${sel}` : null,
472
+ );
473
+ }
474
+
475
+ #revealSelected(): void {
476
+ const c = this.#ctx.controller;
477
+ const item = c.list.itemOf.get(c.selectedHunk);
478
+ if (item === undefined) return;
479
+ const y = this.#offsets[item] as number;
480
+ const top = this.list.scrollTop;
481
+ // Not laid out yet (hidden sheet): position it for when it shows.
482
+ const height = this.list.clientHeight || 640;
483
+ // The first row of a group scrolls its group header into view too.
484
+ const above =
485
+ c.list.items[item - 1]?.kind === 'header'
486
+ ? (this.#offsets[item - 1] as number)
487
+ : y - HEADER_SLOT;
488
+ if (y < top + HEADER_SLOT) this.list.scrollTop = Math.max(0, above);
489
+ else if (y + ROW_SLOT > top + height) this.list.scrollTop = y + ROW_SLOT - height + 8;
490
+ this.renderWindow();
491
+ this.#refreshRows();
492
+ }
493
+
494
+ #renderSticky(top: number): void {
495
+ const items = this.#ctx.controller.list.items;
496
+ let current = -1;
497
+ let next = -1;
498
+ for (let i = 0; i < items.length; i++) {
499
+ if (items[i]?.kind !== 'header') continue;
500
+ if ((this.#offsets[i] as number) <= top) current = i;
501
+ else {
502
+ next = i;
503
+ break;
504
+ }
505
+ }
506
+ if (current < 0 || top <= 0) {
507
+ this.#sticky.hidden = true;
508
+ return;
509
+ }
510
+ const item = items[current] as Extract<ListItem, { kind: 'header' }>;
511
+ const tr = this.#ctx.tr();
512
+ this.#sticky.hidden = false;
513
+ const label = `${tr.t(`phase.${item.phase}`)}|${item.count}`;
514
+ if (this.#sticky.dataset.key !== label) {
515
+ this.#sticky.dataset.key = label;
516
+ replace(
517
+ this.#sticky,
518
+ h(
519
+ 'div',
520
+ { class: 'group' },
521
+ h('span', { text: tr.t(`phase.${item.phase}`) }),
522
+ h('span', { class: 'n', text: tr.num(item.count) }),
523
+ ),
524
+ );
525
+ }
526
+ const push = next >= 0 ? Math.min(0, (this.#offsets[next] as number) - top - HEADER_SLOT) : 0;
527
+ this.#sticky.style.transform = `translateY(${push}px)`;
528
+ }
529
+
530
+ #onListClick(e: MouseEvent): void {
531
+ const c = this.#ctx.controller;
532
+ const target = e.target as Element;
533
+ const rv = target.closest('.rv') as HTMLElement | null;
534
+ if (rv?.dataset.hunk) {
535
+ e.stopPropagation();
536
+ c.toggleReviewed(rv.dataset.hunk);
537
+ return;
538
+ }
539
+ const row = target.closest('.row') as HTMLElement | null;
540
+ if (!row) return;
541
+ const index = Number(row.dataset.index);
542
+ if (Number.isInteger(index)) c.selectHunk(index);
543
+ }
544
+
545
+ // ─── Search ──────────────────────────────────────────────────────────────
546
+
547
+ #setQuery(query: string, focus = false): void {
548
+ this.#query = query;
549
+ if (this.searchInput.value !== query) this.searchInput.value = query;
550
+ this.#activeHit = 0;
551
+ this.#runSearch();
552
+ if (focus) this.searchInput.focus();
553
+ }
554
+
555
+ #runSearch(): void {
556
+ const q = this.#query.trim();
557
+ this.#hits = q ? this.#ctx.controller.search(q, SEARCH_LIMIT) : [];
558
+ const searching = q !== '';
559
+ this.#results.hidden = !searching;
560
+ this.list.hidden = searching;
561
+ this.#sticky.hidden = searching || this.#sticky.hidden;
562
+ attr(this.searchInput, 'aria-expanded', String(searching));
563
+ this.#updateSearchChrome();
564
+ if (!searching) {
565
+ replace(this.#results);
566
+ this.renderWindow();
567
+ return;
568
+ }
569
+ this.#renderResults();
570
+ }
571
+
572
+ #updateSearchChrome(): void {
573
+ const focused = this.#ctx.root.activeElement === this.searchInput;
574
+ this.#searchKbd.hidden = this.#query !== '' || focused;
575
+ this.#searchClear.hidden = this.#query === '';
576
+ }
577
+
578
+ #renderResults(): void {
579
+ const tr = this.#ctx.tr();
580
+ const c = this.#ctx.controller;
581
+ const q = this.#query.trim();
582
+ if (this.#hits.length === 0) {
583
+ replace(
584
+ this.#results,
585
+ h(
586
+ 'div',
587
+ { class: 'list-empty' },
588
+ icon('search'),
589
+ h('span', { text: tr.t('sidebar.noResults', { q }) }),
590
+ ),
591
+ );
592
+ attr(this.searchInput, 'aria-activedescendant', null);
593
+ return;
594
+ }
595
+ const scene = c.scene;
596
+ const rows = this.#hits.map((hit, i) => {
597
+ const sceneIndex = scene?.nodeIndex.get(hit.node.id);
598
+ const sn = sceneIndex !== undefined ? scene?.nodes[sceneIndex] : undefined;
599
+ const family =
600
+ sn?.family ??
601
+ (hit.node.status === 'added'
602
+ ? 'added'
603
+ : hit.node.status === 'removed'
604
+ ? 'removed'
605
+ : hit.node.status === 'modified'
606
+ ? 'content'
607
+ : 'neutral');
608
+ const sub =
609
+ hit.field === 'label' ? hit.node.id : hit.field === 'alias' ? hit.matched : hit.matched;
610
+ return h(
611
+ 'button',
612
+ {
613
+ type: 'button',
614
+ class: 'result',
615
+ role: 'option',
616
+ id: `kg-hit-${i}`,
617
+ tabindex: '-1',
618
+ 'data-family': family,
619
+ 'aria-selected': String(i === this.#activeHit),
620
+ onclick: () => this.#choose(i),
621
+ onpointermove: () => this.#setActiveHit(i),
622
+ },
623
+ h('span', { class: 'sdot', 'aria-hidden': 'true' }),
624
+ h(
625
+ 'span',
626
+ { class: 'txt' },
627
+ h('span', { class: 'title' }, highlight(hit.node.label, hit.field === 'label' ? q : '')),
628
+ h('span', { class: 'sub' }, highlight(sub, hit.field === 'label' ? '' : q)),
629
+ ),
630
+ h('span', { class: 'status', text: tr.t(`status.${hit.node.status}`) }),
631
+ );
632
+ });
633
+ replace(
634
+ this.#results,
635
+ h('div', {
636
+ class: 'results-head',
637
+ text: tr.plural('sidebar.results', this.#hits.length),
638
+ }),
639
+ rows,
640
+ );
641
+ attr(this.searchInput, 'aria-activedescendant', `kg-hit-${this.#activeHit}`);
642
+ }
643
+
644
+ #setActiveHit(i: number): void {
645
+ if (i === this.#activeHit || i < 0 || i >= this.#hits.length) return;
646
+ this.#activeHit = i;
647
+ for (const el of this.#results.querySelectorAll('.result')) {
648
+ attr(el, 'aria-selected', String(el.id === `kg-hit-${i}`));
649
+ }
650
+ attr(this.searchInput, 'aria-activedescendant', `kg-hit-${i}`);
651
+ this.#results.querySelector(`#kg-hit-${i}`)?.scrollIntoView?.({ block: 'nearest' });
652
+ }
653
+
654
+ #choose(i: number): void {
655
+ const hit = this.#hits[i];
656
+ if (!hit) return;
657
+ this.#setQuery('');
658
+ this.#ctx.controller.revealNode(hit.node.id);
659
+ this.focusList();
660
+ }
661
+
662
+ #onSearchKey(e: KeyboardEvent): void {
663
+ switch (e.key) {
664
+ case 'ArrowDown':
665
+ e.preventDefault();
666
+ this.#setActiveHit(Math.min(this.#hits.length - 1, this.#activeHit + 1));
667
+ break;
668
+ case 'ArrowUp':
669
+ e.preventDefault();
670
+ this.#setActiveHit(Math.max(0, this.#activeHit - 1));
671
+ break;
672
+ case 'Enter':
673
+ e.preventDefault();
674
+ this.#choose(this.#activeHit);
675
+ break;
676
+ case 'Escape':
677
+ e.preventDefault();
678
+ e.stopPropagation();
679
+ if (this.#query) this.#setQuery('');
680
+ else this.focusList();
681
+ break;
682
+ default:
683
+ return;
684
+ }
685
+ }
686
+
687
+ // ─── Bottom sheet ────────────────────────────────────────────────────────
688
+
689
+ /** Separator semantics: the sheet's height as a percentage of the available height. */
690
+ #sheetValue(px: number, total: number): void {
691
+ const pct = (v: number): number => Math.round((100 * v) / Math.max(1, total));
692
+ attr(this.#handle, 'aria-valuemin', String(pct(120)));
693
+ attr(this.#handle, 'aria-valuemax', String(pct(total - 96)));
694
+ attr(this.#handle, 'aria-valuenow', String(pct(px)));
695
+ attr(
696
+ this.#handle,
697
+ 'aria-valuetext',
698
+ this.#ctx.tr().t('sidebar.sheetValue', { p: `${pct(px)}%` }),
699
+ );
700
+ }
701
+
702
+ /** Refresh the separator values (layout changes). */
703
+ syncSheet(): void {
704
+ const total = this.el.parentElement?.clientHeight ?? 0;
705
+ if (total > 0) this.#sheetValue(this.el.getBoundingClientRect().height, total);
706
+ }
707
+
708
+ #bindSheet(): void {
709
+ let start: { y: number; h: number; id: number } | null = null;
710
+ const host = (): HTMLElement | null => this.el.parentElement;
711
+ const setHeight = (px: number): void => {
712
+ const total = host()?.clientHeight ?? 600;
713
+ const clamped = Math.max(120, Math.min(total - 96, px));
714
+ this.el.style.setProperty('--sheet-h', `${Math.round(clamped)}px`);
715
+ this.#sheetValue(clamped, total);
716
+ };
717
+ this.#handle.addEventListener('pointerdown', (e) => {
718
+ start = { y: e.clientY, h: this.el.getBoundingClientRect().height, id: e.pointerId };
719
+ this.#handle.setPointerCapture?.(e.pointerId);
720
+ this.el.classList.add('dragging');
721
+ });
722
+ this.#handle.addEventListener('pointermove', (e) => {
723
+ if (start && e.pointerId === start.id) setHeight(start.h + (start.y - e.clientY));
724
+ });
725
+ const end = (e: PointerEvent): void => {
726
+ if (!start || e.pointerId !== start.id) return;
727
+ start = null;
728
+ this.el.classList.remove('dragging');
729
+ };
730
+ this.#handle.addEventListener('pointerup', end);
731
+ this.#handle.addEventListener('pointercancel', end);
732
+ this.#handle.addEventListener('keydown', (e) => {
733
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
734
+ e.preventDefault();
735
+ e.stopPropagation();
736
+ const current = this.el.getBoundingClientRect().height;
737
+ setHeight(current + (e.key === 'ArrowUp' ? 40 : -40));
738
+ });
739
+ }
740
+ }
741
+
742
+ /** The label with the first occurrence of `q` (case-insensitive) marked. */
743
+ function highlight(text: string, q: string): (string | HTMLElement)[] {
744
+ if (!q) return [text];
745
+ const at = text.toLowerCase().indexOf(q.toLowerCase());
746
+ if (at < 0) return [text];
747
+ return [
748
+ text.slice(0, at),
749
+ h('mark', { text: text.slice(at, at + q.length) }),
750
+ text.slice(at + q.length),
751
+ ];
752
+ }