@kgdiff/viewer 0.1.0 → 0.3.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 (51) hide show
  1. package/README.md +44 -3
  2. package/dist/controller.d.ts +40 -7
  3. package/dist/controller.d.ts.map +1 -1
  4. package/dist/controller.js +194 -45
  5. package/dist/controller.js.map +1 -1
  6. package/dist/element.d.ts +12 -2
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js +132 -8
  9. package/dist/element.js.map +1 -1
  10. package/dist/framing.d.ts +20 -1
  11. package/dist/framing.d.ts.map +1 -1
  12. package/dist/framing.js +49 -0
  13. package/dist/framing.js.map +1 -1
  14. package/dist/i18n.d.ts +2 -3
  15. package/dist/i18n.d.ts.map +1 -1
  16. package/dist/i18n.js +4 -6
  17. package/dist/i18n.js.map +1 -1
  18. package/dist/index.d.ts +48 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/prefs.d.ts +7 -1
  22. package/dist/prefs.d.ts.map +1 -1
  23. package/dist/prefs.js +15 -0
  24. package/dist/prefs.js.map +1 -1
  25. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  26. package/dist/standalone/kgdiff-viewer.js +4 -4
  27. package/dist/styles.d.ts.map +1 -1
  28. package/dist/styles.js +29 -17
  29. package/dist/styles.js.map +1 -1
  30. package/dist/text-diff.d.ts +14 -2
  31. package/dist/text-diff.d.ts.map +1 -1
  32. package/dist/text-diff.js +52 -2
  33. package/dist/text-diff.js.map +1 -1
  34. package/dist/ui/detail.d.ts.map +1 -1
  35. package/dist/ui/detail.js +194 -151
  36. package/dist/ui/detail.js.map +1 -1
  37. package/dist/ui/stage.d.ts +4 -0
  38. package/dist/ui/stage.d.ts.map +1 -1
  39. package/dist/ui/stage.js +30 -4
  40. package/dist/ui/stage.js.map +1 -1
  41. package/package.json +6 -6
  42. package/src/controller.ts +206 -41
  43. package/src/element.ts +148 -8
  44. package/src/framing.ts +65 -1
  45. package/src/i18n.ts +4 -6
  46. package/src/index.ts +47 -1
  47. package/src/prefs.ts +26 -1
  48. package/src/styles.ts +29 -17
  49. package/src/text-diff.ts +57 -2
  50. package/src/ui/detail.ts +253 -245
  51. package/src/ui/stage.ts +44 -3
package/src/ui/detail.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
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.
3
+ * family pill, prev / next, reviewed toggle; then what changed, first: one row per changed
4
+ * facet, before → after (character diff for names, word diff for text, set changes paired into
5
+ * edits); merge / split composition, relations as sentences and match evidence; the unchanged
6
+ * rest last, its attributes folded. Added, removed and inspected nodes show their overview,
7
+ * attributes and relations.
6
8
  */
7
9
  import type {
8
10
  AttrChange,
9
- AttrValue,
10
11
  DiffResult,
11
12
  EdgeFacet,
12
13
  MatchEvidence,
@@ -19,12 +20,14 @@ import type { Family, Scene, SceneHunk } from '@kgdiff/scene';
19
20
  import type { Topic } from '../controller.js';
20
21
  import { attr, h, icon, iconButton, replaceKeepingFocus } from '../dom.js';
21
22
  import { formatPercent, type Translator } from '../i18n.js';
22
- import { type DiffSegment, diffChars, diffWords } from '../text-diff.js';
23
+ import { type DiffSegment, diffChars, diffWords, pairItems } from '../text-diff.js';
23
24
  import { formatValue, listItems, sceneHunkGlyph, type UiContext } from './context.js';
24
25
 
25
26
  const MAX_NODES = 12;
26
27
  const MAX_RELATIONS = 12;
27
28
  const CLAMP_CHARS = 180;
29
+ /** Up to this many characters (both sides together) a value change reads `before → after`. */
30
+ const INLINE_CHARS = 60;
28
31
 
29
32
  type Child = Node | string | null | false | undefined;
30
33
 
@@ -80,6 +83,63 @@ function chips(
80
83
  );
81
84
  }
