@kgdiff/viewer 0.4.0 → 0.5.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 (84) hide show
  1. package/dist/context.d.ts +42 -0
  2. package/dist/context.d.ts.map +1 -0
  3. package/dist/context.js +87 -0
  4. package/dist/context.js.map +1 -0
  5. package/dist/controller.d.ts +26 -185
  6. package/dist/controller.d.ts.map +1 -1
  7. package/dist/controller.js +45 -1155
  8. package/dist/controller.js.map +1 -1
  9. package/dist/element.d.ts +6 -3
  10. package/dist/element.d.ts.map +1 -1
  11. package/dist/element.js +49 -229
  12. package/dist/element.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/pipeline.d.ts.map +1 -1
  17. package/dist/pipeline.js +3 -2
  18. package/dist/pipeline.js.map +1 -1
  19. package/dist/root.d.ts +54 -0
  20. package/dist/root.d.ts.map +1 -0
  21. package/dist/root.js +265 -0
  22. package/dist/root.js.map +1 -0
  23. package/dist/schedule.d.ts +22 -0
  24. package/dist/schedule.d.ts.map +1 -0
  25. package/dist/schedule.js +41 -0
  26. package/dist/schedule.js.map +1 -0
  27. package/dist/session.d.ts +253 -0
  28. package/dist/session.d.ts.map +1 -0
  29. package/dist/session.js +1239 -0
  30. package/dist/session.js.map +1 -0
  31. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  32. package/dist/standalone/kgdiff-viewer.js +4 -4
  33. package/dist/styles.d.ts +13 -6
  34. package/dist/styles.d.ts.map +1 -1
  35. package/dist/styles.js +217 -248
  36. package/dist/styles.js.map +1 -1
  37. package/dist/theme.d.ts +25 -0
  38. package/dist/theme.d.ts.map +1 -0
  39. package/dist/theme.js +149 -0
  40. package/dist/theme.js.map +1 -0
  41. package/dist/ui/change-list.d.ts +18 -0
  42. package/dist/ui/change-list.d.ts.map +1 -0
  43. package/dist/ui/change-list.js +331 -0
  44. package/dist/ui/change-list.js.map +1 -0
  45. package/dist/ui/context.d.ts +19 -5
  46. package/dist/ui/context.d.ts.map +1 -1
  47. package/dist/ui/context.js.map +1 -1
  48. package/dist/ui/filters.d.ts +9 -0
  49. package/dist/ui/filters.d.ts.map +1 -0
  50. package/dist/ui/filters.js +68 -0
  51. package/dist/ui/filters.js.map +1 -0
  52. package/dist/ui/header.js +2 -2
  53. package/dist/ui/header.js.map +1 -1
  54. package/dist/ui/search.d.ts +13 -0
  55. package/dist/ui/search.d.ts.map +1 -0
  56. package/dist/ui/search.js +191 -0
  57. package/dist/ui/search.js.map +1 -0
  58. package/dist/ui/sidebar.d.ts +8 -16
  59. package/dist/ui/sidebar.d.ts.map +1 -1
  60. package/dist/ui/sidebar.js +15 -539
  61. package/dist/ui/sidebar.js.map +1 -1
  62. package/dist/ui/stage.js +1 -1
  63. package/dist/ui/stage.js.map +1 -1
  64. package/dist/ui/swatch.js +1 -1
  65. package/dist/ui/swatch.js.map +1 -1
  66. package/package.json +6 -6
  67. package/src/context.ts +106 -0
  68. package/src/controller.ts +70 -1257
  69. package/src/element.ts +52 -243
  70. package/src/index.ts +3 -1
  71. package/src/pipeline.ts +4 -1
  72. package/src/root.ts +308 -0
  73. package/src/schedule.ts +43 -0
  74. package/src/session.ts +1386 -0
  75. package/src/styles.ts +225 -253
  76. package/src/theme.ts +167 -0
  77. package/src/ui/change-list.ts +401 -0
  78. package/src/ui/context.ts +21 -5
  79. package/src/ui/filters.ts +79 -0
  80. package/src/ui/header.ts +2 -2
  81. package/src/ui/search.ts +220 -0
  82. package/src/ui/sidebar.ts +28 -668
  83. package/src/ui/stage.ts +1 -1
  84. package/src/ui/swatch.ts +1 -1
