@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/ui/dock.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The view dock, floating at the bottom of the canvas. It holds two independent choices: how
3
+ * the versions are compared (overlay / swipe / replay, one at a time, keys `1`–`3`) and whether
4
+ * only the changes are shown (key `4`, in any mode). The latter brings its context hops and the
5
+ * nearest “+N” expansion along. Replay's caption and transport stack above it (`stage.ts`).
6
+ */
7
+ import type { Topic } from '../controller.js';
8
+ import { attr, h, icon, relabel, setText } from '../dom.js';
9
+ import type { IconName } from '../icons.js';
10
+ import type { ViewerMode } from '../index.js';
11
+ import type { UiContext } from './context.js';
12
+
13
+ const MODES: readonly { mode: ViewerMode; icon: IconName; key: string }[] = [
14
+ { mode: 'overlay', icon: 'overlay', key: '1' },
15
+ { mode: 'swipe', icon: 'swipe', key: '2' },
16
+ { mode: 'replay', icon: 'replay', key: '3' },
17
+ ];
18
+
19
+ export class Dock {
20
+ readonly el: HTMLElement;
21
+ readonly #seg: HTMLElement;
22
+ readonly #thumb: HTMLElement;
23
+ readonly #segButtons = new Map<ViewerMode, HTMLButtonElement>();
24
+ readonly #scopeButton: HTMLButtonElement;
25
+ readonly #context: HTMLElement;
26
+ readonly #opts: HTMLElement;
27
+ readonly #hopButtons: HTMLButtonElement[] = [];
28
+ readonly #expandButton: HTMLButtonElement;
29
+
30
+ readonly #ctx: UiContext;
31
+
32
+ constructor(ctx: UiContext) {
33
+ this.#ctx = ctx;
34
+ const c = ctx.controller;
35
+ this.#thumb = h('span', { class: 'thumb', 'aria-hidden': 'true' });
36
+ this.#seg = h('div', { class: 'segmented', role: 'radiogroup' }, this.#thumb);
37
+ for (const m of MODES) {
38
+ const b = h(
39
+ 'button',
40
+ {
41
+ type: 'button',
42
+ class: 'seg',
43
+ role: 'radio',
44
+ 'data-mode': m.mode,
45
+ onclick: () => ctx.setMode(m.mode),
46
+ },
47
+ icon(m.icon),
48
+ h('span', { class: 'lbl' }),
49
+ );
50
+ this.#segButtons.set(m.mode, b);
51
+ this.#seg.appendChild(b);
52
+ }
53
+ this.#seg.addEventListener('keydown', (e) => this.#onSegKey(e));
54
+
55
+ this.#scopeButton = h(
56
+ 'button',
57
+ {
58
+ type: 'button',
59
+ class: 'scope-btn',
60
+ 'aria-pressed': 'false',
61
+ 'aria-keyshortcuts': '4',
62
+ onclick: () => ctx.toggleScope(),
63
+ },
64
+ icon('changes'),
65
+ h('span', { class: 'lbl' }),
66
+ );
67
+
68
+ // Context hops (0 / 1 / 2) and the nearest collapsed group: only with the changes scope.
69
+ this.#opts = h('span', { class: 'opts', role: 'radiogroup' });
70
+ for (let n = 0; n <= 2; n++) {
71
+ const b = h('button', {
72
+ type: 'button',
73
+ class: 'opt',
74
+ role: 'radio',
75
+ text: String(n),
76
+ onclick: () => c.setHops(n),
77
+ });
78
+ this.#hopButtons.push(b);
79
+ this.#opts.appendChild(b);
80
+ }
81
+ this.#opts.addEventListener('keydown', (e) => {
82
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
83
+ e.preventDefault();
84
+ e.stopPropagation();
85
+ const next = Math.max(0, Math.min(2, c.hops + (e.key === 'ArrowRight' ? 1 : -1)));
86
+ c.setHops(next);
87
+ this.#hopButtons[next]?.focus();
88
+ });
89
+ this.#expandButton = h(
90
+ 'button',
91
+ { type: 'button', class: 'expand', onclick: () => c.expandNearest() },
92
+ icon('plus'),
93
+ h('span', { class: 'n' }),
94
+ );
95
+ this.#context = h(
96
+ 'div',
97
+ { class: 'context', hidden: true },
98
+ h('span', { class: 'label' }),
99
+ this.#opts,
100
+ this.#expandButton,
101
+ );
102
+
103
+ this.el = h(
104
+ 'div',
105
+ { class: 'dock-bar card', role: 'group' },
106
+ this.#seg,
107
+ h('span', { class: 'dock-sep', 'aria-hidden': 'true' }),
108
+ this.#scopeButton,
109
+ this.#context,
110
+ );
111
+ }
112
+
113
+ update(topics: ReadonlySet<Topic>): void {
114
+ const all = topics.has('status') || topics.has('data') || topics.has('locale');
115
+ if (all) this.#renderLabels();
116
+ if (all || topics.has('mode')) this.#renderMode();
117
+ if (all || topics.has('mode') || topics.has('focus') || topics.has('selection')) {
118
+ this.#renderScope();
119
+ }
120
+ }
121
+
122
+ /** Re-measure the segmented thumb (fonts / size changes). */
123
+ layout(): void {
124
+ this.#renderMode();
125
+ }
126
+
127
+ #renderLabels(): void {
128
+ const tr = this.#ctx.tr();
129
+ attr(this.el, 'aria-label', tr.t('dock.label'));
130
+ attr(this.#seg, 'aria-label', tr.t('mode.label'));
131
+ for (const m of MODES) {
132
+ const b = this.#segButtons.get(m.mode) as HTMLButtonElement;
133
+ const label = tr.t(`mode.${m.mode}`);
134
+ setText(b.querySelector('.lbl') as Element, label);
135
+ attr(b, 'title', `${label} (${m.key})`);
136
+ attr(b, 'aria-label', label);
137
+ }
138
+ setText(this.#scopeButton.querySelector('.lbl') as Element, tr.t('mode.changes'));
139
+ attr(this.#scopeButton, 'aria-label', tr.t('mode.changes'));
140
+ setText(this.#context.querySelector('.label') as Element, tr.t('focus.context'));
141
+ attr(this.#opts, 'aria-label', tr.t('focus.hops'));
142
+ }
143
+
144
+ #renderMode(): void {
145
+ const mode = this.#ctx.controller.mode;
146
+ for (const [m, b] of this.#segButtons) {
147
+ const on = m === mode;
148
+ attr(b, 'aria-checked', String(on));
149
+ b.tabIndex = on ? 0 : -1;
150
+ }
151
+ const active = this.#segButtons.get(mode);
152
+ if (active && active.offsetWidth > 0) {
153
+ this.#thumb.style.width = `${active.offsetWidth}px`;
154
+ this.#thumb.style.transform = `translateX(${active.offsetLeft}px)`;
155
+ }
156
+ }
157
+
158
+ #renderScope(): void {
159
+ const c = this.#ctx.controller;
160
+ const tr = this.#ctx.tr();
161
+ const on = c.scope === 'changes';
162
+ const locked = c.scopeLocked;
163
+ attr(this.#scopeButton, 'aria-pressed', String(on));
164
+ attr(this.#scopeButton, 'aria-disabled', locked ? 'true' : null);
165
+ attr(this.#scopeButton, 'title', locked ? tr.t('scope.locked') : `${tr.t('scope.hint')} (4)`);
166
+ this.#context.hidden = !on;
167
+ // The dock's width changed: keep the thumb under the active mode.
168
+ this.#renderMode();
169
+ if (!on) return;
170
+ this.#hopButtons.forEach((b, n) => {
171
+ attr(b, 'aria-checked', String(n === c.hops));
172
+ b.tabIndex = n === c.hops ? 0 : -1;
173
+ attr(b, 'aria-label', `${tr.t('focus.hops')}: ${n}`);
174
+ });
175
+ const group = c.focus?.collapsed[c.nearestGroup()];
176
+ this.#expandButton.hidden = !group;
177
+ if (group) {
178
+ const usable = c.canExpand(c.nearestGroup()) || group.nodes.length > 0;
179
+ this.#expandButton.disabled = !usable;
180
+ setText(this.#expandButton.querySelector('.n') as Element, tr.num(group.count));
181
+ relabel(
182
+ this.#expandButton,
183
+ `${usable ? tr.t('focus.expandNearest') : tr.t('focus.expandUnavailable')}: ${group.label} (E)`,
184
+ );
185
+ }
186
+ }
187
+
188
+ #onSegKey(e: KeyboardEvent): void {
189
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
190
+ e.preventDefault();
191
+ e.stopPropagation();
192
+ const i = MODES.findIndex((m) => m.mode === this.#ctx.controller.mode);
193
+ const next = MODES[(i + (e.key === 'ArrowRight' ? 1 : MODES.length - 1)) % MODES.length];
194
+ if (!next) return;
195
+ this.#ctx.setMode(next.mode);
196
+ this.#segButtons.get(next.mode)?.focus();
197
+ }
198
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The family filters (RFC 0001 §3, `<kgdiff-filters>` from phase 2): one chip per family the
3
+ * diff has, with its count and the legend's swatch, toggling the session's family filter, and
4
+ * "show all" while a filter is on.
5
+ */
6
+ import { attr, h, setText } from '../dom.js';
7
+ import { familyCounts } from '../list-model.js';
8
+ import type { Topic } from '../session.js';
9
+ import { FAMILIES } from '../styles.js';
10
+ import type { UiContext } from './context.js';
11
+ import { swatch } from './swatch.js';
12
+
13
+ export class Filters {
14
+ readonly el: HTMLElement;
15
+ readonly #chips = new Map<string, HTMLButtonElement>();
16
+ readonly #showAll: HTMLButtonElement;
17
+
18
+ readonly #ctx: UiContext;
19
+
20
+ constructor(ctx: UiContext) {
21
+ this.#ctx = ctx;
22
+ const c = ctx.controller;
23
+ this.el = h('div', { class: 'filters', role: 'group' });
24
+ for (const f of FAMILIES) {
25
+ const chip = h(
26
+ 'button',
27
+ {
28
+ type: 'button',
29
+ class: 'fchip',
30
+ 'data-family': f,
31
+ 'aria-pressed': 'false',
32
+ onclick: () => c.toggleFamily(f),
33
+ },
34
+ swatch(f),
35
+ h('span', { class: 'lbl' }),
36
+ h('span', { class: 'n' }),
37
+ );
38
+ this.#chips.set(f, chip);
39
+ this.el.appendChild(chip);
40
+ }
41
+ this.#showAll = h('button', {
42
+ type: 'button',
43
+ class: 'link-btn',
44
+ hidden: true,
45
+ onclick: () => c.setFamilies([]),
46
+ });
47
+ this.el.appendChild(this.#showAll);
48
+ }
49
+
50
+ update(topics: ReadonlySet<Topic>): void {
51
+ const all = topics.has('status') || topics.has('data') || topics.has('locale');
52
+ if (all) this.#renderChips();
53
+ if (all || topics.has('filters')) this.#renderState();
54
+ }
55
+
56
+ #renderChips(): void {
57
+ const tr = this.#ctx.tr();
58
+ const scene = this.#ctx.controller.scene;
59
+ const counts = scene ? familyCounts(scene) : null;
60
+ attr(this.el, 'aria-label', tr.t('sidebar.filters'));
61
+ setText(this.#showAll, tr.t('sidebar.clearFilters'));
62
+ this.el.hidden = !scene || scene.hunks.length === 0;
63
+ for (const [f, chip] of this.#chips) {
64
+ const n = counts ? counts[f as keyof typeof counts] : 0;
65
+ setText(chip.querySelector('.lbl') as Element, tr.t(`family.${f as keyof typeof counts}`));
66
+ setText(chip.querySelector('.n') as Element, tr.num(n));
67
+ chip.hidden = n === 0;
68
+ attr(chip, 'title', tr.t(`familyHint.${f as keyof typeof counts}`));
69
+ }
70
+ }
71
+
72
+ #renderState(): void {
73
+ const fams = this.#ctx.controller.families;
74
+ for (const [f, chip] of this.#chips) {
75
+ attr(chip, 'aria-pressed', String(fams.has(f as never)));
76
+ }
77
+ this.#showAll.hidden = fams.size === 0;
78
+ }
79
+ }
package/src/ui/header.ts CHANGED
@@ -1,66 +1,37 @@
1
1
  /**
2
- * Header: brand, compared versions, mode segmented control, diffstat pills (filter shortcuts),
3
- * appearance menu, fit, help and sidebar toggle.
2
+ * The canvas HUD: the title (brand and compared versions) floating top-left, the actions
3
+ * (appearance menu, help, sidebar toggle) floating top-right. Both stay up while loading and on
4
+ * errors. How the diff is viewed is chosen in the dock at the bottom (`dock.ts`).
4
5
  */
5
- import type { ChangeFamily } from '@kgdiff/scene';
6
6
  import type { Topic } from '../controller.js';
7
- import { attr, h, icon, iconButton, relabel, replace, setText } from '../dom.js';
8
- import type { IconName } from '../icons.js';
9
- import type { ViewerMode } from '../index.js';
10
- import { familyCounts } from '../list-model.js';
7
+ import { attr, h, iconButton, relabel, replace } from '../dom.js';
11
8
  import type { UiContext } from './context.js';
12
9
 
13
- const MODES: readonly { mode: ViewerMode; icon: IconName; key: string }[] = [
14
- { mode: 'overlay', icon: 'overlay', key: '1' },
15
- { mode: 'swipe', icon: 'swipe', key: '2' },
16
- { mode: 'replay', icon: 'replay', key: '3' },
17
- { mode: 'changes', icon: 'changes', key: '4' },
18
- ];
19
-
20
- interface StatDef {
21
- readonly id: 'added' | 'removed' | 'modified' | 'restructured';
22
- readonly family: ChangeFamily;
23
- readonly families: readonly ChangeFamily[];
24
- readonly glyph: string;
25
- }
26
-
27
- const STATS: readonly StatDef[] = [
28
- { id: 'added', family: 'added', families: ['added'], glyph: '+' },
29
- { id: 'removed', family: 'removed', families: ['removed'], glyph: '−' },
30
- { id: 'modified', family: 'content', families: ['content'], glyph: '~' },
31
- { id: 'restructured', family: 'structure', families: ['structure', 'identity'], glyph: '⇄' },
32
- ];
33
-
34
10
  /** The kgdiff mark: a soft accent diamond holding a node. */
35
11
  function brandMark(): SVGSVGElement {
36
12
  const tpl = document.createElement('template');
37
13
  tpl.innerHTML =
38
14
  '<svg class="brand-mark" viewBox="0 0 20 20" aria-hidden="true" focusable="false">' +
39
15
  '<rect x="4.1" y="4.1" width="11.8" height="11.8" rx="3.2" transform="rotate(45 10 10)" ' +
40
- 'fill="var(--kg-accent-soft)" stroke="var(--kg-accent)" stroke-width="1.5"/>' +
41
- '<circle cx="10" cy="10" r="2.4" fill="var(--kg-accent)"/></svg>';
16
+ 'fill="var(--kgdiff-accent-soft)" stroke="var(--kgdiff-accent)" stroke-width="1.5"/>' +
17
+ '<circle cx="10" cy="10" r="2.4" fill="var(--kgdiff-accent)"/></svg>';
42
18
  return tpl.content.firstElementChild as SVGSVGElement;
43
19
  }
44
20
 
45
21
  export class Header {
46
22
  readonly el: HTMLElement;
47
- readonly #versions: HTMLElement;
48
- readonly #seg: HTMLElement;
49
- readonly #thumb: HTMLElement;
50
- readonly #segButtons = new Map<ViewerMode, HTMLButtonElement>();
51
- readonly #stats: HTMLElement;
52
- readonly #statButtons = new Map<StatDef['id'], HTMLButtonElement>();
53
23
  readonly menuButton: HTMLButtonElement;
54
- readonly #fitButton: HTMLButtonElement;
24
+ readonly #brand: HTMLElement;
25
+ readonly #versions: HTMLElement;
26
+ readonly #actions: HTMLElement;
27
+ readonly #end: HTMLElement;
55
28
  readonly #helpButton: HTMLButtonElement;
56
29
  readonly #sidebarButton: HTMLButtonElement;
57
- readonly #brand: HTMLElement;
58
30
 
59
31
  readonly #ctx: UiContext;
60
32
 
61
33
  constructor(ctx: UiContext) {
62
34
  this.#ctx = ctx;
63
- const c = ctx.controller;
64
35
  this.#brand = h(
65
36
  'div',
66
37
  { class: 'brand' },
@@ -68,95 +39,50 @@ export class Header {
68
39
  h('span', { class: 'brand-name', text: 'kgdiff' }),
69
40
  );
70
41
  this.#versions = h('div', { class: 'versions' });
71
- this.#thumb = h('span', { class: 'thumb', 'aria-hidden': 'true' });
72
- this.#seg = h('div', { class: 'segmented', role: 'radiogroup' }, this.#thumb);
73
- for (const m of MODES) {
74
- const b = h(
75
- 'button',
76
- {
77
- type: 'button',
78
- class: 'seg',
79
- role: 'radio',
80
- 'data-mode': m.mode,
81
- onclick: () => ctx.setMode(m.mode),
82
- },
83
- icon(m.icon),
84
- h('span', { class: 'lbl' }),
85
- );
86
- this.#segButtons.set(m.mode, b);
87
- this.#seg.appendChild(b);
88
- }
89
- this.#seg.addEventListener('keydown', (e) => this.#onSegKey(e));
90
- this.#stats = h('div', { class: 'stats', role: 'group' });
91
- for (const s of STATS) {
92
- const b = h(
93
- 'button',
94
- {
95
- type: 'button',
96
- class: 'pill stat',
97
- 'data-family': s.family,
98
- 'aria-pressed': 'false',
99
- onclick: () => c.setFamilies(s.families),
100
- },
101
- h('span', { class: 'glyph', 'aria-hidden': 'true', text: s.glyph }),
102
- h('span', { class: 'n' }),
103
- );
104
- this.#statButtons.set(s.id, b);
105
- this.#stats.appendChild(b);
106
- }
107
42
  this.menuButton = iconButton('contrast', '', () => ctx.toggleMenu(), {
108
43
  'aria-haspopup': 'menu',
109
44
  'aria-expanded': 'false',
110
45
  });
111
- this.#fitButton = iconButton('fit', '', () => void c.fit());
112
46
  this.#helpButton = iconButton('help', '', () => ctx.openHelp());
113
47
  this.#sidebarButton = iconButton('sidebar', '', () => ctx.toggleSidebar());
48
+ this.#actions = h(
49
+ 'div',
50
+ { class: 'hud-actions card', role: 'group' },
51
+ this.menuButton,
52
+ this.#helpButton,
53
+ this.#sidebarButton,
54
+ );
55
+ // The menu is a sibling of the actions card, not inside it: a backdrop filter would clip
56
+ // the menu's own blur to the card.
57
+ this.#end = h('div', { class: 'hud-end' }, this.#actions);
114
58
  this.el = h(
115
59
  'header',
116
- { class: 'header' },
117
- this.#brand,
118
- h('span', { class: 'header-sep', 'aria-hidden': 'true' }),
119
- this.#versions,
120
- h('div', { class: 'header-center' }, this.#seg),
60
+ { class: 'hud' },
121
61
  h(
122
62
  'div',
123
- { class: 'header-end' },
124
- this.#stats,
125
- this.menuButton,
126
- this.#fitButton,
127
- this.#helpButton,
128
- this.#sidebarButton,
63
+ { class: 'hud-title card' },
64
+ this.#brand,
65
+ h('span', { class: 'header-sep', 'aria-hidden': 'true' }),
66
+ this.#versions,
129
67
  ),
68
+ this.#end,
130
69
  );
131
70
  }
132
71
 
133
- update(topics: ReadonlySet<Topic>): void {
134
- const tr = this.#ctx.tr();
135
- if (topics.has('locale') || topics.has('data') || topics.has('status')) {
136
- attr(this.#brand, 'title', `kgdiff — ${tr.t('brand.tagline')}`);
137
- attr(this.#seg, 'aria-label', tr.t('mode.label'));
138
- attr(this.#stats, 'aria-label', tr.t('stat.label'));
139
- for (const m of MODES) {
140
- const b = this.#segButtons.get(m.mode) as HTMLButtonElement;
141
- const label = tr.t(`mode.${m.mode}`);
142
- setText(b.querySelector('.lbl') as Element, label);
143
- attr(b, 'title', `${label} (${m.key})`);
144
- attr(b, 'aria-label', label);
145
- }
146
- relabel(this.menuButton, tr.t('action.settings'));
147
- relabel(this.#fitButton, `${tr.t('action.fit')} (F)`);
148
- relabel(this.#helpButton, `${tr.t('action.help')} (?)`);
149
- relabel(this.#sidebarButton, `${tr.t('action.sidebar')} (\\)`);
150
- this.#renderVersions();
151
- this.#renderStats();
152
- }
153
- if (topics.has('mode') || topics.has('status') || topics.has('locale')) this.#renderMode();
154
- if (topics.has('filters') || topics.has('data')) this.#renderFilterState();
72
+ /** Place the appearance menu under the actions. */
73
+ attachMenu(menu: HTMLElement): void {
74
+ this.#end.appendChild(menu);
155
75
  }
156
76
 
157
- /** Re-measure the segmented thumb (fonts / size changes). */
158
- layout(): void {
159
- this.#renderMode();
77
+ update(topics: ReadonlySet<Topic>): void {
78
+ if (!(topics.has('locale') || topics.has('data') || topics.has('status'))) return;
79
+ const tr = this.#ctx.tr();
80
+ attr(this.#brand, 'title', `kgdiff — ${tr.t('brand.tagline')}`);
81
+ attr(this.#actions, 'aria-label', tr.t('header.actions'));
82
+ relabel(this.menuButton, tr.t('action.settings'));
83
+ relabel(this.#helpButton, `${tr.t('action.help')} (?)`);
84
+ relabel(this.#sidebarButton, `${tr.t('action.sidebar')} (\\)`);
85
+ this.#renderVersions();
160
86
  }
161
87
 
162
88
  setSidebarState(open: boolean): void {
@@ -171,8 +97,10 @@ export class Header {
171
97
  const data = this.#ctx.controller.data;
172
98
  if (!data) {
173
99
  replace(this.#versions);
100
+ this.el.dataset.titled = 'false';
174
101
  return;
175
102
  }
103
+ this.el.dataset.titled = 'true';
176
104
  const tr = this.#ctx.tr();
177
105
  const { names, versions } = data;
178
106
  const tag = (v: string | undefined) =>
@@ -217,62 +145,4 @@ export class Header {
217
145
  ),
218
146
  );
219
147
  }
220
-
221
- #renderStats(): void {
222
- const c = this.#ctx.controller;
223
- const scene = c.scene;
224
- const tr = this.#ctx.tr();
225
- // Hunks per family: the same numbers as the sidebar's filter chips.
226
- const f = scene ? familyCounts(scene) : null;
227
- const counts = {
228
- added: f?.added ?? 0,
229
- removed: f?.removed ?? 0,
230
- modified: f?.content ?? 0,
231
- restructured: (f?.structure ?? 0) + (f?.identity ?? 0),
232
- };
233
- this.#stats.hidden = !scene || scene.hunks.length === 0;
234
- for (const s of STATS) {
235
- const b = this.#statButtons.get(s.id) as HTMLButtonElement;
236
- const n = counts[s.id];
237
- b.hidden = n === 0;
238
- setText(b.querySelector('.n') as Element, tr.num(n));
239
- const label = `${tr.t(`stat.${s.id}`, { n })} — ${tr.t('stat.filter')}`;
240
- attr(b, 'aria-label', label);
241
- attr(b, 'title', label);
242
- }
243
- }
244
-
245
- #renderFilterState(): void {
246
- const fams = this.#ctx.controller.families;
247
- this.#stats.classList.toggle('filtering', fams.size > 0);
248
- for (const s of STATS) {
249
- const on = fams.size === s.families.length && s.families.every((f) => fams.has(f));
250
- attr(this.#statButtons.get(s.id) as Element, 'aria-pressed', String(on));
251
- }
252
- }
253
-
254
- #renderMode(): void {
255
- const mode = this.#ctx.controller.mode;
256
- for (const [m, b] of this.#segButtons) {
257
- const on = m === mode;
258
- attr(b, 'aria-checked', String(on));
259
- b.tabIndex = on ? 0 : -1;
260
- }
261
- const active = this.#segButtons.get(mode);
262
- if (active && active.offsetWidth > 0) {
263
- this.#thumb.style.width = `${active.offsetWidth}px`;
264
- this.#thumb.style.transform = `translateX(${active.offsetLeft}px)`;
265
- }
266
- }
267
-
268
- #onSegKey(e: KeyboardEvent): void {
269
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
270
- e.preventDefault();
271
- e.stopPropagation();
272
- const i = MODES.findIndex((m) => m.mode === this.#ctx.controller.mode);
273
- const next = MODES[(i + (e.key === 'ArrowRight' ? 1 : MODES.length - 1)) % MODES.length];
274
- if (!next) return;
275
- this.#ctx.setMode(next.mode);
276
- this.#segButtons.get(next.mode)?.focus();
277
- }
278
148
  }
@@ -146,9 +146,10 @@ type Label = MessageKey | readonly MessageKey[];
146
146
  /** Shortcut rows; labels reuse the controls' own names where they exist. */
147
147
  const SHORTCUTS: readonly (readonly [readonly (readonly string[])[], Label])[] = [
148
148
  [
149
- [['1'], ['2'], ['3'], ['4']],
150
- ['mode.overlay', 'mode.swipe', 'mode.replay', 'mode.changes'],
149
+ [['1'], ['2'], ['3']],
150
+ ['mode.overlay', 'mode.swipe', 'mode.replay'],
151
151
  ],
152
+ [[['4']], 'mode.changes'],
152
153
  [[['j'], ['↓']], 'detail.next'],
153
154
  [[['k'], ['↑']], 'detail.prev'],
154
155
  [[['Home'], ['End']], 'help.homeEnd'],