@kgdiff/viewer 0.4.0 → 0.6.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 (257) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/context.d.ts +42 -0
  8. package/dist/context.d.ts.map +1 -0
  9. package/dist/context.js +87 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/controller.d.ts +26 -237
  12. package/dist/controller.d.ts.map +1 -1
  13. package/dist/controller.js +33 -1484
  14. package/dist/controller.js.map +1 -1
  15. package/dist/dom.d.ts +10 -0
  16. package/dist/dom.d.ts.map +1 -1
  17. package/dist/dom.js +29 -0
  18. package/dist/dom.js.map +1 -1
  19. package/dist/element.d.ts +51 -42
  20. package/dist/element.d.ts.map +1 -1
  21. package/dist/element.js +381 -602
  22. package/dist/element.js.map +1 -1
  23. package/dist/elements/actions.d.ts +27 -0
  24. package/dist/elements/actions.d.ts.map +1 -0
  25. package/dist/elements/actions.js +81 -0
  26. package/dist/elements/actions.js.map +1 -0
  27. package/dist/elements/bound.d.ts +63 -0
  28. package/dist/elements/bound.d.ts.map +1 -0
  29. package/dist/elements/bound.js +213 -0
  30. package/dist/elements/bound.js.map +1 -0
  31. package/dist/elements/change-detail.d.ts +25 -0
  32. package/dist/elements/change-detail.d.ts.map +1 -0
  33. package/dist/elements/change-detail.js +19 -0
  34. package/dist/elements/change-detail.js.map +1 -0
  35. package/dist/elements/change-list.d.ts +27 -0
  36. package/dist/elements/change-list.d.ts.map +1 -0
  37. package/dist/elements/change-list.js +29 -0
  38. package/dist/elements/change-list.js.map +1 -0
  39. package/dist/elements/filters.d.ts +25 -0
  40. package/dist/elements/filters.d.ts.map +1 -0
  41. package/dist/elements/filters.js +19 -0
  42. package/dist/elements/filters.js.map +1 -0
  43. package/dist/elements/legend.d.ts +40 -0
  44. package/dist/elements/legend.d.ts.map +1 -0
  45. package/dist/elements/legend.js +87 -0
  46. package/dist/elements/legend.js.map +1 -0
  47. package/dist/elements/minimap.d.ts +38 -0
  48. package/dist/elements/minimap.d.ts.map +1 -0
  49. package/dist/elements/minimap.js +59 -0
  50. package/dist/elements/minimap.js.map +1 -0
  51. package/dist/elements/root.d.ts +40 -0
  52. package/dist/elements/root.d.ts.map +1 -0
  53. package/dist/elements/root.js +67 -0
  54. package/dist/elements/root.js.map +1 -0
  55. package/dist/elements/search.d.ts +29 -0
  56. package/dist/elements/search.d.ts.map +1 -0
  57. package/dist/elements/search.js +23 -0
  58. package/dist/elements/search.js.map +1 -0
  59. package/dist/elements/stage-context.d.ts +31 -0
  60. package/dist/elements/stage-context.d.ts.map +1 -0
  61. package/dist/elements/stage-context.js +4 -0
  62. package/dist/elements/stage-context.js.map +1 -0
  63. package/dist/elements/stage.d.ts +65 -0
  64. package/dist/elements/stage.d.ts.map +1 -0
  65. package/dist/elements/stage.js +259 -0
  66. package/dist/elements/stage.js.map +1 -0
  67. package/dist/elements/title.d.ts +25 -0
  68. package/dist/elements/title.d.ts.map +1 -0
  69. package/dist/elements/title.js +19 -0
  70. package/dist/elements/title.js.map +1 -0
  71. package/dist/elements/transport.d.ts +29 -0
  72. package/dist/elements/transport.d.ts.map +1 -0
  73. package/dist/elements/transport.js +24 -0
  74. package/dist/elements/transport.js.map +1 -0
  75. package/dist/elements/view-bound.d.ts +39 -0
  76. package/dist/elements/view-bound.d.ts.map +1 -0
  77. package/dist/elements/view-bound.js +88 -0
  78. package/dist/elements/view-bound.js.map +1 -0
  79. package/dist/elements/view-dock.d.ts +28 -0
  80. package/dist/elements/view-dock.d.ts.map +1 -0
  81. package/dist/elements/view-dock.js +28 -0
  82. package/dist/elements/view-dock.js.map +1 -0
  83. package/dist/elements/zoom.d.ts +28 -0
  84. package/dist/elements/zoom.d.ts.map +1 -0
  85. package/dist/elements/zoom.js +22 -0
  86. package/dist/elements/zoom.js.map +1 -0
  87. package/dist/i18n.d.ts +10 -0
  88. package/dist/i18n.d.ts.map +1 -1
  89. package/dist/i18n.js +20 -0
  90. package/dist/i18n.js.map +1 -1
  91. package/dist/icons.d.ts +1 -0
  92. package/dist/icons.d.ts.map +1 -1
  93. package/dist/icons.js +1 -0
  94. package/dist/icons.js.map +1 -1
  95. package/dist/index.d.ts +331 -36
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +13 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/keyboard.d.ts +39 -2
  100. package/dist/keyboard.d.ts.map +1 -1
  101. package/dist/keyboard.js +145 -62
  102. package/dist/keyboard.js.map +1 -1
  103. package/dist/layout-store.d.ts +26 -0
  104. package/dist/layout-store.d.ts.map +1 -0
  105. package/dist/layout-store.js +46 -0
  106. package/dist/layout-store.js.map +1 -0
  107. package/dist/panel-levels.d.ts +18 -0
  108. package/dist/panel-levels.d.ts.map +1 -0
  109. package/dist/panel-levels.js +41 -0
  110. package/dist/panel-levels.js.map +1 -0
  111. package/dist/panels.d.ts +28 -0
  112. package/dist/panels.d.ts.map +1 -0
  113. package/dist/panels.js +73 -0
  114. package/dist/panels.js.map +1 -0
  115. package/dist/pipeline.d.ts.map +1 -1
  116. package/dist/pipeline.js +3 -2
  117. package/dist/pipeline.js.map +1 -1
  118. package/dist/prefs.d.ts +5 -2
  119. package/dist/prefs.d.ts.map +1 -1
  120. package/dist/prefs.js +12 -3
  121. package/dist/prefs.js.map +1 -1
  122. package/dist/react.d.ts +460 -0
  123. package/dist/react.d.ts.map +1 -0
  124. package/dist/react.js +2 -0
  125. package/dist/react.js.map +1 -0
  126. package/dist/root-element.d.ts +122 -0
  127. package/dist/root-element.d.ts.map +1 -0
  128. package/dist/root-element.js +541 -0
  129. package/dist/root-element.js.map +1 -0
  130. package/dist/root.d.ts +54 -0
  131. package/dist/root.d.ts.map +1 -0
  132. package/dist/root.js +288 -0
  133. package/dist/root.js.map +1 -0
  134. package/dist/schedule.d.ts +22 -0
  135. package/dist/schedule.d.ts.map +1 -0
  136. package/dist/schedule.js +41 -0
  137. package/dist/schedule.js.map +1 -0
  138. package/dist/session.d.ts +269 -0
  139. package/dist/session.d.ts.map +1 -0
  140. package/dist/session.js +1237 -0
  141. package/dist/session.js.map +1 -0
  142. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  143. package/dist/standalone/kgdiff-viewer.js +4 -4
  144. package/dist/styles.d.ts +17 -6
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +367 -258
  147. package/dist/styles.js.map +1 -1
  148. package/dist/theme.d.ts +32 -0
  149. package/dist/theme.d.ts.map +1 -0
  150. package/dist/theme.js +152 -0
  151. package/dist/theme.js.map +1 -0
  152. package/dist/topics.d.ts +8 -0
  153. package/dist/topics.d.ts.map +1 -0
  154. package/dist/topics.js +18 -0
  155. package/dist/topics.js.map +1 -0
  156. package/dist/ui/change-list.d.ts +18 -0
  157. package/dist/ui/change-list.d.ts.map +1 -0
  158. package/dist/ui/change-list.js +331 -0
  159. package/dist/ui/change-list.js.map +1 -0
  160. package/dist/ui/context.d.ts +34 -7
  161. package/dist/ui/context.d.ts.map +1 -1
  162. package/dist/ui/context.js.map +1 -1
  163. package/dist/ui/filters.d.ts +9 -0
  164. package/dist/ui/filters.d.ts.map +1 -0
  165. package/dist/ui/filters.js +68 -0
  166. package/dist/ui/filters.js.map +1 -0
  167. package/dist/ui/header.d.ts +41 -3
  168. package/dist/ui/header.d.ts.map +1 -1
  169. package/dist/ui/header.js +101 -36
  170. package/dist/ui/header.js.map +1 -1
  171. package/dist/ui/legend.d.ts +15 -0
  172. package/dist/ui/legend.d.ts.map +1 -0
  173. package/dist/ui/legend.js +68 -0
  174. package/dist/ui/legend.js.map +1 -0
  175. package/dist/ui/popovers.d.ts +3 -1
  176. package/dist/ui/popovers.d.ts.map +1 -1
  177. package/dist/ui/popovers.js +62 -23
  178. package/dist/ui/popovers.js.map +1 -1
  179. package/dist/ui/search.d.ts +13 -0
  180. package/dist/ui/search.d.ts.map +1 -0
  181. package/dist/ui/search.js +192 -0
  182. package/dist/ui/search.js.map +1 -0
  183. package/dist/ui/sidebar.d.ts +32 -17
  184. package/dist/ui/sidebar.d.ts.map +1 -1
  185. package/dist/ui/sidebar.js +327 -519
  186. package/dist/ui/sidebar.js.map +1 -1
  187. package/dist/ui/stage.d.ts +56 -1
  188. package/dist/ui/stage.d.ts.map +1 -1
  189. package/dist/ui/stage.js +244 -256
  190. package/dist/ui/stage.js.map +1 -1
  191. package/dist/ui/swatch.js +1 -1
  192. package/dist/ui/swatch.js.map +1 -1
  193. package/dist/ui/transport.d.ts +12 -0
  194. package/dist/ui/transport.d.ts.map +1 -0
  195. package/dist/ui/transport.js +198 -0
  196. package/dist/ui/transport.js.map +1 -0
  197. package/dist/ui/zoom.d.ts +15 -0
  198. package/dist/ui/zoom.d.ts.map +1 -0
  199. package/dist/ui/zoom.js +30 -0
  200. package/dist/ui/zoom.js.map +1 -0
  201. package/dist/view.d.ts +94 -0
  202. package/dist/view.d.ts.map +1 -0
  203. package/dist/view.js +441 -0
  204. package/dist/view.js.map +1 -0
  205. package/package.json +95 -12
  206. package/src/bind.ts +80 -0
  207. package/src/context.ts +106 -0
  208. package/src/controller.ts +52 -1630
  209. package/src/dom.ts +30 -0
  210. package/src/element.ts +386 -619
  211. package/src/elements/actions.ts +94 -0
  212. package/src/elements/bound.ts +247 -0
  213. package/src/elements/change-detail.ts +34 -0
  214. package/src/elements/change-list.ts +51 -0
  215. package/src/elements/filters.ts +34 -0
  216. package/src/elements/legend.ts +103 -0
  217. package/src/elements/minimap.ts +75 -0
  218. package/src/elements/root.ts +86 -0
  219. package/src/elements/search.ts +38 -0
  220. package/src/elements/stage-context.ts +34 -0
  221. package/src/elements/stage.ts +285 -0
  222. package/src/elements/title.ts +34 -0
  223. package/src/elements/transport.ts +39 -0
  224. package/src/elements/view-bound.ts +118 -0
  225. package/src/elements/view-dock.ts +43 -0
  226. package/src/elements/zoom.ts +38 -0
  227. package/src/i18n.ts +20 -0
  228. package/src/icons.ts +1 -0
  229. package/src/index.ts +350 -35
  230. package/src/keyboard.ts +188 -61
  231. package/src/layout-store.ts +71 -0
  232. package/src/panel-levels.ts +53 -0
  233. package/src/panels.ts +81 -0
  234. package/src/pipeline.ts +4 -1
  235. package/src/prefs.ts +12 -4
  236. package/src/react.ts +459 -0
  237. package/src/root-element.ts +624 -0
  238. package/src/root.ts +335 -0
  239. package/src/schedule.ts +43 -0
  240. package/src/session.ts +1375 -0
  241. package/src/styles.ts +396 -263
  242. package/src/theme.ts +170 -0
  243. package/src/topics.ts +37 -0
  244. package/src/ui/change-list.ts +405 -0
  245. package/src/ui/context.ts +36 -7
  246. package/src/ui/filters.ts +79 -0
  247. package/src/ui/header.ts +129 -58
  248. package/src/ui/legend.ts +112 -0
  249. package/src/ui/popovers.ts +68 -25
  250. package/src/ui/search.ts +225 -0
  251. package/src/ui/sidebar.ts +362 -639
  252. package/src/ui/stage.ts +267 -331
  253. package/src/ui/swatch.ts +1 -1
  254. package/src/ui/transport.ts +240 -0
  255. package/src/ui/zoom.ts +45 -0
  256. package/src/view.ts +516 -0
  257. package/vscode.html-custom-data.json +726 -0