82
85
 
86
+ /** `before → after` on one line, wrapping when long. */
87
+ function pair(before: Child[], after: Child[], tr: Translator): HTMLElement {
88
+ return h(
89
+ 'div',
90
+ { class: 'pair' },
91
+ h('span', { class: 'b' }, before),
92
+ h('span', { class: 'arr', 'aria-hidden': 'true', text: '→' }),
93
+ h('span', { class: 'sr-only', text: tr.t('detail.changedTo') }),
94
+ h('span', null, after),
95
+ );
96
+ }
97
+
98
+ /** `before → after` with the changed characters marked. */
99
+ function textPair(before: string, after: string, tr: Translator): HTMLElement {
100
+ const segs = diffChars(before, after);
101
+ return pair(marked(segs, 'before'), marked(segs, 'after'), tr);
102
+ }
103
+
104
+ /** One side of a change on a line of its own: `− before` or `+ after`. */
105
+ function signed(side: 'old' | 'new', content: Child): HTMLElement {
106
+ return h(
107
+ 'div',
108
+ { class: `line ${side}` },
109
+ h('span', { class: 'sign', 'aria-hidden': 'true', text: side === 'old' ? '−' : '+' }),
110
+ h('div', null, content),
111
+ );
112
+ }
113
+
114
+ /** A set change (synonyms, types, parents, list values): edits as pairs, the rest as chips. */
115
+ function items(
116
+ added: readonly string[],
117
+ removed: readonly string[],
118
+ tr: Translator,
119
+ same: readonly string[] = [],
120
+ ): Child[] {
121
+ const p = pairItems(removed, added);
122
+ const out: Child[] = p.pairs.map(([b, a]) => textPair(b, a, tr));
123
+ if (p.added.length > 0 || p.removed.length > 0 || same.length > 0) {
124
+ out.push(chips(p.added, p.removed, same));
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** Key · value rows. */
130
+ function table(rows: readonly (readonly [string, Child | Child[]])[], cls = 'attrs'): HTMLElement {
131
+ return h(
132
+ 'table',
133
+ { class: cls },
134
+ h('colgroup', null, h('col', { class: 'k' }), h('col')),
135
+ h(
136
+ 'tbody',
137
+ null,
138
+ rows.map(([k, v]) => h('tr', null, h('td', { class: 'k', text: k }), h('td', null, v))),
139
+ ),
140
+ );
141
+ }
142
+
83
143
  function familyOfEdge(e: UnionEdge): Family {
84
144
  if (e.status === 'added') return 'added';
85
145
  if (e.status === 'removed') {
@@ -228,11 +288,15 @@ export class Detail {
228
288
  ];
229
289
  const nodeIds = core?.nodes ?? [];
230
290
  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));
291
+ const [lead, rest] = focus
292
+ ? this.#nodeSections(focus, diff, tr, nodeIds.length > 1, true)
293
+ : [[], []];
232
294
  const edges = (core?.edges ?? [])
233
295
  .map((id) => diff.index.edge(id))
234
296
  .filter((e): e is UnionEdge => e !== undefined);
297
+ parts.push(...lead);
235
298
  if (edges.length > 0) parts.push(this.#relations(edges, focusId, scene, diff, tr, true));
299
+ parts.push(...rest);
236
300
  replaceKeepingFocus(this.#ctx.root, this.#body, this.#body, ...parts);
237
301
  }
238
302
 
@@ -245,6 +309,7 @@ export class Detail {
245
309
  return;
246
310
  }
247
311
  const edges = diff.index.edgesOf(node.id);
312
+ const [lead, rest] = this.#nodeSections(node, diff, tr, false, false);
248
313
  replaceKeepingFocus(
249
314
  this.#ctx.root,
250
315
  this.#body,
@@ -261,8 +326,9 @@ export class Detail {
261
326
  this.#nav(tr, undefined),
262
327
  ),
263
328
  h('h2', { class: 'd-title', text: node.label }),
264
- ...this.#nodeSections(node, diff, tr, false),
329
+ ...lead,
265
330
  edges.length > 0 ? this.#relations(edges, node.id, scene, diff, tr, false) : null,
331
+ ...rest,
266
332
  );
267
333
  }
268
334
 
@@ -329,265 +395,207 @@ export class Detail {
329
395
  );
330
396
  }
331
397
 
398
+ /**
399
+ * The node's sections, split around the relations. A modified node (or an unchanged one a hunk
400
+ * points at) leads with what changed and ends with its unchanged rest; added, removed and
401
+ * inspected nodes lead with what they are.
402
+ */
332
403
  #nodeSections(
333
404
  node: UnionNode,
334
405
  diff: DiffResult,
335
406
  tr: Translator,
336
407
  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);
