@webpieces/nx-webpieces-rules 0.4.977 → 0.4.979
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.
- package/README.md +32 -21
- package/package.json +7 -7
- package/src/lib/graph-filter.client.d.ts +25 -11
- package/src/lib/graph-filter.client.js +65 -29
- package/src/lib/graph-filter.client.js.map +1 -1
- package/src/lib/graph-multi-select.client.d.ts +57 -0
- package/src/lib/graph-multi-select.client.js +222 -0
- package/src/lib/graph-multi-select.client.js.map +1 -0
- package/src/lib/graph-node-menu.d.ts +1 -1
- package/src/lib/graph-node-menu.js +1 -1
- package/src/lib/graph-node-menu.js.map +1 -1
- package/src/lib/graph-page-shell.d.ts +23 -2
- package/src/lib/graph-page-shell.js +63 -13
- package/src/lib/graph-page-shell.js.map +1 -1
- package/src/lib/graph-page-styles.d.ts +6 -0
- package/src/lib/graph-page-styles.js +38 -10
- package/src/lib/graph-page-styles.js.map +1 -1
- package/src/lib/graph-render-model.js +2 -2
- package/src/lib/graph-render-model.js.map +1 -1
- package/src/lib/graph-visualizer.client.d.ts +39 -19
- package/src/lib/graph-visualizer.client.js +96 -110
- package/src/lib/graph-visualizer.client.js.map +1 -1
- package/src/lib/graph-visualizer.d.ts +7 -11
- package/src/lib/graph-visualizer.js +14 -32
- package/src/lib/graph-visualizer.js.map +1 -1
- package/src/lib/runtime-html-page.d.ts +6 -1
- package/src/lib/runtime-html-page.js +13 -6
- package/src/lib/runtime-html-page.js.map +1 -1
- package/src/lib/runtime-visualizer.client.js +3 -1
- package/src/lib/runtime-visualizer.client.js.map +1 -1
- package/src/lib/runtime-visualizer.js +5 -3
- package/src/lib/runtime-visualizer.js.map +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* The multi-select pulldown with search behind the drawer's "Keep connected to" and "Focus" (#1385).
|
|
4
|
+
*
|
|
5
|
+
* Prepended to graph-visualizer.client.js like graph-mode-state.client.ts, so both viewers (the
|
|
6
|
+
* architecture page and the runtime page) run the ONE implementation. It knows nothing about graphs:
|
|
7
|
+
* each instance reads and changes its picks through a WpMultiSource the page supplies.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* One multi-select pulldown with search (#1385) — ONE implementation, two instances: "Keep connected
|
|
11
|
+
* to" and "Focus". Its markup is graph-page-shell.ts `multiSelect()`; this owns the behaviour:
|
|
12
|
+
* - one checkbox row per option, alphabetical (the shell's order);
|
|
13
|
+
* - the search matches ANYWHERE in the name, case-insensitively, and highlights the match;
|
|
14
|
+
* - ↑/↓ move, Enter toggles (Space too while the query is empty), and the query stays after a toggle
|
|
15
|
+
* so several matches can be picked in a row;
|
|
16
|
+
* - the closed field shows the picks as removable chips, or "None (off)";
|
|
17
|
+
* - the footer says "N selected", and Clear all turns the pulldown off.
|
|
18
|
+
* It holds no picks of its own: every change goes to the page, and `sync()` repaints from it.
|
|
19
|
+
*/
|
|
20
|
+
class WpMultiSelect {
|
|
21
|
+
key;
|
|
22
|
+
source;
|
|
23
|
+
activeId = null;
|
|
24
|
+
constructor(key, source) {
|
|
25
|
+
this.key = key;
|
|
26
|
+
this.source = source;
|
|
27
|
+
}
|
|
28
|
+
wire() {
|
|
29
|
+
// No stopPropagation: the page's outside-click handler closes every OTHER open pulldown.
|
|
30
|
+
this.byId('field')?.addEventListener('click', () => {
|
|
31
|
+
if (this.isOpen())
|
|
32
|
+
this.close(false);
|
|
33
|
+
else
|
|
34
|
+
this.open();
|
|
35
|
+
});
|
|
36
|
+
this.byId('clear')?.addEventListener('click', () => {
|
|
37
|
+
this.source.clear();
|
|
38
|
+
this.search()?.focus({ preventScroll: true });
|
|
39
|
+
});
|
|
40
|
+
const search = this.search();
|
|
41
|
+
search?.addEventListener('input', () => {
|
|
42
|
+
this.activeId = null;
|
|
43
|
+
this.paint();
|
|
44
|
+
});
|
|
45
|
+
search?.addEventListener('keydown', (ev) => this.onKey(ev));
|
|
46
|
+
for (const row of this.rows()) {
|
|
47
|
+
// Keep keyboard focus in the search box, so a click pick works like an Enter pick.
|
|
48
|
+
row.addEventListener('mousedown', (ev) => ev.preventDefault());
|
|
49
|
+
row.addEventListener('click', () => {
|
|
50
|
+
this.activeId = this.idOf(row);
|
|
51
|
+
this.source.toggle(this.activeId);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
isOpen() {
|
|
56
|
+
return this.pop()?.hidden === false;
|
|
57
|
+
}
|
|
58
|
+
/** Open with the cursor in an EMPTY search box: the query lasts while it is open, not across opens. */
|
|
59
|
+
open() {
|
|
60
|
+
const pop = this.pop();
|
|
61
|
+
if (pop === null)
|
|
62
|
+
return;
|
|
63
|
+
const search = this.search();
|
|
64
|
+
if (search !== null)
|
|
65
|
+
search.value = '';
|
|
66
|
+
this.activeId = null;
|
|
67
|
+
pop.hidden = false;
|
|
68
|
+
this.byId('trigger')?.setAttribute('aria-expanded', 'true');
|
|
69
|
+
this.paint();
|
|
70
|
+
this.search()?.focus({ preventScroll: true });
|
|
71
|
+
}
|
|
72
|
+
/** Close; `returnFocus` puts the keyboard back on the pulldown's own button. */
|
|
73
|
+
close(returnFocus) {
|
|
74
|
+
const pop = this.pop();
|
|
75
|
+
if (pop === null || pop.hidden)
|
|
76
|
+
return;
|
|
77
|
+
pop.hidden = true;
|
|
78
|
+
const trigger = this.byId('trigger');
|
|
79
|
+
trigger?.setAttribute('aria-expanded', 'false');
|
|
80
|
+
if (returnFocus)
|
|
81
|
+
trigger?.focus({ preventScroll: true });
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a click happened inside this pulldown: an outside click closes it. Read from the event's
|
|
85
|
+
* PATH, not from the target's ancestors — a pick repaints the row it was clicked on, so by the time
|
|
86
|
+
* the click reaches the document its target (the old `<mark>`) is no longer in the page.
|
|
87
|
+
*/
|
|
88
|
+
clicked(ev) {
|
|
89
|
+
const root = document.getElementById(`wp-${this.key}`);
|
|
90
|
+
return root !== null && ev.composedPath().includes(root);
|
|
91
|
+
}
|
|
92
|
+
/** Repaint the chips, the footer and (while open) the rows from the page's picks. */
|
|
93
|
+
sync() {
|
|
94
|
+
this.chips();
|
|
95
|
+
const count = this.byId('count');
|
|
96
|
+
if (count !== null)
|
|
97
|
+
count.textContent = `${this.source.selected().size} selected`;
|
|
98
|
+
if (this.isOpen())
|
|
99
|
+
this.paint();
|
|
100
|
+
}
|
|
101
|
+
onKey(ev) {
|
|
102
|
+
const query = this.search()?.value ?? '';
|
|
103
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
104
|
+
ev.preventDefault();
|
|
105
|
+
this.move(ev.key === 'ArrowDown' ? 1 : -1);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const toggles = ev.key === 'Enter' || (ev.key === ' ' && query === '');
|
|
109
|
+
if (!toggles)
|
|
110
|
+
return;
|
|
111
|
+
ev.preventDefault();
|
|
112
|
+
const shown = this.shownRows();
|
|
113
|
+
const active = shown.find((row) => this.idOf(row) === this.activeId) ?? shown[0];
|
|
114
|
+
if (active === undefined)
|
|
115
|
+
return;
|
|
116
|
+
this.activeId = this.idOf(active);
|
|
117
|
+
this.source.toggle(this.activeId);
|
|
118
|
+
}
|
|
119
|
+
move(step) {
|
|
120
|
+
const shown = this.shownRows();
|
|
121
|
+
if (shown.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
const index = shown.findIndex((row) => this.idOf(row) === this.activeId);
|
|
124
|
+
const next = index < 0 ? (step > 0 ? 0 : shown.length - 1) : Math.min(shown.length - 1, Math.max(0, index + step));
|
|
125
|
+
this.activeId = this.idOf(shown[next]);
|
|
126
|
+
this.paint();
|
|
127
|
+
shown[next].scrollIntoView({ block: 'nearest' });
|
|
128
|
+
}
|
|
129
|
+
/** Show the rows the query and the page allow, highlight the match, and tick the picks. */
|
|
130
|
+
paint() {
|
|
131
|
+
const query = (this.search()?.value ?? '').trim().toLowerCase();
|
|
132
|
+
const selected = this.source.selected();
|
|
133
|
+
let shown = 0;
|
|
134
|
+
for (const row of this.rows()) {
|
|
135
|
+
const id = this.idOf(row);
|
|
136
|
+
const at = id.toLowerCase().indexOf(query);
|
|
137
|
+
row.hidden = at < 0 || !this.source.available(id);
|
|
138
|
+
const picked = selected.has(id);
|
|
139
|
+
row.setAttribute('aria-selected', String(picked));
|
|
140
|
+
const box = row.querySelector('input');
|
|
141
|
+
if (box !== null)
|
|
142
|
+
box.checked = picked;
|
|
143
|
+
row.classList.toggle('wp-multi-active', !row.hidden && id === this.activeId);
|
|
144
|
+
if (row.hidden)
|
|
145
|
+
continue;
|
|
146
|
+
shown++;
|
|
147
|
+
this.highlight(row, id, at, query.length);
|
|
148
|
+
}
|
|
149
|
+
const empty = this.byId('empty');
|
|
150
|
+
if (empty !== null)
|
|
151
|
+
empty.hidden = shown > 0;
|
|
152
|
+
}
|
|
153
|
+
/** The name with its matched part in a `<mark>`, built from text nodes so no name is parsed as HTML. */
|
|
154
|
+
highlight(row, id, at, length) {
|
|
155
|
+
const name = row.querySelector('.wp-multi-name');
|
|
156
|
+
if (name === null)
|
|
157
|
+
return;
|
|
158
|
+
if (length === 0) {
|
|
159
|
+
name.replaceChildren(id);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const mark = document.createElement('mark');
|
|
163
|
+
mark.textContent = id.slice(at, at + length);
|
|
164
|
+
name.replaceChildren(id.slice(0, at), mark, id.slice(at + length));
|
|
165
|
+
}
|
|
166
|
+
/** One removable chip per pick, alphabetical; a pick that is not drawn right now says "hidden". */
|
|
167
|
+
chips() {
|
|
168
|
+
const host = this.byId('chips');
|
|
169
|
+
if (host === null)
|
|
170
|
+
return;
|
|
171
|
+
const picks = [...this.source.selected()].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
172
|
+
if (picks.length === 0) {
|
|
173
|
+
const none = document.createElement('span');
|
|
174
|
+
none.className = 'wp-multi-none';
|
|
175
|
+
none.textContent = 'None (off)';
|
|
176
|
+
host.replaceChildren(none);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
host.replaceChildren(...picks.map((id) => this.chip(id)));
|
|
180
|
+
}
|
|
181
|
+
chip(id) {
|
|
182
|
+
const chip = document.createElement('span');
|
|
183
|
+
chip.className = 'wp-multi-chip';
|
|
184
|
+
chip.dataset['wpPick'] = id;
|
|
185
|
+
chip.append(id);
|
|
186
|
+
if (!this.source.available(id)) {
|
|
187
|
+
chip.classList.add('wp-multi-chip-hidden');
|
|
188
|
+
const note = document.createElement('small');
|
|
189
|
+
note.textContent = 'hidden';
|
|
190
|
+
chip.append(' ', note);
|
|
191
|
+
}
|
|
192
|
+
const remove = document.createElement('button');
|
|
193
|
+
remove.type = 'button';
|
|
194
|
+
remove.textContent = '✕';
|
|
195
|
+
remove.setAttribute('aria-label', `Remove ${id}`);
|
|
196
|
+
remove.addEventListener('click', (ev) => {
|
|
197
|
+
ev.stopPropagation();
|
|
198
|
+
this.source.toggle(id);
|
|
199
|
+
});
|
|
200
|
+
chip.append(remove);
|
|
201
|
+
return chip;
|
|
202
|
+
}
|
|
203
|
+
shownRows() {
|
|
204
|
+
return this.rows().filter((row) => !row.hidden);
|
|
205
|
+
}
|
|
206
|
+
rows() {
|
|
207
|
+
return Array.from(document.querySelectorAll(`#wp-${this.key}-list .wp-multi-option`));
|
|
208
|
+
}
|
|
209
|
+
idOf(row) {
|
|
210
|
+
return row.dataset['wpOption'] ?? '';
|
|
211
|
+
}
|
|
212
|
+
search() {
|
|
213
|
+
return this.byId('search');
|
|
214
|
+
}
|
|
215
|
+
pop() {
|
|
216
|
+
return this.byId('pop');
|
|
217
|
+
}
|
|
218
|
+
byId(part) {
|
|
219
|
+
return document.getElementById(`wp-${this.key}-${part}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=graph-multi-select.client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graph-multi-select.client.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/graph-multi-select.client.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The multi-select pulldown with search behind the drawer's \"Keep connected to\" and \"Focus\" (#1385).\n *\n * Prepended to graph-visualizer.client.js like graph-mode-state.client.ts, so both viewers (the\n * architecture page and the runtime page) run the ONE implementation. It knows nothing about graphs:\n * each instance reads and changes its picks through a WpMultiSource the page supplies.\n */\n\n/**\n * What one multi-select pulldown reads and changes on the page: its picks, which options it may offer\n * right now, and how a toggle or Clear all lands. Behaviour, so an interface.\n */\ninterface WpMultiSource {\n selected(): ReadonlySet<string>;\n /** Whether `id` is drawn right now: an unavailable option is not offered, and its chip says \"hidden\". */\n available(id: string): boolean;\n toggle(id: string): void;\n clear(): void;\n}\n\n/**\n * One multi-select pulldown with search (#1385) — ONE implementation, two instances: \"Keep connected\n * to\" and \"Focus\". Its markup is graph-page-shell.ts `multiSelect()`; this owns the behaviour:\n * - one checkbox row per option, alphabetical (the shell's order);\n * - the search matches ANYWHERE in the name, case-insensitively, and highlights the match;\n * - ↑/↓ move, Enter toggles (Space too while the query is empty), and the query stays after a toggle\n * so several matches can be picked in a row;\n * - the closed field shows the picks as removable chips, or \"None (off)\";\n * - the footer says \"N selected\", and Clear all turns the pulldown off.\n * It holds no picks of its own: every change goes to the page, and `sync()` repaints from it.\n */\nclass WpMultiSelect {\n private activeId: string | null = null;\n\n constructor(\n private readonly key: string,\n private readonly source: WpMultiSource,\n ) {}\n\n wire(): void {\n // No stopPropagation: the page's outside-click handler closes every OTHER open pulldown.\n this.byId('field')?.addEventListener('click', () => {\n if (this.isOpen()) this.close(false);\n else this.open();\n });\n this.byId('clear')?.addEventListener('click', () => {\n this.source.clear();\n this.search()?.focus({ preventScroll: true });\n });\n const search = this.search();\n search?.addEventListener('input', () => {\n this.activeId = null;\n this.paint();\n });\n search?.addEventListener('keydown', (ev: KeyboardEvent) => this.onKey(ev));\n for (const row of this.rows()) {\n // Keep keyboard focus in the search box, so a click pick works like an Enter pick.\n row.addEventListener('mousedown', (ev: MouseEvent) => ev.preventDefault());\n row.addEventListener('click', () => {\n this.activeId = this.idOf(row);\n this.source.toggle(this.activeId);\n });\n }\n }\n\n isOpen(): boolean {\n return this.pop()?.hidden === false;\n }\n\n /** Open with the cursor in an EMPTY search box: the query lasts while it is open, not across opens. */\n open(): void {\n const pop = this.pop();\n if (pop === null) return;\n const search = this.search();\n if (search !== null) search.value = '';\n this.activeId = null;\n pop.hidden = false;\n this.byId('trigger')?.setAttribute('aria-expanded', 'true');\n this.paint();\n this.search()?.focus({ preventScroll: true });\n }\n\n /** Close; `returnFocus` puts the keyboard back on the pulldown's own button. */\n close(returnFocus: boolean): void {\n const pop = this.pop();\n if (pop === null || pop.hidden) return;\n pop.hidden = true;\n const trigger = this.byId('trigger');\n trigger?.setAttribute('aria-expanded', 'false');\n if (returnFocus) trigger?.focus({ preventScroll: true });\n }\n\n /**\n * Whether a click happened inside this pulldown: an outside click closes it. Read from the event's\n * PATH, not from the target's ancestors — a pick repaints the row it was clicked on, so by the time\n * the click reaches the document its target (the old `<mark>`) is no longer in the page.\n */\n clicked(ev: Event): boolean {\n const root = document.getElementById(`wp-${this.key}`);\n return root !== null && ev.composedPath().includes(root);\n }\n\n /** Repaint the chips, the footer and (while open) the rows from the page's picks. */\n sync(): void {\n this.chips();\n const count = this.byId('count');\n if (count !== null) count.textContent = `${this.source.selected().size} selected`;\n if (this.isOpen()) this.paint();\n }\n\n private onKey(ev: KeyboardEvent): void {\n const query = this.search()?.value ?? '';\n if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {\n ev.preventDefault();\n this.move(ev.key === 'ArrowDown' ? 1 : -1);\n return;\n }\n const toggles = ev.key === 'Enter' || (ev.key === ' ' && query === '');\n if (!toggles) return;\n ev.preventDefault();\n const shown = this.shownRows();\n const active = shown.find((row: HTMLElement): boolean => this.idOf(row) === this.activeId) ?? shown[0];\n if (active === undefined) return;\n this.activeId = this.idOf(active);\n this.source.toggle(this.activeId);\n }\n\n private move(step: number): void {\n const shown = this.shownRows();\n if (shown.length === 0) return;\n const index = shown.findIndex((row: HTMLElement): boolean => this.idOf(row) === this.activeId);\n const next = index < 0 ? (step > 0 ? 0 : shown.length - 1) : Math.min(shown.length - 1, Math.max(0, index + step));\n this.activeId = this.idOf(shown[next]);\n this.paint();\n shown[next].scrollIntoView({ block: 'nearest' });\n }\n\n /** Show the rows the query and the page allow, highlight the match, and tick the picks. */\n private paint(): void {\n const query = (this.search()?.value ?? '').trim().toLowerCase();\n const selected = this.source.selected();\n let shown = 0;\n for (const row of this.rows()) {\n const id = this.idOf(row);\n const at = id.toLowerCase().indexOf(query);\n row.hidden = at < 0 || !this.source.available(id);\n const picked = selected.has(id);\n row.setAttribute('aria-selected', String(picked));\n const box = row.querySelector('input');\n if (box !== null) box.checked = picked;\n row.classList.toggle('wp-multi-active', !row.hidden && id === this.activeId);\n if (row.hidden) continue;\n shown++;\n this.highlight(row, id, at, query.length);\n }\n const empty = this.byId('empty');\n if (empty !== null) empty.hidden = shown > 0;\n }\n\n /** The name with its matched part in a `<mark>`, built from text nodes so no name is parsed as HTML. */\n private highlight(row: HTMLElement, id: string, at: number, length: number): void {\n const name = row.querySelector('.wp-multi-name');\n if (name === null) return;\n if (length === 0) {\n name.replaceChildren(id);\n return;\n }\n const mark = document.createElement('mark');\n mark.textContent = id.slice(at, at + length);\n name.replaceChildren(id.slice(0, at), mark, id.slice(at + length));\n }\n\n /** One removable chip per pick, alphabetical; a pick that is not drawn right now says \"hidden\". */\n private chips(): void {\n const host = this.byId('chips');\n if (host === null) return;\n const picks = [...this.source.selected()].sort((a: string, b: string): number => a.toLowerCase().localeCompare(b.toLowerCase()));\n if (picks.length === 0) {\n const none = document.createElement('span');\n none.className = 'wp-multi-none';\n none.textContent = 'None (off)';\n host.replaceChildren(none);\n return;\n }\n host.replaceChildren(...picks.map((id: string): HTMLElement => this.chip(id)));\n }\n\n private chip(id: string): HTMLElement {\n const chip = document.createElement('span');\n chip.className = 'wp-multi-chip';\n chip.dataset['wpPick'] = id;\n chip.append(id);\n if (!this.source.available(id)) {\n chip.classList.add('wp-multi-chip-hidden');\n const note = document.createElement('small');\n note.textContent = 'hidden';\n chip.append(' ', note);\n }\n const remove = document.createElement('button');\n remove.type = 'button';\n remove.textContent = '✕';\n remove.setAttribute('aria-label', `Remove ${id}`);\n remove.addEventListener('click', (ev: MouseEvent) => {\n ev.stopPropagation();\n this.source.toggle(id);\n });\n chip.append(remove);\n return chip;\n }\n\n private shownRows(): HTMLElement[] {\n return this.rows().filter((row: HTMLElement): boolean => !row.hidden);\n }\n\n private rows(): HTMLElement[] {\n return Array.from(document.querySelectorAll<HTMLElement>(`#wp-${this.key}-list .wp-multi-option`));\n }\n\n private idOf(row: HTMLElement): string {\n return row.dataset['wpOption'] ?? '';\n }\n\n private search(): HTMLInputElement | null {\n return this.byId('search') as HTMLInputElement | null;\n }\n\n private pop(): HTMLElement | null {\n return this.byId('pop');\n }\n\n private byId(part: string): HTMLElement | null {\n return document.getElementById(`wp-${this.key}-${part}`);\n }\n}\n"]}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* ambient globals — see viz-client-globals.d.ts.
|
|
17
17
|
*
|
|
18
18
|
* What the menu does NOT own is the LOCK BEHAVIOUR, because the pages genuinely differ:
|
|
19
|
-
* - the architecture page already has a
|
|
19
|
+
* - the architecture page already has a multi-select Focus pulldown backed by GraphHighlighter (chain
|
|
20
20
|
* highlight + responsibilities filter), and its menu item drives that, so the two stay in sync;
|
|
21
21
|
* - a design page and the runtime page have neither lock field nor responsibilities, so they use
|
|
22
22
|
* `WpNodeLock` below — dim every other box in that graph, light the locked one.
|
|
@@ -19,7 +19,7 @@ exports.GraphNodeMenu = void 0;
|
|
|
19
19
|
* ambient globals — see viz-client-globals.d.ts.
|
|
20
20
|
*
|
|
21
21
|
* What the menu does NOT own is the LOCK BEHAVIOUR, because the pages genuinely differ:
|
|
22
|
-
* - the architecture page already has a
|
|
22
|
+
* - the architecture page already has a multi-select Focus pulldown backed by GraphHighlighter (chain
|
|
23
23
|
* highlight + responsibilities filter), and its menu item drives that, so the two stay in sync;
|
|
24
24
|
* - a design page and the runtime page have neither lock field nor responsibilities, so they use
|
|
25
25
|
* `WpNodeLock` below — dim every other box in that graph, light the locked one.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph-node-menu.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/graph-node-menu.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAa,aAAa;IACtB;;;;OAIG;IACH,MAAM;QACF,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAmDL,CAAC;IACP,CAAC;IAED;;;;;;OAMG;IACH,SAAS,CAAC,KAAa;QACnB,OAAO;UACL,KAAK,WAAW,KAAK;UACrB,KAAK;UACL,KAAK;UACL,KAAK;UACL,KAAK;UACL,KAAK,yCAAyC,CAAC;IACrD,CAAC;IAED,+FAA+F;IAC/F,MAAM;QACF,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC;IACjF,CAAC;IAED;;;;OAIG;IACK,UAAU;QACd,OAAO;;;;;;UAML,CAAC;IACP,CAAC;IAEO,UAAU;QACd,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE;UACzB,YAAY,CAAC,QAAQ,EAAE;;cAEnB,IAAI,CAAC,WAAW,EAAE;;;;;;;;;;;;;;;;;;;cAmBlB,IAAI,CAAC,cAAc,EAAE;cACrB,IAAI,CAAC,cAAc,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;UA2BzB,CAAC;IACP,CAAC;IAEO,cAAc;QAClB,OAAO;;;;;;;;;CASd,CAAC;IACE,CAAC;IAED,yFAAyF;IACjF,WAAW;QACf,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+Bd,CAAC;IACE,CAAC;IAEO,cAAc;QAClB,OAAO;;;;;;;;UAQL,CAAC;IACP,CAAC;IAED;;;OAGG;IACK,UAAU;QACd,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAkCL,CAAC;IACP,CAAC;IAED,2EAA2E;IACnE,aAAa;QACjB,OAAO;;;;;;;;;;;;;;kBAcG,CAAC;IACf,CAAC;CACJ;AAjRD,sCAiRC;AAED;;;;;;;;GAQG;AACH,MAAM,YAAY;IACd,KAAK,CAAC,IAAoB,EAAE,KAAqC;QAC7D,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAChD,MAAM,CAAC,IAAI,GAAG,QAAQ,CAAC;QACvB,MAAM,CAAC,SAAS,GAAG,mBAAmB,CAAC;QACvC,MAAM,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC;QAChC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC,EAAc,EAAQ,EAAE;YACtD,EAAE,CAAC,cAAc,EAAE,CAAC;YACpB,EAAE,CAAC,eAAe,EAAE,CAAC;YACrB,KAAK,CAAC,IAAI,CAAC,CAAC;YACZ,IAAI,CAAC,QAAQ,EAAE,CAAC;QACpB,CAAC,CAAC,CAAC;QACH,OAAO,MAAM,CAAC;IAClB,CAAC;CACJ","sourcesContent":["/**\n * The floating per-node menu, shared by ALL THREE generated graphs.\n *\n * There is exactly ONE implementation of the menu — its CSS, its positioning, its dismissal and the\n * click wiring that opens it — and `architecture/dependencies.html` (graph-visualizer.ts), every\n * project's `design.html` (di-graph/design-visualizer.ts) and `tmp/webpieces/runtime-architecture.html`\n * (runtime-visualizer.ts) all inline these same bytes. Duplicating it into the three emitters is what\n * this module exists to prevent.\n *\n * Why the browser code is emitted as a STRING here rather than living in a compiled `*.client.ts`\n * beside its emitter: `generateDesignHTML` is called straight from a source checkout (by the\n * di-graph-generate executor's unit tests) and has no injection seam for the client text, so a\n * `readCompiledClient` sibling would make the design page unbuildable from source. A string keeps one\n * copy reachable by every emitter with no build-order coupling. The two pages that DO have a compiled\n * client (`graph-visualizer.client.ts`, `runtime-visualizer.client.ts`) call into these classes as\n * ambient globals — see viz-client-globals.d.ts.\n *\n * What the menu does NOT own is the LOCK BEHAVIOUR, because the pages genuinely differ:\n * - the architecture page already has a `#wp-lock` search field backed by GraphHighlighter (chain\n * highlight + responsibilities filter), and its menu item drives that, so the two stay in sync;\n * - a design page and the runtime page have neither lock field nor responsibilities, so they use\n * `WpNodeLock` below — dim every other box in that graph, light the locked one.\n * All three spell the dim with the SAME class names and the SAME CSS from `dimStyles()`.\n */\nexport class GraphNodeMenu {\n /**\n * Menu chrome + the \"this box is clickable\" affordance. Every node is clickable now (the menu\n * replaced direct navigation), so the cursor/glow is on every box rather than only the ones with\n * a design page.\n */\n styles(): string {\n return `\n .wp-node-menu {\n position: absolute;\n z-index: 1000;\n min-width: 150px;\n padding: 4px 0;\n background: white;\n border: 1px solid #cfcfcf;\n border-radius: 6px;\n box-shadow: 0 4px 14px rgba(0,0,0,0.22);\n font-family: Arial, sans-serif;\n font-size: 14px;\n }\n .wp-node-menu-title {\n padding: 4px 14px 6px;\n color: #777;\n font-size: 12px;\n font-family: monospace;\n border-bottom: 1px solid #eee;\n margin-bottom: 4px;\n }\n .wp-node-menu-item {\n display: block;\n width: 100%;\n padding: 7px 14px;\n border: 0;\n background: none;\n color: #1565C0;\n font: inherit;\n text-align: left;\n cursor: pointer;\n }\n .wp-node-menu-item:hover { background: #E3F2FD; }\n g.wp-node-clickable { cursor: pointer; outline: none; }\n /* DOM keyboard focus is independent of graph highlight/Lock and stays dim with its node. */\n g.wp-node-clickable.wp-keyboard-focus {\n outline: 2px dashed #7b1fa2;\n outline-offset: 2px;\n }\n /* Every shape Graphviz (or the runtime page's own queue-cylinder redraw) can emit for a node\n * body: box/record -> polygon, circle -> ellipse, cylinder and the redrawn queue -> path. A\n * shape left out of this list would be a box that is clickable but never looks it. */\n g.wp-node-clickable polygon,\n g.wp-node-clickable ellipse,\n g.wp-node-clickable path { transition: stroke-width 0.12s ease, filter 0.12s ease; }\n g.wp-node-clickable:hover polygon,\n g.wp-node-clickable:hover ellipse,\n g.wp-node-clickable:hover path {\n stroke: #8b3cf0;\n stroke-width: 5;\n filter: drop-shadow(0 0 6px rgba(139, 60, 240, 0.85));\n }`;\n }\n\n /**\n * The dim/undim rules a lock (or a hover) toggles, scoped to whatever element holds the rendered\n * SVG on this page (`#graph` on the architecture page, `.graph` on a design page).\n *\n * We ONLY dim: the lit subgraph keeps its exact normal look. The un-dim rules repeat\n * `svg.wp-dim` so they out-specify the dim rule, which carries an extra type selector.\n */\n dimStyles(scope: string): string {\n return `\n ${scope} .node, ${scope} .edge { transition: opacity 0.12s ease; }\n ${scope} svg.wp-dim .node,\n ${scope} svg.wp-dim .edge { opacity: 0.15; }\n ${scope} svg.wp-dim .node.wp-focus,\n ${scope} svg.wp-dim .node.wp-neighbor,\n ${scope} svg.wp-dim .edge.wp-hl { opacity: 1; }`;\n }\n\n /** The whole browser side: the menu, the design-page lock, and the global dismiss handlers. */\n script(): string {\n return `${this.menuScript()}\\n${this.lockScript()}\\n${this.dismissScript()}`;\n }\n\n /**\n * `WpNodeMenuItem` + `WpNodeMenu`. Anchored at the node's own bounding box (bottom-left corner,\n * 4px below it) and clamped so a box at the right edge of a very wide graph still shows its whole\n * menu. Opening a menu closes any other, so at most one is ever on screen.\n */\n private itemScript(): string {\n return `\n class WpNodeMenuItem {\n constructor(label, onSelect) {\n this.label = label;\n this.onSelect = onSelect;\n }\n }`;\n }\n\n private menuScript(): string {\n return `${this.itemScript()}\n ${WpMenuButton.toString()}\n class WpNodeMenu {\n ${this.focusScript()}\n static open(nodeEl, name, items) {\n WpNodeMenu.close();\n WpNodeMenu.owner = nodeEl;\n WpNodeMenu.ownerKeyboard = WpNodeMenu.keyboard;\n const menu = document.createElement('div');\n menu.id = 'wp-node-menu';\n menu.className = 'wp-node-menu';\n menu.addEventListener('click', function (ev) { ev.stopPropagation(); });\n const heading = document.createElement('div');\n heading.className = 'wp-node-menu-title';\n heading.textContent = name;\n menu.appendChild(heading);\n for (const item of items) menu.appendChild(WpNodeMenu.button(item));\n document.body.appendChild(menu);\n WpNodeMenu.place(menu, nodeEl);\n menu.querySelector('button')?.focus({ preventScroll: true });\n }\n static button(item) { return new WpMenuButton().build(item, WpNodeMenu.close.bind(WpNodeMenu)); }\n ${this.positionScript()}\n ${this.keyboardScript()}\n static wire(svg, itemsFor) {\n svg.querySelectorAll('g.node').forEach(function (node) {\n const title = node.querySelector('title');\n const name = title === null || title.textContent === null\n ? '' : title.textContent.trim();\n if (name === '') return;\n if (node.classList.contains('wp-layout')) return;\n if (WpNodeMenu.wired.has(node)) return;\n WpNodeMenu.wired.add(node);\n node.addEventListener('focus', function () {\n node.classList.toggle('wp-keyboard-focus', WpNodeMenu.keyboard);\n });\n node.addEventListener('blur', function () {\n node.classList.remove('wp-keyboard-focus');\n });\n node.classList.add('wp-node-clickable');\n node.setAttribute('tabindex', '0');\n node.setAttribute('role', 'button');\n WpNodeMenu.wireKeyboard(node, name, itemsFor);\n node.addEventListener('click', function (ev) {\n ev.preventDefault();\n ev.stopPropagation();\n WpNodeMenu.open(node, name, itemsFor(name, node));\n });\n });\n }\n }`;\n }\n\n private positionScript(): string {\n return ` static place(menu, nodeEl) {\n const box = nodeEl.getBoundingClientRect();\n const own = menu.getBoundingClientRect();\n const maxLeft = window.scrollX + document.documentElement.clientWidth - own.width - 8;\n let left = box.left + window.scrollX;\n if (left > maxLeft) left = Math.max(window.scrollX + 8, maxLeft);\n menu.style.left = left + 'px';\n menu.style.top = (box.bottom + window.scrollY + 4) + 'px';\n }\n`;\n }\n\n /** Explicit input modality avoids Chromium's pointer-focus :focus-visible heuristics. */\n private focusScript(): string {\n return ` static keyboard = false;\n static owner = null;\n static ownerKeyboard = false;\n static wired = new WeakSet();\n static close(returnFocus = false) {\n const open = document.getElementById('wp-node-menu');\n const owner = WpNodeMenu.owner;\n const keyboard = WpNodeMenu.ownerKeyboard;\n WpNodeMenu.owner = null;\n if (open === null) return;\n open.remove();\n if (returnFocus && keyboard && owner?.isConnected) {\n WpNodeMenu.keyboard = true;\n owner.focus({ preventScroll: true });\n }\n }\n static focusedName(svg) {\n const active = document.activeElement;\n const menu = document.getElementById('wp-node-menu');\n const node = menu?.contains(active) && WpNodeMenu.ownerKeyboard\n ? WpNodeMenu.owner : active;\n return svg?.contains(node) && (node === WpNodeMenu.owner || node?.classList.contains('wp-keyboard-focus'))\n ? node.querySelector('title')?.textContent.trim() : null;\n }\n static restoreFocus(svg, name) {\n if (name == null) return;\n const nodes = Array.from(svg.querySelectorAll('g.wp-node-clickable'));\n const node = nodes.find(node => node.querySelector('title')?.textContent.trim() === name);\n WpNodeMenu.keyboard = true;\n (node || nodes[0])?.focus({ preventScroll: true });\n }\n`;\n }\n\n private keyboardScript(): string {\n return `static wireKeyboard(node, name, itemsFor) {\n node.addEventListener('keydown', function (ev) {\n if (ev.key !== 'Enter' && ev.key !== ' ') return;\n ev.preventDefault();\n ev.stopPropagation();\n WpNodeMenu.keyboard = true;\n WpNodeMenu.open(node, name, itemsFor(name, node));\n });\n }`;\n }\n\n /**\n * The lock the DESIGN pages and the RUNTIME page use: neither has a lock field or a responsibilities\n * list, so locking a box dims every other box in that graph and lights the locked one.\n */\n private lockScript(): string {\n return `\n class WpNodeLock {\n constructor(svg) { this.svg = svg; this.locked = null; }\n rebind(svg) {\n const name = this.locked;\n this.svg = svg;\n this.clear();\n this.locked = name;\n if (name === null) return;\n for (const node of svg.querySelectorAll('g.node')) {\n if (node.querySelector('title')?.textContent === name) {\n this.lock(name, node);\n break;\n }\n }\n }\n isLocked(name) { return this.locked === name; }\n toggle(name, nodeEl) {\n if (this.locked === name) this.clear();\n else this.lock(name, nodeEl);\n }\n lock(name, nodeEl) {\n this.clear();\n this.locked = name;\n this.svg.classList.add('wp-dim');\n nodeEl.classList.add('wp-focus');\n }\n clear() {\n this.locked = null;\n this.svg.classList.remove('wp-dim');\n this.svg.querySelectorAll('.wp-focus').forEach(function (el) {\n el.classList.remove('wp-focus');\n });\n }\n }`;\n }\n\n /** Outside click and Escape both dismiss — wired once, on the document. */\n private dismissScript(): string {\n return `\n document.addEventListener('pointerdown', function () {\n WpNodeMenu.keyboard = false;\n document.querySelectorAll('.wp-keyboard-focus').forEach(function (node) {\n node.classList.remove('wp-keyboard-focus');\n });\n }, true);\n document.addEventListener('click', function () { WpNodeMenu.close(); });\n document.addEventListener('keydown', function (ev) {\n if (ev.altKey || ev.ctrlKey || ev.metaKey) return;\n WpNodeMenu.keyboard = true;\n const active = document.activeElement;\n if (active?.classList.contains('wp-node-clickable')) active.classList.add('wp-keyboard-focus');\n if (ev.key === 'Escape') WpNodeMenu.close(true);\n }, true);`;\n }\n}\n\n/**\n * One menu item. Selecting it closes the menu (returning keyboard focus to the box it was opened\n * from) and then runs the item.\n *\n * Real TypeScript serialized into every page's menu script with `toString()` (the technique\n * GraphNavigation uses), so it is type-checked here and still needs no compiled sibling file: the\n * design pages render straight from a source checkout. `close` is handed in because WpNodeMenu,\n * which owns it, is declared after this class in the emitted script.\n */\nclass WpMenuButton {\n build(item: WpNodeMenuItem, close: (returnFocus: boolean) => void): HTMLButtonElement {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'wp-node-menu-item';\n button.textContent = item.label;\n button.addEventListener('click', (ev: MouseEvent): void => {\n ev.preventDefault();\n ev.stopPropagation();\n close(true);\n item.onSelect();\n });\n return button;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"graph-node-menu.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/graph-node-menu.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAa,aAAa;IACtB;;;;OAIG;IACH,MAAM;QACF,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAmDL,CAAC;IACP,CAAC;IAED;;;;;;OAMG;IACH,SAAS,CAAC,KAAa;QACnB,OAAO;UACL,KAAK,WAAW,KAAK;UACrB,KAAK;UACL,KAAK;UACL,KAAK;UACL,KAAK;UACL,KAAK,yCAAyC,CAAC;IACrD,CAAC;IAED,+FAA+F;IAC/F,MAAM;QACF,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC;IACjF,CAAC;IAED;;;;OAIG;IACK,UAAU;QACd,OAAO;;;;;;UAML,CAAC;IACP,CAAC;IAEO,UAAU;QACd,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE;UACzB,YAAY,CAAC,QAAQ,EAAE;;cAEnB,IAAI,CAAC,WAAW,EAAE;;;;;;;;;;;;;;;;;;;cAmBlB,IAAI,CAAC,cAAc,EAAE;cACrB,IAAI,CAAC,cAAc,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;UA2BzB,CAAC;IACP,CAAC;IAEO,cAAc;QAClB,OAAO;;;;;;;;;CASd,CAAC;IACE,CAAC;IAED,yFAAyF;IACjF,WAAW;QACf,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+Bd,CAAC;IACE,CAAC;IAEO,cAAc;QAClB,OAAO;;;;;;;;UAQL,CAAC;IACP,CAAC;IAED;;;OAGG;IACK,UAAU;QACd,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAkCL,CAAC;IACP,CAAC;IAED,2EAA2E;IACnE,aAAa;QACjB,OAAO;;;;;;;;;;;;;;kBAcG,CAAC;IACf,CAAC;CACJ;AAjRD,sCAiRC;AAED;;;;;;;;GAQG;AACH,MAAM,YAAY;IACd,KAAK,CAAC,IAAoB,EAAE,KAAqC;QAC7D,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAChD,MAAM,CAAC,IAAI,GAAG,QAAQ,CAAC;QACvB,MAAM,CAAC,SAAS,GAAG,mBAAmB,CAAC;QACvC,MAAM,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC;QAChC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC,EAAc,EAAQ,EAAE;YACtD,EAAE,CAAC,cAAc,EAAE,CAAC;YACpB,EAAE,CAAC,eAAe,EAAE,CAAC;YACrB,KAAK,CAAC,IAAI,CAAC,CAAC;YACZ,IAAI,CAAC,QAAQ,EAAE,CAAC;QACpB,CAAC,CAAC,CAAC;QACH,OAAO,MAAM,CAAC;IAClB,CAAC;CACJ","sourcesContent":["/**\n * The floating per-node menu, shared by ALL THREE generated graphs.\n *\n * There is exactly ONE implementation of the menu — its CSS, its positioning, its dismissal and the\n * click wiring that opens it — and `architecture/dependencies.html` (graph-visualizer.ts), every\n * project's `design.html` (di-graph/design-visualizer.ts) and `tmp/webpieces/runtime-architecture.html`\n * (runtime-visualizer.ts) all inline these same bytes. Duplicating it into the three emitters is what\n * this module exists to prevent.\n *\n * Why the browser code is emitted as a STRING here rather than living in a compiled `*.client.ts`\n * beside its emitter: `generateDesignHTML` is called straight from a source checkout (by the\n * di-graph-generate executor's unit tests) and has no injection seam for the client text, so a\n * `readCompiledClient` sibling would make the design page unbuildable from source. A string keeps one\n * copy reachable by every emitter with no build-order coupling. The two pages that DO have a compiled\n * client (`graph-visualizer.client.ts`, `runtime-visualizer.client.ts`) call into these classes as\n * ambient globals — see viz-client-globals.d.ts.\n *\n * What the menu does NOT own is the LOCK BEHAVIOUR, because the pages genuinely differ:\n * - the architecture page already has a multi-select Focus pulldown backed by GraphHighlighter (chain\n * highlight + responsibilities filter), and its menu item drives that, so the two stay in sync;\n * - a design page and the runtime page have neither lock field nor responsibilities, so they use\n * `WpNodeLock` below — dim every other box in that graph, light the locked one.\n * All three spell the dim with the SAME class names and the SAME CSS from `dimStyles()`.\n */\nexport class GraphNodeMenu {\n /**\n * Menu chrome + the \"this box is clickable\" affordance. Every node is clickable now (the menu\n * replaced direct navigation), so the cursor/glow is on every box rather than only the ones with\n * a design page.\n */\n styles(): string {\n return `\n .wp-node-menu {\n position: absolute;\n z-index: 1000;\n min-width: 150px;\n padding: 4px 0;\n background: white;\n border: 1px solid #cfcfcf;\n border-radius: 6px;\n box-shadow: 0 4px 14px rgba(0,0,0,0.22);\n font-family: Arial, sans-serif;\n font-size: 14px;\n }\n .wp-node-menu-title {\n padding: 4px 14px 6px;\n color: #777;\n font-size: 12px;\n font-family: monospace;\n border-bottom: 1px solid #eee;\n margin-bottom: 4px;\n }\n .wp-node-menu-item {\n display: block;\n width: 100%;\n padding: 7px 14px;\n border: 0;\n background: none;\n color: #1565C0;\n font: inherit;\n text-align: left;\n cursor: pointer;\n }\n .wp-node-menu-item:hover { background: #E3F2FD; }\n g.wp-node-clickable { cursor: pointer; outline: none; }\n /* DOM keyboard focus is independent of graph highlight/Lock and stays dim with its node. */\n g.wp-node-clickable.wp-keyboard-focus {\n outline: 2px dashed #7b1fa2;\n outline-offset: 2px;\n }\n /* Every shape Graphviz (or the runtime page's own queue-cylinder redraw) can emit for a node\n * body: box/record -> polygon, circle -> ellipse, cylinder and the redrawn queue -> path. A\n * shape left out of this list would be a box that is clickable but never looks it. */\n g.wp-node-clickable polygon,\n g.wp-node-clickable ellipse,\n g.wp-node-clickable path { transition: stroke-width 0.12s ease, filter 0.12s ease; }\n g.wp-node-clickable:hover polygon,\n g.wp-node-clickable:hover ellipse,\n g.wp-node-clickable:hover path {\n stroke: #8b3cf0;\n stroke-width: 5;\n filter: drop-shadow(0 0 6px rgba(139, 60, 240, 0.85));\n }`;\n }\n\n /**\n * The dim/undim rules a lock (or a hover) toggles, scoped to whatever element holds the rendered\n * SVG on this page (`#graph` on the architecture page, `.graph` on a design page).\n *\n * We ONLY dim: the lit subgraph keeps its exact normal look. The un-dim rules repeat\n * `svg.wp-dim` so they out-specify the dim rule, which carries an extra type selector.\n */\n dimStyles(scope: string): string {\n return `\n ${scope} .node, ${scope} .edge { transition: opacity 0.12s ease; }\n ${scope} svg.wp-dim .node,\n ${scope} svg.wp-dim .edge { opacity: 0.15; }\n ${scope} svg.wp-dim .node.wp-focus,\n ${scope} svg.wp-dim .node.wp-neighbor,\n ${scope} svg.wp-dim .edge.wp-hl { opacity: 1; }`;\n }\n\n /** The whole browser side: the menu, the design-page lock, and the global dismiss handlers. */\n script(): string {\n return `${this.menuScript()}\\n${this.lockScript()}\\n${this.dismissScript()}`;\n }\n\n /**\n * `WpNodeMenuItem` + `WpNodeMenu`. Anchored at the node's own bounding box (bottom-left corner,\n * 4px below it) and clamped so a box at the right edge of a very wide graph still shows its whole\n * menu. Opening a menu closes any other, so at most one is ever on screen.\n */\n private itemScript(): string {\n return `\n class WpNodeMenuItem {\n constructor(label, onSelect) {\n this.label = label;\n this.onSelect = onSelect;\n }\n }`;\n }\n\n private menuScript(): string {\n return `${this.itemScript()}\n ${WpMenuButton.toString()}\n class WpNodeMenu {\n ${this.focusScript()}\n static open(nodeEl, name, items) {\n WpNodeMenu.close();\n WpNodeMenu.owner = nodeEl;\n WpNodeMenu.ownerKeyboard = WpNodeMenu.keyboard;\n const menu = document.createElement('div');\n menu.id = 'wp-node-menu';\n menu.className = 'wp-node-menu';\n menu.addEventListener('click', function (ev) { ev.stopPropagation(); });\n const heading = document.createElement('div');\n heading.className = 'wp-node-menu-title';\n heading.textContent = name;\n menu.appendChild(heading);\n for (const item of items) menu.appendChild(WpNodeMenu.button(item));\n document.body.appendChild(menu);\n WpNodeMenu.place(menu, nodeEl);\n menu.querySelector('button')?.focus({ preventScroll: true });\n }\n static button(item) { return new WpMenuButton().build(item, WpNodeMenu.close.bind(WpNodeMenu)); }\n ${this.positionScript()}\n ${this.keyboardScript()}\n static wire(svg, itemsFor) {\n svg.querySelectorAll('g.node').forEach(function (node) {\n const title = node.querySelector('title');\n const name = title === null || title.textContent === null\n ? '' : title.textContent.trim();\n if (name === '') return;\n if (node.classList.contains('wp-layout')) return;\n if (WpNodeMenu.wired.has(node)) return;\n WpNodeMenu.wired.add(node);\n node.addEventListener('focus', function () {\n node.classList.toggle('wp-keyboard-focus', WpNodeMenu.keyboard);\n });\n node.addEventListener('blur', function () {\n node.classList.remove('wp-keyboard-focus');\n });\n node.classList.add('wp-node-clickable');\n node.setAttribute('tabindex', '0');\n node.setAttribute('role', 'button');\n WpNodeMenu.wireKeyboard(node, name, itemsFor);\n node.addEventListener('click', function (ev) {\n ev.preventDefault();\n ev.stopPropagation();\n WpNodeMenu.open(node, name, itemsFor(name, node));\n });\n });\n }\n }`;\n }\n\n private positionScript(): string {\n return ` static place(menu, nodeEl) {\n const box = nodeEl.getBoundingClientRect();\n const own = menu.getBoundingClientRect();\n const maxLeft = window.scrollX + document.documentElement.clientWidth - own.width - 8;\n let left = box.left + window.scrollX;\n if (left > maxLeft) left = Math.max(window.scrollX + 8, maxLeft);\n menu.style.left = left + 'px';\n menu.style.top = (box.bottom + window.scrollY + 4) + 'px';\n }\n`;\n }\n\n /** Explicit input modality avoids Chromium's pointer-focus :focus-visible heuristics. */\n private focusScript(): string {\n return ` static keyboard = false;\n static owner = null;\n static ownerKeyboard = false;\n static wired = new WeakSet();\n static close(returnFocus = false) {\n const open = document.getElementById('wp-node-menu');\n const owner = WpNodeMenu.owner;\n const keyboard = WpNodeMenu.ownerKeyboard;\n WpNodeMenu.owner = null;\n if (open === null) return;\n open.remove();\n if (returnFocus && keyboard && owner?.isConnected) {\n WpNodeMenu.keyboard = true;\n owner.focus({ preventScroll: true });\n }\n }\n static focusedName(svg) {\n const active = document.activeElement;\n const menu = document.getElementById('wp-node-menu');\n const node = menu?.contains(active) && WpNodeMenu.ownerKeyboard\n ? WpNodeMenu.owner : active;\n return svg?.contains(node) && (node === WpNodeMenu.owner || node?.classList.contains('wp-keyboard-focus'))\n ? node.querySelector('title')?.textContent.trim() : null;\n }\n static restoreFocus(svg, name) {\n if (name == null) return;\n const nodes = Array.from(svg.querySelectorAll('g.wp-node-clickable'));\n const node = nodes.find(node => node.querySelector('title')?.textContent.trim() === name);\n WpNodeMenu.keyboard = true;\n (node || nodes[0])?.focus({ preventScroll: true });\n }\n`;\n }\n\n private keyboardScript(): string {\n return `static wireKeyboard(node, name, itemsFor) {\n node.addEventListener('keydown', function (ev) {\n if (ev.key !== 'Enter' && ev.key !== ' ') return;\n ev.preventDefault();\n ev.stopPropagation();\n WpNodeMenu.keyboard = true;\n WpNodeMenu.open(node, name, itemsFor(name, node));\n });\n }`;\n }\n\n /**\n * The lock the DESIGN pages and the RUNTIME page use: neither has a lock field or a responsibilities\n * list, so locking a box dims every other box in that graph and lights the locked one.\n */\n private lockScript(): string {\n return `\n class WpNodeLock {\n constructor(svg) { this.svg = svg; this.locked = null; }\n rebind(svg) {\n const name = this.locked;\n this.svg = svg;\n this.clear();\n this.locked = name;\n if (name === null) return;\n for (const node of svg.querySelectorAll('g.node')) {\n if (node.querySelector('title')?.textContent === name) {\n this.lock(name, node);\n break;\n }\n }\n }\n isLocked(name) { return this.locked === name; }\n toggle(name, nodeEl) {\n if (this.locked === name) this.clear();\n else this.lock(name, nodeEl);\n }\n lock(name, nodeEl) {\n this.clear();\n this.locked = name;\n this.svg.classList.add('wp-dim');\n nodeEl.classList.add('wp-focus');\n }\n clear() {\n this.locked = null;\n this.svg.classList.remove('wp-dim');\n this.svg.querySelectorAll('.wp-focus').forEach(function (el) {\n el.classList.remove('wp-focus');\n });\n }\n }`;\n }\n\n /** Outside click and Escape both dismiss — wired once, on the document. */\n private dismissScript(): string {\n return `\n document.addEventListener('pointerdown', function () {\n WpNodeMenu.keyboard = false;\n document.querySelectorAll('.wp-keyboard-focus').forEach(function (node) {\n node.classList.remove('wp-keyboard-focus');\n });\n }, true);\n document.addEventListener('click', function () { WpNodeMenu.close(); });\n document.addEventListener('keydown', function (ev) {\n if (ev.altKey || ev.ctrlKey || ev.metaKey) return;\n WpNodeMenu.keyboard = true;\n const active = document.activeElement;\n if (active?.classList.contains('wp-node-clickable')) active.classList.add('wp-keyboard-focus');\n if (ev.key === 'Escape') WpNodeMenu.close(true);\n }, true);`;\n }\n}\n\n/**\n * One menu item. Selecting it closes the menu (returning keyboard focus to the box it was opened\n * from) and then runs the item.\n *\n * Real TypeScript serialized into every page's menu script with `toString()` (the technique\n * GraphNavigation uses), so it is type-checked here and still needs no compiled sibling file: the\n * design pages render straight from a source checkout. `close` is handed in because WpNodeMenu,\n * which owns it, is declared after this class in the emitted script.\n */\nclass WpMenuButton {\n build(item: WpNodeMenuItem, close: (returnFocus: boolean) => void): HTMLButtonElement {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'wp-node-menu-item';\n button.textContent = item.label;\n button.addEventListener('click', (ev: MouseEvent): void => {\n ev.preventDefault();\n ev.stopPropagation();\n close(true);\n item.onSelect();\n });\n return button;\n }\n}\n"]}
|
|
@@ -43,10 +43,21 @@ export declare class ScopeOption {
|
|
|
43
43
|
constructor(scope: ChangeScope, label: string, subtitle: string);
|
|
44
44
|
}
|
|
45
45
|
export declare const SCOPE_OPTIONS: readonly ScopeOption[];
|
|
46
|
+
/**
|
|
47
|
+
* One box the "Keep connected to" and "Focus" pulldowns offer (#1385): the node id the SVG is keyed on
|
|
48
|
+
* (what the search matches and what a pick stores), and a short grey label beside it — the level on the
|
|
49
|
+
* architecture page, the role on the runtime page.
|
|
50
|
+
*/
|
|
51
|
+
export declare class GraphPickOption {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly label: string;
|
|
54
|
+
constructor(id: string, label: string);
|
|
55
|
+
}
|
|
46
56
|
/** Everything the shell frames, already rendered by its owner. */
|
|
47
57
|
export declare class ShellParts {
|
|
48
58
|
readonly title: string;
|
|
49
|
-
|
|
59
|
+
/** Every box either pulldown may offer; the shell sorts them alphabetically. */
|
|
60
|
+
readonly pickOptions: readonly GraphPickOption[];
|
|
50
61
|
readonly legend: string;
|
|
51
62
|
readonly edgeKey: string;
|
|
52
63
|
readonly filterStatus: string;
|
|
@@ -63,7 +74,9 @@ export declare class ShellParts {
|
|
|
63
74
|
readonly emptyState: string;
|
|
64
75
|
/** Modes rendered dimmed and unselectable from the first paint, each saying why (#1373). */
|
|
65
76
|
readonly unavailable: readonly ModeUnavailable[];
|
|
66
|
-
constructor(title: string,
|
|
77
|
+
constructor(title: string,
|
|
78
|
+
/** Every box either pulldown may offer; the shell sorts them alphabetically. */
|
|
79
|
+
pickOptions: readonly GraphPickOption[], legend: string, edgeKey: string, filterStatus: string, snapshot: string, responsibilities: string,
|
|
67
80
|
/**
|
|
68
81
|
* One chip per product (#1179), sorted, each colored from the page's ONE palette (#1244);
|
|
69
82
|
* empty hides the Filter's Product group.
|
|
@@ -75,6 +88,14 @@ export declare class ShellParts {
|
|
|
75
88
|
export declare class GraphPageShell {
|
|
76
89
|
body(parts: ShellParts): string;
|
|
77
90
|
private drawer;
|
|
91
|
+
/**
|
|
92
|
+
* One multi-select pulldown (#1385), the same markup for "Keep connected to" and "Focus": a closed
|
|
93
|
+
* field showing the picks as removable chips (or "None (off)"), and a popover holding a search box,
|
|
94
|
+
* one checkbox row per box in ALPHABETICAL order, and a footer with "N selected" and Clear all.
|
|
95
|
+
* Behaviour is WpMultiSelect in graph-visualizer.client.ts; the rows here are the only copy of the
|
|
96
|
+
* options, and the page hides the ones a search or (for Focus) the current graph excludes.
|
|
97
|
+
*/
|
|
98
|
+
private multiSelect;
|
|
78
99
|
/**
|
|
79
100
|
* "Color by": ONE pill-shaped trigger showing the current mode (name + subtitle) that opens a
|
|
80
101
|
* menu of the four. Keys 1/2/3/4 and the #runtime / #architecture / #impact / #product links still
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* carries a `style=` attribute — every look is a class.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.GraphPageShell = exports.ShellParts = exports.SCOPE_OPTIONS = exports.ScopeOption = exports.ChangeScope = void 0;
|
|
15
|
+
exports.GraphPageShell = exports.ShellParts = exports.GraphPickOption = exports.SCOPE_OPTIONS = exports.ScopeOption = exports.ChangeScope = void 0;
|
|
16
16
|
const graph_color_modes_1 = require("./graph-color-modes");
|
|
17
17
|
const dot_syntax_1 = require("./dot-syntax");
|
|
18
18
|
/**
|
|
@@ -59,10 +59,24 @@ exports.SCOPE_OPTIONS = [
|
|
|
59
59
|
new ScopeOption(ChangeScope.BUILD, 'Changed + dependents + dependencies', 'everything CI builds for this branch'),
|
|
60
60
|
new ScopeOption(ChangeScope.EDGES, 'Edge changes only', 'projects that gained or lost a dependency or call since the fork point'),
|
|
61
61
|
];
|
|
62
|
+
/**
|
|
63
|
+
* One box the "Keep connected to" and "Focus" pulldowns offer (#1385): the node id the SVG is keyed on
|
|
64
|
+
* (what the search matches and what a pick stores), and a short grey label beside it — the level on the
|
|
65
|
+
* architecture page, the role on the runtime page.
|
|
66
|
+
*/
|
|
67
|
+
class GraphPickOption {
|
|
68
|
+
id;
|
|
69
|
+
label;
|
|
70
|
+
constructor(id, label) {
|
|
71
|
+
this.id = id;
|
|
72
|
+
this.label = label;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.GraphPickOption = GraphPickOption;
|
|
62
76
|
/** Everything the shell frames, already rendered by its owner. */
|
|
63
77
|
class ShellParts {
|
|
64
78
|
title;
|
|
65
|
-
|
|
79
|
+
pickOptions;
|
|
66
80
|
legend;
|
|
67
81
|
edgeKey;
|
|
68
82
|
filterStatus;
|
|
@@ -74,7 +88,9 @@ class ShellParts {
|
|
|
74
88
|
roles;
|
|
75
89
|
emptyState;
|
|
76
90
|
unavailable;
|
|
77
|
-
constructor(title,
|
|
91
|
+
constructor(title,
|
|
92
|
+
/** Every box either pulldown may offer; the shell sorts them alphabetically. */
|
|
93
|
+
pickOptions, legend, edgeKey, filterStatus, snapshot, responsibilities,
|
|
78
94
|
/**
|
|
79
95
|
* One chip per product (#1179), sorted, each colored from the page's ONE palette (#1244);
|
|
80
96
|
* empty hides the Filter's Product group.
|
|
@@ -83,7 +99,7 @@ class ShellParts {
|
|
|
83
99
|
/** Modes rendered dimmed and unselectable from the first paint, each saying why (#1373). */
|
|
84
100
|
unavailable = []) {
|
|
85
101
|
this.title = title;
|
|
86
|
-
this.
|
|
102
|
+
this.pickOptions = pickOptions;
|
|
87
103
|
this.legend = legend;
|
|
88
104
|
this.edgeKey = edgeKey;
|
|
89
105
|
this.filterStatus = filterStatus;
|
|
@@ -122,10 +138,10 @@ class GraphPageShell {
|
|
|
122
138
|
<button type="button" class="wp-filter-btn" id="wp-filter-open" aria-controls="wp-filter-pop" aria-expanded="false"><span>Filter</span><span class="wp-badge" id="wp-filter-badge" hidden>0</span></button>
|
|
123
139
|
</div>
|
|
124
140
|
<div class="wp-group">
|
|
125
|
-
<
|
|
126
|
-
${parts.
|
|
127
|
-
<
|
|
128
|
-
|
|
141
|
+
<div class="wp-label" id="wp-keep-label">Keep connected to</div>
|
|
142
|
+
${this.multiSelect('keep', 'Keep connected to', parts.pickOptions)}
|
|
143
|
+
<div class="wp-label" id="wp-focus-label">Focus</div>
|
|
144
|
+
${this.multiSelect('focus', 'Focus', parts.pickOptions)}
|
|
129
145
|
</div>
|
|
130
146
|
<div class="wp-group">
|
|
131
147
|
<div class="wp-label wp-label-row">Legend <button type="button" class="wp-icon wp-icon-mini wp-icon-dark" id="wp-legend-pop" title="Pop the legend out over the graph" aria-label="Pop the legend out over the graph">⇱</button></div>
|
|
@@ -141,6 +157,39 @@ class GraphPageShell {
|
|
|
141
157
|
</div>
|
|
142
158
|
</aside>`;
|
|
143
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* One multi-select pulldown (#1385), the same markup for "Keep connected to" and "Focus": a closed
|
|
162
|
+
* field showing the picks as removable chips (or "None (off)"), and a popover holding a search box,
|
|
163
|
+
* one checkbox row per box in ALPHABETICAL order, and a footer with "N selected" and Clear all.
|
|
164
|
+
* Behaviour is WpMultiSelect in graph-visualizer.client.ts; the rows here are the only copy of the
|
|
165
|
+
* options, and the page hides the ones a search or (for Focus) the current graph excludes.
|
|
166
|
+
*/
|
|
167
|
+
multiSelect(key, name, options) {
|
|
168
|
+
const sorted = [...options].sort((a, b) => {
|
|
169
|
+
const folded = a.id.toLowerCase().localeCompare(b.id.toLowerCase());
|
|
170
|
+
return folded !== 0 ? folded : a.id.localeCompare(b.id);
|
|
171
|
+
});
|
|
172
|
+
const rows = sorted
|
|
173
|
+
.map((option) => {
|
|
174
|
+
const id = (0, dot_syntax_1.htmlLabelText)(option.id);
|
|
175
|
+
return (`<div class="wp-multi-option" role="option" aria-selected="false" data-wp-option="${id}">` +
|
|
176
|
+
'<input type="checkbox" tabindex="-1" aria-hidden="true">' +
|
|
177
|
+
`<span class="wp-multi-name">${id}</span><span class="wp-multi-sub">${(0, dot_syntax_1.htmlLabelText)(option.label)}</span></div>`);
|
|
178
|
+
})
|
|
179
|
+
.join('');
|
|
180
|
+
return `<div class="wp-multi" id="wp-${key}" data-wp-multi="${key}">
|
|
181
|
+
<div class="wp-multi-field" id="wp-${key}-field">
|
|
182
|
+
<span class="wp-multi-chips" id="wp-${key}-chips"><span class="wp-multi-none">None (off)</span></span>
|
|
183
|
+
<button type="button" class="wp-multi-trigger" id="wp-${key}-trigger" aria-haspopup="listbox" aria-controls="wp-${key}-pop" aria-expanded="false" aria-label="Choose ${name}"><span class="wp-caret" aria-hidden="true">▾</span></button>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="wp-multi-pop" id="wp-${key}-pop" hidden>
|
|
186
|
+
<input type="search" class="wp-multi-search" id="wp-${key}-search" placeholder="Search…" autocomplete="off" spellcheck="false" aria-label="Search ${name}" aria-controls="wp-${key}-list">
|
|
187
|
+
<div class="wp-multi-list" id="wp-${key}-list" role="listbox" aria-multiselectable="true" aria-label="${name}">${rows}</div>
|
|
188
|
+
<p class="wp-multi-empty" id="wp-${key}-empty" hidden>No match</p>
|
|
189
|
+
<footer><span id="wp-${key}-count">0 selected</span><button type="button" class="wp-text-btn" id="wp-${key}-clear">Clear all</button></footer>
|
|
190
|
+
</div>
|
|
191
|
+
</div>`;
|
|
192
|
+
}
|
|
144
193
|
/**
|
|
145
194
|
* "Color by": ONE pill-shaped trigger showing the current mode (name + subtitle) that opens a
|
|
146
195
|
* menu of the four. Keys 1/2/3/4 and the #runtime / #architecture / #impact / #product links still
|
|
@@ -244,21 +293,22 @@ class GraphPageShell {
|
|
|
244
293
|
return `<div class="wp-pop wp-help" id="wp-help" role="dialog" aria-label="How to read this graph" hidden>
|
|
245
294
|
<header><span>Reading the runtime graph</span><button type="button" class="wp-icon wp-icon-mini" id="wp-help-close" aria-label="Close help">✕</button></header>
|
|
246
295
|
<p>Rows preserve saved runtime call-depth levels. Boxes are services; horizontal cylinders are queues, upright cylinders datastores, and clocks scheduled triggers. Solid arrows are RPC calls, dashed arrows queued events; red arrows admit legacy cycles.</p>
|
|
247
|
-
<p>Click a box for
|
|
248
|
-
<p>
|
|
296
|
+
<p>Click a box for Add to Focus, Keep connected, or Implements. Implements in the box is a plain summary. Click Uses on a relationship line for its complete details. Hover traces chains without opening details.</p>
|
|
297
|
+
<p><strong>Keep connected to</strong> keeps only the picked boxes and every box on their complete incoming and outgoing chains (several picks are the union) and redraws. <strong>Focus</strong> pins the picked boxes' chains in the foreground and dims the rest without moving anything; hover adds another chain temporarily. Clear all in Filter leaves both alone.</p>
|
|
249
298
|
<p>Filters intersect declared runtime, role, product closure and Nx build scope. Counts describe services; attached contextual nodes are counted separately. Impact uses compile-project sets, never runtime reachability. Queues and vendors are context, never source owners or CI projects.</p>
|
|
250
299
|
<p>Runtime uses declared framework colors, Architecture declared roles; contextual nodes keep their kind colors. Product colors come from each project's declared product: tags, painted for the products toggled ON in the legend (the first two on every load); contextual nodes inherit adjacent memberships. Unknown metadata is neutral. Missing saved metadata requires explicit architecture:generate.</p>
|
|
251
300
|
<p><strong>Goal vs Current</strong> compares the saved goal (architecture/runtime-dependencies-goal.json) with the saved current graph, Current → Goal: green added, red dashed removed (still drawn), amber changed, grey unchanged. Click a box for its before/after details, or click an arrow.</p>
|
|
252
|
-
<p><kbd>/</kbd> Focus search · <kbd>1</kbd>–<kbd>5</kbd> modes · <kbd>Esc</kbd>
|
|
301
|
+
<p><kbd>/</kbd> Focus search · <kbd>1</kbd>–<kbd>5</kbd> modes · <kbd>Esc</kbd> close a pulldown, else clear Focus</p>
|
|
253
302
|
</div>`;
|
|
254
303
|
return `<div class="wp-pop wp-help" id="wp-help" role="dialog" aria-label="How to read this graph" hidden>
|
|
255
304
|
<header><span>Reading the graph</span><button type="button" class="wp-icon wp-icon-mini" id="wp-help-close" aria-label="Close help">✕</button></header>
|
|
256
305
|
<p>Every row is one dependency level: the HIGHEST level is the top row and L0, the foundation libraries, is always the bottom row. Servers, clients and apps always form the top row (a bundle sits above the apps it aggregates). Transitive dependencies are allowed but not drawn.</p>
|
|
257
|
-
<p>💡 <strong>Click any box</strong> for its menu: <strong>View Design</strong> (only where the project has a generated <strong>design.html</strong>, i.e. what the AI sees inside it), <strong>
|
|
306
|
+
<p>💡 <strong>Click any box</strong> for its menu: <strong>View Design</strong> (only where the project has a generated <strong>design.html</strong>, i.e. what the AI sees inside it), <strong>Add to Focus</strong> / <strong>Remove from Focus</strong> (the same picks as the Focus pulldown), <strong>Keep connected</strong> / <strong>Stop keeping connected</strong> (the same picks as Keep connected to).</p>
|
|
307
|
+
<p><strong>Keep connected to</strong> keeps only the picked boxes plus every ancestor and dependency of each (several picks are the UNION, so adding one never shrinks the graph), keeping each L-number row. <strong>Focus</strong> then pins the chains of the picked boxes in the foreground and dims the rest, without moving anything. Both pulldowns search anywhere in a name; Clear all turns each off.</p>
|
|
258
308
|
<p><strong>Filter</strong> narrows the graph by what changed on this branch or in the last commit, by runtime, by role and by product; the groups combine. A product keeps its servers, clients and apps plus everything they depend on, so <strong>Product</strong> + <strong>Changed + dependents + dependencies</strong> is exactly what this branch builds that can affect that product. Every box keeps its own L-row, and a level left empty shows as a thin labeled band.</p>
|
|
259
309
|
<p>🔦 <strong>Hover any box</strong> to trace its <em>entire</em> dependency chain — every ancestor above it and every dependency below it, with the lines between — while the rest of the graph dims.</p>
|
|
260
310
|
<p><strong>Runtime</strong>: a box may depend on a box carrying every color it has. <strong>Architecture</strong>: what each box is in the system. <strong>Impact</strong>: what changed on this branch or in the last commit (amber), its dependents (amber outline), and what nx marked for another reason: an input glob (violet hatch) or a root file (teal). Click an affected box for why; with both available, a toggle picks which. <strong>Product</strong>: which products each box belongs to. Toggle products in the legend (the first two are ON on every load): one ON product is its color, several ON are stripes, only OFF products are light grey, neutral means every product while all are ON, and a dashed white box belongs to none.</p>
|
|
261
|
-
<p><kbd>/</kbd>
|
|
311
|
+
<p><kbd>/</kbd> open the Focus search · <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> <kbd>4</kbd> switch mode · <kbd>Esc</kbd> close a pulldown, else clear Focus</p>
|
|
262
312
|
</div>`;
|
|
263
313
|
}
|
|
264
314
|
brandIcon() {
|