@kgdiff/viewer 0.3.0 → 0.4.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 (73) hide show
  1. package/README.md +9 -8
  2. package/dist/controller.d.ts +23 -7
  3. package/dist/controller.d.ts.map +1 -1
  4. package/dist/controller.js +119 -14
  5. package/dist/controller.js.map +1 -1
  6. package/dist/element.d.ts +6 -3
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js +68 -19
  9. package/dist/element.js.map +1 -1
  10. package/dist/hash-sync.d.ts +4 -2
  11. package/dist/hash-sync.d.ts.map +1 -1
  12. package/dist/hash-sync.js +21 -5
  13. package/dist/hash-sync.js.map +1 -1
  14. package/dist/i18n.d.ts +7 -7
  15. package/dist/i18n.d.ts.map +1 -1
  16. package/dist/i18n.js +14 -14
  17. package/dist/i18n.js.map +1 -1
  18. package/dist/index.d.ts +21 -8
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/keyboard.d.ts +6 -4
  22. package/dist/keyboard.d.ts.map +1 -1
  23. package/dist/keyboard.js +6 -5
  24. package/dist/keyboard.js.map +1 -1
  25. package/dist/prefs.d.ts +7 -1
  26. package/dist/prefs.d.ts.map +1 -1
  27. package/dist/prefs.js +15 -3
  28. package/dist/prefs.js.map +1 -1
  29. package/dist/standalone/kgdiff-viewer.js +4 -4
  30. package/dist/styles.d.ts.map +1 -1
  31. package/dist/styles.js +69 -63
  32. package/dist/styles.js.map +1 -1
  33. package/dist/ui/context.d.ts +2 -0
  34. package/dist/ui/context.d.ts.map +1 -1
  35. package/dist/ui/context.js.map +1 -1
  36. package/dist/ui/dock.d.ts +17 -0
  37. package/dist/ui/dock.d.ts.map +1 -0
  38. package/dist/ui/dock.js +151 -0
  39. package/dist/ui/dock.js.map +1 -0
  40. package/dist/ui/header.d.ts +7 -2
  41. package/dist/ui/header.d.ts.map +1 -1
  42. package/dist/ui/header.js +24 -133
  43. package/dist/ui/header.js.map +1 -1
  44. package/dist/ui/popovers.d.ts.map +1 -1
  45. package/dist/ui/popovers.js +3 -2
  46. package/dist/ui/popovers.js.map +1 -1
  47. package/dist/ui/sidebar.d.ts.map +1 -1
  48. package/dist/ui/sidebar.js +2 -1
  49. package/dist/ui/sidebar.js.map +1 -1
  50. package/dist/ui/stage.d.ts +4 -0
  51. package/dist/ui/stage.d.ts.map +1 -1
  52. package/dist/ui/stage.js +41 -94
  53. package/dist/ui/stage.js.map +1 -1
  54. package/dist/ui/swatch.d.ts +7 -0
  55. package/dist/ui/swatch.d.ts.map +1 -0
  56. package/dist/ui/swatch.js +26 -0
  57. package/dist/ui/swatch.js.map +1 -0
  58. package/package.json +6 -6
  59. package/src/controller.ts +131 -20
  60. package/src/element.ts +72 -21
  61. package/src/hash-sync.ts +20 -6
  62. package/src/i18n.ts +15 -14
  63. package/src/index.ts +21 -9
  64. package/src/keyboard.ts +12 -6
  65. package/src/prefs.ts +19 -4
  66. package/src/styles.ts +69 -63
  67. package/src/ui/context.ts +2 -0
  68. package/src/ui/dock.ts +198 -0
  69. package/src/ui/header.ts +38 -168
  70. package/src/ui/popovers.ts +3 -2
  71. package/src/ui/sidebar.ts +2 -1
  72. package/src/ui/stage.ts +49 -114
  73. package/src/ui/swatch.ts +31 -0
