@kgdiff/viewer 0.3.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 (120) hide show
  1. package/README.md +9 -8
  2. package/dist/context.d.ts +42 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +87 -0
  5. package/dist/context.js.map +1 -0
  6. package/dist/controller.d.ts +30 -173
  7. package/dist/controller.d.ts.map +1 -1
  8. package/dist/controller.js +94 -1099
  9. package/dist/controller.js.map +1 -1
  10. package/dist/element.d.ts +12 -6
  11. package/dist/element.d.ts.map +1 -1
  12. package/dist/element.js +105 -236
  13. package/dist/element.js.map +1 -1
  14. package/dist/hash-sync.d.ts +4 -2
  15. package/dist/hash-sync.d.ts.map +1 -1
  16. package/dist/hash-sync.js +21 -5
  17. package/dist/hash-sync.js.map +1 -1
  18. package/dist/i18n.d.ts +7 -7
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +14 -14
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts +24 -10
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/keyboard.d.ts +6 -4
  26. package/dist/keyboard.d.ts.map +1 -1
  27. package/dist/keyboard.js +6 -5
  28. package/dist/keyboard.js.map +1 -1
  29. package/dist/pipeline.d.ts.map +1 -1
  30. package/dist/pipeline.js +3 -2
  31. package/dist/pipeline.js.map +1 -1
  32. package/dist/prefs.d.ts +7 -1
  33. package/dist/prefs.d.ts.map +1 -1
  34. package/dist/prefs.js +15 -3
  35. package/dist/prefs.js.map +1 -1
  36. package/dist/root.d.ts +54 -0
  37. package/dist/root.d.ts.map +1 -0
  38. package/dist/root.js +265 -0
  39. package/dist/root.js.map +1 -0
  40. package/dist/schedule.d.ts +22 -0
  41. package/dist/schedule.d.ts.map +1 -0
  42. package/dist/schedule.js +41 -0
  43. package/dist/schedule.js.map +1 -0
  44. package/dist/session.d.ts +253 -0
  45. package/dist/session.d.ts.map +1 -0
  46. package/dist/session.js +1239 -0
  47. package/dist/session.js.map +1 -0
  48. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  49. package/dist/standalone/kgdiff-viewer.js +4 -4
  50. package/dist/styles.d.ts +13 -6
  51. package/dist/styles.d.ts.map +1 -1
  52. package/dist/styles.js +270 -295
  53. package/dist/styles.js.map +1 -1
  54. package/dist/theme.d.ts +25 -0
  55. package/dist/theme.d.ts.map +1 -0
  56. package/dist/theme.js +149 -0
  57. package/dist/theme.js.map +1 -0
  58. package/dist/ui/change-list.d.ts +18 -0
  59. package/dist/ui/change-list.d.ts.map +1 -0
  60. package/dist/ui/change-list.js +331 -0
  61. package/dist/ui/change-list.js.map +1 -0
  62. package/dist/ui/context.d.ts +21 -5
  63. package/dist/ui/context.d.ts.map +1 -1
  64. package/dist/ui/context.js.map +1 -1
  65. package/dist/ui/dock.d.ts +17 -0
  66. package/dist/ui/dock.d.ts.map +1 -0
  67. package/dist/ui/dock.js +151 -0
  68. package/dist/ui/dock.js.map +1 -0
  69. package/dist/ui/filters.d.ts +9 -0
  70. package/dist/ui/filters.d.ts.map +1 -0
  71. package/dist/ui/filters.js +68 -0
  72. package/dist/ui/filters.js.map +1 -0
  73. package/dist/ui/header.d.ts +7 -2
  74. package/dist/ui/header.d.ts.map +1 -1
  75. package/dist/ui/header.js +26 -135
  76. package/dist/ui/header.js.map +1 -1
  77. package/dist/ui/popovers.d.ts.map +1 -1
  78. package/dist/ui/popovers.js +3 -2
  79. package/dist/ui/popovers.js.map +1 -1
  80. package/dist/ui/search.d.ts +13 -0
  81. package/dist/ui/search.d.ts.map +1 -0
  82. package/dist/ui/search.js +191 -0
  83. package/dist/ui/search.js.map +1 -0
  84. package/dist/ui/sidebar.d.ts +8 -16
  85. package/dist/ui/sidebar.d.ts.map +1 -1
  86. package/dist/ui/sidebar.js +15 -538
  87. package/dist/ui/sidebar.js.map +1 -1
  88. package/dist/ui/stage.d.ts +4 -0
  89. package/dist/ui/stage.d.ts.map +1 -1
  90. package/dist/ui/stage.js +42 -95
  91. package/dist/ui/stage.js.map +1 -1
  92. package/dist/ui/swatch.d.ts +7 -0
  93. package/dist/ui/swatch.d.ts.map +1 -0
  94. package/dist/ui/swatch.js +26 -0
  95. package/dist/ui/swatch.js.map +1 -0
  96. package/package.json +6 -6
  97. package/src/context.ts +106 -0
  98. package/src/controller.ts +120 -1196
  99. package/src/element.ts +114 -254
  100. package/src/hash-sync.ts +20 -6
  101. package/src/i18n.ts +15 -14
  102. package/src/index.ts +24 -10
  103. package/src/keyboard.ts +12 -6
  104. package/src/pipeline.ts +4 -1
  105. package/src/prefs.ts +19 -4
  106. package/src/root.ts +308 -0
  107. package/src/schedule.ts +43 -0
  108. package/src/session.ts +1386 -0
  109. package/src/styles.ts +277 -299
  110. package/src/theme.ts +167 -0
  111. package/src/ui/change-list.ts +401 -0
  112. package/src/ui/context.ts +23 -5
  113. package/src/ui/dock.ts +198 -0
  114. package/src/ui/filters.ts +79 -0
  115. package/src/ui/header.ts +40 -170
  116. package/src/ui/popovers.ts +3 -2
  117. package/src/ui/search.ts +220 -0
  118. package/src/ui/sidebar.ts +28 -667
  119. package/src/ui/stage.ts +50 -115
  120. package/src/ui/swatch.ts +31 -0