package/src/element.ts CHANGED
@@ -1,46 +1,49 @@
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 (`root-element.ts`: the session,
3
+ * keyboard map, live region, events, theme tokens, preferences and methods) and the regions
4
+ * bound to it, the stage with its chrome, the change list, the search, the family filters and
5
+ * the detail panel, placed as they always were (a 360 px sidebar; a bottom sheet below 760 px).
6
+ *
7
+ * Every region has a panel state (`panels.ts`): the host's `panels` attribute or property and
8
+ * the presets `sidebar`, `header` and `chrome` set them, the user changes them (folding the
9
+ * sidebar, the list or the detail panel, opening the legend, dragging the sidebar's width or
10
+ * the list / detail split), and the layout remembers the user's choices, theme, palette and
11
+ * language per origin (`layout-store.ts`; `persist="none"` turns that off). `kgdiff-layout`
12
+ * reports every change.
4
13
  */
5
- import { type Delta, KgdiffError, type Locale } from '@kgdiff/core';
6
- import type { PaletteName } from '@kgdiff/scene';
7
- import { type ControllerHost, type Topic, ViewerController } from './controller.js';
8
- import { h } from './dom.js';
9
- import { formatHash, parseHash } from './hash-sync.js';
10
- import { detectLocale, translate, translator } from './i18n.js';
14
+
15
+ import { provideContext } from './context.js';
16
+ import { ViewerController } from './controller.js';
17
+ import { h, isOpen } from './dom.js';
18
+ import { type StageViewContext, stageContext } from './elements/stage-context.js';
11
19
  import type {
12
- FitOptions,
13
20
  KgdiffViewerElement,
14
21
  LoadOptions,
22
+ PanelRegion,
23
+ PanelStates,
15
24
  ReportPayload,
16
- ReviewState,
17
- ThemePreference,
18
25
  ViewerChrome,
26
+ ViewerLayout,
19
27
  ViewerLegend,
20
- ViewerMode,
21
- ViewerScope,
28
+ ViewerPanels,
22
29
  } from './index.js';
23
- import { isTypingTarget, keyAction } from './keyboard.js';
24
- import { assertPayload } from './payload.js';
25
- import {
26
- DEFAULT_LOOP_DELAY,
27
- LEGACY_CHANGES_MODE,
28
- THEME_PREFERENCES,
29
- toChrome,
30
- toDelay,
31
- toLegend,
32
- toLocale,
33
- toMode,
34
- toPalette,
35
- toScope,
36
- toTheme,
37
- } from './prefs.js';
30
+ import { LAYOUT_KEY, type Remembered, readRemembered, writeRemembered } from './layout-store.js';
31
+ import { presetPanels, resolvePanels, SIDEBAR_REGIONS, samePanels } from './panel-levels.js';
32
+ import { DEFAULT_PANELS, formatPanels, PANEL_REGIONS, parsePanels, toPanels } from './panels.js';
33
+ import { sanitizeView, toChrome, toLegend } from './prefs.js';
38
34
  import { defaultStorage } from './review-store.js';