408
+ inHunk: boolean,
409
+ ): [HTMLElement[], HTMLElement[]] {
342
410
  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
411
+ const fold = node.status === 'modified' || (inHunk && node.status === 'unchanged');
412
+ const changes = this.#changes(node, diff, tr, label);
475
413
  const comp = this.#composition(node, diff, tr, label);
476
- if (comp) out.push(comp);
477
-
478
- // Evidence
479
414
  const evidence = this.#evidence(node, diff, tr, label);
480
- if (evidence) out.push(evidence);
481
- return out;
415
+ const overview = this.#overview(node, tr, showName, fold);
416
+ const present = (xs: readonly (HTMLElement | null)[]): HTMLElement[] =>
417
+ xs.filter((x): x is HTMLElement => x !== null);
418
+ return fold
419
+ ? [present([comp, changes]), present([evidence, ...overview])]
420
+ : [present([...overview, changes, comp, evidence]), []];
482
421
  }
483
422
 
484
- #attributes(
485
- nodeId: string,
486
- changes: readonly AttrChange[],
487
- unchanged: readonly string[],
488
- all: Readonly<Record<string, AttrValue>>,
423
+ /** What changed on the node: one row per facet (per key for attributes), in facet order. */
424
+ #changes(
425
+ node: UnionNode,
426
+ diff: DiffResult,
489
427
  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