package/src/element.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
- * `<kgdiff-viewer>`: attribute / property reflection, lifecycle, public methods and events
3
- * around a `ViewerController` and the Shadow-DOM UI.
2
+ * `<kgdiff-viewer>`, the default layout (RFC 0001 §4): a root (the session, keyboard map, live
3
+ * region, events and theme tokens; `root.ts`) and the regions bound to it, the stage with its
4
+ * chrome, the change list, the search, the family filters and the detail panel, placed as they
5
+ * always were (a 360 px sidebar; a bottom sheet below 760 px). Around them: attribute /
6
+ * property reflection, lifecycle, the public methods and deep links.
4
7
  */
5
8
  import { type Delta, KgdiffError, type Locale } from '@kgdiff/core';
6
9
  import type { PaletteName } from '@kgdiff/scene';
7
- import { type ControllerHost, type Topic, ViewerController } from './controller.js';
10
+ import type { ViewerController } from './controller.js';
8
11
  import { h } from './dom.js';
9
12
  import { formatHash, parseHash } from './hash-sync.js';
10
13
  import { detectLocale, translate, translator } from './i18n.js';
@@ -18,35 +21,41 @@ import type {
18
21
  ViewerChrome,
19
22
  ViewerLegend,
20
23
  ViewerMode,
24
+ ViewerScope,
21
25
  } from './index.js';
22
- import { isTypingTarget, keyAction } from './keyboard.js';
23
26
  import { assertPayload } from './payload.js';
24
27
  import {
25
28
  DEFAULT_LOOP_DELAY,
26
- THEME_PREFERENCES,
29
+ LEGACY_CHANGES_MODE,
27
30
  toChrome,
28
31
  toDelay,
29
32
  toLegend,
30
33
  toLocale,
31
34
  toMode,
32
35
  toPalette,
36
+ toScope,
33
37
  toTheme,
34
38
  } from './prefs.js';
35
- import { defaultStorage } from './review-store.js';
36
- import { STYLES, themeVariables } from './styles.js';
37
- import type { UiContext } from './ui/context.js';
39
+ import { bindRegion, uiContext, ViewerRoot } from './root.js';
40
+ import { ALL_TOPICS, type Topic } from './session.js';
41
+ import { ChangeList } from './ui/change-list.js';
42
+ import type { UiActions } from './ui/context.js';
43
+ import { Detail } from './ui/detail.js';
44
+ import { Filters } from './ui/filters.js';
38
45
  import { Header } from './ui/header.js';