package/src/root.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The root of a viewer (RFC 0001 §2–§6): it owns the session and provides it to the regions
3
+ * bound to it (context `kgdiff.viewer/1`), renders them on every notification, and owns what
4
+ * they share: the keyboard map, the live region, the events, the focus targets and the theme
5
+ * tokens. Regions never reference each other; everything goes through the session or the root.
6
+ *
7
+ * In phase 1 the root lives inside `<kgdiff-viewer>` (the element is its host) and the regions
8
+ * are the default layout's; phase 2 makes it `<kgdiff-root>` and the regions elements of their
9
+ * own.
10
+ */
11
+ import type { Theme } from '@kgdiff/scene';
12
+ import { createContext, provideContext, requestContext } from './context.js';
13
+ import { type ControllerHost, ViewerController } from './controller.js';
14
+ import { h } from './dom.js';
15
+ import type { ThemePreference } from './index.js';
16
+ import { isTypingTarget, keyAction } from './keyboard.js';
17
+ import { THEME_PREFERENCES } from './prefs.js';
18
+ import { defaultStorage } from './review-store.js';
19
+ import { cancelFrame, nextPaint, requestFrame } from './schedule.js';
20
+ import type { Topic } from './session.js';
21
+ import { stylesheet } from './styles.js';
22
+ import { themeFromStyle, tokenSheet } from './theme.js';
23
+ import type { FocusTarget, UiActions, UiContext } from './ui/context.js';
24
+
25
+ /** Something the root renders: a region, or the layout itself. */
26
+ export interface Region {
27
+ update(topics: ReadonlySet<Topic>): void;
28
+ }
29
+
30
+ /** What a root provides to the regions that bind to it. */
31
+ export interface ViewerContext {
32
+ readonly controller: ViewerController;
33
+ readonly actions: UiActions;
34
+ /** The element the root's events are dispatched on and its tokens are set on. */
35
+ readonly host: HTMLElement;
36
+ /** Render `region` on every notification (in binding order); returns the detach. */
37
+ attach(region: Region): () => void;
38
+ target(name: FocusTarget, focus: () => void): () => void;
39
+ focus(name: FocusTarget): boolean;
40
+ /** The keyboard map, for a key event that reached the root. */
41
+ handleKey(e: KeyboardEvent): void;
42
+ }
43
+
44
+ export const viewerContext = createContext<ViewerContext>('kgdiff.viewer/1');
45
+
46
+ function media(query: string): MediaQueryList | null {
47
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
48
+ ? window.matchMedia(query)
49
+ : null;
50
+ }
51
+
52
+ export class ViewerRoot implements ViewerContext {
53
+ readonly controller: ViewerController;
54
+ /** The single polite live region announcing navigation ("Change 3 of 12: …"). */
55
+ readonly live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
56
+ readonly host: HTMLElement;
57
+ readonly actions: UiActions;
58
+ readonly #shadow: ShadowRoot;
59
+ readonly #regions: Region[] = [];
60
+ readonly #targets = new Map<FocusTarget, (() => void)[]>();
61
+ #renderFailed = false;
62
+
63
+ /**
64
+ * `host` dispatches the events and computes the tokens set on `shadow` (its shadow root);
65
+ * `emitted` runs after each event was dispatched.
66
+ */
67
+ constructor(
68
+ host: HTMLElement,
69
+ shadow: ShadowRoot,
70
+ actions: UiActions,
71
+ emitted?: (type: string) => void,
72
+ ) {
73
+ this.host = host;
74
+ this.#shadow = shadow;
75
+ this.actions = actions;
76
+ const emit = (type: string, detail: unknown): void => {
77
+ host.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
78
+ emitted?.(type);
79
+ };
80
+ const events: ControllerHost = {
81
+ ready: (stats) => emit('kgdiff-ready', { stats }),
82
+ select: (hunk, node) => emit('kgdiff-select', { hunk, node }),
83
+ mode: (mode, scope) => emit('kgdiff-mode', { mode, scope }),
84
+ review: (hunk, reviewed, progress) => emit('kgdiff-review', { hunk, reviewed, progress }),
85
+ error: (error) => emit('kgdiff-error', { error }),
86
+ announce: (text) => this.announce(text),
87
+ replayEnd: (loop) => emit('kgdiff-replay-end', { loop }),
88
+ };
89
+ this.controller = new ViewerController(events, {
90
+ now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
91
+ requestFrame,
92
+ cancelFrame,
93
+ yieldToPaint: nextPaint,
94
+ storage: defaultStorage(),
95
+ // The one place the token set is applied: the canvas then draws with the tokens as
96
+ // computed on the host (page styles win).
97
+ theme: (base) => {
98
+ this.#tokens(base);
99
+ const view = host.ownerDocument.defaultView;
100
+ return view ? themeFromStyle(base, view.getComputedStyle(host)) : base;
101
+ },
102
+ });
103
+ this.controller.subscribe((topics) => this.render(topics));
104
+ }
105
+
106
+ /**
107
+ * Provide the session to the regions and follow the system's color scheme and motion
108
+ * preference and other tabs' review progress; returns the function that stops it all.
109
+ */
110
+ connect(): () => void {
111
+ const c = this.controller;
112
+ const stop = [provideContext(this.host, viewerContext, () => this)];
113
+ const dark = media('(prefers-color-scheme: light)');
114
+ const motion = media('(prefers-reduced-motion: reduce)');
115
+ const apply = (): void => {
116
+ c.setSystemDark(!(dark?.matches ?? false));
117
+ c.setReducedMotion(motion?.matches ?? false);
118
+ };
119
+ for (const q of [dark, motion]) {
120
+ q?.addEventListener?.('change', apply);
121
+ stop.push(() => q?.removeEventListener?.('change', apply));
122
+ }
123
+ apply();
124
+ // Page styles may override tokens now that the host is in the document.
125
+ c.refreshTheme();
126
+ const onStorage = (e: StorageEvent): void => c.syncReview(e.key);
127
+ window.addEventListener('storage', onStorage);
128
+ stop.push(() => window.removeEventListener('storage', onStorage));
129
+ return () => {
130
+ for (const fn of stop) fn();
131
+ };
132
+ }
133
+
134
+ attach(region: Region): () => void {
135
+ this.#regions.push(region);
136
+ return () => {
137
+ const i = this.#regions.indexOf(region);
138
+ if (i >= 0) this.#regions.splice(i, 1);
139
+ };
140
+ }
141
+
142
+ target(name: FocusTarget, focus: () => void): () => void {
143
+ const list = this.#targets.get(name) ?? [];
144
+ this.#targets.set(name, list);
145
+ list.push(focus);
146
+ return () => {
147
+ const i = list.indexOf(focus);
148
+ if (i >= 0) list.splice(i, 1);
149
+ };
150
+ }
151
+
152
+ focus(name: FocusTarget): boolean {
153
+ const focus = this.#targets.get(name)?.at(-1);
154
+ focus?.();
155
+ return focus !== undefined;
156
+ }
157
+
158
+ announce(text: string): void {
159
+ const live = this.live;
160
+ live.textContent = '';
161
+ // A fresh text node in a later task makes screen readers announce repeats too.
162
+ setTimeout(() => {
163
+ live.textContent = text;
164
+ }, 30);
165
+ }
166
+
167
+ /** Render every region, never throwing: a failure shows the error state with safe defaults. */
168
+ render(topics: ReadonlySet<Topic>): void {
169
+ try {
170
+ for (const region of this.#regions) region.update(topics);
171
+ this.#renderFailed = false;
172
+ } catch (error) {
173
+ console.error('[kgdiff] render failed:', error);
174
+ if (this.#renderFailed) return;
175
+ this.#renderFailed = true;
176
+ const c = this.controller;
177
+ c.explicit.clear();
178
+ c.setThemePreference('auto');
179
+ c.setPalette('default');
180
+ c.fail(error instanceof Error ? error : new Error(String(error)));
181
+ }
182
+ }
183
+
184
+ /** Apply the token set of `base` (a built-in theme) on the host, in the host's document. */
185
+ #tokens(base: Theme): void {
186
+ const doc = this.host.ownerDocument;
187
+ const tokens = tokenSheet(base, doc);
188
+ if (this.#shadow.adoptedStyleSheets[1] !== tokens) {
189
+ this.#shadow.adoptedStyleSheets = [stylesheet(doc), tokens];
190
+ }
191
+ }
192
+
193
+ handleKey(e: KeyboardEvent): void {
194
+ const target = e.composedPath()[0] ?? null;
195
+ if (isTypingTarget(target)) return;
196
+ const c = this.controller;
197
+ const action = keyAction(e, c.mode, c.scope);
198
+ if (!action) return;
199
+ // Enter / Space on a focused control activate that control.
200
+ const el = target as Element | null;
201
+ if (
202
+ (e.key === 'Enter' || e.key === ' ') &&
203
+ el?.closest?.('button, a, summary, [role="radio"], [role="menuitemradio"]')
204
+ ) {
205
+ return;
206
+ }
207
+ if (c.status !== 'ready' && !['help', 'theme', 'sidebar', 'escape'].includes(action.type))
208
+ return;
209
+ e.preventDefault();
210
+ const a = this.actions;
211
+ switch (action.type) {
212
+ case 'mode':
213
+ c.setMode(action.mode);
214
+ break;
215
+ case 'scope':
216
+ c.toggleScope();
217
+ break;
218
+ case 'next':
219
+ c.next();
220
+ break;
221
+ case 'prev':
222
+ c.prev();
223
+ break;
224
+ case 'first':
225
+ c.first();
226
+ break;
227
+ case 'last':
228
+ c.last();
229
+ break;
230
+ case 'fly':
231
+ c.flyToSelection();
232
+ break;
233
+ case 'escape':
234
+ // An active search is cleared first (focus goes back to it).
235
+ if (c.query) {
236
+ c.setQuery('');
237
+ this.focus('search');
238
+ } else c.clearSelection();
239
+ break;
240
+ case 'play-pause':
241
+ c.togglePlay();
242
+ break;
243
+ case 'step':
244
+ c.stepReplay(action.direction);
245
+ break;
246
+ case 'divider':
247
+ c.setSplit(c.split + action.delta);
248
+ break;
249
+ case 'fit':
250
+ void c.fit();
251
+ break;
252
+ case 'zoom':
253
+ c.zoom(action.direction);
254
+ break;
255
+ case 'search':
256
+ a.focusSearch();
257
+ break;
258
+ case 'review':
259
+ c.toggleReviewed();
260
+ break;
261
+ case 'sidebar':
262
+ a.toggleSidebar();
263
+ break;
264
+ case 'theme': {
265
+ const i = THEME_PREFERENCES.indexOf(c.themePreference);
266
+ a.setThemePreference(
267
+ THEME_PREFERENCES[(i + 1) % THEME_PREFERENCES.length] as ThemePreference,
268
+ );
269
+ break;
270
+ }
271
+ case 'help':
272
+ a.openHelp();
273
+ break;
274
+ case 'expand':
275
+ c.expandNearest();
276
+ break;
277
+ default:
278
+ action satisfies never;
279
+ }
280
+ }
281
+ }
282
+
283
+ /** The UI context of a region placed in `root` (its shadow tree), bound to `ctx`. */
284
+ export function uiContext(ctx: ViewerContext, root: ShadowRoot): UiContext {
285
+ return {
286
+ ...ctx.actions,
287
+ controller: ctx.controller,
288
+ root,
289
+ target: (name, focus) => ctx.target(name, focus),
290
+ focus: (name) => ctx.focus(name),
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Bind a region to the root nearest `anchor` (an element where the region is placed): `make`
296
+ * builds it once the root answers, and the root renders it from then on.
297
+ */
298
+ export function bindRegion<R extends Region>(anchor: Element, make: (ui: UiContext) => R): R {
299
+ const bound: { region?: R } = {};
300
+ requestContext(anchor, viewerContext, (ctx) => {
301
+ if (bound.region) return;
302
+ bound.region = make(uiContext(ctx, anchor.getRootNode() as ShadowRoot));
303
+ ctx.attach(bound.region);
304
+ });
305
+ // Phase 1: regions are bound by the layout that owns the root, which answers at once.
306
+ if (!bound.region) throw new Error('[kgdiff] a region found no root to bind to.');
307
+ return bound.region;
308
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Frames (RFC 0001 §1, Scheduling).
3
+ *
4
+ * The session's frame loop runs on animation frames only (`requestFrame`). It pauses where the
5
+ * browser pauses them, so a looping replay in a background tab costs nothing, and under load it
6
+ * never draws a frame that is not presented.
7
+ *
8
+ * One-off work that must not wait forever races a frame against a timer (`frameOrTimeout`):
9
+ * the loading steps and the change list's window. A hidden embedding container (an inactive
10
+ * tab panel, a collapsed notebook output, a background pane) can pause animation frames without
11
+ * the page reporting itself hidden; with frames alone, loading stalled there.
12
+ */
13
+
14
+ /** One-off work waits at most this long for a frame (ms). */
15
+ export const FRAME_TIMEOUT = 32;
16
+
17
+ /** The next animation frame (0, and no call, where there are none). */
18
+ export function requestFrame(cb: (time: number) => void): number {
19
+ return typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0;
20
+ }
21
+
22
+ export function cancelFrame(id: number): void {
23
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id);
24
+ }
25
+
26
+ /** Call `cb` once: on the next animation frame, or after `FRAME_TIMEOUT` ms if none came. */
27
+ export function frameOrTimeout(cb: () => void): void {
28
+ let done = false;
29
+ const run = (): void => {
30
+ if (done) return;
31
+ done = true;
32
+ cancelFrame(raf);
33
+ clearTimeout(timer);
34
+ cb();
35
+ };
36
+ const timer = setTimeout(run, FRAME_TIMEOUT);
37
+ const raf = requestFrame(run);
38
+ }
39
+
40
+ /** Resolve once the browser had a chance to paint (between loading steps). */
41
+ export function nextPaint(): Promise<void> {
42
+ return new Promise((resolve) => frameOrTimeout(() => setTimeout(resolve, 0)));
43
+ }