- );
428
+ label: (id: UnionId) => string,
429
+ ): HTMLElement | null {
430
+ // Replacement links hold ids as written: name the node they point at when there is one.
431
+ const named = (ids: readonly string[]): string[] =>
432
+ ids.map((id) => (diff.index.nodeByAfter(id) ?? diff.index.nodeByBefore(id))?.label ?? id);
433
+ const rows: [string, Child | Child[]][] = [];
434
+ for (const f of node.facets) {
435
+ switch (f.kind) {
436
+ case 'rekey':
437
+ rows.push([
438
+ tr.t('detail.id'),
439
+ [
440
+ h('div', { class: 'id-text' }, textPair(f.from, f.to, tr)),
441
+ f.score < 1
442
+ ? h('div', {
443
+ class: 'note-line',
444
+ text: tr.t('detail.matchScore', { p: formatPercent(f.score) }),
445
+ })
446
+ : null,
447
+ ],
448
+ ]);
449
+ break;
450
+ case 'rename':
451
+ rows.push([tr.t('detail.name'), textPair(f.from, f.to, tr)]);
452
+ break;
453
+ case 'types':
454
+ case 'aliases':
455
+ rows.push([tr.t(`detail.${f.kind}`), items(f.added, f.removed, tr)]);
456
+ break;
457
+ case 'attrs':
458
+ for (const ch of f.changes) rows.push([ch.key, this.#attrChange(node.id, ch, tr)]);
459
+ break;
460
+ case 'move':
461
+ rows.push([tr.t('detail.hierarchy'), items(f.to.map(label), f.from.map(label), tr)]);
462
+ break;
463
+ case 'deprecate':
464
+ rows.push([
465
+ tr.t('detail.status'),
466
+ h('strong', {
467
+ text: tr.t(f.deprecated ? 'detail.deprecated' : 'detail.undeprecated'),
468
+ }),
469
+ ]);
470
+ break;
471
+ case 'replacement':
472
+ for (const [key, links] of [
473
+ ['detail.replacedBy', f.replacedBy],
474
+ ['detail.replaces', f.replaces],
475
+ ] as const) {
476
+ if (links.added.length > 0 || links.removed.length > 0) {
477
+ rows.push([tr.t(key), items(named(links.added), named(links.removed), tr)]);
478
+ }
479
+ }
480
+ break;
481
+ default:
482
+ // Merges and splits: the composition section.
483
+ break;
523
484
  }
524
485
  }
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);
486
+ return rows.length > 0
487
+ ? section(tr.t('detail.changes'), rows.length, table(rows, 'attrs changes'))
488
+ : null;
545
489
  }
546
490
 
547
- #changeRow(nodeId: string, ch: AttrChange, tr: Translator): HTMLElement {
548
- const empty = (): HTMLElement => h('span', { class: 'empty', text: tr.t('detail.emptyValue') });
491
+ /** One attribute's change: lists as set changes, short values inline, long ones a line a side. */
492
+ #attrChange(nodeId: string, ch: AttrChange, tr: Translator): Child[] {
549
493
  const beforeList = listItems(ch.before);
550
494
  const afterList = listItems(ch.after);
551
- let before: Child | Child[];
552
- let after: Child | Child[];
553
495
  if (beforeList && afterList) {
554
- const a = new Set(afterList);
555
496
  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')),
497
+ const a = new Set(afterList);
498
+ return items(
499
+ afterList.filter((x) => !b.has(x)),
500
+ beforeList.filter((x) => !a.has(x)),
501
+ tr,
502
+ afterList.filter((x) => b.has(x)),
565
503
  );
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
504
  }
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
- );
505
+ const key = `${nodeId}|${ch.key}`;
506
+ const before = formatValue(ch.before);
507
+ const after = formatValue(ch.after);
508
+ if (ch.before === undefined) {
509
+ const ins = h('ins', { class: 'hl', text: after });
510
+ return [signed('new', this.#value(`${key}|a`, after, tr, [ins]))];
511
+ }
512
+ if (ch.after === undefined) {
513
+ const del = h('del', { class: 'hl', text: before });
514
+ return [signed('old', this.#value(`${key}|b`, before, tr, [del]))];
515
+ }
516
+ const segs: readonly DiffSegment[] =
517
+ typeof ch.before === 'string' && typeof ch.after === 'string'
518
+ ? diffWords(before, after)
519
+ : [
520
+ { op: 'delete', text: before },
521
+ { op: 'insert', text: after },
522
+ ];
523
+ const b = marked(segs, 'before');
524
+ const a = marked(segs, 'after');
525
+ return before.length + after.length <= INLINE_CHARS && !/\n/.test(before + after)
526
+ ? [pair(b, a, tr)]
527
+ : [
528
+ signed('old', this.#value(`${key}|b`, before, tr, b)),
529
+ signed('new', this.#value(`${key}|a`, after, tr, a)),
530
+ ];
531
+ }
532
+
533
+ /**
534
+ * Identity and attributes, without what the changes section shows. Folded, the attributes
535
+ * (all unchanged) hide behind a toggle; otherwise they get a section of their own.
536
+ */
537
+ #overview(node: UnionNode, tr: Translator, showName: boolean, fold: boolean): HTMLElement[] {
538
+ const has = (kind: NodeFacet['kind']): boolean => node.facets.some((f) => f.kind === kind);
539
+ const current = node.after ?? node.before;
540
+ const rows: [string, Child | Child[]][] = [];
541
+ if (showName && !has('rename')) rows.push([tr.t('detail.name'), node.label]);
542
+ if (!has('rekey')) {
543
+ rows.push([
544
+ tr.t('detail.id'),
545
+ h('span', { class: 'id-text', text: node.afterId ?? node.beforeId ?? node.id }),
546
+ ]);
547
+ }
548
+ if (!has('types') && node.types.length > 0) {
549
+ rows.push([tr.t('detail.types'), chips([], [], node.types)]);
550
+ }
551
+ if (!has('aliases') && current && current.aliases.length > 0) {
552
+ rows.push([tr.t('detail.aliases'), chips([], [], current.aliases)]);
553
+ }
554
+ const status: Child[] = [tr.t(`status.${node.status}`)];
555
+ if (current?.deprecated && !has('deprecate')) status.push(` · ${tr.t('detail.deprecated')}`);
556
+ if (current?.implicit) status.push(` · ${tr.t('detail.implicit')}`);
557
+ rows.push([tr.t('detail.status'), status]);
558
+
559
+ const all = current?.attrs ?? {};
560
+ const changed = new Set<string>();
561
+ for (const f of node.facets) {
562
+ if (f.kind === 'attrs') for (const c of f.changes) changed.add(c.key);
563
+ }
564
+ const keys = Object.keys(all)
565
+ .filter((k) => !changed.has(k))
566
+ .sort();
567
+ const attrs = (): HTMLElement =>
568
+ table(keys.map((k) => [k, this.#value(`${node.id}|u|${k}`, formatValue(all[k]), tr)]));
569
+ if (!fold) {
570
+ const out = [section(tr.t('detail.identity'), null, kv(rows))];
571
+ if (keys.length > 0) out.push(section(tr.t('detail.attributes'), null, attrs()));
572
+ return out;
573
+ }
574
+ return [
575
+ section(
576
+ tr.t('detail.identity'),
577
+ null,
578
+ kv(rows),
579
+ keys.length > 0
580
+ ? h(
581
+ 'button',
582
+ {
583
+ type: 'button',
584
+ class: 'disclosure',
585
+ 'data-focus-key': 'attrs-more',
586
+ 'aria-expanded': String(this.#showUnchangedAttrs),
587
+ onclick: () => {
588
+ this.#showUnchangedAttrs = !this.#showUnchangedAttrs;
589
+ this.#render();
590
+ },
591
+ },
592
+ tr.plural('detail.unchangedAttrs', keys.length),
593
+ icon('chevron-down'),
594
+ )
595
+ : null,
596
+ keys.length > 0 && this.#showUnchangedAttrs ? attrs() : null,
597
+ ),
598
+ ];
591
599
  }
592
600
 
593
601
  /** A value cell, clamped to 4 lines with “Show more” when long. */
package/src/ui/stage.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * The canvas area and its floating chrome: replay caption and transport, swipe divider,
3
- * changes-only context stepper, legend, zoom controls + minimap, hover tooltip and the
4
- * loading / identical / error / empty states. Overlays never cover the current hunk: the
5
- * element reserves their space through the renderer's safe area.
3
+ * changes-only context stepper, legend (a card, or one line of dots for chrome-less embeds),
4
+ * zoom controls + minimap, hover tooltip and the loading / identical / error / empty states.
5
+ * Overlays never cover the current hunk: the element reserves their space through the
6
+ * renderer's safe area.
6
7
  */
7
8
  import { KgdiffError, type Rect } from '@kgdiff/core';
8
9
  import type { ChangeFamily, Family } from '@kgdiff/scene';
@@ -24,6 +25,9 @@ import { sceneHunkGlyph, type UiContext } from './context.js';
24
25
 
25
26
  const TICK_W = 1000;
26
27
 
28
+ /** How the legend shows: the card, one line of dots, or not at all. */
29
+ export type LegendStyle = 'full' | 'mini' | 'none';
30
+
27
31
  /** Legend swatch: the node cue of each family (docs/visual-language.md §Meaning families). */
28
32
  function swatch(family: Family): SVGSVGElement {
29
33
  const v = (name: string) => `var(--kg-${name})`;
@@ -84,6 +88,7 @@ export class Stage {
84
88
  readonly #legend: HTMLElement;
85
89
  readonly #legendHead: HTMLButtonElement;
86
90
  readonly #legendBody: HTMLElement;
91
+ readonly #miniLegend: HTMLElement;
87
92
  readonly #zoomIn: HTMLButtonElement;
88
93
  readonly #zoomOut: HTMLButtonElement;
89
94
  readonly #fitButton: HTMLButtonElement;
@@ -236,6 +241,7 @@ export class Stage {
236
241
  this.#legendHead,
237
242
  this.#legendBody,
238
243
  );
244
+ this.#miniLegend = h('div', { class: 'mini-legend float bottom-left', role: 'list' });
239
245
 
240
246
  // Zoom + minimap
241
247
  this.#zoomIn = iconButton('plus', '', () => c.zoom(1));
@@ -264,6 +270,7 @@ export class Stage {
264
270
  this.#caption,
265
271
  this.#stepper,
266
272
  this.#legend,
273
+ this.#miniLegend,
267
274
  this.#mapCluster,
268
275
  this.#finished,
269
276
  this.#transport,
@@ -297,6 +304,7 @@ export class Stage {
297
304
  const obstacles: Rect[] = [];
298
305
  const parts = [
299
306
  this.#legend,
307
+ this.#miniLegend,
300
308
  this.#mapCluster,
301
309
  this.#transport,
302
310
  this.#finished,
@@ -326,12 +334,19 @@ export class Stage {
326
334
  attr(this.#legendHead, 'aria-expanded', String(open));
327
335
  }
328
336
 
337
+ /** Legend style, and whether clicks select (the tooltip only hints at clicking then). */
338
+ setChrome(legend: LegendStyle, selectable: boolean): void {
339
+ this.el.dataset.legend = legend;
340
+ this.el.dataset.selectable = String(selectable);
341
+ }
342
+
329
343
  update(topics: ReadonlySet<Topic>): void {
330
344
  const c = this.#ctx.controller;
331
345
  const tr = this.#ctx.tr();
332
346
  const all = topics.has('status') || topics.has('data') || topics.has('locale');
333
347
  if (all) {
334
348
  this.#renderLabels(tr);
349
+ this.#renderMiniLegend(tr);
335
350
  this.#renderState(tr);
336
351
  this.#renderTicks();
337
352
  this.#renderCanvasLabel(tr);
@@ -381,6 +396,32 @@ export class Stage {
381
396
  );
382
397
  }
383
398
 
399
+ /** One line: the families this diff has, then the unchanged context. */
400
+ #renderMiniLegend(tr: Translator): void {
401
+ const scene = this.#ctx.controller.scene;
402
+ const present = new Set<Family>(scene?.hunks.map((x) => x.family));
403
+ if (scene?.nodes.some((n) => n.status === 'unchanged')) present.add('neutral');
404
+ attr(this.#miniLegend, 'aria-label', tr.t('legend.title'));
405
+ replace(
406
+ this.#miniLegend,
407
+ [...FAMILIES, 'neutral' as const]
408
+ .filter((f) => present.has(f))
409
+ .map((f) =>
410
+ h(
411
+ 'span',
412
+ {
413
+ class: 'mini-item',
414
+ role: 'listitem',
415
+ 'data-family': f,
416
+ title: tr.t(`familyHint.${f}`),
417
+ },
418
+ h('span', { class: 'dot', 'aria-hidden': 'true' }),
419
+ tr.t(`family.${f}`),
420
+ ),
421
+ ),
422
+ );
423
+ }
424
+
384
425
  #renderCanvasLabel(tr: Translator): void {
385
426
  const stats = this.#ctx.controller.diff?.stats;
386
427
  const canvas = this.canvasHost.querySelector('canvas');