39
46
  import { HelpDialog, Menu } from './ui/popovers.js';
47
+ import { Search } from './ui/search.js';
40
48
  import { Sidebar } from './ui/sidebar.js';
41
49
  import { Stage } from './ui/stage.js';
42
50
 
43
- /** Below this width the sidebar becomes a bottom sheet and the mode control shows icons. */
51
+ /** Below this width the sidebar becomes a bottom sheet. */
44
52
  export const COMPACT_WIDTH = 760;
45
- /** Below this width the header drops the diffstat pills and version tags. */
53
+ /** Below this width the title drops the version tags. */
46
54
  const NARROW_WIDTH = 1100;
47
55
 
48
56
  const OBSERVED = [
49
57
  'mode',
58
+ 'scope',
50
59
  'theme',
51
60
  'palette',
52
61
  'locale',
@@ -60,6 +69,7 @@ const OBSERVED = [
60
69
  ] as const;
61
70
  const UPGRADED = [
62
71
  'mode',
72
+ 'scope',
63
73
  'theme',
64
74
  'palette',
65
75
  'locale',
@@ -96,31 +106,6 @@ function deepFreeze<T>(value: T): T {
96
106
  return value;
97
107
  }
98
108
 
99
- function media(query: string): MediaQueryList | null {
100
- return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
101
- ? window.matchMedia(query)
102
- : null;
103
- }
104
-
105
- /**
106
- * Resolve once the browser had a chance to paint the loading step. Hidden tabs never run
107
- * animation frames, so a short timer resolves it there (loading must never stall).
108
- */
109
- function nextPaint(): Promise<void> {
110
- return new Promise((resolve) => {
111
- let done = false;
112
- const finish = (): void => {
113
- if (done) return;
114
- done = true;
115
- resolve();
116
- };
117
- if (typeof requestAnimationFrame === 'function') {
118
- requestAnimationFrame(() => setTimeout(finish, 0));
119
- }
120
- setTimeout(finish, 50);
121
- });
122
- }
123
-
124
109
  export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
125
110
  static get observedAttributes(): readonly string[] {
126
111
  return OBSERVED;
@@ -129,18 +114,17 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
129
114
  /** @internal */
130
115
  readonly controller: ViewerController;
131
116
  readonly #shadow: ShadowRoot;
117
+ readonly #root: ViewerRoot;
132
118
  #ui: {
133
119
  root: HTMLElement;
134
- vars: HTMLStyleElement;
135
120
  header: Header;
136
121
  stage: Stage;
122
+ list: ChangeList;
137
123
  sidebar: Sidebar;
138
124
  menu: Menu;
139
125
  help: HelpDialog;
140
- live: HTMLElement;
141
126
  } | null = null;
142
127
  #currentPayload: ReportPayload | null = null;
143
- #renderFailed = false;
144
128
  #reflecting = false;
145
129
  #compact = false;
146
130
  readonly #cleanup: (() => void)[] = [];
@@ -152,34 +136,13 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
152
136
  constructor() {
153
137
  super();
154
138
  this.#shadow = this.attachShadow({ mode: 'open' });
155
- const host: ControllerHost = {
156
- ready: (stats) => this.#emit('kgdiff-ready', { stats }),
157
- select: (hunk, node) => {
158
- this.#emit('kgdiff-select', { hunk, node });
159
- this.#writeHash();
160
- },
161
- mode: (mode) => {
162
- this.#emit('kgdiff-mode', { mode });
163
- this.#writeHash();
164
- },
165
- review: (hunk, reviewed, progress) =>
166
- this.#emit('kgdiff-review', { hunk, reviewed, progress }),
167
- error: (error) => this.#emit('kgdiff-error', { error }),
168
- announce: (text) => this.#announce(text),
169
- replayEnd: (loop) => this.#emit('kgdiff-replay-end', { loop }),
170
- };
171
- this.controller = new ViewerController(host, {
172
- now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
173
- requestFrame: (cb) =>
174
- typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0,
175
- cancelFrame: (id) => {
176
- if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id);
177
- },
178
- yieldToPaint: nextPaint,
179
- storage: defaultStorage(),
139
+ this.#root = new ViewerRoot(this, this.#shadow, this.#actions(), (type) => {
140
+ if (type === 'kgdiff-select' || type === 'kgdiff-mode') this.#writeHash();
180
141
  });
142
+ this.controller = this.#root.controller;
181
143
  this.controller.locale = toLocale(this.getAttribute('locale')) ?? detectLocale();
182
- this.controller.subscribe((topics) => this.#render(topics));
144
+ // The layout renders first, then the regions in the order they bound to the root.
145
+ this.#root.attach({ update: (topics) => this.#update(topics) });
183
146
  }
184
147
 
185
148
  // ─── Lifecycle ────────────────────────────────────────────────────────────
@@ -196,6 +159,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
196
159
  }
197
160
  }
