Files
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## Summary

This improves how agents discover, author, inspect, and verify Webstudio
projects while reducing unnecessary MCP context usage.

## What changed

- Adds an **Agents** section to Project Settings with ready-to-run setup
commands and client configuration for Claude Code, Codex, Cursor, and VS
Code.
- Makes large list and schema responses compact and paginated by
default, with **verbose** available when complete records or diagnostics
are needed.
- Applies consistent compact output to assets, instances, styles, design
tokens, CLI schemas, and project-session metadata.
- Returns actionable expression warnings for props, resources, and
Collection bindings, including unknown variables and risky render-time
mutation resources.
- Improves Collection expression guidance so agents can bind array items
with the correct scoped variables.
- Reports rendered audit outcomes explicitly as complete, partial,
confirmation-required, or failed.
- Expands runtime output schemas so MCP clients can validate responses
reliably.
- Strengthens the packaged CLI smoke workflow with a realistic
seven-page stdio MCP scenario.

## Architecture

- Consolidates `project-build` from 65 file-oriented entrypoints to 11
architecture-owned surfaces.
- Separates the core model, contracts, runtime authoring, transfer,
state, project sessions, MCP, visual tooling, persistence codecs, and
authenticated server services.
- Isolates Node-only JSX evaluation from browser-safe transfer utilities
and authenticated server modules.
- Keeps persistence codecs independent from authenticated database
services, preventing unrelated module initialization.
- Defines shared instance-path types once and reuses them across runtime
modules.
- Preserves Immer initialization through explicit package side-effect
metadata.
- Adds package tests that prevent a return to source-file entrypoints or
generic exposure of Node-only surfaces.

## Validation

- Full repository checks, tests, typechecks, generated-file checks, and
fixtures
- All 163 Builder test files and 2,141 tests
- All 71 project-build test files and 1,794 tests
- Lint and package-boundary validation
- Full packed CLI release smoke test, including install, preview, agent
setup, seven-page stdio MCP workflow, rendered audit, and structured
commands
2026-07-15 18:11:28 +02:00

416 lines
11 KiB
TypeScript

