Skip to content

Commit 73dd8e4

Browse files
committed
feat(studio): make agent edits visually legible
1 parent 0131f8b commit 73dd8e4

8 files changed

Lines changed: 277 additions & 60 deletions

File tree

CONCEPTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ The stage records the strongest fact actually proven. Completion of a request is
2020

2121
### Topology Lens
2222

23-
The Topology Lens is Studio-only chrome that reveals the exact element an agent is inspecting or editing and reflects the write's receipt stage without becoming part of the composition output.
23+
The Topology Lens is Studio-only chrome that reveals the inspected source context, focuses the exact element an agent edits, and reflects the write's receipt stage without becoming part of the composition output.
24+
25+
Its context field explains where the agent searched; its target focus proves what it chose. A persistence seal appears only when the receipt proves a durable write.
2426

2527
### Project Content Revision
2628

docs/solutions/integration-issues/source-safe-proof-carrying-webmcp-edits.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
title: Source-safe, proof-carrying WebMCP edits in Studio
33
date: 2026-09-01
4+
last_updated: 2026-09-01
45
category: integration-issues
56
module: studio-webmcp
67
problem_type: integration_issue
@@ -91,6 +92,14 @@ Receipts separate lifecycle from whether bytes changed:
9192

9293
Fire-and-forget animation handlers remain `dispatched`. No-op saves preserve `changed: false`, and the Topology Lens does not show a success seal for them.
9394

95+
### Visualize context and focus as different facts
96+
97+
An agent edit has two spatial truths: the source boundary that explains where the agent searched, and the exact element that proves what it chose. The Topology Lens therefore measures both rectangles from the same source-scoped scene tree.
98+
99+
During acquisition, the source field reveals first and labeled contours make the local topology legible. The exact target then receives the stronger focus border. During persistence, the complete HyperFrames mark resolves in the source field only after a durable receipt. The inspector and timeline remain unchanged because they do not own this transaction state.
100+
101+
If the source boundary cannot be measured, the field falls back to the exact target rectangle. The target itself never falls back to an ancestor, because that would make a precise edit look broader than it was.
102+
94103
### Resolve animation state from the target
95104

96105
Animation ownership and geometry routing use the explicit target's source file and element identity. The human selection cache is reusable only when it belongs to that exact live element. Raw JavaScript expressions are rejected at both the WebMCP input boundary and the server mutation boundary.
@@ -118,6 +127,7 @@ The Topology Lens is derived from the same receipt lifecycle, so its visual lang
118127
- Open a conflict breaker with work already queued and assert that queued work never starts.
119128
- Change project content during thumbnail generation and assert the generated pixels are not cached under the prior signature.
120129
- Capture Studio, the composition frame, and a thumbnail while the Topology Lens is active. Only Studio should contain it.
130+
- Capture acquisition and sealing after their first rendered frame. A state assertion can pass while clipping, stacking, or timing still makes the visual proof misleading.
121131

122132
## Related Issues
123133