198
161
  }
162
+ // Provide the session (the regions bind to it) and follow the system's preferences.
163
+ this.#cleanup.push(this.#root.connect());
199
164
  if (!this.#ui) this.#build();
200
165
  connected.push(this);
201
166
  const ui = this.#ui;
@@ -211,35 +176,17 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
211
176
  canvas.addEventListener('pointerdown', focusCanvas);
212
177
  this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
213
178
  }
214
- this.#watchMedia();
215
179
  this.#watchSize();
216
180
  const onKey = (e: KeyboardEvent): void => this.#onKeyDown(e);
217
181
  document.addEventListener('keydown', onKey);
218
182
  this.#cleanup.push(() => document.removeEventListener('keydown', onKey));
219
- const onStorage = (e: StorageEvent): void => this.controller.syncReview(e.key);
220
- window.addEventListener('storage', onStorage);
221
- this.#cleanup.push(() => window.removeEventListener('storage', onStorage));
222
183
  const onHash = (): void => this.#readHash();
223
184
  window.addEventListener('hashchange', onHash);
224
185
  this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
225
186
  this.controller.setChrome(() => ui.stage.chromeLayout());
226
- document.fonts?.ready?.then(() => this.#ui?.header.layout()).catch(() => undefined);
187
+ document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
227
188
  this.#readHash();
228
- this.#render(
229
- new Set<Topic>([
230
- 'status',
231
- 'data',
232
- 'mode',
233
- 'theme',
234
- 'locale',
235
- 'filters',
236
- 'review',
237
- 'selection',
238
- 'focus',
239
- 'swipe',
240
- 'replay',
241
- ]),
242
- );
189
+ this.#root.render(ALL_TOPICS);
243
190
  }
244
191
 