package/src/i18n.ts CHANGED
@@ -10,11 +10,18 @@ import type { Locale } from '@kgdiff/core';
10
10
  const en = {
11
11
  'brand.tagline': 'git diff for knowledge graphs',
12
12
  'header.compare': 'Compared versions',
13
- 'mode.label': 'View mode',
13
+ 'header.actions': 'Viewer actions',
14
+ 'dock.label': 'View',
15
+ 'mode.label': 'Compare by',
14
16
  'mode.overlay': 'Overlay',
15
17
  'mode.swipe': 'Swipe',
16
18
  'mode.replay': 'Replay',
17
19
  'mode.changes': 'Changes only',
20
+ 'scope.hint': 'Show only the changes, with nearby context',
21
+ 'scope.on': 'Showing only the changes',
22
+ 'scope.off': 'Showing the whole graph',
23
+ 'scope.locked':
24
+ 'This graph was laid out around its changes only: the whole graph cannot be shown.',
18
25
  'family.added': 'Added',
19
26
  'family.removed': 'Removed',
20
27
  'family.content': 'Content',
@@ -41,12 +48,6 @@ const en = {
41
48
  'status.added': 'Added',
42
49
  'status.removed': 'Removed',
43
50
  'status.modified': 'Modified',
44
- 'stat.added': '{n} added',
45
- 'stat.removed': '{n} removed',
46
- 'stat.modified': '{n} modified',
47
- 'stat.restructured': '{n} restructured',
48
- 'stat.filter': 'Show only these changes',
49
- 'stat.label': 'Change summary',
50
51
  'action.fit': 'Fit to view',
51
52
  'action.help': 'Keyboard shortcuts',
52
53
  'action.sidebar': 'Toggle sidebar',
@@ -199,11 +200,17 @@ export type Messages = Readonly<Record<MessageKey, string>>;
199
200
  const zhCN: Messages = {
200
201
  'brand.tagline': '知识图谱的 git diff',
201
202
  'header.compare': '对比的版本',
202
- 'mode.label': '视图模式',
203
+ 'header.actions': '查看器操作',
204
+ 'dock.label': '视图',
205
+ 'mode.label': '对比方式',
203
206
  'mode.overlay': '叠加',
204
207
  'mode.swipe': '滑动对比',
205
208
  'mode.replay': '回放',
206
209
  'mode.changes': '仅看变更',
210
+ 'scope.hint': '只显示变更,以及附近的上下文',
211
+ 'scope.on': '只显示变更',
212
+ 'scope.off': '显示全图',
213
+ 'scope.locked': '这张图只对变更部分做了布局,无法显示全图。',
207
214
  'family.added': '新增',
208
215
  'family.removed': '删除',
209
216
  'family.content': '内容',
@@ -230,12 +237,6 @@ const zhCN: Messages = {
230
237
  'status.added': '新增',
231
238
  'status.removed': '删除',
232
239
  'status.modified': '修改',
233
- 'stat.added': '新增 {n} 处',
234
- 'stat.removed': '删除 {n} 处',
235
- 'stat.modified': '修改 {n} 处',
236
- 'stat.restructured': '结构调整 {n} 处',
237
- 'stat.filter': '只显示这类变更',
238
- 'stat.label': '变更概览',
239
240
  'action.fit': '适应视图',
240
241
  'action.help': '键盘快捷键',
241
242
  'action.sidebar': '显示或隐藏侧栏',
package/src/index.ts CHANGED
@@ -25,7 +25,10 @@ import { KgdiffViewer } from './element.js';
25
25
  import { hydrate } from './payload.js';
26
26
  import { viewerTags } from './registry.js';
27
27
 
28
- export type ViewerMode = 'overlay' | 'swipe' | 'replay' | 'changes';
28
+ /** How the two versions are told apart: by color (overlay), side by side (swipe) or over time (replay). */
29
+ export type ViewerMode = 'overlay' | 'swipe' | 'replay';
30
+ /** What is drawn, in any mode: the whole graph, or only the changes with a few hops of context. */
31
+ export type ViewerScope = 'all' | 'changes';
29
32
  export type ThemePreference = 'auto' | 'light' | 'dark';
30
33
  /** `none`: the graph alone, for small embeds (see `KgdiffViewerElement.chrome`). */
31
34
  export type ViewerChrome = 'full' | 'none';
@@ -49,7 +52,9 @@ export interface ReportPayload {
49
52
  };
50
53
  /** Initial view preferences. */
51
54
  readonly view?: {
55
+ /** `changes` (from reports made before scopes existed) means `overlay` + scope `changes`. */
52
56
  readonly mode?: ViewerMode;
57
+ readonly scope?: ViewerScope;
53
58
  readonly theme?: ThemePreference;
54
59
  readonly palette?: PaletteName;
55
60
  readonly locale?: Locale;
@@ -80,7 +85,7 @@ export interface ReviewState {
80
85
  export interface KgdiffViewerEventMap {
81
86
  'kgdiff-ready': CustomEvent<{ readonly stats: DiffStats }>;
82
87
  'kgdiff-select': CustomEvent<{ readonly hunk: string | null; readonly node: string | null }>;
83
- 'kgdiff-mode': CustomEvent<{ readonly mode: ViewerMode }>;
88
+ 'kgdiff-mode': CustomEvent<{ readonly mode: ViewerMode; readonly scope: ViewerScope }>;
84
89
  'kgdiff-review': CustomEvent<{
85
90
  readonly hunk: string;
86
91
  readonly reviewed: boolean;
@@ -104,21 +109,28 @@ export interface FitOptions {
104
109
  /**
105
110
  * Public surface of the `<kgdiff-viewer>` element.
106
111
  *
107
- * Boolean attributes: `sync-hash` (deep links `#kgdiff=hunk:<id>;mode:<mode>`) and
112
+ * Boolean attributes: `sync-hash` (deep links `#kgdiff=hunk:<id>;mode:<mode>;scope:<scope>`) and
108
113
  * `global-shortcuts`: keyboard shortcuts normally apply only while focus is inside the viewer;
109
114
  * with this attribute they also apply when nothing on the page has focus (for full-page
110
115
  * viewers such as HTML reports), except the keys pages scroll with (arrows, Home / End, Page
111
116
  * Up / Down, Space).
112
117
  *
113
- * `header="compact"` leaves the brand and the compared names out of the header, for hosts that
118
+ * `header="compact"` leaves the brand and the compared names out of the canvas, for hosts that
114
119
  * show their own (such as the playground).
115
120
  *
116
121
  * A small looping replay for another page: `mode="replay" chrome="none" loop` (with a height of
117
122
  * your choosing, such as 320px).
118
123
  */
119
124
  export interface KgdiffViewerElement extends HTMLElement {
120
- /** Reflected to the `mode` attribute. Default `overlay` (`changes` for large graphs). */
121
- mode: ViewerMode;
125
+ /** Reflected to the `mode` attribute. Default `overlay`. */
126
+ get mode(): ViewerMode;
127
+ /** The legacy `changes` mode sets `overlay` + scope `changes`. */
128
+ set mode(value: ViewerMode | 'changes');
129
+ /**
130
+ * Reflected to the `scope` attribute. Default `all` (`changes` for large graphs). Fixed to
131
+ * `changes` when the layout covers only the changes.
132
+ */
133
+ scope: ViewerScope;
122
134
  /** Reflected to the `theme` attribute. Default `auto`. */
123
135
  theme: ThemePreference;
124
136
  /** Reflected to the `palette` attribute. Default `default`. */
@@ -126,9 +138,9 @@ export interface KgdiffViewerElement extends HTMLElement {
126
138
  /** Reflected to the `locale` attribute. Default from `navigator.language`. */
127
139
  locale: Locale;
128
140
  /**
129
- * Reflected to the `chrome` attribute. `none` shows the graph alone: no header, change list,
130
- * step caption, playback bar, zoom buttons, minimap or legend card; the hover tooltip and a
131
- * one-line legend stay. Clicks and keyboard shortcuts do nothing, the minimum height drops
141
+ * Reflected to the `chrome` attribute. `none` shows the graph alone: no title, actions, view
142
+ * dock (with the step caption and playback bar), change list, zoom buttons, minimap or legend
143
+ * card; the hover tooltip and a one-line legend stay. Clicks and keyboard shortcuts do nothing, the minimum height drops
132
144
  * from 420px to 120px, and the view stays fitted: when the canvas is resized (unless the user
133
145
  * panned or zoomed since) and at the start of every loop. Labels are never cut at the edge:
134
146
  * one pushed from its spot by a neighbour moves inside or is left out. Default `full`.
package/src/keyboard.ts CHANGED
@@ -2,10 +2,11 @@
2
2
  * The keyboard map (docs/interaction.md §Keyboard map), as a pure function from a key event to
3
3
  * an action, so it can be tested without a DOM.
4
4
  */
5
- import type { ViewerMode } from './index.js';
5
+ import type { ViewerMode, ViewerScope } from './index.js';
6
6
 
7
7
  export type KeyAction =
8
8
  | { readonly type: 'mode'; readonly mode: ViewerMode }
9
+ | { readonly type: 'scope' }
9
10
  | { readonly type: 'next' }
10
11
  | { readonly type: 'prev' }
11
12
  | { readonly type: 'first' }
@@ -36,7 +37,6 @@ const MODE_KEYS: Readonly<Record<string, ViewerMode>> = {
36
37
  '1': 'overlay',
37
38
  '2': 'swipe',
38
39
  '3': 'replay',
39
- '4': 'changes',
40
40
  };
41
41
 
42
42
  /** Divider steps for swipe, as a fraction of the width. */
@@ -44,15 +44,21 @@ export const DIVIDER_STEP = 0.02;
44
44
  export const DIVIDER_STEP_LARGE = 0.1;
45
45
 
46
46
  /**
47
- * Map a key press to an action in `mode`, or null when the key means nothing here. Chords with
48
- * Ctrl / Meta / Alt are never handled (they belong to the browser and the host page).
47
+ * Map a key press to an action in `mode` and `scope`, or null when the key means nothing here.
48
+ * Chords with Ctrl / Meta / Alt are never handled (they belong to the browser and the host page).
49
49
  */
50
- export function keyAction(e: KeyLike, mode: ViewerMode): KeyAction | null {
50
+ export function keyAction(
51
+ e: KeyLike,
52
+ mode: ViewerMode,
53
+ scope: ViewerScope = 'all',
54
+ ): KeyAction | null {
51
55
  if (e.ctrlKey || e.metaKey || e.altKey) return null;
52
56
  const key = e.key;
53
57
  const mapped = MODE_KEYS[key];
54
58
  if (mapped !== undefined) return { type: 'mode', mode: mapped };
55
59
  switch (key) {
60
+ case '4':
61
+ return { type: 'scope' };
56
62
  case 'j':
57
63
  case 'ArrowDown':
58
64
  return { type: 'next' };
@@ -104,7 +110,7 @@ export function keyAction(e: KeyLike, mode: ViewerMode): KeyAction | null {
104
110
  return { type: 'theme' };
105
111
  case 'e':
106
112
  case 'E':
107
- return mode === 'changes' ? { type: 'expand' } : null;
113
+ return scope === 'changes' ? { type: 'expand' } : null;
108
114
  case '?':
109
115
  return { type: 'help' };
110
116
  default:
package/src/prefs.ts CHANGED
@@ -12,9 +12,11 @@ import type {
12
12
  ViewerChrome,
13
13
  ViewerLegend,
14
14
  ViewerMode,
15
+ ViewerScope,
15
16
  } from './index.js';
16
17
 
17
- const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay', 'changes'];
18
+ const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay'];
19
+ const SCOPES: readonly ViewerScope[] = ['all', 'changes'];
18
20
  const THEMES: readonly ThemePreference[] = ['auto', 'light', 'dark'];
19
21
  const PALETTES: readonly PaletteName[] = ['default', 'colorblind'];
20
22
  const CHROMES: readonly ViewerChrome[] = ['full', 'none'];
@@ -29,6 +31,16 @@ export function toMode(value: unknown): ViewerMode | null {
29
31
  return MODES.includes(value as ViewerMode) ? (value as ViewerMode) : null;
30
32
  }
31
33
 
34
+ export function toScope(value: unknown): ViewerScope | null {
35
+ return SCOPES.includes(value as ViewerScope) ? (value as ViewerScope) : null;
36
+ }
37
+
38
+ /**
39
+ * Before scopes existed, “changes only” was a fourth mode. Attributes, properties, deep links
40
+ * and reports may still say `changes`: it means overlay + the `changes` scope.
41
+ */
42
+ export const LEGACY_CHANGES_MODE = 'changes';
43
+
32
44
  export function toTheme(value: unknown): ThemePreference | null {
33
45
  return THEMES.includes(value as ThemePreference) ? (value as ThemePreference) : null;
34
46
  }
@@ -61,18 +73,21 @@ type View = NonNullable<ReportPayload['view']>;
61
73
  export function sanitizeView(view: unknown): View | undefined {
62
74
  if (typeof view !== 'object' || view === null) return undefined;
63
75
  const v = view as Record<string, unknown>;
64
- const mode = toMode(v.mode);
76
+ const legacy = v.mode === LEGACY_CHANGES_MODE;
77
+ const mode = legacy ? 'overlay' : toMode(v.mode);
78
+ const scope = toScope(v.scope) ?? (legacy && v.scope === undefined ? 'changes' : null);
65
79
  const theme = toTheme(v.theme);
66
80
  const palette = toPalette(v.palette);
67
81
  const locale = toLocale(v.locale);
68
- const dropped = (['mode', 'theme', 'palette', 'locale'] as const).filter(
69
- (k) => v[k] !== undefined && { mode, theme, palette, locale }[k] === null,
82
+ const dropped = (['mode', 'scope', 'theme', 'palette', 'locale'] as const).filter(
83
+ (k) => v[k] !== undefined && { mode, scope, theme, palette, locale }[k] === null,
70
84
  );
71
85
  if (dropped.length > 0 && typeof console !== 'undefined') {
72
86
  console.warn(`[kgdiff] ignoring invalid report view preferences: ${dropped.join(', ')}`);
73
87
  }
74
88
  return {
75
89
  ...(mode ? { mode } : {}),
90
+ ...(scope ? { scope } : {}),
76
91
  ...(theme ? { theme } : {}),
77
92
  ...(palette ? { palette } : {}),
78
93
  ...(locale ? { locale } : {}),
package/src/styles.ts CHANGED
@@ -121,7 +121,7 @@ kbd {
121
121
  background: var(--kg-bg);
122
122
  }
123
123
  .body { position: relative; flex: 1; min-height: 0; display: flex; }
124
- .stage-wrap { position: relative; flex: 1; min-width: 0; min-height: 0; overflow: hidden; background: var(--kg-bg); }
124
+ .stage-wrap { position: relative; flex: 1; min-width: 0; min-height: 0; overflow: hidden; background: var(--kg-bg); container-type: inline-size; }
125
125
  .stage { position: absolute; inset: 0; }
126
126
  .stage canvas { outline: none; }
127
127
  .stage canvas:focus-visible { outline: 2px solid var(--kg-accent); outline-offset: -2px; }
@@ -148,17 +148,22 @@ kbd {
148
148
  .link-btn { min-height: 28px; color: var(--kg-accent-text); font-weight: 500; font-size: 12px; border-radius: 4px; }
149
149
  .link-btn:hover { text-decoration: underline; text-underline-offset: 3px; }
150
150
 
151
- /* ── Header ──────────────────────────────────────────────────────────────── */
152
- .header {
153
- position: relative; z-index: 3; flex: none; height: 52px; display: flex; align-items: center; gap: 12px;
154
- padding: 0 10px 0 14px; background: var(--kg-panel); border-bottom: 1px solid var(--kg-border);
155
- }
151
+ /* ── HUD: title (top-left) and actions (top-right), over the canvas ──────── */
152
+ .hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }
153
+ .hud > * { pointer-events: auto; }
154
+ .hud-title {
155
+ position: absolute; top: 12px; left: 12px; max-width: calc(100% - 184px); height: 40px;
156
+ display: flex; align-items: center; gap: 10px; padding: 0 14px 0 10px;
157
+ }
158
+ .hud[data-titled="false"] .header-sep { display: none; }
159
+ :host([header="compact"]) .hud-title { display: none; }
160
+ .hud-end { position: absolute; top: 12px; right: 12px; }
161
+ .hud-actions { display: flex; align-items: center; gap: 2px; padding: 4px; }
156
162
  .brand { display: flex; align-items: center; gap: 8px; flex: none; color: var(--kg-text); }
157
163
  .brand-mark { width: 20px; height: 20px; display: block; }
158
164
  .brand-name { font-size: 14px; font-weight: 650; letter-spacing: -0.01em; }
159
- .header-sep { width: 1px; height: 20px; background: var(--kg-border-strong); flex: none; }
160
- .versions { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1 1 0; font-size: 13px; overflow: hidden;
161
- mask-image: linear-gradient(90deg, #000 calc(100% - 16px), transparent); }
165
+ .header-sep { width: 1px; height: 18px; background: var(--kg-border-strong); flex: none; }
166
+ .versions { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 0 1 auto; font-size: 13px; overflow: hidden; }
162
167
  .versions .name { font-weight: 550; color: var(--kg-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
163
168
  .versions .arrow { color: var(--kg-text-3); flex: none; }
164
169
  .versions .side { display: flex; align-items: center; gap: 6px; min-width: 0; }
@@ -167,16 +172,9 @@ kbd {
167
172
  font: 500 11px/1 var(--kg-mono); color: var(--kg-text-2); background: var(--kg-hover);
168
173
  box-shadow: inset 0 0 0 1px var(--kg-border); white-space: nowrap; max-width: 160px; overflow: hidden; text-overflow: ellipsis;
169
174
  }
170
- .header-center { flex: none; display: flex; justify-content: center; }
171
- /* header="compact": no brand or names (the host shows them); the names keep their space so the
172
- mode control stays centered. */
173
- :host([header="compact"]) .brand, :host([header="compact"]) .header-sep { display: none; }
174
- :host([header="compact"]) .versions { visibility: hidden; }
175
- .header-end { display: flex; align-items: center; gap: 4px; flex: 1 0 auto; justify-content: flex-end; }
176
- .stats { display: flex; align-items: center; gap: 4px; margin-right: 6px; }
177
175
 
178
176
  .segmented {
179
- position: relative; display: inline-flex; align-items: center; gap: 2px; height: 32px; padding: 2px;
177
+ position: relative; display: inline-flex; align-items: center; gap: 2px; height: 32px; padding: 2px; flex: none;
180
178
  border-radius: 9px; background: var(--kg-hover); box-shadow: inset 0 0 0 1px var(--kg-border);
181
179
  }
182
180
  .segmented .thumb {
@@ -193,17 +191,6 @@ kbd {
193
191
  .seg[aria-checked="true"] { color: var(--kg-text); }
194
192
  .seg .icon { opacity: 0.9; }
195
193
 
196
- .pill {
197
- display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 9px; border-radius: 14px;
198
- font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap;
199
- transition: box-shadow var(--kg-fast) var(--kg-ease), background var(--kg-fast) var(--kg-ease), opacity var(--kg-fast) var(--kg-ease);
200
- }
201
- .stat { color: var(--fam-text); background: var(--fam-soft); box-shadow: inset 0 0 0 1px transparent; }
202
- .stat:hover { box-shadow: inset 0 0 0 1px var(--fam-base); }
203
- .stat[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px var(--fam-base); }
204
- .stats.filtering .stat[aria-pressed="false"] { opacity: 0.55; }
205
- .stat .glyph { font-weight: 700; }
206
-
207
194
 
208
195
  ${familyRules}
209
196
 
@@ -213,7 +200,6 @@ ${familyRules}
213
200
  backdrop-filter: blur(16px) saturate(140%);
214
201
  }
215
202
  .float { position: absolute; }
216
- .top-left { top: 12px; left: 12px; }
217
203
  .bottom-left { bottom: 16px; left: 16px; }
218
204
  .bottom-right { bottom: 16px; right: 16px; }
219
205
  .chip {
@@ -224,7 +210,10 @@ ${familyRules}
224
210
  .map-cluster { display: flex; align-items: flex-end; gap: 8px; }
225
211
  .zoom-bar { display: flex; flex-direction: column; padding: 2px; gap: 2px; }
226
212
  .zoom-bar .icon-btn { width: 30px; height: 30px; }
227
- .minimap-host { position: relative; width: 184px; height: 128px; border-radius: 12px; box-shadow: 0 0 0 1px var(--kg-border); }
213
+ .minimap-host { position: relative; width: 184px; height: 128px; border-radius: 12px; box-shadow: 0 0 0 1px var(--kg-border);
214
+ animation: fade var(--kg-base) var(--kg-ease); }
215
+ /* Everything is in view: the minimap has nothing to add. */
216
+ .minimap-host[data-hidden="true"] { display: none; }
228
217
  .minimap-host canvas { border-radius: 12px; }
229
218
 
230
219
  .legend { padding: 6px; min-width: 0; }
@@ -250,28 +239,49 @@ ${familyRules}
250
239
  .stage-wrap[data-legend="mini"] .legend, .stage-wrap[data-legend="none"] .legend { display: none; }
251
240
 
252
241
  .caption {
253
- position: absolute; top: 12px; left: 50%; transform: translateX(-50%); max-width: min(560px, calc(100% - 200px));
254
- display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px 0 6px; font-size: 12.5px; font-weight: 550;
255
- transition: opacity var(--kg-base) var(--kg-ease);
242
+ max-width: min(560px, 100%); display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px 0 6px;
243
+ font-size: 12.5px; font-weight: 550;
256
244
  }
257
245
  .caption .text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
258
246
  .caption .count { color: var(--kg-text-3); font-weight: 500; font-variant-numeric: tabular-nums; flex: none; }
259
- .caption[data-empty="true"] { opacity: 0; pointer-events: none; }
247
+ .caption[data-empty="true"] { display: none; }
260
248
 
261
249
  .glyph-box {
262
250
  display: inline-flex; align-items: center; justify-content: center; flex: none; width: 20px; height: 20px; border-radius: 6px;
263
251
  font-size: 12px; font-weight: 700; line-height: 1; color: var(--fam-text); background: var(--fam-soft);
264
252
  }
265
253
 
266
- .stepper { display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 4px 0 12px; }
267
- .stepper .label { font-size: 12px; font-weight: 550; color: var(--kg-text-2); }
268
- .stepper .opts { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--kg-hover); }
269
- .stepper .opt { width: 28px; height: 28px; border-radius: 6px; font-size: 12px; font-weight: 600; color: var(--kg-text-2); font-variant-numeric: tabular-nums; }
270
- .stepper .opt:hover { color: var(--kg-text); }
271
- .stepper .expand { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 10px 0 8px; margin-left: 4px; border-radius: 7px; font-size: 12px; font-weight: 600; color: var(--kg-text-2); box-shadow: inset 0 0 0 1px var(--kg-border-strong); font-variant-numeric: tabular-nums; }
272
- .stepper .expand:hover:not(:disabled) { color: var(--kg-text); background: var(--kg-hover); }
273
- .stepper .expand .icon { width: 14px; height: 14px; }
274
- .stepper .opt[aria-checked="true"] { background: var(--kg-raised); color: var(--kg-text); box-shadow: inset 0 0 0 1px var(--kg-border-strong); }
254
+ /* View dock (bottom center): replay's finished pill, caption and transport stack above the bar. */
255
+ .chrome > .dock {
256
+ position: absolute; left: 16px; right: 16px; bottom: 16px; display: flex; flex-direction: column; align-items: center;
257
+ gap: 8px; pointer-events: none;
258
+ }
259
+ .dock > * { pointer-events: auto; }
260
+ .dock-bar { display: flex; align-items: center; gap: 4px; height: 44px; padding: 0 6px; max-width: 100%; }
261
+ .dock-sep { width: 1px; height: 20px; margin: 0 2px; flex: none; background: var(--kg-border-strong); }
262
+ .scope-btn {
263
+ display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border-radius: 9px; flex: none;
264
+ font-size: 12.5px; font-weight: 500; color: var(--kg-text-2); white-space: nowrap;
265
+ transition: background var(--kg-fast) var(--kg-ease), color var(--kg-fast) var(--kg-ease), box-shadow var(--kg-fast) var(--kg-ease);
266
+ }
267
+ .scope-btn:hover { color: var(--kg-text); background: var(--kg-hover); }
268
+ .scope-btn[aria-pressed="true"] { color: var(--kg-accent-text); background: var(--kg-accent-soft); box-shadow: inset 0 0 0 1px var(--kg-accent-soft); }
269
+ .scope-btn[aria-disabled="true"] { cursor: default; }
270
+ .context { display: inline-flex; align-items: center; gap: 8px; padding-left: 2px; flex: none; animation: reveal var(--kg-base) var(--kg-ease); }
271
+ @keyframes reveal { from { opacity: 0; transform: translateX(-6px); } }
272
+ .context .label { font-size: 12px; font-weight: 550; color: var(--kg-text-3); white-space: nowrap; }
273
+ .context .opts { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--kg-hover); }
274
+ .context .opt { width: 28px; height: 28px; border-radius: 6px; font-size: 12px; font-weight: 600; color: var(--kg-text-2); font-variant-numeric: tabular-nums; }
275
+ .context .opt:hover { color: var(--kg-text); }
276
+ .context .opt[aria-checked="true"] { background: var(--kg-raised); color: var(--kg-text); box-shadow: inset 0 0 0 1px var(--kg-border-strong); }
277
+ .context .expand { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 10px 0 8px; border-radius: 7px; font-size: 12px; font-weight: 600; color: var(--kg-text-2); box-shadow: inset 0 0 0 1px var(--kg-border-strong); font-variant-numeric: tabular-nums; }
278
+ .context .expand:hover:not(:disabled) { color: var(--kg-text); background: var(--kg-hover); }
279
+ .context .expand .icon { width: 14px; height: 14px; }
280
+ /* Narrower canvases keep the dock clear of the legend and the minimap: labels give way to icons
281
+ (each control keeps its name as a tooltip). */
282
+ @container (max-width: 1180px) { .context .label { display: none; } }
283
+ @container (max-width: 1000px) { .dock-bar .seg .lbl { display: none; } .dock-bar .seg { padding: 0 9px; } }
284
+ @container (max-width: 640px) { .scope-btn .lbl { display: none; } .scope-btn { padding: 0 9px; } }
275
285
 
276
286
  /* Swipe */
277
287
  .swipe { position: absolute; inset: 0; pointer-events: none; }
@@ -291,14 +301,14 @@ ${familyRules}
291
301
  .swipe .divider:hover .handle, .swipe .divider.dragging .handle { transform: scale(1.08); }
292
302
  .swipe .divider:focus-visible { outline: none; }
293
303
  .swipe .divider:focus-visible .handle { box-shadow: var(--kg-shadow), 0 0 0 2px var(--kg-accent), 0 0 0 5px var(--kg-accent-soft); }
294
- .swipe .side-chip { position: absolute; top: 12px; pointer-events: none; }
304
+ /* Below the HUD's title and actions. */
305
+ .swipe .side-chip { position: absolute; top: 64px; pointer-events: none; }
295
306
  .swipe .side-chip.before { left: 12px; }
296
307
  .swipe .side-chip.after { right: 12px; }
297
308
 
298
- /* Replay transport */
309
+ /* Replay transport (in the dock, clear of the legend and the minimap) */
299
310
  .transport {
300
- position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); width: min(600px, calc(100% - 496px));
301
- min-width: 360px;
311
+ width: min(600px, calc(100% - 464px)); min-width: min(360px, 100%);
302
312
  display: flex; align-items: center; gap: 4px; height: 48px; padding: 0 8px;
303
313
  }
304
314
  .transport .play { width: 36px; height: 36px; border-radius: 10px; color: var(--kg-text); }
@@ -321,12 +331,12 @@ ${familyRules}
321
331
  display: flex; align-items: center; gap: 6px;
322
332
  }
323
333
  .finished {
324
- position: absolute; left: 50%; bottom: 76px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
334
+ display: flex; align-items: center; gap: 10px;
325
335
  height: 36px; padding: 0 6px 0 12px; font-size: 12.5px; font-weight: 550; color: var(--kg-text);
326
336
  animation: rise var(--kg-base) var(--kg-ease);
327
337
  }
328
338
  .finished .icon { color: var(--kg-accent-text); }
329
- @keyframes rise { from { opacity: 0; transform: translate(-50%, 6px); } }
339
+ @keyframes rise { from { opacity: 0; transform: translateY(6px); } }
330
340
 
331
341
  /* Tooltip */
332
342
  .tooltip {
@@ -424,7 +434,7 @@ ${familyRules}
424
434
  transition: background var(--kg-fast) var(--kg-ease), box-shadow var(--kg-fast) var(--kg-ease), color var(--kg-fast) var(--kg-ease);
425
435
  }
426
436
  .fchip:hover { background: var(--kg-hover); color: var(--kg-text); }
427
- .fchip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--fam-base); flex: none; }
437
+ .fchip .swatch { width: 16px; height: 16px; margin: 0 -1px; }
428
438
  .fchip .n { color: var(--kg-text-3); font-variant-numeric: tabular-nums; font-weight: 500; }
429
439
  .fchip[aria-pressed="true"] { background: var(--fam-soft); color: var(--fam-text); box-shadow: inset 0 0 0 1px var(--fam-base); }
430
440
  .fchip[aria-pressed="true"] .n { color: var(--fam-text); opacity: 0.8; }
@@ -578,7 +588,7 @@ ins.hl { text-decoration: none; background: var(--kg-added-soft); color: var(--k
578
588
 
579
589
  /* ── Popovers ────────────────────────────────────────────────────────────── */
580
590
  .menu {
581
- position: absolute; z-index: 10; top: 48px; right: 10px; width: 240px; padding: 6px; border-radius: 12px;
591
+ position: absolute; z-index: 10; top: calc(100% + 8px); right: 0; width: 240px; padding: 6px; border-radius: 12px;
582
592
  animation: pop var(--kg-fast) var(--kg-ease);
583
593
  }
584
594
  @keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(0.98); } }
@@ -607,8 +617,7 @@ ins.hl { text-decoration: none; background: var(--kg-added-soft); color: var(--k
607
617
  .key-row .ks { display: inline-flex; align-items: center; gap: 3px; flex: none; color: var(--kg-text-3); font-size: 11px; }
608
618
 
609
619
  /* ── chrome="none": the graph, its hover tooltip and the legend line only ──── */
610
- .root[data-chrome="none"] .header, .root[data-chrome="none"] .sidebar, .root[data-chrome="none"] .caption,
611
- .root[data-chrome="none"] .transport, .root[data-chrome="none"] .finished, .root[data-chrome="none"] .stepper,
620
+ .root[data-chrome="none"] .hud, .root[data-chrome="none"] .sidebar, .root[data-chrome="none"] .dock,
612
621
  .root[data-chrome="none"] .map-cluster, .root[data-chrome="none"] .side-chip { display: none !important; }
613
622
  .stage-wrap[data-selectable="false"] .tooltip .t-hint { display: none; }
614
623
 
@@ -627,20 +636,17 @@ ins.hl { text-decoration: none; background: var(--kg-added-soft); color: var(--k
627
636
  }
628
637
  .root[data-compact="true"] .sheet-handle::before { content: ""; width: 36px; height: 4px; border-radius: 2px; background: var(--kg-control-border); }
629
638
  .root[data-compact="true"] .sb-head { padding-top: 4px; }
630
- .root[data-compact="true"] .seg .lbl { display: none; }
631
- .root[data-compact="true"] .seg { padding: 0 9px; }
632
- .root[data-compact="true"] .versions, .root[data-compact="true"] .header-sep, .root[data-compact="true"] .stats,
639
+ .root[data-compact="true"] .versions, .root[data-compact="true"] .header-sep,
633
640
  .root[data-compact="true"] .brand-name { display: none; }
634
- .root[data-compact="true"] .header-center { flex: 1; justify-content: flex-start; }
635
- .root[data-compact="true"] .header-end { flex: none; }
641
+ .root[data-compact="true"] .hud-title { padding: 0 10px; }
636
642
  .root[data-compact="true"] .minimap-host, .root[data-compact="true"] .legend { display: none; }
637
- /* Zoom controls move to the top-right corner, clear of the full-width replay transport. */
638
- .root[data-compact="true"] .map-cluster { top: 52px; bottom: auto; right: 12px; }
639
- .root[data-compact="true"] .transport { bottom: 24px; width: calc(100% - 24px); min-width: 0; }
643
+ /* Zoom controls move under the actions, clear of the full-width replay transport. */
644
+ .root[data-compact="true"] .map-cluster { top: 64px; bottom: auto; right: 12px; }
645
+ /* The bottom sheet's rounded top overlaps the canvas. */
646
+ .root[data-compact="true"] .chrome > .dock { left: 12px; right: 12px; bottom: 26px; }
647
+ .root[data-compact="true"] .transport { width: 100%; min-width: 0; }
640
648
  .root[data-compact="true"] .transport .pos { min-width: 48px; }
641
649
  .root[data-compact="true"] .detail { max-height: 50%; }
642
- .root[data-compact="true"] .caption { max-width: calc(100% - 24px); }
643
- .root[data-narrow="true"] .stats { display: none; }
644
650
  .root[data-narrow="true"] .versions .tag, .root[data-narrow="true"] .versions .arrow { display: none; }
645
651
 
646
652
  @media (prefers-reduced-motion: reduce) {
package/src/ui/context.ts CHANGED
@@ -12,6 +12,8 @@ export interface UiContext {
12
12
  readonly controller: ViewerController;
13
13
  readonly tr: () => Translator;
14
14
  setMode(mode: ViewerMode): void;
15
+ /** Toggle the `changes` scope (explains itself when it is locked). */
16
+ toggleScope(): void;
15
17
  setThemePreference(pref: ThemePreference): void;
16
18
  setPalette(palette: PaletteName): void;
17
19
  setLocale(locale: Locale): void;