39
- import { STYLES, themeVariables } from './styles.js';
40
- import type { UiContext } from './ui/context.js';
35
+ import { bindRegion, uiContext } from './root.js';
36
+ import { ROOT_ATTRIBUTES, RootElement } from './root-element.js';
37
+ import type { SessionDeps, SessionHost, Topic } from './session.js';
38
+ import { stylesheet } from './styles.js';
39
+ import { ChangeList } from './ui/change-list.js';
40
+ import type { UiActions } from './ui/context.js';
41
+ import { Detail } from './ui/detail.js';
42
+ import { Filters } from './ui/filters.js';
41
43
  import { Header } from './ui/header.js';
42
44
  import { HelpDialog, Menu } from './ui/popovers.js';
43
- import { Sidebar } from './ui/sidebar.js';
45
+ import { Search } from './ui/search.js';
46
+ import { SIDEBAR_WIDTH, Sidebar } from './ui/sidebar.js';
44
47
  import { Stage } from './ui/stage.js';
45
48
 
46
49
  /** Below this width the sidebar becomes a bottom sheet. */
@@ -49,165 +52,96 @@ export const COMPACT_WIDTH = 760;
49
52
  const NARROW_WIDTH = 1100;
50
53
 
51
54
  const OBSERVED = [
52
- 'mode',
53
- 'scope',
54
- 'theme',
55
- 'palette',
56
- 'locale',
55
+ ...ROOT_ATTRIBUTES,
57
56
  'sidebar',
58
- 'sync-hash',
57
+ 'header',
59
58
  'chrome',
60
59
  'legend',
61
- 'loop',
62
- 'loop-delay',
63
- 'pause-on-hover',
64
- ] as const;
65
- const UPGRADED = [
66
- 'mode',
67
- 'scope',
68
- 'theme',
69
- 'palette',
70
- 'locale',
71
- 'chrome',
72
- 'legend',
73
- 'loop',
74
- 'loopDelay',
75
- 'pauseOnHover',
76
- 'payload',
60
+ 'panels',
61
+ 'persist',
62
+ 'persist-key',
77
63
  ] as const;
78
64
 
79
- /** Keys the page itself scrolls with: never taken at document level. */
80
- const PAGE_KEYS: ReadonlySet<string> = new Set([
81
- 'ArrowUp',
82
- 'ArrowDown',
83
- 'ArrowLeft',
84
- 'ArrowRight',
85
- 'Home',
86
- 'End',
87
- 'PageUp',
88
- 'PageDown',
89
- ' ',
90
- 'Spacebar',
91
- ]);
92
-
93
- /** Connected viewers, in connection order (`global-shortcuts` goes to the first opted-in one). */
94
- const connected: KgdiffViewer[] = [];
95
-
96
- function deepFreeze<T>(value: T): T {
97
- if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
98
- for (const v of Object.values(value)) deepFreeze(v);
99
- Object.freeze(value);
100
- }
101
- return value;
102
- }
65
+ type Preset = 'sidebar' | 'header' | 'chrome';
66
+ type MutablePanels = { -readonly [R in PanelRegion]?: PanelStates[R] };
103
67
 
104
- function media(query: string): MediaQueryList | null {
105
- return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
106
- ? window.matchMedia(query)
107
- : null;
68
+ function warnPanels(invalid: readonly string[]): void {
69
+ if (invalid.length > 0) console.warn(`[kgdiff] ignoring invalid panels: ${invalid.join(' ')}`);
108
70
  }
109
71
 
110
72
  /**
111
- * Resolve once the browser had a chance to paint the loading step. Hidden tabs never run
112
- * animation frames, so a short timer resolves it there (loading must never stall).
73
+ * @tagname kgdiff-viewer
74
+ * @attr {'open' | 'closed'} sidebar - A preset: `closed` starts with the sidebar folded (`\` and the
75
+ * sidebar button open it), `open` with its four regions open. Reflects the sidebar's state
76
+ * once the user toggles it.
77
+ * @attr {'compact'} header - A preset: `compact` leaves the brand and the compared names out of
78
+ * the canvas, for hosts that show their own (`panels="title:hidden"`).
79
+ * @slot top-start - The page's content over the canvas, top left (under the title while it shows).
80
+ * @slot top-end - The page's content over the canvas, top right (under the actions while they show).
81
+ * @slot bottom-start - The page's content over the canvas, bottom left, above the legend.
82
+ * @slot bottom-center - The page's content above the view dock and the transport.
83
+ * @slot bottom-end - The page's content over the canvas, bottom right, above the zoom buttons and the minimap.
113
84
  */
114
- function nextPaint(): Promise<void> {
115
- return new Promise((resolve) => {
116
- let done = false;
117
- const finish = (): void => {
118
- if (done) return;
119
- done = true;
120
- resolve();
121
- };
122
- if (typeof requestAnimationFrame === 'function') {
123
- requestAnimationFrame(() => setTimeout(finish, 0));
124
- }
125
- setTimeout(finish, 50);
126
- });
127
- }
128
-
129
- export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
85
+ export class KgdiffViewer extends RootElement<ViewerController> implements KgdiffViewerElement {
130
86
  static get observedAttributes(): readonly string[] {
131
87
  return OBSERVED;
132
88
  }
133
89
 
134
- /** @internal */
135
- readonly controller: ViewerController;
136
- readonly #shadow: ShadowRoot;
137
90
  #ui: {
138
91
  root: HTMLElement;
139
- vars: HTMLStyleElement;
140
92
  header: Header;
141
93
  stage: Stage;
94
+ list: ChangeList;
142
95
  sidebar: Sidebar;
143
96
  menu: Menu;
144
97
  help: HelpDialog;
145
- live: HTMLElement;
146
98
  } | null = null;
147
- #currentPayload: ReportPayload | null = null;
148
- #renderFailed = false;
149
- #reflecting = false;
150
99
  #compact = false;
151
100
  readonly #cleanup: (() => void)[] = [];
152
- #lastHash = '';
153
- #upgraded = false;
154
- /** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
155
- #pointerOver = false;
101
+
102
+ // Panel states, from the first level that names a region (see `panels.ts`).
103
+ /** What the user changed in this page. */
104
+ readonly #runtime: MutablePanels = {};
105
+ /** The host's `panels`. */
106
+ #hostPanels: ViewerPanels = {};
107
+ /** The presets, from the attributes as the host set them (not as the layout reflects them). */
108
+ readonly #presetAttrs: Record<Preset, string | null> = {
109
+ sidebar: null,
110
+ header: null,
111
+ chrome: null,
112
+ };
113
+ #presets: ViewerPanels = {};
114
+ /** What the user chose before (`persist`), read at the first connection. */
115
+ #remembered: Remembered = { panels: {} };
116
+ /** The report's `view.panels`. */
117
+ #report: ViewerPanels = {};
118
+ #states: PanelStates = DEFAULT_PANELS;
119
+ /** The sidebar's regions that were open when the user folded it. */
120
+ #folded: readonly PanelRegion[] | null = null;
121
+ /** `kgdiff-layout` reports changes from the first connection on. */
122
+ #layoutReady = false;
123
+ readonly #storage = defaultStorage();
156
124
 