245
192
  disconnectedCallback(): void {
@@ -256,6 +203,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
256
203
  if (this.#reflecting) return;
257
204
  switch (name) {
258
205
  case 'mode':
206
+ case 'scope':
259
207
  case 'theme':
260
208
  case 'palette':
261
209
  case 'locale':
@@ -286,23 +234,39 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
286
234
 
287
235
  /**
288
236
  * Apply a preference from an attribute or a property. Valid values are explicit (a report's
289
- * `view` does not override them); invalid ones fall back to the default (`overlay`, `auto`,
290
- * `default`, the browser's language). Properties reflect the normalized value.
237
+ * `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
238
+ * `auto`, `default`, the browser's language). Properties reflect the normalized value; the
239
+ * legacy mode `changes` reflects as `overlay` + scope `changes`.
291
240
  */
292
241
  #applyPreference(
293
- name: 'mode' | 'theme' | 'palette' | 'locale',
242
+ name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
294
243
  raw: unknown,
295
244
  reflect: boolean,
296
245
  ): void {
297
246
  const c = this.controller;
247
+ // `mode="changes"` is rewritten to the attribute pair it stands for.
248
+ const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
298
249
  let value: string | null;
299
250
  switch (name) {
300
251
  case 'mode': {
252
+ if (legacy) {
253
+ c.explicit.add('scope');
254
+ c.setView('overlay', 'changes');
255
+ value = 'overlay';
256
+ break;
257
+ }
301
258
  const v = toMode(raw);
302
259
  c.setMode(v ?? 'overlay');
303
260
  value = v;
304
261
  break;
305
262
  }
263
+ case 'scope': {
264
+ const v = toScope(raw);
265
+ c.setScope(v ?? 'all');
266
+ // A locked scope stays `changes`: the attribute says what is shown.
267
+ value = v === null ? null : c.scope;
268
+ break;
269
+ }
306
270
  case 'theme': {
307
271
  const v = toTheme(raw);
308
272
  c.setThemePreference(v ?? 'auto');
@@ -326,7 +290,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
326
290
  }
327
291
  if (value === null) c.explicit.delete(name);
328
292
  else c.explicit.add(name);
329
- if (reflect) this.#reflect(name, value);
293
+ if (reflect || legacy) this.#reflect(name, value);
294
+ if (legacy) this.#reflect('scope', c.scope);
330
295
  }
331
296
 
332
297
  // ─── Properties ───────────────────────────────────────────────────────────
@@ -335,10 +300,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
335
300
  return this.controller.mode;
336
301
  }
337
302
 
338
- set mode(value: ViewerMode) {
303
+ set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
339
304
  this.#applyPreference('mode', value, true);
340
305
  }
341
306
 
307
+ get scope(): ViewerScope {
308
+ return this.controller.scope;
309
+ }
310
+
311
+ set scope(value: ViewerScope) {
312
+ this.#applyPreference('scope', value, true);
313
+ }
314
+
342
315
  get theme(): ThemePreference {
343
316
  return this.controller.themePreference;
344
317
  }
@@ -481,10 +454,6 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
481
454
 
482
455
  // ─── Internals ────────────────────────────────────────────────────────────
483
456
 
484
- #emit(type: string, detail: unknown): void {
485
- this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
486
- }
487
-
488
457
  #reflect(name: string, value: string | null): void {
489
458
  if (this.getAttribute(name) === value) return;
490
459
  this.#reflecting = true;
@@ -496,16 +465,6 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
496
465
  }
497
466
  }
498
467
 
499
- #announce(text: string): void {
500
- const live = this.#ui?.live;
501
- if (!live) return;
502
- live.textContent = '';
503
- // A fresh text node in a later task makes screen readers announce repeats too.
504
- setTimeout(() => {
505
- live.textContent = text;
506
- }, 30);
507
- }
508
-
509
468
  get #sidebarOpen(): boolean {
510
469
  return this.getAttribute('sidebar') !== 'closed';
511
470
  }
@@ -545,16 +504,12 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
545
504
  this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
546
505
  }
547
506
 
