@kgdiff/viewer 0.0.0-stage → 0.1.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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -2
  3. package/dist/compute-loader.d.ts +13 -0
  4. package/dist/compute-loader.d.ts.map +1 -0
  5. package/dist/compute-loader.js +46 -0
  6. package/dist/compute-loader.js.map +1 -0
  7. package/dist/compute.d.ts +8 -0
  8. package/dist/compute.d.ts.map +1 -0
  9. package/dist/compute.js +8 -0
  10. package/dist/compute.js.map +1 -0
  11. package/dist/controller.d.ts +199 -0
  12. package/dist/controller.d.ts.map +1 -0
  13. package/dist/controller.js +1254 -0
  14. package/dist/controller.js.map +1 -0
  15. package/dist/dom.d.ts +32 -0
  16. package/dist/dom.d.ts.map +1 -0
  17. package/dist/dom.js +105 -0
  18. package/dist/dom.js.map +1 -0
  19. package/dist/element.d.ts +43 -0
  20. package/dist/element.d.ts.map +1 -0
  21. package/dist/element.js +656 -0
  22. package/dist/element.js.map +1 -0
  23. package/dist/framing.d.ts +45 -0
  24. package/dist/framing.d.ts.map +1 -0
  25. package/dist/framing.js +107 -0
  26. package/dist/framing.js.map +1 -0
  27. package/dist/hash-sync.d.ts +18 -0
  28. package/dist/hash-sync.d.ts.map +1 -0
  29. package/dist/hash-sync.js +45 -0
  30. package/dist/hash-sync.js.map +1 -0
  31. package/dist/i18n.d.ts +223 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +436 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/icons.d.ts +37 -0
  36. package/dist/icons.d.ts.map +1 -0
  37. package/dist/icons.js +36 -0
  38. package/dist/icons.js.map +1 -0
  39. package/dist/index.d.ts +151 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +41 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/keyboard.d.ts +64 -0
  44. package/dist/keyboard.d.ts.map +1 -0
  45. package/dist/keyboard.js +95 -0
  46. package/dist/keyboard.js.map +1 -0
  47. package/dist/list-model.d.ts +35 -0
  48. package/dist/list-model.d.ts.map +1 -0
  49. package/dist/list-model.js +70 -0
  50. package/dist/list-model.js.map +1 -0
  51. package/dist/payload.d.ts +10 -0
  52. package/dist/payload.d.ts.map +1 -0
  53. package/dist/payload.js +138 -0
  54. package/dist/payload.js.map +1 -0
  55. package/dist/pipeline.d.ts +31 -0
  56. package/dist/pipeline.d.ts.map +1 -0
  57. package/dist/pipeline.js +122 -0
  58. package/dist/pipeline.js.map +1 -0
  59. package/dist/prefs.d.ts +18 -0
  60. package/dist/prefs.d.ts.map +1 -0
  61. package/dist/prefs.js +38 -0
  62. package/dist/prefs.js.map +1 -0
  63. package/dist/registry.d.ts +3 -0
  64. package/dist/registry.d.ts.map +1 -0
  65. package/dist/registry.js +3 -0
  66. package/dist/registry.js.map +1 -0
  67. package/dist/renderer-port.d.ts +14 -0
  68. package/dist/renderer-port.d.ts.map +1 -0
  69. package/dist/renderer-port.js +16 -0
  70. package/dist/renderer-port.js.map +1 -0
  71. package/dist/replay.d.ts +55 -0
  72. package/dist/replay.d.ts.map +1 -0
  73. package/dist/replay.js +147 -0
  74. package/dist/replay.js.map +1 -0
  75. package/dist/review-store.d.ts +30 -0
  76. package/dist/review-store.d.ts.map +1 -0
  77. package/dist/review-store.js +120 -0
  78. package/dist/review-store.js.map +1 -0
  79. package/dist/search.d.ts +25 -0
  80. package/dist/search.d.ts.map +1 -0
  81. package/dist/search.js +90 -0
  82. package/dist/search.js.map +1 -0
  83. package/dist/standalone/kgdiff-viewer-compute.js +1 -0
  84. package/dist/standalone/kgdiff-viewer.js +5 -0
  85. package/dist/standalone-compute.d.ts +2 -0
  86. package/dist/standalone-compute.d.ts.map +1 -0
  87. package/dist/standalone-compute.js +8 -0
  88. package/dist/standalone-compute.js.map +1 -0
  89. package/dist/standalone.d.ts +2 -0
  90. package/dist/standalone.d.ts.map +1 -0
  91. package/dist/standalone.js +18 -0
  92. package/dist/standalone.js.map +1 -0
  93. package/dist/styles.d.ts +11 -0
  94. package/dist/styles.d.ts.map +1 -0
  95. package/dist/styles.js +627 -0
  96. package/dist/styles.js.map +1 -0
  97. package/dist/text-diff.d.ts +32 -0
  98. package/dist/text-diff.d.ts.map +1 -0
  99. package/dist/text-diff.js +211 -0
  100. package/dist/text-diff.js.map +1 -0
  101. package/dist/ui/context.d.ts +32 -0
  102. package/dist/ui/context.d.ts.map +1 -0
  103. package/dist/ui/context.js +49 -0
  104. package/dist/ui/context.js.map +1 -0
  105. package/dist/ui/detail.d.ts +9 -0
  106. package/dist/ui/detail.d.ts.map +1 -0
  107. package/dist/ui/detail.js +566 -0
  108. package/dist/ui/detail.js.map +1 -0
  109. package/dist/ui/header.d.ts +14 -0
  110. package/dist/ui/header.d.ts.map +1 -0
  111. package/dist/ui/header.js +195 -0
  112. package/dist/ui/header.js.map +1 -0
  113. package/dist/ui/popovers.d.ts +20 -0
  114. package/dist/ui/popovers.d.ts.map +1 -0
  115. package/dist/ui/popovers.js +180 -0
  116. package/dist/ui/popovers.js.map +1 -0
  117. package/dist/ui/sidebar.d.ts +25 -0
  118. package/dist/ui/sidebar.d.ts.map +1 -0
  119. package/dist/ui/sidebar.js +609 -0
  120. package/dist/ui/sidebar.js.map +1 -0
  121. package/dist/ui/stage.d.ts +18 -0
  122. package/dist/ui/stage.d.ts.map +1 -0
  123. package/dist/ui/stage.js +664 -0
  124. package/dist/ui/stage.js.map +1 -0
  125. package/package.json +58 -3
  126. package/src/compute-loader.ts +58 -0
  127. package/src/compute.ts +7 -0
  128. package/src/controller.ts +1393 -0
  129. package/src/dom.ts +131 -0
  130. package/src/element.ts +697 -0
  131. package/src/framing.ts +150 -0
  132. package/src/hash-sync.ts +54 -0
  133. package/src/i18n.ts +469 -0
  134. package/src/icons.ts +48 -0
  135. package/src/index.ts +171 -0
  136. package/src/keyboard.ts +128 -0
  137. package/src/list-model.ts +86 -0
  138. package/src/payload.ts +165 -0
  139. package/src/pipeline.ts +179 -0
  140. package/src/prefs.ts +55 -0
  141. package/src/registry.ts +2 -0
  142. package/src/renderer-port.ts +31 -0
  143. package/src/replay.ts +163 -0
  144. package/src/review-store.ts +125 -0
  145. package/src/search.ts +113 -0
  146. package/src/standalone-compute.ts +8 -0
  147. package/src/standalone.ts +18 -0
  148. package/src/styles.ts +638 -0
  149. package/src/text-diff.ts +208 -0
  150. package/src/ui/context.ts +70 -0
  151. package/src/ui/detail.ts +921 -0
  152. package/src/ui/header.ts +278 -0
  153. package/src/ui/popovers.ts +251 -0
  154. package/src/ui/sidebar.ts +752 -0
  155. package/src/ui/stage.ts +853 -0