157
125
  constructor() {
158
126
  super();
159
- this.#shadow = this.attachShadow({ mode: 'open' });
160
- const host: ControllerHost = {
161
- ready: (stats) => this.#emit('kgdiff-ready', { stats }),
162
- select: (hunk, node) => {
163
- this.#emit('kgdiff-select', { hunk, node });
164
- this.#writeHash();
165
- },
166
- mode: (mode, scope) => {
167
- this.#emit('kgdiff-mode', { mode, scope });
168
- this.#writeHash();
169
- },
170
- review: (hunk, reviewed, progress) =>
171
- this.#emit('kgdiff-review', { hunk, reviewed, progress }),
172
- error: (error) => this.#emit('kgdiff-error', { error }),
173
- announce: (text) => this.#announce(text),
174
- replayEnd: (loop) => this.#emit('kgdiff-replay-end', { loop }),
175
- };
176
- this.controller = new ViewerController(host, {
177
- now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
178
- requestFrame: (cb) =>
179
- typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0,
180
- cancelFrame: (id) => {
181
- if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id);
182
- },
183
- yieldToPaint: nextPaint,
184
- storage: defaultStorage(),
185
- });
186
- this.controller.locale = toLocale(this.getAttribute('locale')) ?? detectLocale();
187
- this.controller.subscribe((topics) => this.#render(topics));
127
+ // The layout renders first, then the regions in the order they bound to the root.
128
+ this.root.attach({ update: (topics) => this.#update(topics) });
188
129
  }
189
130
 
190
131
  // ─── Lifecycle ────────────────────────────────────────────────────────────
191
132
 
192
- connectedCallback(): void {
193
- if (!this.#upgraded) {
194
- this.#upgraded = true;
195
- // Properties set before the element was defined shadow the accessors: re-apply them.
196
- for (const prop of UPGRADED) {
197
- if (Object.hasOwn(this, prop)) {
198
- const value = (this as unknown as Record<string, unknown>)[prop];
199
- delete (this as unknown as Record<string, unknown>)[prop];
200
- (this as unknown as Record<string, unknown>)[prop] = value;
201
- }
202
- }
203
- }
133
+ protected connected(): void {
204
134
  if (!this.#ui) this.#build();
205
- connected.push(this);
206
135
  const ui = this.#ui;
207
136
  if (!ui) return;
208
- this.controller.attach(ui.stage.canvasHost, ui.stage.minimapHost);
137
+ if (!this.#layoutReady) {
138
+ this.#loadRemembered();
139
+ this.#layoutReady = true;
140
+ }
141
+ // The stage adds its own minimap to the view (unless a slotted one replaces it).
142
+ this.controller.attach(ui.stage.canvasHost, null);
209
143
  // The graph takes keyboard focus (shortcuts apply where focus is), also on click.
210
- const canvas = ui.stage.canvasHost.querySelector('canvas');
144
+ const canvas = this.#canvas();
211
145
  if (canvas) {
212
146
  canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
213
147
  const focusCanvas = (): void => {
@@ -216,191 +150,105 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
216
150
  canvas.addEventListener('pointerdown', focusCanvas);
217
151
  this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
218
152
  }
219
- this.#watchMedia();
220
153
  this.#watchSize();
221
- const onKey = (e: KeyboardEvent): void => this.#onKeyDown(e);
222
- document.addEventListener('keydown', onKey);
223
- this.#cleanup.push(() => document.removeEventListener('keydown', onKey));
224
- const onStorage = (e: StorageEvent): void => this.controller.syncReview(e.key);
225
- window.addEventListener('storage', onStorage);
226
- this.#cleanup.push(() => window.removeEventListener('storage', onStorage));
227
- const onHash = (): void => this.#readHash();
228
- window.addEventListener('hashchange', onHash);
229
- this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
230
154
  this.controller.setChrome(() => ui.stage.chromeLayout());
231
155
  document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
232
- this.#readHash();
233
- this.#render(
234
- new Set<Topic>([
235
- 'status',
236
- 'data',
237
- 'mode',
238
- 'theme',
239
- 'locale',
240
- 'filters',
241
- 'review',
242
- 'selection',
243
- 'focus',
244
- 'swipe',
245
- 'replay',
246
- 'overview',
247
- ]),
248
- );
249
156
  }
250
157
 
251
- disconnectedCallback(): void {
252
- const i = connected.indexOf(this);
253
- if (i >= 0) connected.splice(i, 1);
158
+ protected disconnected(): void {
254
159
  for (const fn of this.#cleanup.splice(0)) fn();
255
160
  this.controller.detach();
256
- // Moved while hovered: no pointerleave follows.
257
- this.#pointerOver = false;
258
- this.#applyHold();
259
161
  }
260
162
 
261
- attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
262
- if (this.#reflecting) return;
163
+ protected override attributeChanged(
164
+ name: string,
165
+ value: string | null,
166
+ old: string | null,
167
+ ): void {
263
168
  switch (name) {
264
- case 'mode':
265
- case 'scope':
266
- case 'theme':
267
- case 'palette':
268
- case 'locale':
269
- this.#applyPreference(name, value, false);
270
- break;
271
169
  case 'sidebar':
272
- this.#applySidebar();
170
+ // The layout reflects the sidebar's state here (after `\`): the page changing the
171
+ // attribute from what it says now is a command, even back to what the page said
172
+ // before; setting what it already says is not.
173
+ this.#preset(name, value, value !== old);
273
174
  break;
274
- case 'sync-hash':
275
- if (value !== null) this.#readHash();
175
+ case 'header':
176
+ this.#preset(name, value);
276
177
  break;
277
178
  case 'chrome':
278
- case 'legend':
179
+ this.#preset(name, value);
279
180
  this.#applyChrome();
280
181
  break;
281
- case 'loop':
282
- case 'loop-delay':
283
- this.controller.setLoop(this.loop ? this.loopDelay : null);
284
- this.#applyHold();
285
- break;
286
- case 'pause-on-hover':
287
- this.#applyHold();
288
- break;
289
- default:
290
- break;
291
- }
292
- }
293
-
294
- /**
295
- * Apply a preference from an attribute or a property. Valid values are explicit (a report's
296
- * `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
297
- * `auto`, `default`, the browser's language). Properties reflect the normalized value; the
298
- * legacy mode `changes` reflects as `overlay` + scope `changes`.
299
- */
300
- #applyPreference(
301
- name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
302
- raw: unknown,
303
- reflect: boolean,
304
- ): void {
305
- const c = this.controller;
306
- // `mode="changes"` is rewritten to the attribute pair it stands for.
307
- const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
308
- let value: string | null;
309
- switch (name) {
310
- case 'mode': {
311
- if (legacy) {
312
- c.explicit.add('scope');
313
- c.setView('overlay', 'changes');
314
- value = 'overlay';
315
- break;
316
- }
317
- const v = toMode(raw);
318
- c.setMode(v ?? 'overlay');
319
- value = v;
320
- break;
321
- }
322
- case 'scope': {
323
- const v = toScope(raw);
324
- c.setScope(v ?? 'all');
325
- // A locked scope stays `changes`: the attribute says what is shown.
326
- value = v === null ? null : c.scope;
327
- break;
328
- }
329
- case 'theme': {
330
- const v = toTheme(raw);
331
- c.setThemePreference(v ?? 'auto');
332
- value = v;
182
+ case 'legend':
183
+ this.#applyChrome();
333
184
  break;
334
- }
335
- case 'palette': {
336
- const v = toPalette(raw);
337
- c.setPalette(v ?? 'default');
338
- value = v;
185
+ case 'panels': {
186
+ const { panels, invalid } = parsePanels(value ?? '');
187
+ warnPanels(invalid);
188
+ this.#setHostPanels(panels);
339
189
  break;
340
190
  }
341
- case 'locale': {
342
- const v = toLocale(raw);
343
- c.setLocale(v ?? detectLocale());
344
- value = v;
191
+ case 'persist':
192
+ case 'persist-key':
193
+ // Turned on, or another store, in the page: what the user chose before there applies
194
+ // now, as a host change.
195
+ if (this.#layoutReady && this.persist === 'local') this.#remembered = this.#recall();
196
+ this.#resolve('host');
345
197
  break;
346
- }
347
198
  default:
348
- return;
199
+ break;
349
200
  }
350
- if (value === null) c.explicit.delete(name);
351
- else c.explicit.add(name);
352
- if (reflect || legacy) this.#reflect(name, value);
353
- if (legacy) this.#reflect('scope', c.scope);
354
201
  }
355
202
 
356
- // ─── Properties ───────────────────────────────────────────────────────────
357
-
358
- get mode(): ViewerMode {
359
- return this.controller.mode;
203
+ protected override upgradedProperties(): readonly string[] {
204
+ return ['chrome', 'legend', 'panels', 'persist', 'persistKey'];
360
205
  }
361
206
 
362
- set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
363
- this.#applyPreference('mode', value, true);
207
+ protected sheets(doc: Document): readonly CSSStyleSheet[] {
208
+ return [stylesheet(doc)];
364
209
  }
365
210
 
366
- get scope(): ViewerScope {
367
- return this.controller.scope;
211
+ protected createSession(host: SessionHost, deps: SessionDeps): ViewerController {
212
+ return new ViewerController(host, deps);
368
213
  }
369
214
 
370
- set scope(value: ViewerScope) {
371
- this.#applyPreference('scope', value, true);
215
+ protected override keysEnabled(): boolean {
216
+ return this.#ui !== null && this.chrome !== 'none';
372
217
  }
373
218
 
374
- get theme(): ThemePreference {
375
- return this.controller.themePreference;
376
- }
377
-
378
- set theme(value: ThemePreference) {
379
- this.#applyPreference('theme', value, true);
380
- }
381
-
382
- get palette(): PaletteName {
383
- return this.controller.palette;
219
+ protected override interceptKey(e: KeyboardEvent): boolean {
220
+ const ui = this.#ui;
221
+ if (!ui || !(ui.help.open || ui.menu.open)) return false;
222
+ if (e.key === 'Escape') {
223
+ e.preventDefault();
224
+ ui.help.hide();
225
+ this.#toggleMenu(false);
226
+ }
227
+ return true;
384
228
  }
385
229
 
386
- set palette(value: PaletteName) {
387
- this.#applyPreference('palette', value, true);
230
+ protected override payloadSet(payload: ReportPayload): void {
231
+ // Validated (and warned about) once more when it loads.
232
+ this.#report = sanitizeView(payload.view, true)?.panels ?? {};
233
+ this.#resolve('host');
388
234
  }
389
235
 
390
- get locale(): Locale {
391
- return this.controller.locale;
236
+ override load(options: LoadOptions): Promise<void> {
237
+ this.#report = {};
238
+ this.#resolve('host');
239
+ return super.load(options);
392
240
  }
393
241
 
394
- set locale(value: Locale) {
395
- this.#applyPreference('locale', value, true);
396
- }
242
+ // ─── Properties ───────────────────────────────────────────────────────────
397
243
 
398
244
  get chrome(): ViewerChrome {
399
245
  return toChrome(this.getAttribute('chrome')) ?? 'full';
400
246
  }
401
247
 
402
248
  set chrome(value: ViewerChrome) {
403
- this.#reflect('chrome', toChrome(value) === 'none' ? 'none' : null);
249
+ const v = toChrome(value) === 'none' ? 'none' : null;
250
+ this.reflect('chrome', v);
251
+ this.#preset('chrome', v);
404
252
  this.#applyChrome();
405
253
  }
406
254
 
@@ -410,148 +258,199 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
410
258
 
411
259
  set legend(value: ViewerLegend) {
412
260
  const v = toLegend(value);
413
- this.#reflect('legend', v === 'auto' ? null : v);
261
+ this.reflect('legend', v === 'auto' ? null : v);
414
262
  this.#applyChrome();
415
263
  }
416
264
 
417
- get loop(): boolean {
418
- return this.hasAttribute('loop');
419
- }
420
-
421
- set loop(value: boolean) {
422
- this.toggleAttribute('loop', Boolean(value));
265
+ /** The host's declaration (the `panels` attribute as an object); `layout` has the states. */
266
+ get panels(): ViewerPanels {
267
+ return { ...this.#hostPanels };
423
268
  }
424
269
 
425
- get loopDelay(): number {
426
- return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
270
+ set panels(value: ViewerPanels | string | null) {
271
+ const { panels, invalid } = toPanels(value);
272
+ warnPanels(invalid);
273
+ this.reflect('panels', formatPanels(panels) || null);
274
+ this.#setHostPanels(panels);
427
275
  }
428
276
 
429
- set loopDelay(value: number) {
430
- const v = toDelay(value);
431
- if (v === null) this.removeAttribute('loop-delay');
432
- else this.setAttribute('loop-delay', String(v));
277
+ get persist(): 'local' | 'none' {
278
+ return this.getAttribute('persist') === 'none' ? 'none' : 'local';
433
279
  }
434
280
 
435
- get pauseOnHover(): boolean {
436
- return this.getAttribute('pause-on-hover') !== 'false';
281
+ set persist(value: 'local' | 'none') {
282
+ if (value === 'none') this.setAttribute('persist', 'none');
283
+ else this.removeAttribute('persist');
437
284
  }
438
285
 
439
- set pauseOnHover(value: boolean) {
440
- if (value) this.removeAttribute('pause-on-hover');
441
- else this.setAttribute('pause-on-hover', 'false');
286
+ /** Where the layout remembers (`persist-key`): a `localStorage` key, `kgdiff:viewer` by default. */
287
+ get persistKey(): string {
288
+ return this.getAttribute('persist-key') || LAYOUT_KEY;
442
289
  }
443
290
 
444
- get payload(): ReportPayload | null {
445
- return this.#currentPayload;
291
+ set persistKey(value: string) {
292
+ if (!value || value === LAYOUT_KEY) this.removeAttribute('persist-key');
293
+ else this.setAttribute('persist-key', String(value));
446
294
  }
447
295
 
448
- set payload(value: ReportPayload | null) {
449
- this.#currentPayload = value;
450
- if (value === null || value === undefined) return;
451
- let payload: ReportPayload;
452
- try {
453
- payload = assertPayload(value);
454
- } catch (error) {
455
- this.controller.fail(error as Error);
456
- return;
457
- }
458
- this.controller.loadPayload(payload).catch(() => {
459
- // Already surfaced: error state + `kgdiff-error`.
460
- });
296
+ /** Every region's state and the sizes now, as `kgdiff-layout` reports them. */
297
+ get layout(): ViewerLayout {
298
+ const sidebar = this.#ui?.sidebar;
299
+ return {
300
+ panels: { ...this.#states },
301
+ sidebarWidth: sidebar?.width ?? SIDEBAR_WIDTH,
302
+ detailShare: sidebar?.detailShare ?? null,
303
+ };
461
304
  }
462
305
 
463
- // ─── Methods ──────────────────────────────────────────────────────────────
464
-
465
- load(options: LoadOptions): Promise<void> {
466
- this.#currentPayload = null;
467
- return this.controller.load(options);
306
+ // ─── Panels ───────────────────────────────────────────────────────────────
307
+
308
+ /** Some region's state may have changed: apply the new states, and report them. */
309
+ #resolve(source: 'user' | 'host'): void {
310
+ const remembered = this.persist === 'local' ? this.#remembered.panels : {};
311
+ const next = resolvePanels([
312
+ this.#runtime,
313
+ this.#hostPanels,
314
+ this.#presets,
315
+ remembered,
316
+ this.#report,
317
+ ]);
318
+ if (samePanels(next, this.#states)) return;
319
+ this.#states = next;
320
+ this.#applyPanels();
321
+ if (this.#layoutReady) this.#emitLayout(source);
322
+ }
323
+
324
+ #emitLayout(source: 'user' | 'host'): void {
325
+ this.dispatchEvent(
326
+ new CustomEvent('kgdiff-layout', {
327
+ detail: { ...this.layout, source },
328
+ bubbles: true,
329
+ composed: true,
330
+ }),
331
+ );
468
332
  }
469
333
 
470
- selectHunk(id: string | null): void {
471
- this.controller.selectHunkById(id, { fly: true });
334
+ /**
335
+ * The host declared `panels`. Where its value changed, what the user changed there in this
336
+ * page gives way; declaring the same again (a framework re-rendering, an attribute set to
337
+ * the value it has) leaves the user's change alone.
338
+ */
339
+ #setHostPanels(panels: ViewerPanels): void {
340
+ this.#yield(this.#hostPanels, panels);
341
+ this.#hostPanels = panels;
342
+ this.#resolve('host');
472
343
  }
473
344
 
474
- next(): void {
475
- this.controller.next();
345
+ /**
346
+ * A preset attribute changed. `commanded` (`sidebar`, which the layout reflects): the
347
+ * sidebar's regions yield even when the preset the page set last is the same.
348
+ */
349
+ #preset(name: Preset, value: string | null, commanded = false): void {
350
+ this.#presetAttrs[name] = value;
351
+ const { sidebar, header, chrome } = this.#presetAttrs;
352
+ const next = presetPanels(sidebar, header, chrome);
353
+ const yielded = this.#yield(this.#presets, next);
354
+ if (commanded) {
355
+ for (const r of SIDEBAR_REGIONS) delete this.#runtime[r];
356
+ }
357
+ if (commanded || yielded.some((r) => SIDEBAR_REGIONS.includes(r))) this.#folded = null;
358
+ this.#presets = next;
359
+ this.#resolve('host');
476
360
  }
477
361
 
478
- prev(): void {
479
- this.controller.prev();
362
+ /** Drop the user's in-page change of every region `before` and `after` disagree on. */
363
+ #yield(before: ViewerPanels, after: ViewerPanels): PanelRegion[] {
364
+ const changed = PANEL_REGIONS.filter((r) => before[r] !== after[r]);
365
+ for (const r of changed) delete this.#runtime[r];
366
+ return changed;
480
367
  }
481
368
 
482
- play(): void {
483
- this.controller.play();
369
+ /** The user changed some regions: they hold in this page, and are remembered. */
370
+ #userPanels(changes: ViewerPanels): void {
371
+ Object.assign(this.#runtime, changes);
372
+ this.#remember({ panels: { ...this.#remembered.panels, ...changes } });
373
+ this.#resolve('user');
484
374
  }
485
375
 
486
- pause(): void {
487
- this.controller.pause();
376
+ /** What the user chose before; theme, palette and language apply unless the host set them. */
377
+ #recall(): Remembered {
378
+ const r = readRemembered(this.#storage, this.persistKey);
379
+ // A preference the host set wins; one the user chose before wins over a report's view.
380
+ const c = this.controller;
381
+ if (r.theme && !this.hasAttribute('theme') && !c.explicit.has('theme')) {
382
+ c.setThemePreference(r.theme);
383
+ c.explicit.add('theme');
384
+ }
385
+ if (r.palette && !this.hasAttribute('palette') && !c.explicit.has('palette')) {
386
+ c.setPalette(r.palette);
387
+ c.explicit.add('palette');
388
+ }
389
+ if (r.locale && !this.hasAttribute('locale') && !c.explicit.has('locale')) {
390
+ c.setLocale(r.locale);
391
+ c.explicit.add('locale');
392
+ }
393
+ this.#ui?.sidebar.setSizes(r.sidebarWidth ?? SIDEBAR_WIDTH, r.detailShare ?? null, true);
394
+ return r;
488
395
  }
489
396
 
490
- fit(options?: FitOptions): Promise<void> {
491
- return this.controller.fit(options);
397
+ /** The first connection: the remembered states are the first ones (not a change, no event). */
398
+ #loadRemembered(): void {
399
+ if (this.persist !== 'local') return;
400
+ this.#remembered = this.#recall();
401
+ this.#resolve('host');
492
402
  }
493
403
 
494
- exportDelta(): Delta {
495
- const diff = this.controller.diff;
496
- if (!diff) throw new KgdiffError('invalid-input', translate('en', 'error.notLoaded'));
497
- // A copy: callers can never mutate the diff the viewer shows.
498
- return deepFreeze(structuredClone(diff.delta));
404
+ /** Remember the user's choice (merged into what other viewers of this origin wrote). */
405
+ #remember(patch: Partial<Remembered>): void {
406
+ if (this.persist !== 'local') return;
407
+ const stored = readRemembered(this.#storage, this.persistKey);
408
+ const next: Remembered = {
409
+ ...stored,
410
+ ...patch,
411
+ panels: { ...stored.panels, ...(patch.panels ?? {}) },
412
+ };
413
+ this.#remembered = { ...this.#remembered, ...patch, panels: next.panels };
414
+ writeRemembered(this.#storage, next, this.persistKey);
499
415
  }
500
416
 
501
- reviewState(): ReviewState {
502
- return this.controller.reviewState();
417
+ /** The user resized the sidebar or moved the split (once the drag or key press ended). */
418
+ #resized(width: number, detailShare: number | null): void {
419
+ this.#remember({ sidebarWidth: width, detailShare });
420
+ this.#emitLayout('user');
503
421
  }
504
422
 
505
- setReviewed(hunkId: string, reviewed: boolean): void {
506
- this.controller.setReviewed(hunkId, reviewed);
423
+ #applyPanels(): void {
424
+ const ui = this.#ui;
425
+ if (!ui) return;
426
+ const s = this.#states;
427
+ ui.sidebar.setPanels(s);
428
+ ui.stage.setPanels(s);
429
+ const sidebar = SIDEBAR_REGIONS.some((r) => s[r] !== 'hidden');
430
+ // A `<kgdiff-title>` or `<kgdiff-actions>` slotted into the viewer replaces the HUD's.
431
+ const title = ui.stage.replaces('title') ? 'hidden' : s.title;
432
+ const actions = ui.stage.replaces('actions') ? 'hidden' : s.actions;
433
+ ui.header.setPanels(title, actions, sidebar, () => this.#canvas());
434
+ ui.header.setSidebarState(this.#sidebarOpen);
435
+ // The top corners sit under what the HUD shows there.
436
+ const hud = [title === 'open' ? 'title' : '', actions === 'open' ? 'actions' : ''];
437
+ ui.stage.el.dataset.hud = hud.filter(Boolean).join(' ');
507
438
  }
508
439
 
509
- /** Show an error state (used for undecodable embedded payloads). */
510
- showError(error: Error): void {
511
- this.controller.fail(error);
440
+ #canvas(): HTMLElement | null {
441
+ return this.#ui?.stage.canvasHost.querySelector('canvas') ?? null;
512
442
  }
513
443
 
514
444
  // ─── Internals ────────────────────────────────────────────────────────────
515
445
 
516
- #emit(type: string, detail: unknown): void {
517
- this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
518
- }
519
-
520
- #reflect(name: string, value: string | null): void {
521
- if (this.getAttribute(name) === value) return;
522
- this.#reflecting = true;
523
- try {
524
- if (value === null) this.removeAttribute(name);
525
- else this.setAttribute(name, value);
526
- } finally {
527
- this.#reflecting = false;
528
- }
529
- }
530
-
531
- #announce(text: string): void {
532
- const live = this.#ui?.live;
533
- if (!live) return;
534
- live.textContent = '';
535
- // A fresh text node in a later task makes screen readers announce repeats too.
536
- setTimeout(() => {
537
- live.textContent = text;
538
- }, 30);
539
- }
540
-
446
+ /** The sidebar is open while one of its regions is. */
541
447
  get #sidebarOpen(): boolean {
542
- return this.getAttribute('sidebar') !== 'closed';
543
- }
544
-
545
- #applySidebar(): void {
546
- const ui = this.#ui;
547
- if (!ui) return;
548
- ui.sidebar.setOpen(this.#sidebarOpen);
549
- ui.header.setSidebarState(this.#sidebarOpen);
448
+ return SIDEBAR_REGIONS.some((r) => this.#states[r] === 'open');
550
449
  }
551
450
 
552
451
  /**
553
452
  * `chrome` and `legend`: what the stage shows, whether clicks select, whether the view stays
554
- * fitted. The CSS hides the rest (`.root[data-chrome="none"]`).
453
+ * fitted. Which regions show is the `chrome="none"` preset's (see `panels.ts`).
555
454
  */
556
455
  #applyChrome(): void {
557
456
  const bare = this.chrome === 'none';
@@ -564,7 +463,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
564
463
  ui.root.dataset.chrome = this.chrome;
565
464
  const legend = this.legend;
566
465
  ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
567
- const canvas = ui.stage.canvasHost.querySelector('canvas');
466
+ const canvas = this.#canvas();
568
467
  if (canvas) canvas.tabIndex = bare ? -1 : 0;
569
468
  if (bare) {
570
469
  ui.help.hide();
@@ -572,57 +471,68 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
572
471
  }
573
472
  }
574
473
 
575
- /** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
576
- #applyHold(): void {
577
- this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
578
- }
579
-
580
- #build(): void {
581
- const style = h('style', { text: STYLES });
582
- const vars = h('style');
583
- const ctx: UiContext = {
584
- controller: this.controller,
585
- tr: () => translator(this.controller.locale),
586
- root: this.#shadow,
587
- setMode: (mode) => {
588
- this.controller.setMode(mode);
589
- },
590
- toggleScope: () => {
591
- this.controller.toggleScope();
592
- },
474
+ /** What the layout does for the regions: preferences reflect as attributes, panels open. */
475
+ protected actions(): UiActions {
476
+ return {
477
+ ...this.baseActions(),
593
478
  setThemePreference: (pref) => {
594
479
  this.theme = pref;
480
+ this.#remember({ theme: this.theme });
595
481
  },
596
482
  setPalette: (p) => {
597
483
  this.palette = p;
484
+ this.#remember({ palette: this.palette });
598
485
  },
599
486
  setLocale: (l) => {
600
487
  this.locale = l;
488
+ this.#remember({ locale: this.locale });
601
489
  },
602
490
  toggleSidebar: (open) => this.#toggleSidebar(open),
603
491
  sidebarOpen: () => this.#sidebarOpen,
492
+ hasSidebar: () => SIDEBAR_REGIONS.some((r) => this.#states[r] !== 'hidden'),
604
493
  toggleMenu: (open) => this.#toggleMenu(open),
605
494
  openHelp: () => this.#ui?.help.show(),
606
495
  focusSearch: () => this.#focusSearch(),
496
+ setPanel: (region, state) => this.#userPanels({ [region]: state }),
607
497
  };
608
- const header = new Header(ctx);
609
- const stage = new Stage(ctx);
610
- const sidebar = new Sidebar(ctx);
611
- const menu = new Menu(ctx);
612
- const help = new HelpDialog(ctx);
613
- const live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
498
+ }
499
+
500
+ #build(): void {
501
+ const root = h('div', { class: 'root' });
502
+ this.shadow.append(root);
503
+ // Each region binds to the root on its own (context protocol); the layout only places them.
504
+ const header = bindRegion(root, (ui) => new Header(ui));
505
+ const list = bindRegion(root, (ui) => new ChangeList(ui));
506
+ const search = bindRegion(root, (ui) => new Search(ui));
507
+ const filters = bindRegion(root, (ui) => new Filters(ui));
508
+ const detail = bindRegion(root, (ui) => new Detail(ui));
509
+ // The stage's slots are the viewer's: the page's content goes over the canvas.
510
+ const stage = bindRegion(root, (ui) => new Stage(ui, true));
511
+ stage.view = this.controller.view;
512
+ stage.onReplaced = () => this.#applyPanels();
513
+ this.#provideStage(stage);
514
+ const ui = uiContext(this.root, this.shadow);
515
+ const menu = new Menu(ui);
516
+ const help = new HelpDialog(ui);
517
+ const sidebar = new Sidebar(
518
+ ui,
519
+ {
520
+ setPanel: (region, state) => this.#userPanels({ [region]: state }),
521
+ resized: (width, share) => this.#resized(width, share),
522
+ focusOutside: () => (isOpen(header.sidebarButton) ? header.sidebarButton : this.#canvas()),
523
+ },
524
+ list,
525
+ search,
526
+ filters,
527
+ detail,
528
+ );
614
529
  // The title and the actions float over the canvas; the menu drops from the actions.
615
530
  header.attachMenu(menu.el);
616
531
  stage.el.appendChild(header.el);
617
- const root = h(
618
- 'div',
619
- { class: 'root' },
620
- h('div', { class: 'body' }, stage.el, sidebar.el),
621
- help.el,
622
- live,
623
- );
624
- this.#shadow.append(style, vars, root);
625
- this.#ui = { root, vars, header, stage, sidebar, menu, help, live };
532
+ root.append(h('div', { class: 'body' }, stage.el, sidebar.el), help.el, this.root.live);
533
+ // Content slotted before the layout was built.
534
+ stage.slotted();
535
+ this.#ui = { root, header, stage, list, sidebar, menu, help };
626
536
  // Close the menu on outside clicks.
627
537
  root.addEventListener('pointerdown', (e) => {
628
538
  if (!menu.open) return;
@@ -631,69 +541,75 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
631
541
  });
632
542
  const hover = (over: boolean) => (e: PointerEvent) => {
633
543
  if (e.pointerType === 'touch') return;
634
- this.#pointerOver = over;
635
- this.#applyHold();
544
+ this.pointerOver(over);
636
545
  };
637
546
  stage.el.addEventListener('pointerenter', hover(true));
638
547
  stage.el.addEventListener('pointerleave', hover(false));
639
- this.#applySidebar();
548
+ sidebar.setSizes(SIDEBAR_WIDTH, null, true);
549
+ this.#applyPanels();
640
550
  this.#applyChrome();
641
551
  }
642
552
 
643
- /** Render, never throwing: a failure shows the error state with safe appearance defaults. */
644
- #render(topics: ReadonlySet<Topic>): void {
645
- try {
646
- this.#renderTopics(topics);
647
- this.#renderFailed = false;
648
- } catch (error) {
649
- console.error('[kgdiff] render failed:', error);
650
- if (this.#renderFailed) return;
651
- this.#renderFailed = true;
652
- const c = this.controller;
653
- c.explicit.clear();
654
- c.setThemePreference('auto');
655
- c.setPalette('default');
656
- c.fail(error instanceof Error ? error : new Error(String(error)));
657
- }
553
+ /**
554
+ * The viewer is the stage of the chrome that acts on a view (`<kgdiff-zoom>`,
555
+ * `<kgdiff-minimap>`) inside it or naming it with `stage`: its built-in stage's view.
556
+ */
557
+ #provideStage(stage: Stage): void {
558
+ const view = this.controller.view;
559
+ const ctx: StageViewContext = {
560
+ stage: this,
561
+ root: this.root,
562
+ view,
563
+ registerObstacle: (el) => stage.registerObstacle(el),
564
+ watchView: (listener) => {
565
+ listener(view);
566
+ return () => undefined;
567
+ },
568
+ };
569
+ provideContext(this, stageContext, () => ctx);
658
570
  }
659
571
 
660
- #renderTopics(topics: ReadonlySet<Topic>): void {
572
+ /** The layout's part of a render (the root renders the regions after it). */
573
+ #update(topics: ReadonlySet<Topic>): void {
661
574
  const ui = this.#ui;
662
575
  const c = this.controller;
663
- if (topics.has('mode')) {
664
- this.#reflect('mode', c.mode);
665
- this.#reflect('scope', c.scope);
666
- }
667
- if (topics.has('theme')) {
668
- this.#reflect(
669
- 'theme',
670
- c.themePreference === 'auto' && !this.hasAttribute('theme') ? null : c.themePreference,
671
- );
672
- }
576
+ this.reflectSession(topics);
673
577
  if (!ui) return;
674
- if (topics.has('theme') || topics.has('status')) {
675
- ui.vars.textContent = `:host { ${themeVariables(c.theme)} }`;
676
- ui.root.dataset.theme = c.themeName;
677
- }
578
+ if (topics.has('theme') || topics.has('status')) ui.root.dataset.theme = c.themeName;
678
579
  if (topics.has('locale') || topics.has('status')) {
679
580
  ui.root.setAttribute('lang', c.locale);
680
581
  if (ui.menu.open) ui.menu.render();
681
582
  if (ui.help.open) ui.help.render();
682
583
  }
683
584
  if (topics.has('theme') && ui.menu.open) ui.menu.render();
684
- ui.header.update(topics);
685
585
  ui.sidebar.update(topics);
686
- ui.stage.update(topics);
687
586
  if (topics.has('mode') || topics.has('status')) {
688
587
  ui.root.dataset.mode = c.mode;
689
588
  ui.root.dataset.scope = c.scope;
690
589
  }
691
590
  }
692
591
 
592
+ /**
593
+ * Fold the sidebar (its open regions collapse) or open it again (the regions that were open,
594
+ * or all four). Reflected to the `sidebar` attribute.
595
+ */
693
596
  #toggleSidebar(open?: boolean): void {
597
+ const s = this.#states;
598
+ const shown = SIDEBAR_REGIONS.filter((r) => s[r] !== 'hidden');
599
+ if (shown.length === 0) return;
694
600
  const next = open ?? !this.#sidebarOpen;
695
- this.#reflect('sidebar', next ? 'open' : 'closed');
696
- this.#applySidebar();
601
+ this.reflect('sidebar', next ? 'open' : 'closed');
602
+ if (next === this.#sidebarOpen) return;
603
+ const changes: MutablePanels = {};
604
+ if (next) {
605
+ const restore = this.#folded?.filter((r) => shown.includes(r)) ?? [];
606
+ for (const r of restore.length > 0 ? restore : shown) changes[r] = 'open';
607
+ this.#folded = null;
608
+ } else {
609
+ this.#folded = shown.filter((r) => s[r] === 'open');
610
+ for (const r of shown) changes[r] = 'collapsed';
611
+ }
612
+ this.#userPanels(changes);
697
613
  }
698
614
 
699
615
  #toggleMenu(open?: boolean): void {
@@ -705,24 +621,13 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
705
621
  ui.header.setMenuState(next);
706
622
  }
707
623
 
624
+ /** `/`: an open search takes focus; else this layout's search opens, unless it is hidden. */
708
625
  #focusSearch(): void {
626
+ if (this.root.focus('search')) return;
627
+ if (this.#states.search === 'hidden') return;
709
628
  if (!this.#sidebarOpen) this.#toggleSidebar(true);
710
- this.#ui?.sidebar.focusSearch();
711
- }
712
-
713
- #watchMedia(): void {
714
- const dark = media('(prefers-color-scheme: light)');
715
- const motion = media('(prefers-reduced-motion: reduce)');
716
- const apply = (): void => {
717
- this.controller.setSystemDark(!(dark?.matches ?? false));
718
- this.controller.setReducedMotion(motion?.matches ?? false);
719
- };
720
- apply();
721
- for (const q of [dark, motion]) {
722
- if (!q) continue;
723
- q.addEventListener?.('change', apply);
724
- this.#cleanup.push(() => q.removeEventListener?.('change', apply));
725
- }
629
+ if (this.#states.search === 'collapsed') this.#userPanels({ search: 'open' });
630
+ this.root.focus('search');
726
631
  }
727
632
 
728
633
  #watchSize(): void {
@@ -738,8 +643,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
738
643
  ui.root.dataset.compact = String(compact);
739
644
  }
740
645
  ui.stage.layout();
741
- ui.sidebar.renderWindow();
742
- if (compact) ui.sidebar.syncSheet();
646
+ ui.list.renderWindow();
647
+ ui.sidebar.layout(compact);
743
648
  this.controller.layoutChanged();
744
649
  });
745
650
  ro.observe(this);
@@ -747,142 +652,4 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
747
652
  if (this.#ui) ro.observe(this.#ui.stage.el);
748
653
  this.#cleanup.push(() => ro.disconnect());
749
654
  }
750
-
751
- #onKeyDown(e: KeyboardEvent): void {
752
- if (e.defaultPrevented || e.isComposing) return;
753
- const ui = this.#ui;
754
- if (!ui || this.chrome === 'none') return;
755
- const path = e.composedPath();
756
- if (!path.includes(this)) {
757
- // Outside the component only with `global-shortcuts`, when nothing else has focus, for
758
- // the first such viewer, and never the keys pages scroll with.
759
- if (!this.hasAttribute('global-shortcuts') || PAGE_KEYS.has(e.key)) return;
760
- const active = document.activeElement;
761
- if (active && active !== document.body && active !== document.documentElement) return;
762
- if (connected.find((v) => v.hasAttribute('global-shortcuts')) !== this) return;
763
- }
764
- if (ui.help.open || ui.menu.open) {
765
- if (e.key === 'Escape') {
766
- e.preventDefault();
767
- ui.help.hide();
768
- this.#toggleMenu(false);
769
- }
770
- return;
771
- }
772
- const target = path[0] ?? null;
773
- if (isTypingTarget(target)) return;
774
- const c = this.controller;
775
- const action = keyAction(e, c.mode, c.scope);
776
- if (!action) return;
777
- // Enter / Space on a focused control activate that control.
778
- const el = target as Element | null;
779
- if (
780
- (e.key === 'Enter' || e.key === ' ') &&
781
- el?.closest?.('button, a, summary, [role="radio"], [role="menuitemradio"]')
782
- ) {
783
- return;
784
- }
785
- if (c.status !== 'ready' && !['help', 'theme', 'sidebar', 'escape'].includes(action.type))
786
- return;
787
- e.preventDefault();
788
- switch (action.type) {
789
- case 'mode':
790
- c.setMode(action.mode);
791
- break;
792
- case 'scope':
793
- c.toggleScope();
794
- break;
795
- case 'next':
796
- c.next();
797
- break;
798
- case 'prev':
799
- c.prev();
800
- break;
801
- case 'first':
802
- c.first();
803
- break;
804
- case 'last':
805
- c.last();
806
- break;
807
- case 'fly':
808
- c.flyToSelection();
809
- break;
810
- case 'escape':
811
- if (!ui.sidebar.escape()) c.clearSelection();
812
- break;
813
- case 'play-pause':
814
- c.togglePlay();
815
- break;
816
- case 'step':
817
- c.stepReplay(action.direction);
818
- break;
819
- case 'divider':
820
- c.setSplit(c.split + action.delta);
821
- break;
822
- case 'fit':
823
- void c.fit();
824
- break;
825
- case 'zoom':
826
- c.zoom(action.direction);
827
- break;
828
- case 'search':
829
- this.#focusSearch();
830
- break;
831
- case 'review':
832
- c.toggleReviewed();
833
- break;
834
- case 'sidebar':
835
- this.#toggleSidebar();
836
- break;
837
- case 'theme': {
838
- const i = THEME_PREFERENCES.indexOf(c.themePreference);
839
- this.theme = THEME_PREFERENCES[(i + 1) % THEME_PREFERENCES.length] as ThemePreference;
840
- break;
841
- }
842
- case 'help':
843
- ui.help.show();
844
- break;
845
- case 'expand':
846
- c.expandNearest();
847
- break;
848
- default:
849
- action satisfies never;
850
- }
851
- }
852
-
853
- // ─── Deep links ───────────────────────────────────────────────────────────
854
-
855
- #readHash(): void {
856
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
857
- if (location.hash === this.#lastHash) return;
858
- const state = parseHash(location.hash);
859
- this.#lastHash = location.hash;
860
- if (!state) return;
861
- if (state.mode || state.scope) {
862
- // A deep link outranks the large-graph default and a report's preferred view.
863
- const c = this.controller;
864
- if (state.mode) c.explicit.add('mode');
865
- if (state.scope) c.explicit.add('scope');
866
- c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
867
- }
868
- if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
869
- }
870
-
871
- #writeHash(): void {
872
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
873
- const c = this.controller;
874
- const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
875
- const hash = formatHash({
876
- ...(hunk !== undefined ? { hunk } : {}),
877
- mode: c.mode,
878
- scope: c.scope,
879
- });
880
- if (hash === location.hash) return;
881
- this.#lastHash = hash;
882
- try {
883
- history.replaceState(history.state, '', hash);
884
- } catch {
885
- location.hash = hash;
886
- }
887
- }
888
655
  }