548
- #build(): void {
549
- const style = h('style', { text: STYLES });
550
- const vars = h('style');
551
- const ctx: UiContext = {
552
- controller: this.controller,
507
+ /** What the layout does for the regions: preferences reflect as attributes, panels open. */
508
+ #actions(): UiActions {
509
+ return {
553
510
  tr: () => translator(this.controller.locale),
554
- root: this.#shadow,
555
- setMode: (mode) => {
556
- this.controller.setMode(mode);
557
- },
511
+ setMode: (mode) => this.controller.setMode(mode),
512
+ toggleScope: () => this.controller.toggleScope(),
558
513
  setThemePreference: (pref) => {
559
514
  this.theme = pref;
560
515
  },
@@ -570,23 +525,27 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
570
525
  openHelp: () => this.#ui?.help.show(),
571
526
  focusSearch: () => this.#focusSearch(),
572
527
  };
573
- const header = new Header(ctx);
574
- const stage = new Stage(ctx);
575
- const sidebar = new Sidebar(ctx);
576
- const menu = new Menu(ctx);
577
- const help = new HelpDialog(ctx);
578
- const live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
579
- const root = h(
580
- 'div',
581
- { class: 'root' },
582
- header.el,
583
- h('div', { class: 'body' }, stage.el, sidebar.el),
584
- menu.el,
585
- help.el,
586
- live,
587
- );
588
- this.#shadow.append(style, vars, root);
589
- this.#ui = { root, vars, header, stage, sidebar, menu, help, live };
528
+ }
529
+
530
+ #build(): void {
531
+ const root = h('div', { class: 'root' });
532
+ this.#shadow.append(root);
533
+ // Each region binds to the root on its own (context protocol); the layout only places them.
534
+ const header = bindRegion(root, (ui) => new Header(ui));
535
+ const list = bindRegion(root, (ui) => new ChangeList(ui));
536
+ const search = bindRegion(root, (ui) => new Search(ui));
537
+ const filters = bindRegion(root, (ui) => new Filters(ui));
538
+ const detail = bindRegion(root, (ui) => new Detail(ui));
539
+ const stage = bindRegion(root, (ui) => new Stage(ui));
540
+ const ui = uiContext(this.#root, this.#shadow);
541
+ const menu = new Menu(ui);
542
+ const help = new HelpDialog(ui);
543
+ const sidebar = new Sidebar(ui.tr, list, search, filters, detail);
544
+ // The title and the actions float over the canvas; the menu drops from the actions.
545
+ header.attachMenu(menu.el);
546
+ stage.el.appendChild(header.el);
547
+ root.append(h('div', { class: 'body' }, stage.el, sidebar.el), help.el, this.#root.live);
548
+ this.#ui = { root, header, stage, list, sidebar, menu, help };
590
549
  // Close the menu on outside clicks.
591
550
  root.addEventListener('pointerdown', (e) => {
592
551
  if (!menu.open) return;
@@ -604,27 +563,14 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
604
563
  this.#applyChrome();
605
564
  }
606
565
 
607
- /** Render, never throwing: a failure shows the error state with safe appearance defaults. */
608
- #render(topics: ReadonlySet<Topic>): void {
609
- try {
610
- this.#renderTopics(topics);
611
- this.#renderFailed = false;
612
- } catch (error) {
613
- console.error('[kgdiff] render failed:', error);
614
- if (this.#renderFailed) return;
615
- this.#renderFailed = true;
616
- const c = this.controller;
617
- c.explicit.clear();
618
- c.setThemePreference('auto');
619
- c.setPalette('default');
620
- c.fail(error instanceof Error ? error : new Error(String(error)));
621
- }
622
- }
623
-
624
- #renderTopics(topics: ReadonlySet<Topic>): void {
566
+ /** The layout's part of a render (the root renders the regions after it). */
567
+ #update(topics: ReadonlySet<Topic>): void {
625
568
  const ui = this.#ui;
626
569
  const c = this.controller;
627
- if (topics.has('mode')) this.#reflect('mode', c.mode);
570
+ if (topics.has('mode')) {
571
+ this.#reflect('mode', c.mode);
572
+ this.#reflect('scope', c.scope);
573
+ }
628
574
  if (topics.has('theme')) {
629
575
  this.#reflect(
630
576
  'theme',
@@ -632,20 +578,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
632
578
  );
633
579
  }
634
580
  if (!ui) return;
635
- if (topics.has('theme') || topics.has('status')) {
636
- ui.vars.textContent = `:host { ${themeVariables(c.theme)} }`;
637
- ui.root.dataset.theme = c.themeName;
638
- }
581
+ if (topics.has('theme') || topics.has('status')) ui.root.dataset.theme = c.themeName;
639
582
  if (topics.has('locale') || topics.has('status')) {
640
583
  ui.root.setAttribute('lang', c.locale);
641
584
  if (ui.menu.open) ui.menu.render();
642
585
  if (ui.help.open) ui.help.render();
643
586
  }
644
587
  if (topics.has('theme') && ui.menu.open) ui.menu.render();
645
- ui.header.update(topics);
646
588
  ui.sidebar.update(topics);
647
- ui.stage.update(topics);
648
- if (topics.has('mode') || topics.has('status')) ui.root.dataset.mode = c.mode;
589
+ if (topics.has('mode') || topics.has('status')) {
590
+ ui.root.dataset.mode = c.mode;
591
+ ui.root.dataset.scope = c.scope;
592
+ }
649
593
  }
650
594
 
651
595
  #toggleSidebar(open?: boolean): void {
@@ -665,22 +609,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
665
609
 
666
610
  #focusSearch(): void {
667
611
  if (!this.#sidebarOpen) this.#toggleSidebar(true);
668
- this.#ui?.sidebar.focusSearch();
669
- }
670
-
671
- #watchMedia(): void {
672
- const dark = media('(prefers-color-scheme: light)');
673
- const motion = media('(prefers-reduced-motion: reduce)');
674
- const apply = (): void => {
675
- this.controller.setSystemDark(!(dark?.matches ?? false));
676
- this.controller.setReducedMotion(motion?.matches ?? false);
677
- };
678
- apply();
679
- for (const q of [dark, motion]) {
680
- if (!q) continue;
681
- q.addEventListener?.('change', apply);
682
- this.#cleanup.push(() => q.removeEventListener?.('change', apply));
683
- }
612
+ this.#root.focus('search');
684
613
  }