import type { Instance } from "@webstudio-is/sdk";
import { idAttribute, selectorIdAttribute } from "@webstudio-is/react-sdk";
import type { InstanceSelector } from "@webstudio-is/project-build/runtime";
import { getIsVisuallyHidden } from "./visually-hidden";
export const getInstanceIdFromElement = (
element: Element
): Instance["id"] | undefined => {
return element.getAttribute(idAttribute) ?? undefined;
};
// traverse dom to the root and find all instances
export const getInstanceSelectorFromElement = (element: Element) => {
// Change logic to support Portals
const matched: undefined | Element =
element.closest(`[${idAttribute}]`) ?? undefined;
const instanceSelector: InstanceSelector =
matched?.getAttribute(selectorIdAttribute)?.split(",") ?? [];
if (instanceSelector.length === 0) {
return;
}
return instanceSelector;
};
export const getElementByInstanceSelector = (
instanceSelector: InstanceSelector | Readonly<InstanceSelector>
) => {
return (
document.querySelector<HTMLElement>(
`[${selectorIdAttribute}="${instanceSelector.join(",")}"]`
) ?? undefined
);
};
// Determine if the element is detached, or lacks visual layout.
// We want to exclude elements that are display: none, option tags, or are not in the DOM
const hasLayout = (element: HTMLElement) => {
// Detached element
if (false === document.documentElement.contains(element)) {
return false;
}
if (element.tagName.toLowerCase() === "option") {
return false;
}
// Display none
if (getComputedStyle(element)?.display?.toLowerCase() === "none") {
return false;
}
return true;
};
export const getVisibleElementsByInstanceSelector = (
instanceSelector: InstanceSelector | Readonly<InstanceSelector>
) => {
return getElementsByInstanceSelector(instanceSelector, true);
};
export const getAllElementsByInstanceSelector = (
instanceSelector: InstanceSelector | Readonly<InstanceSelector>
) => {
return getElementsByInstanceSelector(instanceSelector, false);
};
/**
* Get root visible elements, even if instance
**/
const getElementsByInstanceSelector = (
instanceSelector: InstanceSelector | Readonly<InstanceSelector>,
skipHidden: boolean
) => {
const descendantsOrSelf = [
...document.querySelectorAll<HTMLElement>(
`[${selectorIdAttribute}$="${instanceSelector.join(",")}"]`
),
].filter((element) =>
skipHidden ? getIsVisuallyHidden(element) === false : true
);
const visibleIdSelectors = descendantsOrSelf.map(
(element) => element.getAttribute(selectorIdAttribute) ?? ""
);
// Find root selectors (i.e. selectors that are not descendants of other selectors)
let rootSelectors = [...visibleIdSelectors];
const isDescendant = (testSelector: string, selector: string) =>
testSelector.endsWith(`,${selector}`);
for (const selector of visibleIdSelectors) {
rootSelectors = rootSelectors.filter(
(rootSelector) => isDescendant(rootSelector, selector) === false
);
}
const rootSelectorSet = new Set(rootSelectors);
const rootElements = descendantsOrSelf.filter((element) =>
rootSelectorSet.has(element.getAttribute(selectorIdAttribute) ?? "")
);
return rootElements.map((element) => {
let elementResult: HTMLElement = element;
while (
skipHidden &&
false === hasLayout(elementResult) &&
elementResult.parentElement !== null
) {
elementResult = elementResult.parentElement;
}
return elementResult;
});
};
type Rect = {
top: number;
right: number;
bottom: number;
left: number;
};
const sumRects = (first: Rect, second: Rect) => {
return {
top: Math.min(first.top, second.top),
right: Math.max(first.right, second.right),
bottom: Math.max(first.bottom, second.bottom),
left: Math.min(first.left, second.left),
};
};
type BoundingBoxOptions = {
fallbackToParent?: boolean;
};
const getElementsBoundingBox = (
elements: Element[],
options: BoundingBoxOptions,
depth: number
): DOMRect => {
const rects: Rect[] = [];
if (elements.length === 0) {
return DOMRect.fromRect({ width: 0, height: 0, x: 0, y: 0 });
}
for (const element of elements) {
const rect = element.getBoundingClientRect();
// possible display: contents
if (rect.width !== 0 || rect.height !== 0) {
rects.push(rect);
continue;
}
if (element.children.length === 0) {
const textNode = element.firstChild;
if (textNode?.nodeType === Node.TEXT_NODE) {
// Create a range object
const range = document.createRange();
// Set the range to encompass the text node
range.selectNodeContents(textNode);
// Get the bounding rectangle
const rect = range.getBoundingClientRect();
if (rect.width !== 0 || rect.height !== 0) {
rects.push(rect);
range.detach();
continue;
}
range.detach();
}
}
if (element.children.length > 0) {
const childRect = getElementsBoundingBox(
[...element.children],
options,
depth + 1
);
if (childRect.width !== 0 || childRect.height !== 0) {
const { top, right, bottom, left } = childRect;
rects.push({ top, right, bottom, left });
continue;
}
}
if (depth > 0 || options.fallbackToParent === false) {
continue;
}
// We here, let's try ancestor size
const parentElement = element.parentElement;
if (parentElement === null) {
continue;
}
const parentRect = getElementsBoundingBox([parentElement], {}, 0);
if (parentRect.width !== 0 || parentRect.height !== 0) {
const { top, right, bottom, left } = parentRect;
rects.push({ top, right, bottom, left });
continue;
}
}
if (rects.length === 0) {
// To preserve position even if width/height is 0
return elements[0].getBoundingClientRect();
}
const { top, right, bottom, left } = rects.reduce(sumRects);
return DOMRect.fromRect({
x: left,
y: top,
height: bottom - top,
width: right - left,
});
};
export const getAllElementsBoundingBox = (
elements: Element[],
options: BoundingBoxOptions = {}
): DOMRect => {
return getElementsBoundingBox(elements, options, 0);
};
const doNotTrackMutationAttribute = "data-ws-do-not-track-mutation";
export const doNotTrackMutation = (element: Element) => {
element.setAttribute(doNotTrackMutationAttribute, "true");
};
export const hasDoNotTrackMutationAttribute = (element: Element) => {
return element.hasAttribute(doNotTrackMutationAttribute);
};
export const hasDoNotTrackMutationRecord = (
mutationRecords: MutationRecord[]
) => {
return mutationRecords.some((record) =>
record.type === "childList"
? [...record.addedNodes.values()].some(
(node) =>
node instanceof Element &&
node.hasAttribute(doNotTrackMutationAttribute)
)
: false
);
};
/**
* Get a DOMMatrix mapping the container's local coords to viewport coords.
* This uses one test DIV (width=100, height=100) placed at (0,0) in container space.
*/
const getLocalToViewportMatrix = (container: Element): DOMMatrix => {
const rectSize = 100;
const testDiv = document.createElement("div");
Object.assign(testDiv.style, {
position: "absolute",
left: "0px",
top: "0px",
width: `${rectSize}px`,
height: `${rectSize}px`,
pointerEvents: "none",
background: "transparent",
visibility: "hidden",
});
container.appendChild(testDiv);
testDiv.setAttribute(doNotTrackMutationAttribute, "true");
const { left, top, width, height } = testDiv.getBoundingClientRect();
container.removeChild(testDiv);
const x1 = left;
const y1 = top;
const x2 = left + width;
const y2 = top;
const x3 = left;
const y3 = top + height;
const a = (x2 - x1) / rectSize;
const b = (y2 - y1) / rectSize;
const c = (x3 - x1) / rectSize;
const d = (y3 - y1) / rectSize;
const e = x1;
const f = y1;
return new DOMMatrix([a, b, c, d, e, f]);
};
const getViewportToLocalMatrix = (container: Element): DOMMatrix => {
return getLocalToViewportMatrix(container).inverse();
};
const transformDOMRect = (rect: DOMRect, matrix: DOMMatrix) => {
const topLeft = new DOMPoint(rect.x, rect.y).matrixTransform(matrix);
const topRight = new DOMPoint(rect.x + rect.width, rect.y).matrixTransform(
matrix
);
const bottomLeft = new DOMPoint(rect.x, rect.y + rect.height).matrixTransform(
matrix
);
const bottomRight = new DOMPoint(
rect.x + rect.width,
rect.y + rect.height
).matrixTransform(matrix);
const xs = [topLeft.x, topRight.x, bottomLeft.x, bottomRight.x];
const ys = [topLeft.y, topRight.y, bottomLeft.y, bottomRight.y];
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
};
/**
* `overflow: hidden` can be scrolled
*/
const isScrollable = (
node: HTMLElement,
checkForOverflow: boolean
): boolean => {
if (!node) {
return false;
}
const style = window.getComputedStyle(node);
let isScrollable = /(auto|scroll|hidden)/.test(
style.overflow + style.overflowX + style.overflowY
);
if (isScrollable && checkForOverflow) {
isScrollable =
node.scrollHeight !== node.clientHeight ||
node.scrollWidth !== node.clientWidth;
}
return isScrollable;
};
const getScrollParent = (
node: HTMLElement,
checkForOverflow: boolean
): HTMLElement | null => {
for (
let scrollableNode = node.parentElement;
scrollableNode !== null;
scrollableNode = scrollableNode.parentElement
) {
if (isScrollable(scrollableNode, checkForOverflow)) {
return scrollableNode;
}
}
return document.documentElement;
};
/**
* We scroll using rectangle and anchor calculations because `scrollIntoView` does not work
* reliably for certain elements, such as those with `display: contents`.
* For these elements, we display a selected or hovered outline on the canvas using the
* bounding rectangles of their children or the selection range.
* Here, we ensure scrolling works for these elements as well.
*/
export const scrollIntoView = (anchor: HTMLElement, rect: DOMRect) => {
const scrollParent = getScrollParent(anchor, true);
if (false === scrollParent instanceof HTMLElement) {
return;
}
requestAnimationFrame(() => {
const savedPosition = (scrollParent as HTMLElement).style.position;
// avoid updating <html> to prevent full page repaint and freeze on big projects
if (scrollParent.tagName !== "HTML") {
(scrollParent as HTMLElement).style.position = "relative";
}
const matrix = getViewportToLocalMatrix(scrollParent);
const transformedRect = transformDOMRect(rect, matrix);
const scrollDiv = document.createElement("div");
Object.assign(scrollDiv.style, {
position: "absolute",
left: `${transformedRect.left}px`,
top: `${transformedRect.top}px`,
width: `${transformedRect.width}px`,
height: `${transformedRect.height}px`,
pointerEvents: "none",
background: "transparent",
scrollMargin: "20px",
});
scrollParent.appendChild(scrollDiv);
scrollDiv.scrollIntoView({ behavior: "smooth", block: "nearest" });
scrollParent.removeChild(scrollDiv);
// avoid updating <html> to prevent full page repaint and freeze on big projects
if (scrollParent.tagName !== "HTML") {
(scrollParent as HTMLElement).style.position = savedPosition;
}
});
};