@@ -0,0 +1,921 @@
1
+ /**
2
+ * Detail panel (docs/interaction.md §Detail panel): for the selected hunk (or node) — title,
3
+ * family pill, prev / next, reviewed toggle; identity, name (character diff), attributes
4
+ * (word diff), synonyms / types chips, hierarchy, relations as sentences, merge / split
5
+ * composition and match evidence. Unchanged nodes show their attributes and relations.
6
+ */
7
+ import type {
8
+ AttrChange,
9
+ AttrValue,
10
+ DiffResult,
11
+ EdgeFacet,
12
+ MatchEvidence,
13
+ NodeFacet,
14
+ UnionEdge,
15
+ UnionId,
16
+ UnionNode,
17
+ } from '@kgdiff/core';
18
+ import type { Family, Scene, SceneHunk } from '@kgdiff/scene';
19
+ import type { Topic } from '../controller.js';
20
+ import { attr, h, icon, iconButton, replaceKeepingFocus } from '../dom.js';
21
+ import { formatPercent, type Translator } from '../i18n.js';
22
+ import { type DiffSegment, diffChars, diffWords } from '../text-diff.js';
23
+ import { formatValue, listItems, sceneHunkGlyph, type UiContext } from './context.js';
24
+
25
+ const MAX_NODES = 12;
26
+ const MAX_RELATIONS = 12;
27
+ const CLAMP_CHARS = 180;
28
+
29
+ type Child = Node | string | null | false | undefined;
30
+
31
+ function section(title: string, count: number | null, ...children: Child[]): HTMLElement {
32
+ return h(
33
+ 'section',
34
+ { class: 'section' },
35
+ h('h3', null, title, count !== null ? h('span', { class: 'n', text: String(count) }) : null),
36
+ ...children,
37
+ );
38
+ }
39
+
40
+ function kv(rows: readonly (readonly [string, Child | Child[]])[]): HTMLElement {
41
+ const dl = h('dl', { class: 'kv' });
42
+ for (const [k, v] of rows) dl.append(h('dt', { text: k }), h('dd', null, ...[v].flat()));
43
+ return dl;
44
+ }
45
+
46
+ /** Render one side of a diff: equal text plus that side's changes marked. */
47
+ function marked(segs: readonly DiffSegment[], side: 'before' | 'after'): (string | HTMLElement)[] {
48
+ const out: (string | HTMLElement)[] = [];
49
+ for (const s of segs) {
50
+ if (s.op === 'equal') out.push(s.text);
51
+ else if (s.op === 'delete' && side === 'before')
52
+ out.push(h('del', { class: 'hl', text: s.text }));
53
+ else if (s.op === 'insert' && side === 'after')
54
+ out.push(h('ins', { class: 'hl', text: s.text }));
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function chip(text: string, kind: 'add' | 'del' | 'same'): HTMLElement {
60
+ const sign = kind === 'add' ? '+' : kind === 'del' ? '−' : '';
61
+ return h(
62
+ 'span',
63
+ { class: `vchip${kind === 'same' ? '' : ` ${kind}`}` },
64
+ sign ? h('span', { class: 'sign', 'aria-hidden': 'true', text: sign }) : null,
65
+ text,
66
+ );
67
+ }
68
+
69
+ function chips(
70
+ added: readonly string[],
71
+ removed: readonly string[],
72
+ same: readonly string[] = [],
73
+ ): HTMLElement {
74
+ return h(
75
+ 'div',
76
+ { class: 'chips' },
77
+ removed.map((x) => chip(x, 'del')),
78
+ added.map((x) => chip(x, 'add')),
79
+ same.map((x) => chip(x, 'same')),
80
+ );
81
+ }
82
+
83
+ function familyOfEdge(e: UnionEdge): Family {
84
+ if (e.status === 'added') return 'added';
85
+ if (e.status === 'removed') {
86
+ return e.facets.some((f) => f.kind === 'collapsed') ? 'identity' : 'removed';
87
+ }
88
+ for (const f of e.facets) {
89
+ if (f.kind === 'retarget')
90
+ return f.cause === 'merge' || f.cause === 'split' ? 'identity' : 'structure';
91
+ if (f.kind === 'predicate') return 'structure';
92
+ }
93
+ return e.status === 'modified' ? 'content' : 'neutral';
94
+ }
95
+
96
+ export class Detail {
97
+ readonly el: HTMLElement;
98
+ readonly #body: HTMLElement;
99
+ #showUnchangedAttrs = false;
100
+ #showAllRelations = false;
101
+ #showAllNodes = false;
102
+ readonly #expandedValues = new Set<string>();
103
+ #lastKey = '';
104
+
105
+ readonly #ctx: UiContext;
106
+
107
+ constructor(ctx: UiContext) {
108
+ this.#ctx = ctx;
109
+ this.#body = h('div', { class: 'detail-scroll scroll', tabindex: '-1' });
110
+ this.el = h('section', { class: 'detail', 'data-empty': 'true' }, this.#body);
111
+ }
112
+
113
+ update(topics: ReadonlySet<Topic>): void {
114
+ if (
115
+ topics.has('selection') ||
116
+ topics.has('data') ||
117
+ topics.has('locale') ||
118
+ topics.has('review') ||
119
+ topics.has('status') ||
120
+ topics.has('filters')
121
+ ) {
122
+ this.#render();
123
+ }
124
+ }
125
+
126
+ #render(): void {
127
+ const c = this.#ctx.controller;
128
+ const tr = this.#ctx.tr();
129
+ attr(this.el, 'aria-label', tr.t('detail.label'));
130
+ const scene = c.scene;
131
+ const diff = c.diff;
132
+ this.el.hidden =
133
+ c.status !== 'ready' || !scene || !diff || (scene.hunks.length === 0 && c.selectedNode < 0);
134
+ if (this.el.hidden || !scene || !diff) return;
135
+ const key = `${c.selectedHunk}|${c.selectedNode}`;
136
+ if (key !== this.#lastKey) {
137
+ this.#lastKey = key;
138
+ this.#showUnchangedAttrs = false;
139
+ this.#showAllRelations = false;
140
+ this.#showAllNodes = false;
141
+ this.#expandedValues.clear();
142
+ this.#body.scrollTop = 0;
143
+ }
144
+ if (c.selectedHunk < 0 && c.selectedNode < 0) {
145
+ attr(this.el, 'data-empty', 'true');
146
+ replaceKeepingFocus(
147
+ this.#ctx.root,
148
+ this.#body,
149
+ this.#body,
150
+ h(
151
+ 'div',
152
+ { class: 'detail-empty' },
153
+ icon('changes'),
154
+ h(
155
+ 'div',
156
+ null,
157
+ h('div', { text: tr.t('detail.empty') }),
158
+ h('div', { class: 'sub', text: tr.t('detail.emptyHint') }),
159
+ ),
160
+ ),
161
+ );
162
+ return;
163
+ }
164
+ attr(this.el, 'data-empty', 'false');
165
+ const hunk = c.selectedHunk >= 0 ? scene.hunks[c.selectedHunk] : undefined;
166
+ if (hunk) this.#renderHunk(hunk, scene, diff, tr);
167
+ else this.#renderNode(scene, diff, tr);
168
+ }
169
+
170
+ #nav(tr: Translator, hunk: SceneHunk | undefined): HTMLElement {
171
+ const c = this.#ctx.controller;
172
+ const items: Child[] = [];
173
+ if (hunk) {
174
+ const reviewed = c.isReviewed(hunk.id);
175
+ items.push(
176
+ h(
177
+ 'button',
178
+ {
179
+ type: 'button',
180
+ class: 'rv-btn',
181
+ 'data-focus-key': 'review',
182
+ 'aria-pressed': String(reviewed),
183
+ title: `${tr.t(reviewed ? 'review.unmark' : 'review.mark')} (X)`,
184
+ onclick: () => c.toggleReviewed(hunk.id),
185
+ },
186
+ h('span', { class: 'ring' }, icon('check')),
187
+ tr.t('review.toggle'),
188
+ ),
189
+ );
190
+ }
191
+ const rank = hunk ? c.list.rank.get(hunk.index) : undefined;
192
+ const prev = iconButton('chevron-up', `${tr.t('detail.prev')} (K)`, () => c.prev(), {
193
+ class: 'icon-btn sm',
194
+ 'data-focus-key': 'prev',
195
+ });
196
+ const next = iconButton('chevron-down', `${tr.t('detail.next')} (J)`, () => c.next(), {
197
+ class: 'icon-btn sm',
198
+ 'data-focus-key': 'next',
199
+ });
200
+ prev.disabled = rank === 0 || c.list.order.length === 0;
201
+ next.disabled =
202
+ (rank !== undefined && rank >= c.list.order.length - 1) || c.list.order.length === 0;
203
+ items.push(prev, next);
204
+ return h('div', { class: 'd-nav' }, ...items);
205
+ }
206
+
207
+ #renderHunk(hunk: SceneHunk, scene: Scene, diff: DiffResult, tr: Translator): void {
208
+ const c = this.#ctx.controller;
209
+ const core = diff.index.hunk(hunk.id);
210
+ const focusIndex =
211
+ c.selectedNode >= 0 && hunk.nodes.includes(c.selectedNode) ? c.selectedNode : hunk.anchor;
212
+ const focusId = focusIndex >= 0 ? scene.nodes[focusIndex]?.id : core?.anchor;
213
+ const focus = focusId !== undefined ? diff.index.node(focusId) : undefined;
214
+ const parts: Child[] = [
215
+ h(
216
+ 'div',
217
+ { class: 'd-top' },
218
+ h(
219
+ 'span',
220
+ { class: 'fam-pill', 'data-family': hunk.family },
221
+ h('span', { class: 'g', 'aria-hidden': 'true', text: sceneHunkGlyph(hunk) }),
222
+ tr.t(`kind.${hunk.kind}`),
223
+ ),
224
+ this.#nav(tr, hunk),
225
+ ),
226
+ h('h2', { class: 'd-title', text: hunk.title }),
227
+ hunk.subtitle ? h('div', { class: 'd-sub', text: hunk.subtitle }) : null,
228
+ ];
229
+ const nodeIds = core?.nodes ?? [];
230
+ if (nodeIds.length > 1) parts.push(this.#nodeList(nodeIds, focusId, scene, diff, tr));
231
+ if (focus) parts.push(...this.#nodeSections(focus, diff, tr, nodeIds.length > 1));
232
+ const edges = (core?.edges ?? [])
233
+ .map((id) => diff.index.edge(id))
234
+ .filter((e): e is UnionEdge => e !== undefined);
235
+ if (edges.length > 0) parts.push(this.#relations(edges, focusId, scene, diff, tr, true));
236
+ replaceKeepingFocus(this.#ctx.root, this.#body, this.#body, ...parts);
237
+ }
238
+
239
+ #renderNode(scene: Scene, diff: DiffResult, tr: Translator): void {
240
+ const c = this.#ctx.controller;
241
+ const sn = scene.nodes[c.selectedNode];
242
+ const node = sn ? diff.index.node(sn.id) : undefined;
243
+ if (!sn || !node) {
244
+ replaceKeepingFocus(this.#ctx.root, this.#body, this.#body);
245
+ return;
246
+ }
247
+ const edges = diff.index.edgesOf(node.id);
248
+ replaceKeepingFocus(
249
+ this.#ctx.root,
250
+ this.#body,
251
+ this.#body,
252
+ h(
253
+ 'div',
254
+ { class: 'd-top' },
255
+ h(
256
+ 'span',
257
+ { class: 'fam-pill', 'data-family': sn.family },
258
+ h('span', { class: 'g', 'aria-hidden': 'true', text: '·' }),
259
+ tr.t(node.status === 'unchanged' ? 'detail.unchangedNode' : `status.${node.status}`),
260
+ ),
261
+ this.#nav(tr, undefined),
262
+ ),
263
+ h('h2', { class: 'd-title', text: node.label }),
264
+ ...this.#nodeSections(node, diff, tr, false),
265
+ edges.length > 0 ? this.#relations(edges, node.id, scene, diff, tr, false) : null,
266
+ );
267
+ }
268
+
269
+ // ─── Sections ────────────────────────────────────────────────────────────
270
+
271
+ #nodeList(
272
+ ids: readonly UnionId[],
273
+ focusId: UnionId | undefined,
274
+ scene: Scene,
275
+ diff: DiffResult,
276
+ tr: Translator,
277
+ ): HTMLElement {
278
+ const c = this.#ctx.controller;
279
+ const shown = this.#showAllNodes ? ids : ids.slice(0, MAX_NODES);
280
+ const list = h('div', { class: 'nodes' });
281
+ for (const id of shown) {
282
+ const n = diff.index.node(id);
283
+ const i = scene.nodeIndex.get(id);
284
+ const fam = i !== undefined ? (scene.nodes[i]?.family ?? 'neutral') : 'neutral';
285
+ list.appendChild(
286
+ h(
287
+ 'button',
288
+ {
289
+ type: 'button',
290
+ class: 'node-link',
291
+ 'data-focus-key': `node:${id}`,
292
+ 'data-family': fam,
293
+ 'aria-current': String(id === focusId),
294
+ onclick: () => {
295
+ if (i !== undefined) c.selectNode(i, { fly: true });
296
+ },
297
+ },
298
+ h('span', { class: 'sdot', 'aria-hidden': 'true' }),
299
+ h('span', { class: 'lbl', text: n?.label ?? id, title: n?.label ?? id }),
300
+ h('span', { class: 'meta', text: n ? tr.t(`status.${n.status}`) : '' }),
301
+ ),
302
+ );
303
+ }
304
+ return section(
305
+ tr.t('detail.nodes'),
306
+ ids.length,
307
+ list,
308
+ ids.length > MAX_NODES
309
+ ? this.#moreToggle(tr, this.#showAllNodes, () => {
310
+ this.#showAllNodes = !this.#showAllNodes;
311
+ this.#render();
312
+ })
313
+ : null,
314
+ );
315
+ }
316
+
317
+ #moreToggle(tr: Translator, expanded: boolean, onClick: () => void): HTMLElement {
318
+ return h(
319
+ 'button',
320
+ {
321
+ type: 'button',
322
+ class: 'disclosure',
323
+ 'data-focus-key': 'nodes-more',
324
+ 'aria-expanded': String(expanded),
325
+ onclick: onClick,
326
+ },
327
+ tr.t(expanded ? 'detail.showLess' : 'detail.showMore'),
328
+ icon('chevron-down'),
329
+ );
330
+ }
331
+
332
+ #nodeSections(
333
+ node: UnionNode,
334
+ diff: DiffResult,
335
+ tr: Translator,
336
+ showName: boolean,
337
+ ): HTMLElement[] {
338
+ const out: HTMLElement[] = [];
339
+ const facets = node.facets;
340
+ const find = <K extends NodeFacet['kind']>(kind: K) =>
341
+ facets.find((f): f is Extract<NodeFacet, { kind: K }> => f.kind === kind);
342
+ const label = (id: UnionId): string => diff.index.node(id)?.label ?? id;
343
+
344
+ // Identity
345
+ const rekey = find('rekey');
346
+ const types = find('types');
347
+ const deprecate = find('deprecate');
348
+ const current = node.after ?? node.before;
349
+ const idRows: [string, Child | Child[]][] = [];
350
+ if (showName) idRows.push([tr.t('detail.name'), h('span', { text: node.label })]);
351
+ idRows.push([
352
+ tr.t('detail.id'),
353
+ rekey
354
+ ? [
355
+ h(
356
+ 'span',
357
+ { class: 'id-text' },
358
+ h('del', { class: 'hl', text: rekey.from }),
359
+ ' → ',
360
+ h('ins', { class: 'hl', text: rekey.to }),
361
+ ),
362
+ rekey.score < 1
363
+ ? h('span', {
364
+ class: 'note-line',
365
+ text: ` · ${tr.t('detail.matchScore', { p: formatPercent(rekey.score) })}`,
366
+ })
367
+ : null,
368
+ ]
369
+ : h('span', { class: 'id-text', text: node.afterId ?? node.beforeId ?? node.id }),
370
+ ]);
371
+ if (types) idRows.push([tr.t('detail.types'), chips(types.added, types.removed)]);
372
+ else if (node.types.length > 0) idRows.push([tr.t('detail.types'), chips([], [], node.types)]);
373
+ const statusBits: Child[] = [tr.t(`status.${node.status}`)];
374
+ if (current?.deprecated && !deprecate) statusBits.push(` · ${tr.t('detail.deprecated')}`);
375
+ if (deprecate) {
376
+ statusBits.push(
377
+ ' · ',
378
+ h('strong', {
379
+ text: tr.t(deprecate.deprecated ? 'detail.deprecated' : 'detail.undeprecated'),
380
+ }),
381
+ );
382
+ }
383
+ if (current?.implicit) statusBits.push(` · ${tr.t('detail.implicit')}`);
384
+ idRows.push([tr.t('detail.status'), statusBits]);
385
+ out.push(section(tr.t('detail.identity'), null, kv(idRows)));
386
+
387
+ // Name
388
+ const rename = find('rename');
389
+ if (rename) {
390
+ const segs = diffChars(rename.from, rename.to);
391
+ out.push(
392
+ section(
393
+ tr.t('detail.name'),
394
+ null,
395
+ h(
396
+ 'div',
397
+ { class: 'namediff' },
398
+ h(
399
+ 'div',
400
+ { class: 'line old', 'aria-label': `${tr.t('detail.before')}: ${rename.from}` },
401
+ h('span', { class: 'sign', 'aria-hidden': 'true', text: '−' }),
402
+ h('span', null, ...marked(segs, 'before')),
403
+ ),
404
+ h(
405
+ 'div',
406
+ { class: 'line new', 'aria-label': `${tr.t('detail.after')}: ${rename.to}` },
407
+ h('span', { class: 'sign', 'aria-hidden': 'true', text: '+' }),
408
+ h('span', null, ...marked(segs, 'after')),
409
+ ),
410
+ ),
411
+ ),
412
+ );
413
+ }
414
+
415
+ // Attributes
416
+ const attrs = find('attrs');
417
+ const changes = attrs?.changes ?? [];
418
+ const all = current?.attrs ?? {};
419
+ const changedKeys = new Set(changes.map((x) => x.key));
420
+ const unchanged = Object.keys(all)
421
+ .filter((k) => !changedKeys.has(k))
422
+ .sort();
423
+ if (changes.length > 0 || unchanged.length > 0) {
424
+ out.push(this.#attributes(node.id, changes, unchanged, all, tr));
425
+ }
426
+
427
+ // Synonyms
428
+ const aliases = find('aliases');
429
+ if (aliases)
430
+ out.push(section(tr.t('detail.aliases'), null, chips(aliases.added, aliases.removed)));
431
+ else if (current && current.aliases.length > 0 && node.status === 'unchanged') {
432
+ out.push(section(tr.t('detail.aliases'), null, chips([], [], current.aliases)));
433
+ }
434
+
435
+ // Hierarchy
436
+ const move = find('move');
437
+ if (move) {
438
+ const dl = h('dl', { class: 'hier' });
439
+ dl.append(
440
+ h('dt', { text: tr.t('detail.before') }),
441
+ h(
442
+ 'dd',
443
+ null,
444
+ move.from.length
445
+ ? move.from.map((id) => chip(label(id), 'del'))
446
+ : chip(tr.t('detail.none'), 'same'),
447
+ ),
448
+ h('dt', { text: tr.t('detail.after') }),
449
+ h(
450
+ 'dd',
451
+ null,
452
+ move.to.length
453
+ ? move.to.map((id) => chip(label(id), 'add'))
454
+ : chip(tr.t('detail.none'), 'same'),
455
+ ),
456
+ );
457
+ out.push(section(tr.t('detail.hierarchy'), null, dl));
458
+ }
459
+
460
+ // Replacement links
461
+ const replacement = find('replacement');
462
+ if (replacement) {
463
+ const rows: [string, Child][] = [];
464
+ const { replacedBy, replaces } = replacement;
465
+ if (replacedBy.added.length || replacedBy.removed.length) {
466
+ rows.push([tr.t('detail.replacedBy'), chips(replacedBy.added, replacedBy.removed)]);
467
+ }
468
+ if (replaces.added.length || replaces.removed.length) {
469
+ rows.push([tr.t('detail.replaces'), chips(replaces.added, replaces.removed)]);
470
+ }
471
+ if (rows.length) out.push(section(tr.t('detail.deprecated'), null, kv(rows)));
472
+ }
473
+
474
+ // Composition
475
+ const comp = this.#composition(node, diff, tr, label);
476
+ if (comp) out.push(comp);
477
+
478
+ // Evidence
479
+ const evidence = this.#evidence(node, diff, tr, label);
480
+ if (evidence) out.push(evidence);
481
+ return out;
482
+ }
483
+
484
+ #attributes(
485
+ nodeId: string,
486
+ changes: readonly AttrChange[],
487
+ unchanged: readonly string[],
488
+ all: Readonly<Record<string, AttrValue>>,
489
+ tr: Translator,
490
+ ): HTMLElement {
491
+ const table = h(
492
+ 'table',
493
+ { class: 'attrs' },
494
+ h('colgroup', null, h('col', { class: 'k' }), h('col'), h('col')),
495
+ );
496
+ if (changes.length > 0) {
497
+ table.appendChild(
498
+ h(
499
+ 'thead',
500
+ null,
501
+ h(
502
+ 'tr',
503
+ null,
504
+ h('th', { scope: 'col', text: tr.t('detail.key') }),
505
+ h('th', { scope: 'col', text: tr.t('detail.before') }),
506
+ h('th', { scope: 'col', text: tr.t('detail.after') }),
507
+ ),
508
+ ),
509
+ );
510
+ }
511
+ const body = h('tbody');
512
+ for (const ch of changes) body.appendChild(this.#changeRow(nodeId, ch, tr));
513
+ if (this.#showUnchangedAttrs || changes.length === 0) {
514
+ for (const k of unchanged) {
515
+ body.appendChild(
516
+ h(
517
+ 'tr',
518
+ { class: 'unchanged' },
519
+ h('td', { class: 'k', text: k }),
520
+ h('td', { colspan: '2' }, this.#value(`${nodeId}|u|${k}`, formatValue(all[k]), tr)),
521
+ ),
522
+ );
523
+ }
524
+ }
525
+ table.appendChild(body);
526
+ const toggle =
527
+ changes.length > 0 && unchanged.length > 0
528
+ ? h(
529
+ 'button',
530
+ {
531
+ type: 'button',
532
+ class: 'disclosure',
533
+ 'data-focus-key': 'attrs-more',
534
+ 'aria-expanded': String(this.#showUnchangedAttrs),
535
+ onclick: () => {
536
+ this.#showUnchangedAttrs = !this.#showUnchangedAttrs;
537
+ this.#render();
538
+ },
539
+ },
540
+ tr.plural('detail.unchangedAttrs', unchanged.length),
541
+ icon('chevron-down'),
542
+ )
543
+ : null;
544
+ return section(tr.t('detail.attributes'), null, table, toggle);
545
+ }
546
+
547
+ #changeRow(nodeId: string, ch: AttrChange, tr: Translator): HTMLElement {
548
+ const empty = (): HTMLElement => h('span', { class: 'empty', text: tr.t('detail.emptyValue') });
549
+ const beforeList = listItems(ch.before);
550
+ const afterList = listItems(ch.after);
551
+ let before: Child | Child[];
552
+ let after: Child | Child[];
553
+ if (beforeList && afterList) {
554
+ const a = new Set(afterList);
555
+ const b = new Set(beforeList);
556
+ before = h(
557
+ 'div',
558
+ { class: 'chips' },
559
+ beforeList.map((x) => chip(x, a.has(x) ? 'same' : 'del')),
560
+ );
561
+ after = h(
562
+ 'div',
563
+ { class: 'chips' },
564
+ afterList.map((x) => chip(x, b.has(x) ? 'same' : 'add')),
565
+ );
566
+ } else if (typeof ch.before === 'string' && typeof ch.after === 'string') {
567
+ const segs = diffWords(ch.before, ch.after);
568
+ before = this.#value(`${nodeId}|b|${ch.key}`, ch.before, tr, marked(segs, 'before'));
569
+ after = this.#value(`${nodeId}|a|${ch.key}`, ch.after, tr, marked(segs, 'after'));
570
+ } else {
571
+ before =
572
+ ch.before === undefined
573
+ ? empty()
574
+ : this.#value(`${nodeId}|b|${ch.key}`, formatValue(ch.before), tr, [
575
+ h('del', { class: 'hl', text: formatValue(ch.before) }),
576
+ ]);
577
+ after =
578
+ ch.after === undefined
579
+ ? empty()
580
+ : this.#value(`${nodeId}|a|${ch.key}`, formatValue(ch.after), tr, [
581
+ h('ins', { class: 'hl', text: formatValue(ch.after) }),
582
+ ]);
583
+ }
584
+ return h(
585
+ 'tr',
586
+ { class: 'changed' },
587
+ h('td', { class: 'k', text: ch.key }),
588
+ h('td', { class: 'b' }, ...[before].flat()),
589
+ h('td', { class: 'a' }, ...[after].flat()),
590
+ );
591
+ }
592
+
593
+ /** A value cell, clamped to 4 lines with “Show more” when long. */
594
+ #value(
595
+ key: string,
596
+ text: string,
597
+ tr: Translator,
598
+ content?: (string | HTMLElement)[],
599
+ ): HTMLElement {
600
+ const long = text.length > CLAMP_CHARS;
601
+ const open = this.#expandedValues.has(key);
602
+ const v = h('div', { class: `val${long && !open ? ' clamped' : ''}` }, ...(content ?? [text]));
603
+ if (!long) return v;
604
+ return h(
605
+ 'div',
606
+ null,
607
+ v,
608
+ h('button', {
609
+ type: 'button',
610
+ class: 'link-btn more',
611
+ 'data-focus-key': `value:${key}`,
612
+ text: tr.t(open ? 'detail.showLess' : 'detail.showMore'),
613
+ onclick: () => {
614
+ if (open) this.#expandedValues.delete(key);
615
+ else this.#expandedValues.add(key);
616
+ this.#render();
617
+ },
618
+ }),
619
+ );
620
+ }
621
+
622
+ #composition(
623
+ node: UnionNode,
624
+ diff: DiffResult,
625
+ tr: Translator,
626
+ label: (id: UnionId) => string,
627
+ ): HTMLElement | null {
628
+ const rows: HTMLElement[] = [];
629
+ const via = (v: 'hint' | 'heuristic', confidence: number): string =>
630
+ v === 'hint'
631
+ ? tr.t('detail.viaHint')
632
+ : `${tr.t('detail.viaHeuristic')} · ${tr.t('detail.confidence', { p: formatPercent(confidence) })}`;
633
+ for (const f of node.facets) {
634
+ switch (f.kind) {
635
+ case 'mergedInto':
636
+ rows.push(
637
+ h(
638
+ 'div',
639
+ { class: 'comp-row' },
640
+ chip(node.label, 'del'),
641
+ h('span', { class: 'arrow', 'aria-hidden': 'true', text: '→' }),
642
+ chip(label(f.target), 'same'),
643
+ h('span', { class: 'note', text: via(f.via, f.confidence) }),
644
+ ),
645
+ );
646
+ break;
647
+ case 'mergeTarget': {
648
+ const notes = f.sources.map((s) => {
649
+ const src = diff.index.node(s);
650
+ const m = src?.facets.find((x) => x.kind === 'mergedInto');
651
+ return m && m.kind === 'mergedInto' ? via(m.via, m.confidence) : '';
652
+ });
653
+ rows.push(
654
+ h(
655
+ 'div',
656
+ { class: 'comp-row' },
657
+ h('span', { class: 'note', text: tr.t('detail.mergedFrom') }),
658
+ ),
659
+ );
660
+ f.sources.forEach((s, i) => {
661
+ rows.push(
662
+ h(
663
+ 'div',
664
+ { class: 'comp-row' },
665
+ chip(label(s), 'del'),
666
+ h('span', { class: 'arrow', 'aria-hidden': 'true', text: '→' }),
667
+ chip(node.label, 'same'),
668
+ notes[i] ? h('span', { class: 'note', text: notes[i] }) : null,
669
+ ),
670
+ );
671
+ });
672
+ break;
673
+ }
674
+ case 'splitFrom':
675
+ rows.push(
676
+ h(
677
+ 'div',
678
+ { class: 'comp-row' },
679
+ chip(label(f.source), 'same'),
680
+ h('span', { class: 'arrow', 'aria-hidden': 'true', text: '→' }),
681
+ chip(node.label, 'add'),
682
+ h('span', { class: 'note', text: via(f.via, f.confidence) }),
683
+ ),
684
+ );
685
+ break;
686
+ case 'splitInto':
687
+ rows.push(
688
+ h(
689
+ 'div',
690
+ { class: 'comp-row' },
691
+ h('span', { class: 'note', text: tr.t('detail.splitInto') }),
692
+ ),
693
+ h(
694
+ 'div',
695
+ { class: 'comp-row' },
696
+ chip(node.label, 'same'),
697
+ h('span', { class: 'arrow', 'aria-hidden': 'true', text: '→' }),
698
+ f.targets.map((t) => chip(label(t), 'add')),
699
+ ),
700
+ );
701
+ break;
702
+ default:
703
+ break;
704
+ }
705
+ }
706
+ return rows.length
707
+ ? section(tr.t('detail.composition'), null, h('div', { class: 'comp' }, rows))
708
+ : null;
709
+ }
710
+
711
+ #evidence(
712
+ node: UnionNode,
713
+ diff: DiffResult,
714
+ tr: Translator,
715
+ label: (id: UnionId) => string,
716
+ ): HTMLElement | null {
717
+ const parts: Child[] = [];
718
+ const rekey = node.facets.find((f) => f.kind === 'rekey');
719
+ if (rekey && node.afterId !== undefined) {
720
+ const m = diff.matches.find((x) => x.after === node.afterId);
721
+ if (m?.method === 'hint')
722
+ parts.push(h('div', { class: 'note-line', text: tr.t('detail.matchHint') }));
723
+ else if (m) {
724
+ parts.push(
725
+ h('div', {
726
+ class: 'note-line',
727
+ text: tr.t('detail.matchScore', { p: formatPercent(m.score) }),
728
+ }),
729
+ );
730
+ if (m.evidence) parts.push(this.#bars(m.evidence, tr));
731
+ }
732
+ }
733
+ const suggestions = diff.suggestions.filter(
734
+ (s) =>
735
+ (node.beforeId !== undefined && node.status === 'removed' && s.before === node.beforeId) ||
736
+ (node.afterId !== undefined && node.status === 'added' && s.after === node.afterId),
737
+ );
738
+ for (const s of suggestions) {
739
+ const other =
740
+ node.status === 'removed'
741
+ ? diff.index.nodeByAfter(s.after)
742
+ : diff.index.nodeByBefore(s.before);
743
+ parts.push(
744
+ h(
745
+ 'div',
746
+ { class: 'comp-row', style: 'margin-top: 8px' },
747
+ h('span', { class: 'note', text: tr.t('detail.suggestion') }),
748
+ chip(other ? label(other.id) : node.status === 'removed' ? s.after : s.before, 'same'),
749
+ h('span', {
750
+ class: 'note',
751
+ text: `${tr.t('detail.matchScore', { p: formatPercent(s.score) })} · ${tr.t(
752
+ s.reason === 'ambiguous' ? 'detail.suggestion.ambiguous' : 'detail.suggestion.below',
753
+ )}`,
754
+ }),
755
+ ),
756
+ this.#bars(s.evidence, tr),
757
+ );
758
+ }
759
+ return parts.length ? section(tr.t('detail.evidence'), null, ...parts) : null;
760
+ }
761
+
762
+ #bars(e: MatchEvidence, tr: Translator): HTMLElement {
763
+ const rows: [string, number][] = [
764
+ [tr.t('detail.evidence.label'), e.label],
765
+ [tr.t('detail.evidence.neighbors'), e.neighbors],
766
+ [tr.t('detail.evidence.types'), e.types],
767
+ [tr.t('detail.evidence.attrs'), e.attrs],
768
+ ];
769
+ return h(
770
+ 'div',
771
+ { class: 'bars', style: 'margin-top: 8px' },
772
+ rows.map(([k, v]) =>
773
+ h(
774
+ 'div',
775
+ { class: 'bar-row' },
776
+ h('span', { class: 'lbl', text: k }),
777
+ h(
778
+ 'span',
779
+ {
780
+ class: 'track',
781
+ role: 'meter',
782
+ 'aria-label': k,
783
+ 'aria-valuemin': '0',
784
+ 'aria-valuemax': '100',
785
+ 'aria-valuenow': String(Math.round(v * 100)),
786
+ },
787
+ h('span', {
788
+ class: 'fill',
789
+ style: `display:block; width: ${Math.round(Math.max(0, Math.min(1, v)) * 100)}%`,
790
+ }),
791
+ ),
792
+ h('span', { class: 'v', text: formatPercent(v) }),
793
+ ),
794
+ ),
795
+ );
796
+ }
797
+
798
+ // ─── Relations ───────────────────────────────────────────────────────────
799
+
800
+ #relations(
801
+ edges: readonly UnionEdge[],
802
+ focusId: UnionId | undefined,
803
+ scene: Scene,
804
+ diff: DiffResult,
805
+ tr: Translator,
806
+ changed: boolean,
807
+ ): HTMLElement {
808
+ const sorted = changed
809
+ ? edges
810
+ : [...edges].sort((a, b) => Number(a.source !== focusId) - Number(b.source !== focusId));
811
+ const shown = this.#showAllRelations ? sorted : sorted.slice(0, MAX_RELATIONS);
812
+ const list = h('div', { class: 'rels' });
813
+ for (const e of shown) list.appendChild(this.#relation(e, focusId, scene, diff, tr));
814
+ const rest = sorted.length - shown.length;
815
+ return section(
816
+ tr.t('detail.relations'),
817
+ edges.length,
818
+ list,
819
+ rest > 0 || this.#showAllRelations
820
+ ? h(
821
+ 'button',
822
+ {
823
+ type: 'button',
824
+ class: 'disclosure',
825
+ 'data-focus-key': 'rels-more',
826
+ 'aria-expanded': String(this.#showAllRelations),
827
+ onclick: () => {
828
+ this.#showAllRelations = !this.#showAllRelations;
829
+ this.#render();
830
+ },
831
+ },
832
+ this.#showAllRelations
833
+ ? tr.t('detail.showLess')
834
+ : tr.plural('detail.moreRelations', rest),
835
+ icon('chevron-down'),
836
+ )
837
+ : null,
838
+ );
839
+ }
840
+
841
+ #relation(
842
+ e: UnionEdge,
843
+ focusId: UnionId | undefined,
844
+ scene: Scene,
845
+ diff: DiffResult,
846
+ tr: Translator,
847
+ ): HTMLElement {
848
+ const c = this.#ctx.controller;
849
+ const label = (id: UnionId): string => diff.index.node(id)?.label ?? id;
850
+ const node = (id: UnionId): HTMLElement => h('span', { class: 'node', text: label(id) });
851
+ const was = (id: UnionId): HTMLElement =>
852
+ h('span', { class: 'was', text: ` (${tr.t('detail.was', { x: tr.quote(label(id)) })})` });
853
+ const retarget = e.facets.find(
854
+ (f): f is Extract<EdgeFacet, { kind: 'retarget' }> => f.kind === 'retarget',
855
+ );
856
+ const predicate = e.facets.find(
857
+ (f): f is Extract<EdgeFacet, { kind: 'predicate' }> => f.kind === 'predicate',
858
+ );
859
+ const relabel = e.facets.find(
860
+ (f): f is Extract<EdgeFacet, { kind: 'label' }> => f.kind === 'label',
861
+ );
862
+ const pred = h('span', { class: 'pred', text: ` —${e.label}→ ` });
863
+ const outgoing = focusId !== undefined && e.source === focusId && e.target !== focusId;
864
+ const incoming = focusId !== undefined && e.target === focusId && e.source !== focusId;
865
+ const s: Child[] = [];
866
+ if (outgoing) {
867
+ s.push(h('span', { class: 'pred', text: `→ ${e.label} → ` }), node(e.target));
868
+ if (retarget?.end === 'target') s.push(was(retarget.from));
869
+ } else if (incoming) {
870
+ s.push(h('span', { class: 'pred', text: `← ${e.label} ← ` }), node(e.source));
871
+ if (retarget?.end === 'source') s.push(was(retarget.from));
872
+ } else {
873
+ s.push(node(e.source));
874
+ if (retarget?.end === 'source') s.push(was(retarget.from));
875
+ s.push(pred, node(e.target));
876
+ if (retarget?.end === 'target') s.push(was(retarget.from));
877
+ }
878
+ const prevLabel = predicate
879
+ ? (e.before?.label ?? predicate.from)
880
+ : relabel
881
+ ? relabel.from
882
+ : undefined;
883
+ if (prevLabel !== undefined) {
884
+ s.push(
885
+ h('span', { class: 'was', text: ` (${tr.t('detail.was', { x: tr.quote(prevLabel) })})` }),
886
+ );
887
+ }
888
+ if (e.facets.some((f) => f.kind === 'attrs')) {
889
+ s.push(h('span', { class: 'was', text: ` · ${tr.t('detail.edgeAttrs')}` }));
890
+ }
891
+ if (e.facets.some((f) => f.kind === 'collapsed')) {
892
+ s.push(h('span', { class: 'was', text: ` · ${tr.t('detail.collapsed')}` }));
893
+ }
894
+ const sign =
895
+ e.status === 'added'
896
+ ? '+'
897
+ : e.status === 'removed'
898
+ ? '−'
899
+ : e.status === 'modified'
900
+ ? '~'
901
+ : '·';
902
+ const j = scene.edgeIndex.get(e.id);
903
+ const family = familyOfEdge(e);
904
+ return h(
905
+ 'button',
906
+ {
907
+ type: 'button',
908
+ class: `rel ${e.status}`,
909
+ 'data-focus-key': `rel:${e.id}`,
910
+ 'data-family': family,
911
+ title: tr.t('detail.showOnCanvas'),
912
+ disabled: j === undefined,
913
+ onclick: () => {
914
+ if (j !== undefined) c.pinEdge(j);
915
+ },
916
+ },
917
+ h('span', { class: 'sign', 'aria-hidden': 'true', text: sign }),
918
+ h('span', { class: 's' }, ...s),
919
+ );
920
+ }
921
+ }