685
614
 
686
615
  #watchSize(): void {
@@ -695,8 +624,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
695
624
  this.#compact = compact;
696
625
  ui.root.dataset.compact = String(compact);
697
626
  }
698
- ui.header.layout();
699
- ui.sidebar.renderWindow();
627
+ ui.stage.layout();
628
+ ui.list.renderWindow();
700
629
  if (compact) ui.sidebar.syncSheet();
701
630
  this.controller.layoutChanged();
702
631
  });
@@ -727,82 +656,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
727
656
  }
728
657
  return;
729
658
  }
730
- const target = path[0] ?? null;
731
- if (isTypingTarget(target)) return;
732
- const c = this.controller;
733
- const action = keyAction(e, c.mode);
734
- if (!action) return;
735
- // Enter / Space on a focused control activate that control.
736
- const el = target as Element | null;
737
- if (
738
- (e.key === 'Enter' || e.key === ' ') &&
739
- el?.closest?.('button, a, summary, [role="radio"], [role="menuitemradio"]')
740
- ) {
741
- return;
742
- }
743
- if (c.status !== 'ready' && !['help', 'theme', 'sidebar', 'escape'].includes(action.type))
744
- return;
745
- e.preventDefault();
746
- switch (action.type) {
747
- case 'mode':
748
- c.setMode(action.mode);
749
- break;
750
- case 'next':
751
- c.next();
752
- break;
753
- case 'prev':
754
- c.prev();
755
- break;
756
- case 'first':
757
- c.first();
758
- break;
759
- case 'last':
760
- c.last();
761
- break;
762
- case 'fly':
763
- c.flyToSelection();
764
- break;
765
- case 'escape':
766
- if (!ui.sidebar.escape()) c.clearSelection();
767
- break;
768
- case 'play-pause':
769
- c.togglePlay();
770
- break;
771
- case 'step':
772
- c.stepReplay(action.direction);
773
- break;
774
- case 'divider':
775
- c.setSplit(c.split + action.delta);
776
- break;
777
- case 'fit':
778
- void c.fit();
779
- break;
780
- case 'zoom':
781
- c.zoom(action.direction);
782
- break;
783
- case 'search':
784
- this.#focusSearch();
785
- break;
786
- case 'review':
787
- c.toggleReviewed();
788
- break;
789
- case 'sidebar':
790
- this.#toggleSidebar();
791
- break;
792
- case 'theme': {
793
- const i = THEME_PREFERENCES.indexOf(c.themePreference);
794
- this.theme = THEME_PREFERENCES[(i + 1) % THEME_PREFERENCES.length] as ThemePreference;
795
- break;
796
- }
797
- case 'help':
798
- ui.help.show();
799
- break;
800
- case 'expand':
801
- c.expandNearest();
802
- break;
803
- default:
804
- action satisfies never;
805
- }
659
+ this.#root.handleKey(e);
806
660
  }
807
661
 
808
662
  // ─── Deep links ───────────────────────────────────────────────────────────
@@ -813,10 +667,12 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
813
667
  const state = parseHash(location.hash);
814
668
  this.#lastHash = location.hash;
815
669
  if (!state) return;
816
- if (state.mode) {
817
- // A deep link outranks the large-graph default and a report's preferred mode.
818
- this.controller.explicit.add('mode');
819
- this.controller.setMode(state.mode);
670
+ if (state.mode || state.scope) {
671
+ // A deep link outranks the large-graph default and a report's preferred view.
672
+ const c = this.controller;
673
+ if (state.mode) c.explicit.add('mode');
674
+ if (state.scope) c.explicit.add('scope');
675
+ c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
820
676
  }
821
677
  if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
822
678
  }
