@gakim-digital/dexter-bridge 0.13.4 → 0.13.5
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/package.json +1 -1
- package/src/framerRequirementContract.js +26 -15
- package/src/framerTools.js +36 -16
package/package.json
CHANGED
|
@@ -140,6 +140,13 @@ function hasContent(value) {
|
|
|
140
140
|
// descendant cannot satisfy a claim about its parent. A dotted name such as
|
|
141
141
|
// `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
|
|
142
142
|
function propertyValues(node, property) {
|
|
143
|
+
// Child layers are named by id, name, type, and text, not their settings.
|
|
144
|
+
if (property === 'children') {
|
|
145
|
+
const children = Array.isArray(node.children) ? node.children.filter(isRecord) : [];
|
|
146
|
+
return children.length
|
|
147
|
+
? [children.map((child) => ({ id: child.id, name: child.name ?? null, type: child.type, ...(child.text ? { text: child.text } : {}) }))]
|
|
148
|
+
: [];
|
|
149
|
+
}
|
|
143
150
|
const values = keyValues(node, property);
|
|
144
151
|
const [head, ...path] = property.split('.');
|
|
145
152
|
if (values.length > 0 || path.length === 0) return values;
|
|
@@ -305,27 +312,31 @@ export function summarizeRequirements(requirements, checkResults) {
|
|
|
305
312
|
}
|
|
306
313
|
|
|
307
314
|
// Compares two snapshots of layers that had to stay as they were. A snapshot
|
|
308
|
-
// maps a layer id to { name, rect, props } where props is the
|
|
309
|
-
// layer without children
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
315
|
+
// maps a layer id to { name, origin, rect, props } where props is the
|
|
316
|
+
// serialized layer without children, origin is the layer a breakpoint or
|
|
317
|
+
// variant copy follows, and rect is null when Dexter could not measure the
|
|
318
|
+
// layer, so only its settings are compared. Sizes are compared only when both
|
|
319
|
+
// readings came from the same source (the user's editor or Framer MCP), and
|
|
320
|
+
// only size counts, not position: a section that grows moves every section
|
|
321
|
+
// below it.
|
|
322
|
+
// A change is edited when the run edited the layer, or the layer it follows;
|
|
323
|
+
// any other change happened without the model touching the layer.
|
|
324
|
+
export function unchangedViolations(before, after, edited = new Set(), tolerance = 1) {
|
|
313
325
|
const changes = [];
|
|
314
326
|
for (const [id, earlier] of Object.entries(before || {})) {
|
|
315
327
|
const later = after?.[id];
|
|
316
328
|
const name = earlier.name || id;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
329
|
+
const source = edited.has(id) ? 'self' : earlier.origin && edited.has(earlier.origin) ? 'origin' : null;
|
|
330
|
+
const note = source === 'self' ? ' (you edited it)' : source === 'origin' ? ` (it follows your edit of ${earlier.origin})` : '';
|
|
321
331
|
const a = earlier.rect;
|
|
322
|
-
const b = later
|
|
332
|
+
const b = later?.rect;
|
|
323
333
|
const comparable = a && b && (a.source || null) === (b.source || null);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
334
|
+
let text = null;
|
|
335
|
+
if (!later) text = `${name} was removed`;
|
|
336
|
+
else if (comparable && ['width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
|
|
337
|
+
text = `${name} changed size (${a.width}×${a.height} → ${b.width}×${b.height})`;
|
|
338
|
+
} else if (earlier.props !== later.props) text = `${name} has different settings`;
|
|
339
|
+
if (text) changes.push({ text: `${text}${note}`, edited: Boolean(source) });
|
|
329
340
|
if (changes.length >= 20) break;
|
|
330
341
|
}
|
|
331
342
|
return changes;
|
package/src/framerTools.js
CHANGED
|
@@ -76,7 +76,7 @@ const CHECK_SCHEMA = {
|
|
|
76
76
|
properties: {
|
|
77
77
|
requirementId: { type: 'string' },
|
|
78
78
|
nodeId: { type: 'string' },
|
|
79
|
-
property: { type: 'string', description: 'A Framer property to read, such as fill, link.href, or text. Use either property or measure.' },
|
|
79
|
+
property: { type: 'string', description: 'A Framer property to read, such as fill, link.href, or text; children lists the child layers of the layer by name. Use either property or measure.' },
|
|
80
80
|
contains: { type: 'string', description: 'Text the property value must contain, such as #151515 or /contact.' },
|
|
81
81
|
absent: { type: 'boolean', description: 'True when the requirement is that the property is not set on the layer, such as no appearEffect on a background image.' },
|
|
82
82
|
measure: { type: 'string', enum: MEASURES, description: 'width, height, gap (to otherNodeId), or insideParent (the layer stays inside its parent).' },
|
|
@@ -789,10 +789,15 @@ function helpersUsed(program) {
|
|
|
789
789
|
|
|
790
790
|
// Reads what framer_verify needs in one program: the layers whose properties
|
|
791
791
|
// are checked, and each measure in order.
|
|
792
|
-
|
|
792
|
+
// Layers whose checks name children are read one level deep so their
|
|
793
|
+
// child layers can be checked.
|
|
794
|
+
export function verifyProgram(propertyNodeIds, measures, childNodeIds = []) {
|
|
793
795
|
return `${MEASURE_PRELUDE}
|
|
794
796
|
const tree = api.tree;
|
|
795
|
-
const
|
|
797
|
+
const withChildren = new Set(${JSON.stringify(childNodeIds)});
|
|
798
|
+
const flat = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}.filter((id) => !withChildren.has(id)), { depth: 0 });
|
|
799
|
+
const nested = withChildren.size ? await tree.serializeNodes([...withChildren], { depth: 1 }) : [];
|
|
800
|
+
const nodes = [...(flat || []), ...(nested || [])];
|
|
796
801
|
const measured = [];
|
|
797
802
|
for (const check of ${JSON.stringify(measures)}) {
|
|
798
803
|
try {
|
|
@@ -824,8 +829,9 @@ return { nodes, measured };`;
|
|
|
824
829
|
}
|
|
825
830
|
|
|
826
831
|
// Records layers the user wants left as they are: each layer's measured box,
|
|
827
|
-
// when Framer has laid it out,
|
|
828
|
-
// layer inside them.
|
|
832
|
+
// when Framer has laid it out, a fingerprint of its settings, and the layer a
|
|
833
|
+
// breakpoint or variant copy follows, for every layer inside them. The read
|
|
834
|
+
// after the run passes walk: false to read back exactly the recorded layers.
|
|
829
835
|
// Layers name colour styles only as var(--token-id). Reads carry the style's
|
|
830
836
|
// name and values next to them, so a model can tell a dark page colour from
|
|
831
837
|
// a light surface without guessing from the id.
|
|
@@ -851,14 +857,16 @@ export function describeColorStyle(style) {
|
|
|
851
857
|
return `${style?.name || 'Unnamed colour style'}${values ? ` (${values})` : ''}`;
|
|
852
858
|
}
|
|
853
859
|
|
|
854
|
-
export function snapshotProgram(rootIds) {
|
|
860
|
+
export function snapshotProgram(rootIds, { walk = true } = {}) {
|
|
855
861
|
return `${LAYOUT_CHECK}
|
|
856
862
|
const tree = api.tree;
|
|
857
863
|
const fingerprint = (text) => { let hash = 2166136261; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(36); };
|
|
858
864
|
const ids = new Set();
|
|
859
865
|
for (const root of ${JSON.stringify(rootIds)}) {
|
|
866
|
+
if (${walk ? 'true' : 'false'} && ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
|
|
860
867
|
if (!tree.get(root)) continue;
|
|
861
868
|
ids.add(root);
|
|
869
|
+
if (!${walk ? 'true' : 'false'}) continue;
|
|
862
870
|
for (const node of tree.walk(root)) {
|
|
863
871
|
if (ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
|
|
864
872
|
if (node && node.id) ids.add(node.id);
|
|
@@ -871,7 +879,7 @@ for (const id of ids) {
|
|
|
871
879
|
const rect = (await __dexterLaidOut(id)) ? await __dexterRect(id) : null;
|
|
872
880
|
let props = '';
|
|
873
881
|
try { const { children, ...rest } = JSON.parse(JSON.stringify(node)); props = fingerprint(JSON.stringify(rest)); } catch {}
|
|
874
|
-
out[id] = { name: node.name || null, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, props };
|
|
882
|
+
out[id] = { name: node.name || null, origin: node.$originalId || null, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, props };
|
|
875
883
|
}
|
|
876
884
|
return out;`;
|
|
877
885
|
}
|
|
@@ -923,6 +931,7 @@ export function createFramerAgentToolRuntime({
|
|
|
923
931
|
const unchangedBefore = {};
|
|
924
932
|
let unchangedPending = [];
|
|
925
933
|
let unchangedChanges = [];
|
|
934
|
+
const weighedPropertyErrors = new Set();
|
|
926
935
|
const colorStyles = new Map();
|
|
927
936
|
let promptContext = {};
|
|
928
937
|
// Set once the panel fails to answer, so a closed panel does not cost a
|
|
@@ -974,6 +983,10 @@ export function createFramerAgentToolRuntime({
|
|
|
974
983
|
|
|
975
984
|
const client = mcpClient || createFramerMcpClient({ getAccessToken: tokenGetter });
|
|
976
985
|
|
|
986
|
+
function propertyErrorKey(error) {
|
|
987
|
+
return `${error.nodeId}.${error.property}`;
|
|
988
|
+
}
|
|
989
|
+
|
|
977
990
|
function lingeringPropertyErrors() {
|
|
978
991
|
let open = [];
|
|
979
992
|
for (const receipt of mutationReceipts) {
|
|
@@ -1181,8 +1194,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1181
1194
|
// Records the layers the plan said to leave as they are. Boxes come from the
|
|
1182
1195
|
// editor where it measured them, otherwise from Framer MCP where that page is
|
|
1183
1196
|
// laid out; each records its source so before and after compare like with like.
|
|
1184
|
-
async function snapshotLayers(roots) {
|
|
1185
|
-
const snapshot = outputOf(await client.exec(snapshotProgram(roots))).result;
|
|
1197
|
+
async function snapshotLayers(roots, options) {
|
|
1198
|
+
const snapshot = outputOf(await client.exec(snapshotProgram(roots, options))).result;
|
|
1186
1199
|
if (!snapshot || typeof snapshot !== 'object') return null;
|
|
1187
1200
|
for (const entry of Object.values(snapshot)) if (entry?.rect) entry.rect.source = 'mcp';
|
|
1188
1201
|
const layers = await measureInEditor(Object.keys(snapshot));
|
|
@@ -1417,7 +1430,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1417
1430
|
const measures = measureChecks(checks);
|
|
1418
1431
|
const layers = await measureInEditor(measures.flatMap((check) => [check.nodeId, check.otherNodeId]));
|
|
1419
1432
|
const fromEditor = measures.map((check) => editorMeasurement(check, layers));
|
|
1420
|
-
const
|
|
1433
|
+
const childNodeIds = [...new Set(checks.filter((check) => check.property === 'children').map((check) => check.nodeId))];
|
|
1434
|
+
const result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index]), childNodeIds));
|
|
1421
1435
|
const output = outputOf(result);
|
|
1422
1436
|
if (output.error) throw toolError('FRAMER_PROGRAM_FAILED', String(output.error).split('\n')[0].slice(0, 300));
|
|
1423
1437
|
const { nodes, measured: fromMcp } = output.result && typeof output.result === 'object' ? output.result : {};
|
|
@@ -1615,6 +1629,7 @@ export function createFramerAgentToolRuntime({
|
|
|
1615
1629
|
const missing = verification().missingChecks;
|
|
1616
1630
|
const propertyErrors = lingeringPropertyErrors();
|
|
1617
1631
|
const facts = [];
|
|
1632
|
+
for (const error of propertyErrors) weighedPropertyErrors.add(propertyErrorKey(error));
|
|
1618
1633
|
if (propertyErrors.length) {
|
|
1619
1634
|
facts.push(`Framer rejected these properties, so they are not set: ${propertyErrors.map((error) => `${error.nodeId}.${error.property}: ${error.message}`).join('; ')}.`);
|
|
1620
1635
|
}
|
|
@@ -1636,7 +1651,7 @@ export function createFramerAgentToolRuntime({
|
|
|
1636
1651
|
facts.push(`These requirements from your plan have not been checked since your last change, so they are not confirmed either way: ${unchecked.map((item) => item.text).join('; ')}.`);
|
|
1637
1652
|
}
|
|
1638
1653
|
if (unchangedChanges.length) {
|
|
1639
|
-
facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.join('; ')}.`);
|
|
1654
|
+
facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.map((change) => change.text).join('; ')}.`);
|
|
1640
1655
|
}
|
|
1641
1656
|
if (!facts.length) return null;
|
|
1642
1657
|
return [
|
|
@@ -1700,8 +1715,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1700
1715
|
if (mutationCalls > 0 && Object.keys(unchangedBefore).length) {
|
|
1701
1716
|
try {
|
|
1702
1717
|
await ensureProject();
|
|
1703
|
-
const after = await snapshotLayers(Object.keys(unchangedBefore)
|
|
1704
|
-
unchangedChanges = after ? unchangedViolations(unchangedBefore, after) : [];
|
|
1718
|
+
const after = await snapshotLayers(Object.keys(unchangedBefore), { walk: false });
|
|
1719
|
+
unchangedChanges = after ? unchangedViolations(unchangedBefore, after, known) : [];
|
|
1705
1720
|
readCalls += 1;
|
|
1706
1721
|
} catch {
|
|
1707
1722
|
unchangedChanges = [];
|
|
@@ -1739,7 +1754,9 @@ export function createFramerAgentToolRuntime({
|
|
|
1739
1754
|
output: 'No layer changed for a summary that claimed a change.',
|
|
1740
1755
|
});
|
|
1741
1756
|
}
|
|
1742
|
-
|
|
1757
|
+
// A rejected property the model was shown and then finished without is
|
|
1758
|
+
// its judgment, such as reaching the goal another way.
|
|
1759
|
+
const propertyErrors = lingeringPropertyErrors().filter((error) => !weighedPropertyErrors.has(propertyErrorKey(error)));
|
|
1743
1760
|
if (propertyErrors.length) {
|
|
1744
1761
|
checks.push({
|
|
1745
1762
|
command: PROPERTY_ERROR_CHECK,
|
|
@@ -1774,11 +1791,14 @@ export function createFramerAgentToolRuntime({
|
|
|
1774
1791
|
.slice(0, 2000),
|
|
1775
1792
|
});
|
|
1776
1793
|
}
|
|
1777
|
-
|
|
1794
|
+
// A layer the model edited, or one that follows its edit, is a change it
|
|
1795
|
+
// made and weighed; only changes nothing in the run explains fail it.
|
|
1796
|
+
const unexplained = unchangedChanges.filter((change) => !change.edited);
|
|
1797
|
+
if (unexplained.length) {
|
|
1778
1798
|
checks.push({
|
|
1779
1799
|
command: UNCHANGED_CHECK,
|
|
1780
1800
|
status: 'failed',
|
|
1781
|
-
output:
|
|
1801
|
+
output: unexplained.map((change) => change.text).join('; ').slice(0, 2000),
|
|
1782
1802
|
});
|
|
1783
1803
|
}
|
|
1784
1804
|
// The model's own plan sets the evidence it owes; a run that changed the
|