@caddis/cli 0.4.49 → 0.4.51
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +4 -0
- package/bundles/antigravity-plugin/session_end_agy.py +36 -2
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +4 -1
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +18 -1
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +6 -1
- package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +16 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +20 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/bars.test.ts +90 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/bars.ts +53 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +8 -3
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +17 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +2 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +4 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +61 -4
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +56 -4
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +38 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +4 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +18 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +6 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +1 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +16 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +1 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +20 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.test.ts +90 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.ts +53 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +8 -3
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +17 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +2 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +4 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +61 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +56 -4
- package/bundles/manifest.json +3 -3
- package/dist/cli.js +94 -1
- package/package.json +1 -1
|
@@ -198,6 +198,11 @@
|
|
|
198
198
|
.node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
|
|
199
199
|
.node .s.has-spark { padding-right:60px; }
|
|
200
200
|
.node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
|
|
201
|
+
.node svg.bars { position:absolute; left:10px; right:10px; bottom:6px; width:calc(100% - 20px); height:22px; overflow:visible; }
|
|
202
|
+
.node svg.bars rect { fill:var(--muted); opacity:.6; }
|
|
203
|
+
.node svg.bars rect.last { fill:var(--accent); opacity:1; }
|
|
204
|
+
.node svg.bars rect.low, .node svg.bars rect.miss { fill:var(--crit); opacity:1; }
|
|
205
|
+
.node svg.bars rect[data-hl="true"] { stroke:var(--fg); stroke-width:1.5px; vector-effect:non-scaling-stroke; opacity:1; }
|
|
201
206
|
.node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
|
|
202
207
|
.node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
|
|
203
208
|
.node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
|
|
@@ -326,6 +331,8 @@
|
|
|
326
331
|
const metric = n.metrics;
|
|
327
332
|
const invalidSeries = metric?.series !== void 0 && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some((v) => !Number.isFinite(v)));
|
|
328
333
|
if (metric !== void 0 && (!metric || !Number.isFinite(metric.value) || metric.max != null && !Number.isFinite(metric.max) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
|
|
334
|
+
const badBars = n.bars !== void 0 && (!Array.isArray(n.bars) || n.bars.length > 120 || n.bars.some((b) => !b || typeof b.key !== "string" || b.value !== null && (typeof b.value !== "number" || !Number.isFinite(b.value) || b.value < 0)));
|
|
335
|
+
if (badBars) add("error", "bars-invalid", w, "Bars need a list of at most 120 items, each with a string key and a finite, non-negative value or null.");
|
|
329
336
|
if (n.sla !== void 0 && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
|
|
330
337
|
}
|
|
331
338
|
for (const n of nodes) {
|
|
@@ -975,6 +982,7 @@
|
|
|
975
982
|
|
|
976
983
|
// src/layout.ts
|
|
977
984
|
var DEFAULTS = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
|
|
985
|
+
var BARS_EXTRA = 26;
|
|
978
986
|
var LANE_PAD_TOP = 38;
|
|
979
987
|
var LANE_PAD_BOTTOM = 14;
|
|
980
988
|
function computeGeometry(m, opt = {}) {
|
|
@@ -988,7 +996,7 @@
|
|
|
988
996
|
const world = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
|
|
989
997
|
const nodes = {};
|
|
990
998
|
for (const n of m.nodes) {
|
|
991
|
-
const h = NODE_HEIGHT[n.kind] ?? 54;
|
|
999
|
+
const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
|
|
992
1000
|
if (m.layout === "free") {
|
|
993
1001
|
nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
|
|
994
1002
|
continue;
|
|
@@ -1105,6 +1113,47 @@
|
|
|
1105
1113
|
return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
|
|
1106
1114
|
}
|
|
1107
1115
|
|
|
1116
|
+
// src/bars.ts
|
|
1117
|
+
var BAR_UNIT = 4;
|
|
1118
|
+
function barsHtml(bars, height = 22) {
|
|
1119
|
+
if (!bars || bars.length === 0) return "";
|
|
1120
|
+
const width = bars.length * BAR_UNIT;
|
|
1121
|
+
const max = Math.max(1, ...bars.map((b) => b.value ?? 0));
|
|
1122
|
+
const lastWithData = bars.map((b) => b.value !== null).lastIndexOf(true);
|
|
1123
|
+
const rects = bars.map((b, i) => {
|
|
1124
|
+
const x = i * BAR_UNIT;
|
|
1125
|
+
const w = BAR_UNIT - 1;
|
|
1126
|
+
const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
|
|
1127
|
+
if (b.value === null) {
|
|
1128
|
+
return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
|
|
1129
|
+
}
|
|
1130
|
+
const h = Math.max(1, b.value / max * (height - 5));
|
|
1131
|
+
const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
|
|
1132
|
+
return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
|
|
1133
|
+
});
|
|
1134
|
+
const first = esc(bars[0].key), last = esc(bars[bars.length - 1].key);
|
|
1135
|
+
return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
|
|
1136
|
+
}
|
|
1137
|
+
function markKey(root, key) {
|
|
1138
|
+
for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
|
|
1139
|
+
if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
|
|
1140
|
+
else r.removeAttribute("data-hl");
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
function installBars(ctx) {
|
|
1144
|
+
const set = (key) => {
|
|
1145
|
+
if (ctx.hoverKey === key) return;
|
|
1146
|
+
ctx.hoverKey = key;
|
|
1147
|
+
markKey(ctx.els.world, key);
|
|
1148
|
+
ctx.onHoverKey?.(key);
|
|
1149
|
+
};
|
|
1150
|
+
on(ctx, ctx.els.world, "mouseover", (e) => {
|
|
1151
|
+
const bar = e.target?.closest?.("rect[data-key]");
|
|
1152
|
+
set(bar ? bar.getAttribute("data-key") : null);
|
|
1153
|
+
});
|
|
1154
|
+
on(ctx, ctx.els.world, "mouseleave", () => set(null));
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1108
1157
|
// src/render.ts
|
|
1109
1158
|
function loopRoute(ctx, fromId, toId, from, to, channel) {
|
|
1110
1159
|
const detourFor = (end, other) => {
|
|
@@ -1141,7 +1190,7 @@
|
|
|
1141
1190
|
const arrow = n.kind === "chip" ? '<span aria-hidden="true">\u2191 </span>' : "";
|
|
1142
1191
|
const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> \xB7 ` : "";
|
|
1143
1192
|
const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">\u25B8 ${n.contains.length}</span>` : "";
|
|
1144
|
-
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
|
|
1193
|
+
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${barsHtml(n.bars)}${bar}${improved}${upgrade}`;
|
|
1145
1194
|
}
|
|
1146
1195
|
function build(ctx) {
|
|
1147
1196
|
const { model, els } = ctx;
|
|
@@ -1176,7 +1225,7 @@
|
|
|
1176
1225
|
for (const n of model.nodes) {
|
|
1177
1226
|
const p = ctx.geom.nodes[n.id];
|
|
1178
1227
|
if (!p) continue;
|
|
1179
|
-
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
|
|
1228
|
+
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
|
|
1180
1229
|
d.id = "n_" + n.id;
|
|
1181
1230
|
d.tabIndex = 0;
|
|
1182
1231
|
d.setAttribute("role", "button");
|
|
@@ -1311,6 +1360,7 @@
|
|
|
1311
1360
|
d.style.height = r.h + "px";
|
|
1312
1361
|
}
|
|
1313
1362
|
}
|
|
1363
|
+
markKey(ctx.els.world, ctx.hoverKey);
|
|
1314
1364
|
ctx.fn.buildChrome();
|
|
1315
1365
|
ctx.fn.trace(ctx.selected);
|
|
1316
1366
|
ctx.fn.renderInspector();
|
|
@@ -2506,6 +2556,8 @@
|
|
|
2506
2556
|
root,
|
|
2507
2557
|
els,
|
|
2508
2558
|
onSelect: opts.onSelect,
|
|
2559
|
+
onHoverKey: opts.onHoverKey,
|
|
2560
|
+
hoverKey: null,
|
|
2509
2561
|
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
2510
2562
|
nodes: new Map(model.nodes.map((n) => [n.id, n])),
|
|
2511
2563
|
links: model.links,
|
|
@@ -2548,6 +2600,7 @@
|
|
|
2548
2600
|
style.textContent = stateCss(model.states);
|
|
2549
2601
|
install(ctx);
|
|
2550
2602
|
install2(ctx);
|
|
2603
|
+
installBars(ctx);
|
|
2551
2604
|
install3(ctx);
|
|
2552
2605
|
install4(ctx);
|
|
2553
2606
|
install5(ctx);
|
|
@@ -2594,6 +2647,10 @@
|
|
|
2594
2647
|
ctx.fn.fit(animate);
|
|
2595
2648
|
},
|
|
2596
2649
|
select: (id) => ctx.fn.select(id),
|
|
2650
|
+
highlightKey: (key) => {
|
|
2651
|
+
ctx.hoverKey = key;
|
|
2652
|
+
markKey(ctx.els.world, key);
|
|
2653
|
+
},
|
|
2597
2654
|
selected: () => ctx.selected,
|
|
2598
2655
|
mode: () => ctx.mode,
|
|
2599
2656
|
setMode: (id) => ctx.fn.setMode(id),
|
|
@@ -2619,7 +2676,7 @@
|
|
|
2619
2676
|
const changes = diffModels(ctx.model, prepared);
|
|
2620
2677
|
const saved = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter((n) => n.kind === "group").map((n) => n.id)), playback: playback.playback() };
|
|
2621
2678
|
api.destroy();
|
|
2622
|
-
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
|
|
2679
|
+
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
|
|
2623
2680
|
notifySelection(saved.selected, replacement.selected(), opts.onSelect);
|
|
2624
2681
|
Object.assign(api, replacement);
|
|
2625
2682
|
window.FlowStudio = api;
|
|
@@ -184,6 +184,11 @@
|
|
|
184
184
|
.node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
|
|
185
185
|
.node .s.has-spark { padding-right:60px; }
|
|
186
186
|
.node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
|
|
187
|
+
.node svg.bars { position:absolute; left:10px; right:10px; bottom:6px; width:calc(100% - 20px); height:22px; overflow:visible; }
|
|
188
|
+
.node svg.bars rect { fill:var(--muted); opacity:.6; }
|
|
189
|
+
.node svg.bars rect.last { fill:var(--accent); opacity:1; }
|
|
190
|
+
.node svg.bars rect.low, .node svg.bars rect.miss { fill:var(--crit); opacity:1; }
|
|
191
|
+
.node svg.bars rect[data-hl="true"] { stroke:var(--fg); stroke-width:1.5px; vector-effect:non-scaling-stroke; opacity:1; }
|
|
187
192
|
.node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
|
|
188
193
|
.node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
|
|
189
194
|
.node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
|
|
@@ -69,6 +69,8 @@ function validateModel(input) {
|
|
|
69
69
|
const metric = n.metrics;
|
|
70
70
|
const invalidSeries = metric?.series !== void 0 && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some((v) => !Number.isFinite(v)));
|
|
71
71
|
if (metric !== void 0 && (!metric || !Number.isFinite(metric.value) || metric.max != null && !Number.isFinite(metric.max) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
|
|
72
|
+
const badBars = n.bars !== void 0 && (!Array.isArray(n.bars) || n.bars.length > 120 || n.bars.some((b) => !b || typeof b.key !== "string" || b.value !== null && (typeof b.value !== "number" || !Number.isFinite(b.value) || b.value < 0)));
|
|
73
|
+
if (badBars) add("error", "bars-invalid", w, "Bars need a list of at most 120 items, each with a string key and a finite, non-negative value or null.");
|
|
72
74
|
if (n.sla !== void 0 && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
|
|
73
75
|
}
|
|
74
76
|
for (const n of nodes) {
|
|
@@ -749,6 +751,7 @@ function on(ctx, target, type, fn, opts) {
|
|
|
749
751
|
|
|
750
752
|
// src/layout.ts
|
|
751
753
|
var DEFAULTS = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
|
|
754
|
+
var BARS_EXTRA = 26;
|
|
752
755
|
var LANE_PAD_TOP = 38;
|
|
753
756
|
var LANE_PAD_BOTTOM = 14;
|
|
754
757
|
function computeGeometry(m, opt = {}) {
|
|
@@ -762,7 +765,7 @@ function computeGeometry(m, opt = {}) {
|
|
|
762
765
|
const world = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
|
|
763
766
|
const nodes = {};
|
|
764
767
|
for (const n of m.nodes) {
|
|
765
|
-
const h = NODE_HEIGHT[n.kind] ?? 54;
|
|
768
|
+
const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
|
|
766
769
|
if (m.layout === "free") {
|
|
767
770
|
nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
|
|
768
771
|
continue;
|
|
@@ -884,6 +887,47 @@ function sparkline(metric, width = 56, height = 12) {
|
|
|
884
887
|
return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
|
|
885
888
|
}
|
|
886
889
|
|
|
890
|
+
// src/bars.ts
|
|
891
|
+
var BAR_UNIT = 4;
|
|
892
|
+
function barsHtml(bars, height = 22) {
|
|
893
|
+
if (!bars || bars.length === 0) return "";
|
|
894
|
+
const width = bars.length * BAR_UNIT;
|
|
895
|
+
const max = Math.max(1, ...bars.map((b) => b.value ?? 0));
|
|
896
|
+
const lastWithData = bars.map((b) => b.value !== null).lastIndexOf(true);
|
|
897
|
+
const rects = bars.map((b, i) => {
|
|
898
|
+
const x = i * BAR_UNIT;
|
|
899
|
+
const w = BAR_UNIT - 1;
|
|
900
|
+
const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
|
|
901
|
+
if (b.value === null) {
|
|
902
|
+
return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
|
|
903
|
+
}
|
|
904
|
+
const h = Math.max(1, b.value / max * (height - 5));
|
|
905
|
+
const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
|
|
906
|
+
return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
|
|
907
|
+
});
|
|
908
|
+
const first = esc(bars[0].key), last = esc(bars[bars.length - 1].key);
|
|
909
|
+
return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
|
|
910
|
+
}
|
|
911
|
+
function markKey(root, key) {
|
|
912
|
+
for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
|
|
913
|
+
if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
|
|
914
|
+
else r.removeAttribute("data-hl");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function installBars(ctx) {
|
|
918
|
+
const set = (key) => {
|
|
919
|
+
if (ctx.hoverKey === key) return;
|
|
920
|
+
ctx.hoverKey = key;
|
|
921
|
+
markKey(ctx.els.world, key);
|
|
922
|
+
ctx.onHoverKey?.(key);
|
|
923
|
+
};
|
|
924
|
+
on(ctx, ctx.els.world, "mouseover", (e) => {
|
|
925
|
+
const bar = e.target?.closest?.("rect[data-key]");
|
|
926
|
+
set(bar ? bar.getAttribute("data-key") : null);
|
|
927
|
+
});
|
|
928
|
+
on(ctx, ctx.els.world, "mouseleave", () => set(null));
|
|
929
|
+
}
|
|
930
|
+
|
|
887
931
|
// src/render.ts
|
|
888
932
|
function loopRoute(ctx, fromId, toId, from, to, channel) {
|
|
889
933
|
const detourFor = (end, other) => {
|
|
@@ -920,7 +964,7 @@ function nodeHtml(ctx, n) {
|
|
|
920
964
|
const arrow = n.kind === "chip" ? '<span aria-hidden="true">\u2191 </span>' : "";
|
|
921
965
|
const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> \xB7 ` : "";
|
|
922
966
|
const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">\u25B8 ${n.contains.length}</span>` : "";
|
|
923
|
-
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
|
|
967
|
+
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${barsHtml(n.bars)}${bar}${improved}${upgrade}`;
|
|
924
968
|
}
|
|
925
969
|
function build(ctx) {
|
|
926
970
|
const { model, els } = ctx;
|
|
@@ -955,7 +999,7 @@ function build(ctx) {
|
|
|
955
999
|
for (const n of model.nodes) {
|
|
956
1000
|
const p = ctx.geom.nodes[n.id];
|
|
957
1001
|
if (!p) continue;
|
|
958
|
-
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
|
|
1002
|
+
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
|
|
959
1003
|
d.id = "n_" + n.id;
|
|
960
1004
|
d.tabIndex = 0;
|
|
961
1005
|
d.setAttribute("role", "button");
|
|
@@ -1090,6 +1134,7 @@ function paint(ctx, fromSwitch = false) {
|
|
|
1090
1134
|
d.style.height = r.h + "px";
|
|
1091
1135
|
}
|
|
1092
1136
|
}
|
|
1137
|
+
markKey(ctx.els.world, ctx.hoverKey);
|
|
1093
1138
|
ctx.fn.buildChrome();
|
|
1094
1139
|
ctx.fn.trace(ctx.selected);
|
|
1095
1140
|
ctx.fn.renderInspector();
|
|
@@ -2310,6 +2355,8 @@ function mount(root, model, opts = {}) {
|
|
|
2310
2355
|
root,
|
|
2311
2356
|
els,
|
|
2312
2357
|
onSelect: opts.onSelect,
|
|
2358
|
+
onHoverKey: opts.onHoverKey,
|
|
2359
|
+
hoverKey: null,
|
|
2313
2360
|
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
2314
2361
|
nodes: new Map(model.nodes.map((n) => [n.id, n])),
|
|
2315
2362
|
links: model.links,
|
|
@@ -2352,6 +2399,7 @@ function mount(root, model, opts = {}) {
|
|
|
2352
2399
|
style.textContent = stateCss(model.states);
|
|
2353
2400
|
install(ctx);
|
|
2354
2401
|
install2(ctx);
|
|
2402
|
+
installBars(ctx);
|
|
2355
2403
|
install3(ctx);
|
|
2356
2404
|
install4(ctx);
|
|
2357
2405
|
install5(ctx);
|
|
@@ -2398,6 +2446,10 @@ function mount(root, model, opts = {}) {
|
|
|
2398
2446
|
ctx.fn.fit(animate);
|
|
2399
2447
|
},
|
|
2400
2448
|
select: (id) => ctx.fn.select(id),
|
|
2449
|
+
highlightKey: (key) => {
|
|
2450
|
+
ctx.hoverKey = key;
|
|
2451
|
+
markKey(ctx.els.world, key);
|
|
2452
|
+
},
|
|
2401
2453
|
selected: () => ctx.selected,
|
|
2402
2454
|
mode: () => ctx.mode,
|
|
2403
2455
|
setMode: (id) => ctx.fn.setMode(id),
|
|
@@ -2423,7 +2475,7 @@ function mount(root, model, opts = {}) {
|
|
|
2423
2475
|
const changes = diffModels(ctx.model, prepared);
|
|
2424
2476
|
const saved = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter((n) => n.kind === "group").map((n) => n.id)), playback: playback.playback() };
|
|
2425
2477
|
api.destroy();
|
|
2426
|
-
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
|
|
2478
|
+
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
|
|
2427
2479
|
notifySelection(saved.selected, replacement.selected(), opts.onSelect);
|
|
2428
2480
|
Object.assign(api, replacement);
|
|
2429
2481
|
window.FlowStudio = api;
|
|
@@ -649,3 +649,41 @@ The methodology in this document was extracted from a real multi-agent UI-polish
|
|
|
649
649
|
| Debug | 7 issues catalogued (A–G) | Fix-up plan created |
|
|
650
650
|
|
|
651
651
|
This end-to-end cycle took **~10 sessions** with full artefact trail and zero rework of fundamental design decisions.
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
## agy as an implementer: what was measured (2026-10-08)
|
|
657
|
+
|
|
658
|
+
A 17-hour run with 14 lanes used agy (Antigravity) as the main implementer. Details: `.caddis/kb/agy-headless-implementer-limits.md` in the caddis source repo. Read the rule first, then the evidence.
|
|
659
|
+
|
|
660
|
+
**Rule:** send agy only small, exact, mechanical cards, test fixes and CI watching. Send design-fidelity work, cross-file refactors and judgment to Codex or Claude. Give Opus only short contract-sized tasks.
|
|
661
|
+
|
|
662
|
+
**Why (measured):**
|
|
663
|
+
- On that machine only the Flash models worked. `gemini-3.1-pro-high` failed with "not supported in the selected location".
|
|
664
|
+
- Small exact cards: 112 of 114 passed. A card to port an app shell from screenshots ran 40 minutes and changed one icon file.
|
|
665
|
+
- In `--print` mode agy started the full test suite as a background task and waited for a notification that never came (35 to 40 minutes lost, twice).
|
|
666
|
+
- On narrow gates it invented statuses, counts and names. 11 of 14 lanes needed up to 3 review rounds.
|
|
667
|
+
|
|
668
|
+
### The agy card template
|
|
669
|
+
|
|
670
|
+
Every card that goes to agy has these sections. A card without them is not ready.
|
|
671
|
+
|
|
672
|
+
1. `## Goal`: one sentence, one component or one page.
|
|
673
|
+
2. `## Files`: a bullet list of every file the card may touch. The gate rejects any change outside it.
|
|
674
|
+
3. `## Tests first`: the failing tests to write before the code, and the one targeted command that runs them.
|
|
675
|
+
4. `## Rules for agy` (copy this block as it is):
|
|
676
|
+
- Edit first. Run foreground commands only.
|
|
677
|
+
- Never start a background task. Never wait for a notification.
|
|
678
|
+
- Never run the full test suite. The orchestrator gate runs it.
|
|
679
|
+
- No invented data: no made-up statuses, counts, names or sign-offs. If a fact is missing, say so in the card result.
|
|
680
|
+
- No fake controls. Do not remove a feature to make a test pass.
|
|
681
|
+
- Codes go through the shared formatter.
|
|
682
|
+
5. `## Done when`: the exact observable result, and "report what you changed and what you could not do".
|
|
683
|
+
|
|
684
|
+
### Orchestrator rules from the same run
|
|
685
|
+
|
|
686
|
+
- The gate runs the full suite, requires that a NEW commit exists, and stages only source folders.
|
|
687
|
+
- Restore known-noisy files (CRLF snapshot rewrites, screenshots) before checking for changes.
|
|
688
|
+
- Do not let Codex `-s workspace-write` run builds. Files it creates are owned by a sandbox user and can block worktree removal.
|
|
689
|
+
- Do not run `caddis-push` or `caddis update` while any `agy.exe` runs. The release now refuses or skips in that case.
|
|
@@ -70,7 +70,9 @@ In either route, derive node and link IDs from immutable business keys. Preserve
|
|
|
70
70
|
8. **Short titles**: titles fit one line at column width. Long descriptions belong in subtitles or member lists.
|
|
71
71
|
9. **Fixed node dimensions**: node kinds use fixed box sizes so font rendering does not shift layout.
|
|
72
72
|
10. **Reduced motion**: removes all animations while preserving static borders and status glows.
|
|
73
|
-
11. **
|
|
73
|
+
11. **The lane is the owner**: a lane is the app, team or system that owns its row. Never add a column that repeats the owner (an "Apps" or "Owner" column); it adds a block between the real stages and the pages and says nothing new.
|
|
74
|
+
12. **Draw a shared node once**: when several rows use the same database, job or table, draw one node, centre it on the rows it serves, and link it to each node downstream. A chip (`kind: "chip"` with `ref`) is only for a node that lives in another lane, never a copy inside the same lane.
|
|
75
|
+
13. **Thin links, dotted canvas**: links are hairlines (about 0.8 px, with a 6 px arrowhead) and the canvas background is faint dots, not a grid. Both come from tokens (`--edge-w`, `--dot`, ...), so an app can change them with one CSS line; see `references/design-language.md`.
|
|
74
76
|
|
|
75
77
|
## Model summary
|
|
76
78
|
|
|
@@ -80,7 +82,7 @@ columns [{id, title, short?, width, phase?, continues?}] stages, le
|
|
|
80
82
|
lanes [{id, title, badge?, badgeTone?, note?, gap?}] rows; gap true = dashed amber lane
|
|
81
83
|
states {id: {word, tone, border, edge, motion, bucket}} tone: ok|warn|crit|info|muted|accent; bucket: good|neutral|gap
|
|
82
84
|
nodes [{id, kind, lane?, col?, colSpan?, row?, x?, y?, w?, h?, state, title, subtitle?, members?, weight?,
|
|
83
|
-
ref?, upgrade?, contains?, collapsed?, stateIn?, visibleIn?, sla?, metrics?}]
|
|
85
|
+
ref?, upgrade?, contains?, collapsed?, stateIn?, visibleIn?, sla?, metrics?, bars?}]
|
|
84
86
|
links [{id, from, to, state, kind?, label?, stateIn?, visibleIn?}] kind: forward | loop-back
|
|
85
87
|
views [{id, label, default?}] first view serves as baseline
|
|
86
88
|
inspector {relations [up to 3], actions [{id, label, dir, staleText}]}
|
|
@@ -75,7 +75,10 @@
|
|
|
75
75
|
"sla": { "type": "number", "exclusiveMinimum": 0, "description": "Target service level agreement in seconds" },
|
|
76
76
|
"metrics": { "type": "object", "required": ["value"], "additionalProperties": false,
|
|
77
77
|
"properties": { "value": { "type": "number" }, "max": { "type": ["number", "null"], "description": "Upper bound for the badge fill bar; null means there is no maximum" }, "unit": { "type": "string" }, "label": { "type": "string" },
|
|
78
|
-
"series": { "type": "array", "maxItems": 60, "items": { "type": "number" } } } }
|
|
78
|
+
"series": { "type": "array", "maxItems": 60, "items": { "type": "number" } } } },
|
|
79
|
+
"bars": { "type": "array", "maxItems": 120, "description": "A small bar chart on the node, one bar per key. A null value is a missing key (a red tick, never zero). Hovering a bar marks the same key on every chart.",
|
|
80
|
+
"items": { "type": "object", "required": ["key", "value"], "additionalProperties": false,
|
|
81
|
+
"properties": { "key": { "type": "string" }, "value": { "type": ["number", "null"], "minimum": 0 }, "kind": { "enum": ["low"] }, "label": { "type": "string" } } } }
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
},
|
|
@@ -7,15 +7,17 @@ const lifecycle = vi.hoisted(() => ({
|
|
|
7
7
|
alive: 0, updates: [] as Model[], mounts: 0, listenerCount: 0,
|
|
8
8
|
notes: [] as (NoteDef[] | undefined)[],
|
|
9
9
|
onSelect: undefined as ((id: string | null) => void) | undefined,
|
|
10
|
+
onHoverKey: undefined as ((key: string | null) => void) | undefined,
|
|
10
11
|
}));
|
|
11
12
|
vi.mock('../templates/flow-studio.lib.mjs', () => ({
|
|
12
13
|
SHELL_BODY_HTML: '<div id="viewport"></div>',
|
|
13
|
-
mount: (_root: ParentNode, _model: Model, options: { notes?: NoteDef[]; onSelect?: (id: string | null) => void }) => {
|
|
14
|
+
mount: (_root: ParentNode, _model: Model, options: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; onHoverKey?: (key: string | null) => void }) => {
|
|
14
15
|
lifecycle.mounts++;
|
|
15
16
|
lifecycle.alive++;
|
|
16
17
|
lifecycle.listenerCount++;
|
|
17
18
|
lifecycle.notes.push(options.notes);
|
|
18
19
|
lifecycle.onSelect = options.onSelect;
|
|
20
|
+
lifecycle.onHoverKey = options.onHoverKey;
|
|
19
21
|
return {
|
|
20
22
|
update: (model: Model) => { lifecycle.updates.push(model); return { ok: true, issues: [] }; },
|
|
21
23
|
destroy: () => { lifecycle.alive--; lifecycle.listenerCount--; },
|
|
@@ -56,6 +58,21 @@ describe('FlowStudioView', () => {
|
|
|
56
58
|
expect(globals.flowStudioMount).toBe(previousMount);
|
|
57
59
|
});
|
|
58
60
|
|
|
61
|
+
it('forwards the hovered bar key to the latest onHoverKey, without remounting', () => {
|
|
62
|
+
lifecycle.mounts = 0;
|
|
63
|
+
const first = vi.fn();
|
|
64
|
+
const second = vi.fn();
|
|
65
|
+
const rendered = render(<FlowStudioView model={model} onHoverKey={first} />);
|
|
66
|
+
lifecycle.onHoverKey?.('2026-09-02');
|
|
67
|
+
expect(first).toHaveBeenCalledWith('2026-09-02');
|
|
68
|
+
rendered.rerender(<FlowStudioView model={model} onHoverKey={second} />);
|
|
69
|
+
expect(lifecycle.mounts).toBe(1);
|
|
70
|
+
lifecycle.onHoverKey?.(null);
|
|
71
|
+
expect(second).toHaveBeenCalledWith(null);
|
|
72
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
73
|
+
rendered.unmount();
|
|
74
|
+
});
|
|
75
|
+
|
|
59
76
|
it('passes notes at mount and ignores later notes-only changes', () => {
|
|
60
77
|
lifecycle.updates = [];
|
|
61
78
|
lifecycle.notes = [];
|
|
@@ -7,6 +7,8 @@ export interface FlowStudioViewProps {
|
|
|
7
7
|
/** Read once at mount; later changes to this prop are ignored. For notes that change with the data, put them on `model.notes` — `update` forwards them. */
|
|
8
8
|
notes?: NoteDef[];
|
|
9
9
|
onSelect?: (id: string | null) => void;
|
|
10
|
+
/** The bar key under the pointer (a node's `bars`), or null when it leaves. The latest function is always called. */
|
|
11
|
+
onHoverKey?: (key: string | null) => void;
|
|
10
12
|
className?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -17,16 +19,18 @@ let mountedViews = 0;
|
|
|
17
19
|
let previousGlobalMount: typeof mount | undefined;
|
|
18
20
|
|
|
19
21
|
/** Mounts the finished engine in React. The shell stays outside React's child tree. */
|
|
20
|
-
export function FlowStudioView({ model, notes, onSelect, className }: FlowStudioViewProps) {
|
|
22
|
+
export function FlowStudioView({ model, notes, onSelect, onHoverKey, className }: FlowStudioViewProps) {
|
|
21
23
|
const root = useRef<HTMLDivElement>(null);
|
|
22
24
|
const api = useRef<FlowStudio | null>(null);
|
|
23
25
|
const currentModel = useRef(model);
|
|
24
26
|
const currentNotes = useRef(notes);
|
|
25
27
|
const currentSelect = useRef(onSelect);
|
|
28
|
+
const currentHoverKey = useRef(onHoverKey);
|
|
26
29
|
const lastModel = useRef<string | null>(null);
|
|
27
30
|
currentModel.current = model;
|
|
28
31
|
currentNotes.current = notes;
|
|
29
32
|
currentSelect.current = onSelect;
|
|
33
|
+
currentHoverKey.current = onHoverKey;
|
|
30
34
|
const modelJson = JSON.stringify(model);
|
|
31
35
|
|
|
32
36
|
useEffect(() => {
|
|
@@ -36,6 +40,7 @@ export function FlowStudioView({ model, notes, onSelect, className }: FlowStudio
|
|
|
36
40
|
const instance = mount(container, currentModel.current, {
|
|
37
41
|
notes: currentNotes.current,
|
|
38
42
|
onSelect: id => currentSelect.current?.(id),
|
|
43
|
+
onHoverKey: key => currentHoverKey.current?.(key),
|
|
39
44
|
});
|
|
40
45
|
if (mountedViews === 0) {
|
|
41
46
|
previousGlobalMount = window.flowStudioMount;
|
|
@@ -22,7 +22,7 @@ function FlowPage({ state, onRetry, onSelect }: { state: State; onRetry: () => v
|
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
`FlowStudioView` takes a required `model` and optional `notes`, `onSelect`, and `className`. `notes` is read once at mount; later changes to the prop are ignored. For notes that change with the data, put them on the model (`model.notes`) — `update` forwards them. `onSelect` receives a stable node id or `null` when selection clears. The component mounts the engine once, sends changed models to `update`, and destroys its listeners on unmount. React StrictMode's development mount cycle is supported. Several views may be mounted at once; the engine's global mount helper stays available until the last one unmounts.
|
|
25
|
+
`FlowStudioView` takes a required `model` and optional `notes`, `onSelect`, `onHoverKey`, and `className`. `notes` is read once at mount; later changes to the prop are ignored. For notes that change with the data, put them on the model (`model.notes`) — `update` forwards them. `onSelect` receives a stable node id or `null` when selection clears. `onHoverKey` receives the key of the bar under the pointer (see `bars` on a node) or `null`; call `window.FlowStudio?.highlightKey(key)` to mark a key from outside. The component mounts the engine once, sends changed models to `update`, and destroys its listeners on unmount. React StrictMode's development mount cycle is supported. Several views may be mounted at once; the engine's global mount helper stays available until the last one unmounts.
|
|
26
26
|
|
|
27
27
|
## Live data contract
|
|
28
28
|
|
|
@@ -112,6 +112,7 @@ Nodes represent processing units, cards, gateways, groups, and milestones.
|
|
|
112
112
|
| `collapsed` | `boolean` | Optional | `true` | When true, group starts collapsed hiding child nodes. |
|
|
113
113
|
| `sla` | `number` | Optional | - | Target service level agreement threshold in seconds. |
|
|
114
114
|
| `metrics` | `object` | Optional | - | Telemetry payload (see metrics table). |
|
|
115
|
+
| `bars` | `array` | Optional | - | A small bar chart on the node, one bar per `key` (see bars table). |
|
|
115
116
|
|
|
116
117
|
---
|
|
117
118
|
|
|
@@ -129,6 +130,21 @@ Provides numerical telemetry, health bars, and sparklines.
|
|
|
129
130
|
|
|
130
131
|
---
|
|
131
132
|
|
|
133
|
+
### `bars` items (under `nodes`)
|
|
134
|
+
|
|
135
|
+
A bar chart drawn inside the node, for example rows per day. Hovering a bar marks the bars with the same `key` on
|
|
136
|
+
every node, so a reader can ask "was this day bad everywhere?". The host page can follow the hover with the
|
|
137
|
+
`onHoverKey` mount option and drive it with `highlightKey(key)`. At most 120 bars.
|
|
138
|
+
|
|
139
|
+
| Property | Type | Required? | Default | Description |
|
|
140
|
+
|---|---|---|---|---|
|
|
141
|
+
| `key` | `string` | Required | - | Opaque id of the bar (a date or a step). Equal keys are linked across charts. |
|
|
142
|
+
| `value` | `number` or `null` | Required | - | Bar size (finite, not negative). `null` means no data for that key: a short red tick, never a zero bar. |
|
|
143
|
+
| `kind` | `string` | Optional | - | `"low"` draws the bar in the critical colour. The last bar with data is drawn in the accent colour, unless it is `low`. |
|
|
144
|
+
| `label` | `string` | Optional | - | Tooltip text; defaults to `key: value`. |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
132
148
|
## 8. `links` array items
|
|
133
149
|
|
|
134
150
|
Links represent directional data transit, dependencies, or workflow transitions.
|
|
@@ -21,7 +21,7 @@ You can start from these base states and adjust words to match your domain:
|
|
|
21
21
|
|
|
22
22
|
- **Example file:** `examples/lineage.json`
|
|
23
23
|
- **Scenario:** Trace data assets from ingestion sources through storage, pipelines, transformations, to consuming dashboards and applications.
|
|
24
|
-
- **Typical columns:** Source, Host, Database, Pipeline, Tables, Refresher, Views, App
|
|
24
|
+
- **Typical columns:** Source, Host, Database, Pipeline, Tables, Refresher, Views, Dashboard. Do not add an App or Owner column: the lane is the app (ux-rules 11).
|
|
25
25
|
- **Typical lanes:** Customer Portal, Order Service, Data Warehouse, Reporting Platform. A lane without documentation uses `gap: true`.
|
|
26
26
|
- **Node count guideline:** 15 to 30 nodes.
|
|
27
27
|
- **Recommended inspector relations:**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# UX and design rules
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The 12 rules ensure flow-studio pages remain readable, informative, accessible, and fast.
|
|
4
4
|
|
|
5
|
-
## The
|
|
5
|
+
## The 12 rules
|
|
6
6
|
|
|
7
7
|
### 1. One screen
|
|
8
8
|
- **Rule statement:** The full flow fits on one screen without requiring panning in the initial default view.
|
|
@@ -66,6 +66,20 @@ The 10 rules ensure flow-studio pages remain readable, informative, accessible,
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
+
### 11. The lane is the owner
|
|
70
|
+
- **Rule statement:** A lane names the app, team or system that owns its row. No column repeats the owner.
|
|
71
|
+
- **Rationale:** An "Apps" column between the real stages and the pages adds a block that says nothing new. In the estate lineage it sat between the reporting tables and the pages, and the owner asked why it was there (2026-10-09).
|
|
72
|
+
- **Pre-flight checklist question:** Does any column hold one node per lane that only repeats the lane title?
|
|
73
|
+
- **Common mistake & fix:** Adding an "App" column because the pattern list showed one. *Fix:* Remove the column. Link the stage that feeds a page straight to the page; a page that reads no table stands alone.
|
|
74
|
+
|
|
75
|
+
### 12. Draw a shared node once
|
|
76
|
+
- **Rule statement:** A database, job or table that several rows share is one node, centred on the rows it serves, with a link to each node downstream. A chip is only for a node that lives in another lane.
|
|
77
|
+
- **Rationale:** Copying a shared node into every row hides that it is shared and makes the reader compare copies. In the app lineage page, a database repeated in every source row looked like several databases (2026-10-09).
|
|
78
|
+
- **Pre-flight checklist question:** Does any node id appear as a chip inside the lane that already holds the real node?
|
|
79
|
+
- **Common mistake & fix:** One row per source, each with its own copy of the host, database and job. *Fix:* Draw each distinct node once, place it at the mean position of its rows, move it down if it would touch its neighbour, and de-duplicate links by their two ends.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
69
83
|
## Pre-flight checklist for agents
|
|
70
84
|
|
|
71
85
|
Before presenting a generated flow page, verify:
|
|
@@ -77,6 +91,8 @@ Before presenting a generated flow page, verify:
|
|
|
77
91
|
6. **Accessible names:** Does every button, input, tab, and control have an `aria-label` or visible text?
|
|
78
92
|
7. **Keyboard navigation:** Can a user Tab through controls and press Escape to close the inspector?
|
|
79
93
|
8. **Responsive breakdown:** Does the layout degrade cleanly down to 820x900?
|
|
94
|
+
9. **No owner column:** Does any column only repeat the lane's owner? Remove it (rule 11).
|
|
95
|
+
10. **Shared nodes once:** Is every shared database, job or table drawn once, not copied per row (rule 12)?
|
|
80
96
|
|
|
81
97
|
---
|
|
82
98
|
|
|
@@ -88,4 +104,6 @@ Before presenting a generated flow page, verify:
|
|
|
88
104
|
- **Text spill:** Node labels exceed card width. *Fix:* Use short titles (under 24 characters) and set `subtitle` for auxiliary metadata.
|
|
89
105
|
- **Permanent side panel:** The inspector remains open on load. *Fix:* The inspector must start closed and open only on selection.
|
|
90
106
|
- **Flat visual hierarchy:** Every card looks identical. *Fix:* Vary card kinds (`card`, `group`, `gateway`, `chip`) and state tones (`ok`, `warn`, `crit`, `accent`).
|
|
107
|
+
- **Owner column:** A column of one node per lane that repeats the lane title. *Fix:* Remove it; the lane is the owner.
|
|
108
|
+
- **Copies of a shared node:** The same database or job drawn in every row. *Fix:* Draw it once and link it to each node downstream.
|
|
91
109
|
- **Unlabelled controls:** Icon buttons without text lack screen reader descriptions. *Fix:* Provide descriptive `aria-label` attributes on all buttons.
|