packages/studio/src/components/editor/TopologyLens.test.tsx

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,23 @@ function finish(
8585
beforeEach(() => {
8686
vi.useFakeTimers();
8787
geometryMock.measure.mockReset().mockReturnValue({
88-
target: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
89-
descendants: [
90-
{ left: 20, top: 30, width: 80, height: 20, editScaleX: 1, editScaleY: 1 },
91-
{ left: 110, top: 30, width: 60, height: 50, editScaleX: 1, editScaleY: 1 },
88+
field: {
89+
label: "section",
90+
rect: { left: 5, top: 10, width: 320, height: 180, editScaleX: 1, editScaleY: 1 },
91+
},
92+
target: {
93+
label: "h1",
94+
rect: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
95+
},
96+
contours: [
97+
{
98+
label: "p",
99+
rect: { left: 20, top: 30, width: 80, height: 20, editScaleX: 1, editScaleY: 1 },
100+
},
101+
{
102+
label: "aside",
103+
rect: { left: 110, top: 30, width: 60, height: 50, editScaleX: 1, editScaleY: 1 },
104+
},
92105
],
93106
});
94107
});
@@ -110,12 +123,26 @@ describe("TopologyLens", () => {
110123
const callId = begin();
111124

112125
expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
126+
expect(
127+
host?.querySelector('[data-topology-field="true"][data-topology-node="section"]'),
128+
).not.toBeNull();
129+
expect(host?.querySelector('[data-topology-target][data-topology-node="h1"]')).not.toBeNull();
113130
expect(host?.querySelectorAll('[data-topology-contour="true"]')).toHaveLength(2);
131+
expect(
132+
[...(host?.querySelectorAll<HTMLElement>("[data-topology-contour]") ?? [])].map(
133+
(element) => element.dataset.topologyNode,
134+
),
135+
).toEqual(["p", "aside"]);
114136
expect(host?.querySelector('[data-topology-scan="true"]')).not.toBeNull();
115137

116138
finish(callId, "saved");
117139
expect(host?.querySelector('[data-topology-lens="sealing"]')).not.toBeNull();
118-
expect(host?.querySelector('[data-topology-seal="saved"]')).not.toBeNull();
140+
expect(
141+
host
142+
?.querySelector('[data-topology-field][data-topology-phase="sealing"]')
143+
?.querySelector('[data-topology-seal="saved"]'),
144+
).not.toBeNull();
145+
expect(host?.querySelector("[data-topology-target] [data-topology-seal]")).toBeNull();
119146
expect(geometryMock.measure).toHaveBeenCalledTimes(2);
120147

121148
act(() => vi.advanceTimersByTime(240));
@@ -126,12 +153,26 @@ describe("TopologyLens", () => {
126153
it("remeasures the post-write target before drawing the persistence seal", () => {
127154
geometryMock.measure
128155
.mockReturnValueOnce({
129-
target: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
130-
descendants: [],
156+
field: {
157+
label: "section",
158+
rect: { left: 5, top: 10, width: 300, height: 180, editScaleX: 1, editScaleY: 1 },
159+
},
160+
target: {
161+
label: "h1",
162+
rect: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
163+
},
164+
contours: [],
131165
})
132166
.mockReturnValueOnce({
133-
target: { left: 70, top: 40, width: 240, height: 120, editScaleX: 1, editScaleY: 1 },
134-
descendants: [],
167+
field: {
168+
label: "section",
169+
rect: { left: 50, top: 20, width: 360, height: 220, editScaleX: 1, editScaleY: 1 },
170+
},
171+
target: {
172+
label: "h1",
173+
rect: { left: 70, top: 40, width: 240, height: 120, editScaleX: 1, editScaleY: 1 },
174+
},
175+
contours: [],
135176
});
136177
mount();
137178
const callId = begin();

packages/studio/src/components/editor/TopologyLens.tsx

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ interface MeasuredLens {
2727
geometry: TopologyLensGeometry;
2828
}
2929

30-
function rectStyle(rect: TopologyLensGeometry["target"]): CSSProperties {
30+
function rectStyle(rect: TopologyLensGeometry["target"]["rect"]): CSSProperties {
3131
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
3232
}
3333

@@ -147,24 +147,42 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
147147
>
148148
{visible && (
149149
<>
150-
{state.phase === "acquiring" && (
150+
{(state.phase === "acquiring" || state.phase === "sealing") && (
151+
<div
152+
data-topology-field="true"
153+
data-topology-node={geometry.field.label}
154+
data-topology-phase={state.phase}
155+
className="hf-topology-field pointer-events-none absolute overflow-hidden rounded-md"
156+
style={rectStyle(geometry.field.rect)}
157+
>
158+
{state.phase === "sealing" && (
159+
<HyperframesMark
160+
data-topology-seal={state.receiptStage}
161+
className="hf-topology-seal absolute right-2 top-2 h-7 w-11 overflow-visible"
162+
viewBox="0 18 100 64"
163+
/>
164+
)}
165+
</div>
166+
)}
167+
{(state.phase === "acquiring" || state.phase === "sealing") && (
151168
<>
152-
{geometry.descendants.map((rect, index) => (
169+
{geometry.contours.map(({ label, rect }, index) => (
153170
<div
154171
key={index}
155172
data-topology-contour="true"
156-
className="hf-topology-contour pointer-events-none absolute rounded"
173+
data-topology-node={label}
174+
className="hf-topology-contour pointer-events-none absolute z-[1] rounded"
157175
style={{
158176
...rectStyle(rect),
159-
animationDelay: `${Math.min(index, 8) * 8}ms`,
177+
animationDelay: `${Math.min(index, 5) * 28}ms`,
160178
}}
161179
/>
162180
))}
163181
{!reducedMotion && (
164182
<div
165183
data-topology-scan="true"
166-
className="pointer-events-none absolute overflow-hidden rounded"
167-
style={rectStyle(geometry.target)}
184+
className="pointer-events-none absolute z-[2] overflow-hidden rounded"
185+
style={rectStyle(geometry.field.rect)}
168186
>
169187
<div className="hf-topology-scan absolute inset-y-0 left-0 w-1/4" />
170188
</div>
@@ -173,21 +191,14 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
173191
)}
174192
<div
175193
data-topology-target="true"
194+
data-topology-node={geometry.target.label}
176195
data-topology-phase={state.phase}
177196
data-topology-terminal={
178197
state.phase === "localizing" ? (state.terminal ?? undefined) : undefined
179198
}
180-
className="hf-topology-target pointer-events-none absolute rounded-md"
181-
style={rectStyle(geometry.target)}
182-
>
183-
{state.phase === "sealing" && (
184-
<HyperframesMark
185-
data-topology-seal={state.receiptStage}
186-
className="hf-topology-seal absolute right-1.5 top-1.5 h-5 w-8 overflow-visible"
187-
viewBox="0 18 100 64"
188-
/>
189-
)}
190-
</div>
199+
className="hf-topology-target pointer-events-none absolute z-[3] rounded-md"
200+
style={rectStyle(geometry.target.rect)}
201+
/>
191202
</>
192203
)}
193204
</div>

packages/studio/src/components/editor/topologyLensGeometry.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { previewDoc } from "../../webmcp/webmcpTestUtils";
66
import { resolveTopologyLensElements } from "./topologyLensGeometry";
77

88
describe("Topology Lens scene geometry", () => {
9-
it("uses the source-safe Studio scene preorder and excludes unrelated branches", () => {
9+
it("reveals the nearest same-source context while preserving the exact target", () => {
1010
const doc = previewDoc(`
1111
<main data-composition-id="root" data-composition-file="index.html">
1212
<section id="duplicate"><span id="root-child">root</span></section>
1313
<section data-composition-id="nested" data-composition-file="compositions/nested.html">
14-
<div id="duplicate"><strong id="nested-child">nested</strong></div>
15-
<aside id="unrelated">outside target</aside>
14+
<article id="nested-context">
15+
<div id="duplicate"><strong id="nested-child">nested</strong></div>
16+
<aside id="unrelated">outside target</aside>
17+
</article>
1618
</section>
1719
</main>
1820
`);
@@ -26,7 +28,10 @@ describe("Topology Lens scene geometry", () => {
2628
const resolved = resolveTopologyLensElements(doc, "index.html", handle);
2729

2830
expect(resolved?.target.textContent).toBe("nested");
29-
expect(resolved?.descendants.map((element) => element.id)).toEqual(["nested-child"]);
31+
expect(resolved?.context.getAttribute("data-composition-file")).toBe(
32+
"compositions/nested.html",
33+
);
34+
expect(resolved?.contours.map((element) => element.id)).toEqual(["nested-child", "unrelated"]);
3035
});
3136

3237
it("returns no geometry for a stale handle", () => {
Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,48 @@
11
import { resolveElementHandle } from "../../webmcp/handles";
22
import { collectStudioLookScene } from "../../webmcp/tools/lookTools";
3+
import type { DomEditLayerItem } from "./domEditingTypes";
34
import { toVisibleOverlayRects, type OverlayRect } from "./domEditOverlayGeometry";
45

6+
export interface TopologyLensNode {
7+
label: string;
8+
rect: OverlayRect;
9+
}
10+
511
export interface TopologyLensGeometry {
6-
target: OverlayRect;
7-
descendants: OverlayRect[];
12+
field: TopologyLensNode;
13+
target: TopologyLensNode;
14+
contours: TopologyLensNode[];
15+
}
16+
17+
function findSourceContextIndex(items: readonly DomEditLayerItem[], targetIndex: number): number {
18+
const target = items[targetIndex]!;
19+
let contextIndex = targetIndex;
20+
let parentDepth = target.depth - 1;
21+
for (let index = targetIndex - 1; index >= 0 && parentDepth >= 0; index--) {
22+
const item = items[index]!;
23+
if (item.depth !== parentDepth) continue;
24+
if (item.sourceFile !== target.sourceFile) break;
25+
contextIndex = index;
26+
parentDepth -= 1;
27+
}
28+
return contextIndex;
29+
}
30+
31+
function collectSourceContours(
32+
items: readonly DomEditLayerItem[],
33+
contextIndex: number,
34+
target: DomEditLayerItem,
35+
): HTMLElement[] {
36+
const context = items[contextIndex]!;
37+
const contours: HTMLElement[] = [];
38+
for (let index = contextIndex + 1; index < items.length; index++) {
39+
const item = items[index]!;
40+
if (item.depth <= context.depth) break;
41+
if (item.sourceFile === target.sourceFile && item.element !== target.element) {
42+
contours.push(item.element);
43+
}
44+
}
45+
return contours;
846
}
947

1048
/**
@@ -16,22 +54,29 @@ export function resolveTopologyLensElements(
1654
doc: Document,
1755
activeCompositionPath: string | null,
1856
handle: string,
19-
): { target: HTMLElement; descendants: HTMLElement[] } | null {
57+
): { context: HTMLElement; target: HTMLElement; contours: HTMLElement[] } | null {
2058
const target = resolveElementHandle(doc, handle);
2159
if (!target) return null;
2260
const scene = collectStudioLookScene(doc, activeCompositionPath, null);
2361
if (scene.status === "loading") return null;
2462
const targetIndex = scene.items.findIndex((item) => item.element === target);
25-
if (targetIndex < 0) return { target, descendants: [] };
26-
27-
const targetDepth = scene.items[targetIndex]!.depth;
28-
const descendants: HTMLElement[] = [];
29-
for (let index = targetIndex + 1; index < scene.items.length; index++) {
30-
const item = scene.items[index]!;
31-
if (item.depth <= targetDepth) break;
32-
descendants.push(item.element);
33-
}
34-
return { target, descendants };
63+
if (targetIndex < 0) return { context: target, target, contours: [] };
64+
65+
const targetItem = scene.items[targetIndex]!;
66+
const contextIndex = findSourceContextIndex(scene.items, targetIndex);
67+
const contextItem = scene.items[contextIndex]!;
68+
const sourceContext = target.closest<HTMLElement>(
69+
"[data-composition-file], [data-composition-src]",
70+
);
71+
return {
72+
context: sourceContext ?? contextItem.element,
73+
target,
74+
contours: collectSourceContours(scene.items, contextIndex, targetItem),
75+
};
76+
}
77+
78+
function isRenderableRect(rect: OverlayRect | null | undefined): rect is OverlayRect {
79+
return Boolean(rect && rect.width > 0 && rect.height > 0);
3580
}
3681

3782
export function measureTopologyLensGeometry(input: {
@@ -44,16 +89,31 @@ export function measureTopologyLensGeometry(input: {
4489
if (!doc) return null;
4590
const elements = resolveTopologyLensElements(doc, input.activeCompositionPath, input.handle);
4691
if (!elements) return null;
47-
const measured = toVisibleOverlayRects(input.overlay, input.iframe, [
48-
elements.target,
49-
...elements.descendants,
50-
]);
51-
const target = measured[0];
52-
if (!target || target.width <= 0 || target.height <= 0) return null;
92+
const sharedField = elements.context === elements.target;
93+
const measured = toVisibleOverlayRects(
94+
input.overlay,
95+
input.iframe,
96+
sharedField
97+
? [elements.target, ...elements.contours]
98+
: [elements.context, elements.target, ...elements.contours],
99+
);
100+
const target = measured[sharedField ? 0 : 1];
101+
if (!isRenderableRect(target)) return null;
102+
const measuredField = measured[0];
103+
const fieldUsesContext = !sharedField && isRenderableRect(measuredField);
104+
const contourOffset = sharedField ? 1 : 2;
53105
return {
54-
target,
55-
descendants: measured
56-
.slice(1)
57-
.filter((rect): rect is OverlayRect => Boolean(rect && rect.width > 0 && rect.height > 0)),
106+
field: {
107+
label: (fieldUsesContext ? elements.context : elements.target).tagName.toLowerCase(),
108+
rect: fieldUsesContext ? measuredField : target,
109+
},
110+
target: { label: elements.target.tagName.toLowerCase(), rect: target },
111+
contours: measured
112+
.slice(contourOffset)
113+
.map((rect, index) => ({
114+
label: elements.contours[index]!.tagName.toLowerCase(),
115+
rect,
116+
}))
117+
.filter((item): item is TopologyLensNode => isRenderableRect(item.rect)),
58118
};
59119
}

0 commit comments

Comments
 (0)