@@ -825,7 +681,11 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
825
681
  if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
826
682
  const c = this.controller;
827
683
  const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
828
- const hash = formatHash({ ...(hunk !== undefined ? { hunk } : {}), mode: c.mode });
684
+ const hash = formatHash({
685
+ ...(hunk !== undefined ? { hunk } : {}),
686
+ mode: c.mode,
687
+ scope: c.scope,
688
+ });
829
689
  if (hash === location.hash) return;
830
690
  this.#lastHash = hash;
831
691
  try {
package/src/hash-sync.ts CHANGED
@@ -1,22 +1,24 @@
1
1
  /**
2
- * Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>`.
2
+ * Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>;scope:<scope>`. The legacy
3
+ * `mode:changes` reads as overlay + the `changes` scope.
3
4
  *
4
5
  * Values are percent-encoded (hunk ids embed union ids, which may be IRIs containing `;`, `#`
5
6
  * or `:`), so parsing splits on the first `:` of each `;`-separated field. Unknown fields are
6
7
  * ignored; a malformed value drops just that field.
7
8
  */
8
- import type { ViewerMode } from './index.js';
9
+ import type { ViewerMode, ViewerScope } from './index.js';
10
+ import { LEGACY_CHANGES_MODE, toMode, toScope } from './prefs.js';
9
11
 
10
12
  export interface HashState {
11
13
  readonly hunk?: string;
12
14
  readonly mode?: ViewerMode;
15
+ readonly scope?: ViewerScope;
13
16
  }
14
17
 
15
18
  const PREFIX = 'kgdiff=';
16
- const MODES: ReadonlySet<string> = new Set<ViewerMode>(['overlay', 'swipe', 'replay', 'changes']);
17
19
 
18
20
  export function isViewerMode(value: unknown): value is ViewerMode {
19
- return typeof value === 'string' && MODES.has(value);
21
+ return toMode(value) !== null;
20
22
  }
21
23
 
22
24
  function decode(value: string): string | undefined {
@@ -33,6 +35,8 @@ export function parseHash(hash: string): HashState | null {
33
35
  if (!raw.startsWith(PREFIX)) return null;
34
36
  let hunk: string | undefined;
35
37
  let mode: ViewerMode | undefined;
38
+ let scope: ViewerScope | undefined;
39
+ let legacy = false;
36
40
  for (const field of raw.slice(PREFIX.length).split(';')) {
37
41
  const colon = field.indexOf(':');
38
42
  if (colon <= 0) continue;
@@ -40,9 +44,18 @@ export function parseHash(hash: string): HashState | null {
40
44
  const value = decode(field.slice(colon + 1));
41
45
  if (value === undefined || value === '') continue;
42
46
  if (key === 'hunk') hunk = value;
43
- else if (key === 'mode' && isViewerMode(value)) mode = value;
47
+ else if (key === 'mode' && value === LEGACY_CHANGES_MODE) {
48
+ mode = 'overlay';
49
+ legacy = true;
50
+ } else if (key === 'mode') mode = toMode(value) ?? mode;
51
+ else if (key === 'scope') scope = toScope(value) ?? scope;
44
52
  }
45
- return { ...(hunk !== undefined ? { hunk } : {}), ...(mode !== undefined ? { mode } : {}) };
53
+ if (legacy) scope ??= 'changes';
54
+ return {
55
+ ...(hunk !== undefined ? { hunk } : {}),
56
+ ...(mode !== undefined ? { mode } : {}),
57
+ ...(scope !== undefined ? { scope } : {}),
58
+ };
46
59
  }
47
60
 
48
61
  /** Encode `state` as a hash (`#kgdiff=…`); empty state gives `#kgdiff=`. */
@@ -50,5 +63,6 @@ export function formatHash(state: HashState): string {
50
63
  const fields: string[] = [];
51
64
  if (state.hunk !== undefined) fields.push(`hunk:${encodeURIComponent(state.hunk)}`);
52
65
  if (state.mode !== undefined) fields.push(`mode:${state.mode}`);
66
+ if (state.scope !== undefined) fields.push(`scope:${state.scope}`);
53
67
  return `#${PREFIX}${fields.join(';')}`;
54
68
  }