@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/i18n.ts CHANGED
@@ -10,11 +10,18 @@ import type { Locale } from '@kgdiff/core';
10
10
  const en = {
11
11
  'brand.tagline': 'git diff for knowledge graphs',
12
12
  'header.compare': 'Compared versions',
13
- 'mode.label': 'View mode',
13
+ 'header.actions': 'Viewer actions',
14
+ 'dock.label': 'View',
15
+ 'mode.label': 'Compare by',
14
16
  'mode.overlay': 'Overlay',
15
17
  'mode.swipe': 'Swipe',
16
18
  'mode.replay': 'Replay',
17
19
  'mode.changes': 'Changes only',
20
+ 'scope.hint': 'Show only the changes, with nearby context',
21
+ 'scope.on': 'Showing only the changes',
22
+ 'scope.off': 'Showing the whole graph',
23
+ 'scope.locked':
24
+ 'This graph was laid out around its changes only: the whole graph cannot be shown.',
18
25
  'family.added': 'Added',
19
26
  'family.removed': 'Removed',
20
27
  'family.content': 'Content',
@@ -41,12 +48,6 @@ const en = {
41
48
  'status.added': 'Added',
42
49
  'status.removed': 'Removed',
43
50
  'status.modified': 'Modified',
44
- 'stat.added': '{n} added',
45
- 'stat.removed': '{n} removed',
46
- 'stat.modified': '{n} modified',
47
- 'stat.restructured': '{n} restructured',
48
- 'stat.filter': 'Show only these changes',
49
- 'stat.label': 'Change summary',
50
51
  'action.fit': 'Fit to view',
51
52
  'action.help': 'Keyboard shortcuts',
52
53
  'action.sidebar': 'Toggle sidebar',
@@ -199,11 +200,17 @@ export type Messages = Readonly<Record<MessageKey, string>>;
199
200
  const zhCN: Messages = {
200
201
  'brand.tagline': '知识图谱的 git diff',
201
202
  'header.compare': '对比的版本',
202
- 'mode.label': '视图模式',
203
+ 'header.actions': '查看器操作',
204
+ 'dock.label': '视图',
205
+ 'mode.label': '对比方式',
203
206
  'mode.overlay': '叠加',
204
207
  'mode.swipe': '滑动对比',
205
208
  'mode.replay': '回放',
206
209
  'mode.changes': '仅看变更',
210
+ 'scope.hint': '只显示变更,以及附近的上下文',
211
+ 'scope.on': '只显示变更',
212
+ 'scope.off': '显示全图',
213
+ 'scope.locked': '这张图只对变更部分做了布局,无法显示全图。',
207
214
  'family.added': '新增',
208
215
  'family.removed': '删除',
209
216
  'family.content': '内容',
@@ -230,12 +237,6 @@ const zhCN: Messages = {
230
237
  'status.added': '新增',
231
238
  'status.removed': '删除',
232
239
  'status.modified': '修改',
233
- 'stat.added': '新增 {n} 处',
234
- 'stat.removed': '删除 {n} 处',
235
- 'stat.modified': '修改 {n} 处',
236
- 'stat.restructured': '结构调整 {n} 处',
237
- 'stat.filter': '只显示这类变更',
238
- 'stat.label': '变更概览',
239
240
  'action.fit': '适应视图',
240
241
  'action.help': '键盘快捷键',
241
242
  'action.sidebar': '显示或隐藏侧栏',
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  GraphInput,
18
18
  KGraph,
19
19
  Locale,
20
+ PackedDiffJSON,
20
21
  } from '@kgdiff/core';
21
22
  import type { ParseOptions } from '@kgdiff/formats';
22
23
  import type { LayoutJSON, LayoutOptions } from '@kgdiff/layout';
@@ -25,7 +26,10 @@ import { KgdiffViewer } from './element.js';
25
26
  import { hydrate } from './payload.js';
26
27
  import { viewerTags } from './registry.js';
27
28
 
28
- export type ViewerMode = 'overlay' | 'swipe' | 'replay' | 'changes';
29
+ /** How the two versions are told apart: by color (overlay), side by side (swipe) or over time (replay). */
30
+ export type ViewerMode = 'overlay' | 'swipe' | 'replay';
31
+ /** What is drawn, in any mode: the whole graph, or only the changes with a few hops of context. */
32
+ export type ViewerScope = 'all' | 'changes';
29
33
  export type ThemePreference = 'auto' | 'light' | 'dark';
30
34
  /** `none`: the graph alone, for small embeds (see `KgdiffViewerElement.chrome`). */
31
35
  export type ViewerChrome = 'full' | 'none';
@@ -36,7 +40,8 @@ export type ViewerLegend = 'auto' | 'full' | 'mini' | 'none';
36
40
  export interface ReportPayload {
37
41
  readonly format: 'kgdiff.report';
38
42
  readonly version: 1;
39
- readonly diff: DiffJSON;
43
+ /** The diff, packed (`packDiffJSON`, what the CLI writes) or not. */
44
+ readonly diff: DiffJSON | PackedDiffJSON;
40
45
  readonly layout: LayoutJSON;
41
46
  readonly meta: {
42
47
  readonly title?: string;
@@ -49,7 +54,9 @@ export interface ReportPayload {
49
54
  };
50
55
  /** Initial view preferences. */
51
56
  readonly view?: {
57
+ /** `changes` (from reports made before scopes existed) means `overlay` + scope `changes`. */
52
58
  readonly mode?: ViewerMode;
59
+ readonly scope?: ViewerScope;
53
60
  readonly theme?: ThemePreference;
54
61
  readonly palette?: PaletteName;
55
62
  readonly locale?: Locale;
@@ -80,7 +87,7 @@ export interface ReviewState {
80
87
  export interface KgdiffViewerEventMap {
81
88
  'kgdiff-ready': CustomEvent<{ readonly stats: DiffStats }>;
82
89
  'kgdiff-select': CustomEvent<{ readonly hunk: string | null; readonly node: string | null }>;
83
- 'kgdiff-mode': CustomEvent<{ readonly mode: ViewerMode }>;
90
+ 'kgdiff-mode': CustomEvent<{ readonly mode: ViewerMode; readonly scope: ViewerScope }>;
84
91
  'kgdiff-review': CustomEvent<{
85
92
  readonly hunk: string;
86
93
  readonly reviewed: boolean;
@@ -104,21 +111,28 @@ export interface FitOptions {
104
111
  /**
105
112
  * Public surface of the `<kgdiff-viewer>` element.
106
113
  *
107
- * Boolean attributes: `sync-hash` (deep links `#kgdiff=hunk:<id>;mode:<mode>`) and
114
+ * Boolean attributes: `sync-hash` (deep links `#kgdiff=hunk:<id>;mode:<mode>;scope:<scope>`) and
108
115
  * `global-shortcuts`: keyboard shortcuts normally apply only while focus is inside the viewer;
109
116
  * with this attribute they also apply when nothing on the page has focus (for full-page
110
117
  * viewers such as HTML reports), except the keys pages scroll with (arrows, Home / End, Page
111
118
  * Up / Down, Space).
112
119
  *
113
- * `header="compact"` leaves the brand and the compared names out of the header, for hosts that
120
+ * `header="compact"` leaves the brand and the compared names out of the canvas, for hosts that
114
121
  * show their own (such as the playground).
115
122
  *
116
123
  * A small looping replay for another page: `mode="replay" chrome="none" loop` (with a height of
117
124
  * your choosing, such as 320px).
118
125
  */
119
126
  export interface KgdiffViewerElement extends HTMLElement {
120
- /** Reflected to the `mode` attribute. Default `overlay` (`changes` for large graphs). */
121
- mode: ViewerMode;
127
+ /** Reflected to the `mode` attribute. Default `overlay`. */
128
+ get mode(): ViewerMode;
129
+ /** The legacy `changes` mode sets `overlay` + scope `changes`. */
130
+ set mode(value: ViewerMode | 'changes');
131
+ /**
132
+ * Reflected to the `scope` attribute. Default `all` (`changes` for large graphs). Fixed to
133
+ * `changes` when the layout covers only the changes.
134
+ */
135
+ scope: ViewerScope;
122
136
  /** Reflected to the `theme` attribute. Default `auto`. */
123
137
  theme: ThemePreference;
124
138
  /** Reflected to the `palette` attribute. Default `default`. */
@@ -126,9 +140,9 @@ export interface KgdiffViewerElement extends HTMLElement {
126
140
  /** Reflected to the `locale` attribute. Default from `navigator.language`. */
127
141
  locale: Locale;
128
142
  /**
129
- * Reflected to the `chrome` attribute. `none` shows the graph alone: no header, change list,
130
- * step caption, playback bar, zoom buttons, minimap or legend card; the hover tooltip and a
131
- * one-line legend stay. Clicks and keyboard shortcuts do nothing, the minimum height drops
143
+ * Reflected to the `chrome` attribute. `none` shows the graph alone: no title, actions, view
144
+ * dock (with the step caption and playback bar), change list, zoom buttons, minimap or legend
145
+ * card; the hover tooltip and a one-line legend stay. Clicks and keyboard shortcuts do nothing, the minimum height drops
132
146
  * from 420px to 120px, and the view stays fitted: when the canvas is resized (unless the user
133
147
  * panned or zoomed since) and at the start of every loop. Labels are never cut at the edge:
134
148
  * one pushed from its spot by a neighbour moves inside or is left out. Default `full`.
package/src/keyboard.ts CHANGED
@@ -2,10 +2,11 @@
2
2
  * The keyboard map (docs/interaction.md §Keyboard map), as a pure function from a key event to
3
3
  * an action, so it can be tested without a DOM.
4
4
  */
5
- import type { ViewerMode } from './index.js';
5
+ import type { ViewerMode, ViewerScope } from './index.js';
6
6
 
7
7
  export type KeyAction =
8
8
  | { readonly type: 'mode'; readonly mode: ViewerMode }
9
+ | { readonly type: 'scope' }
9
10
  | { readonly type: 'next' }
10
11
  | { readonly type: 'prev' }
11
12
  | { readonly type: 'first' }
@@ -36,7 +37,6 @@ const MODE_KEYS: Readonly<Record<string, ViewerMode>> = {
36
37
  '1': 'overlay',
37
38
  '2': 'swipe',
38
39
  '3': 'replay',
39
- '4': 'changes',
40
40
  };
41
41
 
42
42
  /** Divider steps for swipe, as a fraction of the width. */
@@ -44,15 +44,21 @@ export const DIVIDER_STEP = 0.02;
44
44
  export const DIVIDER_STEP_LARGE = 0.1;
45
45
 
46
46
  /**
47
- * Map a key press to an action in `mode`, or null when the key means nothing here. Chords with
48
- * Ctrl / Meta / Alt are never handled (they belong to the browser and the host page).
47
+ * Map a key press to an action in `mode` and `scope`, or null when the key means nothing here.
48
+ * Chords with Ctrl / Meta / Alt are never handled (they belong to the browser and the host page).
49
49
  */
50
- export function keyAction(e: KeyLike, mode: ViewerMode): KeyAction | null {
50
+ export function keyAction(
51
+ e: KeyLike,
52
+ mode: ViewerMode,
53
+ scope: ViewerScope = 'all',
54
+ ): KeyAction | null {
51
55
  if (e.ctrlKey || e.metaKey || e.altKey) return null;
52
56
  const key = e.key;
53
57
  const mapped = MODE_KEYS[key];
54
58
  if (mapped !== undefined) return { type: 'mode', mode: mapped };
55
59
  switch (key) {
60
+ case '4':
61
+ return { type: 'scope' };
56
62
  case 'j':
57
63
  case 'ArrowDown':
58
64
  return { type: 'next' };
@@ -104,7 +110,7 @@ export function keyAction(e: KeyLike, mode: ViewerMode): KeyAction | null {
104
110
  return { type: 'theme' };
105
111
  case 'e':
106
112
  case 'E':
107
- return mode === 'changes' ? { type: 'expand' } : null;
113
+ return scope === 'changes' ? { type: 'expand' } : null;
108
114
  case '?':
109
115
  return { type: 'help' };
110
116
  default:
package/src/pipeline.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  isKGraph,
12
12
  type KGraph,
13
13
  KgdiffError,
14
+ unpackDiffJSON,
14
15
  } from '@kgdiff/core';
15
16
  import { type LayoutOptions, type LayoutResult, layoutFromJSON } from '@kgdiff/layout';
16
17
  import { loadCompute } from './compute-loader.js';
@@ -157,7 +158,9 @@ export async function readPayload(
157
158
  onStep: (step: LoadStep) => Promise<void>,
158
159
  ): Promise<LoadedDiff> {
159
160
  await onStep('match');
160
- const diff = diffFromJSON(payload.diff);
161
+ const json =
162
+ payload.diff.format === 'kgdiff.diff.packed' ? unpackDiffJSON(payload.diff) : payload.diff;
163
+ const diff = diffFromJSON(json);
161
164
  await onStep('layout');
162
165
  const layout = layoutFromJSON(payload.layout);
163
166
  const vb = str(diff.before.meta.version);
package/src/prefs.ts CHANGED
@@ -12,9 +12,11 @@ import type {
12
12
  ViewerChrome,
13
13
  ViewerLegend,
14
14
  ViewerMode,
15
+ ViewerScope,
15
16
  } from './index.js';
16
17
 
17
- const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay', 'changes'];
18
+ const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay'];
19
+ const SCOPES: readonly ViewerScope[] = ['all', 'changes'];
18
20
  const THEMES: readonly ThemePreference[] = ['auto', 'light', 'dark'];
19
21
  const PALETTES: readonly PaletteName[] = ['default', 'colorblind'];
20
22
  const CHROMES: readonly ViewerChrome[] = ['full', 'none'];
@@ -29,6 +31,16 @@ export function toMode(value: unknown): ViewerMode | null {
29
31
  return MODES.includes(value as ViewerMode) ? (value as ViewerMode) : null;
30
32
  }
31
33
 
34
+ export function toScope(value: unknown): ViewerScope | null {
35
+ return SCOPES.includes(value as ViewerScope) ? (value as ViewerScope) : null;
36
+ }
37
+
38
+ /**
39
+ * Before scopes existed, “changes only” was a fourth mode. Attributes, properties, deep links
40
+ * and reports may still say `changes`: it means overlay + the `changes` scope.
41
+ */
42
+ export const LEGACY_CHANGES_MODE = 'changes';
43
+
32
44
  export function toTheme(value: unknown): ThemePreference | null {
33
45
  return THEMES.includes(value as ThemePreference) ? (value as ThemePreference) : null;
34
46
  }
@@ -61,18 +73,21 @@ type View = NonNullable<ReportPayload['view']>;
61
73
  export function sanitizeView(view: unknown): View | undefined {
62
74
  if (typeof view !== 'object' || view === null) return undefined;
63
75
  const v = view as Record<string, unknown>;
64
- const mode = toMode(v.mode);
76
+ const legacy = v.mode === LEGACY_CHANGES_MODE;
77
+ const mode = legacy ? 'overlay' : toMode(v.mode);
78
+ const scope = toScope(v.scope) ?? (legacy && v.scope === undefined ? 'changes' : null);
65
79
  const theme = toTheme(v.theme);
66
80
  const palette = toPalette(v.palette);
67
81
  const locale = toLocale(v.locale);
68
- const dropped = (['mode', 'theme', 'palette', 'locale'] as const).filter(
69
- (k) => v[k] !== undefined && { mode, theme, palette, locale }[k] === null,
82
+ const dropped = (['mode', 'scope', 'theme', 'palette', 'locale'] as const).filter(
83
+ (k) => v[k] !== undefined && { mode, scope, theme, palette, locale }[k] === null,
70
84
  );
71
85
  if (dropped.length > 0 && typeof console !== 'undefined') {
72
86
  console.warn(`[kgdiff] ignoring invalid report view preferences: ${dropped.join(', ')}`);
73
87
  }
74
88
  return {
75
89
  ...(mode ? { mode } : {}),
90
+ ...(scope ? { scope } : {}),
76
91
  ...(theme ? { theme } : {}),
77
92
  ...(palette ? { palette } : {}),
78
93
  ...(locale ? { locale } : {}),
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
+ }