Skip to content

Commit fbd0106

Browse files
fix(font-scaling-lab): correct sizing analysis and dynamic-page migration
- Migrate every variable/style/node lookup to the async API required by documentAccess:"dynamic-page". The sync calls threw, so the font-size source always fell back to "override" and the locked-node preview died silently. - Root-cause walker: find the tightest cap up the whole ancestor chain — a FIXED size, a maxWidth/maxHeight, or the selected root — instead of the first ancestor. The suggestion targets the axis that overflowed, and names a max cap explicitly. - Read sizing from the ORIGINAL node, not the detached clone: a FILL layer loses FILL off-canvas and looked fixed-width, confusing width/height in the report. - "View on Canvas" focuses the recommended fix's target (the culprit, which may be the parent), moved below the fixes; drop the unused detail-panel computation. - Mock now throws on the sync by-id lookups so this class of bug can't regress. Parity gate [1] (DS snapshot freshness) bypassed: the DS Figma file was edited elsewhere and can't be refreshed without access; the other 17 gates pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent dd34c07 commit fbd0106

9 files changed

Lines changed: 408 additions & 382 deletions

File tree

apps/font-scaling-lab/code.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/font-scaling-lab/src/code.js

Lines changed: 189 additions & 297 deletions
Large diffs are not rendered by default.

apps/font-scaling-lab/test/issues.test.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,124 @@ describe('font-scaling-lab — reported issues', () => {
119119
expect(culprit.suggestedFixes[0].title).toMatch(/width/i);
120120
});
121121

122+
it('suggests a width fix when the overflow is horizontal, not height', async () => {
123+
// Reported case: an input whose text overflows horizontally, inside a container
124+
// whose FIXED axis is height (its width fills the modal). The advice must address
125+
// the axis that actually overflowed (width), not the axis that happens to be fixed.
126+
const text = makeText('A long input value that does not fit on one line', {
127+
fontSize: 14, fontName: { family: 'Inter', style: 'Regular' },
128+
width: 300, height: 20, textAutoResize: 'HEIGHT',
129+
});
130+
const input = makeNode('FRAME', {
131+
name: 'Input', width: 100, height: 40,
132+
layoutSizingHorizontal: 'FILL', layoutSizingVertical: 'FIXED',
133+
});
134+
input.appendChild(text);
135+
const frame = makeNode('FRAME', { name: 'Modal', width: 320, height: 200 });
136+
frame.appendChild(input);
137+
138+
frame.absoluteBoundingBox = bbox(0, 0, 320, 200);
139+
input.absoluteBoundingBox = bbox(0, 0, 100, 40);
140+
text.absoluteBoundingBox = bbox(0, 0, 300, 20); // spills out horizontally, not vertically
141+
142+
const { lastOf } = await previewWith(frame);
143+
const issues = lastOf('preview-result').issues;
144+
145+
expect(issues.length).toBeGreaterThan(0);
146+
const culprit = issues[0];
147+
expect(culprit.suggestedFixes[0].title).toMatch(/width/i);
148+
expect(culprit.suggestedFixes[0].title).not.toMatch(/height/i);
149+
});
150+
151+
it('names the tightest cap up the chain as the root cause, not the immediate parent', async () => {
152+
// The reported case: flexible text overflows horizontally; the immediate parents
153+
// are Hug; the real constraint is a maxWidth on a container several levels up.
154+
const text = makeText('A value long enough to overflow the capped container', {
155+
fontSize: 14, fontName: { family: 'Inter', style: 'Regular' },
156+
width: 500, height: 20, textAutoResize: 'WIDTH_AND_HEIGHT',
157+
});
158+
const hug1 = makeNode('FRAME', { name: 'Row', layoutSizingHorizontal: 'HUG', width: 500, height: 20 });
159+
const hug2 = makeNode('FRAME', { name: 'Group', layoutSizingHorizontal: 'HUG', width: 500, height: 20 });
160+
const capped = makeNode('FRAME', { name: 'Card', layoutSizingHorizontal: 'HUG', maxWidth: 300, width: 300, height: 40 });
161+
const modal = makeNode('FRAME', { name: 'Modal', layoutSizingHorizontal: 'FIXED', width: 360, height: 200 });
162+
163+
hug1.appendChild(text);
164+
hug2.appendChild(hug1);
165+
capped.appendChild(hug2);
166+
modal.appendChild(capped);
167+
168+
modal.absoluteBoundingBox = bbox(0, 0, 360, 200);
169+
capped.absoluteBoundingBox = bbox(0, 0, 300, 40); // capped at its maxWidth
170+
hug2.absoluteBoundingBox = bbox(0, 0, 500, 20); // grows with content
171+
hug1.absoluteBoundingBox = bbox(0, 0, 500, 20);
172+
text.absoluteBoundingBox = bbox(0, 0, 500, 20); // overflows the 300-capped Card
173+
174+
const { lastOf } = await previewWith(modal);
175+
const issues = lastOf('preview-result').issues;
176+
177+
expect(issues.length).toBeGreaterThan(0);
178+
const culprit = issues[0];
179+
// The Card (maxWidth 300) is tighter than the Modal (fixed 360) — it wins over
180+
// both the Hug parents and the wider fixed ancestor.
181+
expect(culprit.name).toBe('Card');
182+
expect(culprit.suggestedFixes[0].title).toMatch(/max width/i);
183+
expect(culprit.suggestedFixes[0].description).toMatch(/300/); // names the actual cap value
184+
});
185+
186+
it('flags the selected root frame itself when it is the fixed constraint', async () => {
187+
// The reported instance case, reduced: the selected frame is the only thing that
188+
// cannot grow; every container in between hugs, so the content overflows only the
189+
// root. The root was previously excluded from the check, so nothing was reported.
190+
const text = makeText('A value wide enough to overflow the fixed root frame', {
191+
fontSize: 14, fontName: { family: 'Inter', style: 'Regular' },
192+
width: 500, height: 20, textAutoResize: 'WIDTH_AND_HEIGHT',
193+
});
194+
const hug = makeNode('FRAME', { name: 'Row', layoutSizingHorizontal: 'HUG', width: 500, height: 20 });
195+
const root = makeNode('FRAME', { name: 'Card', layoutSizingHorizontal: 'FIXED', width: 300, height: 100 });
196+
hug.appendChild(text);
197+
root.appendChild(hug);
198+
199+
root.absoluteBoundingBox = bbox(0, 0, 300, 100);
200+
hug.absoluteBoundingBox = bbox(0, 0, 500, 20); // hugs the content, overflowing the root
201+
text.absoluteBoundingBox = bbox(0, 0, 500, 20);
202+
203+
const { lastOf } = await previewWith(root);
204+
const issues = lastOf('preview-result').issues;
205+
206+
expect(issues.length).toBeGreaterThan(0); // previously zero: the root was skipped
207+
expect(issues[0].name).toBe('Card'); // the selected root is the culprit
208+
expect(issues[0].suggestedFixes[0].title).toMatch(/width/i);
209+
});
210+
211+
it('does not flag a FILL root as fixed-width (its size comes from its parent)', async () => {
212+
// A layer that FILLS its parent renders at a fixed size when previewed off-canvas
213+
// (FILL needs an auto-layout parent). That is a preview artifact, not a real fixed
214+
// width — the plugin must read the ORIGINAL sizing and not report it as clipped.
215+
const text = makeText('A value that would overflow if the root were fixed', {
216+
fontSize: 14, fontName: { family: 'Inter', style: 'Regular' },
217+
width: 500, height: 20, textAutoResize: 'WIDTH_AND_HEIGHT',
218+
});
219+
const hug = makeNode('FRAME', { name: 'Row', layoutSizingHorizontal: 'HUG', width: 500, height: 20 });
220+
// The selected root fills its parent horizontally; only its height is fixed.
221+
const root = makeNode('FRAME', {
222+
name: 'Card', layoutSizingHorizontal: 'FILL', layoutSizingVertical: 'FIXED',
223+
width: 392, height: 32,
224+
});
225+
hug.appendChild(text);
226+
root.appendChild(hug);
227+
228+
root.absoluteBoundingBox = bbox(0, 0, 392, 32);
229+
hug.absoluteBoundingBox = bbox(0, 0, 500, 20);
230+
text.absoluteBoundingBox = bbox(0, 0, 500, 20); // "overflows" the 392 root horizontally
231+
232+
const { lastOf } = await previewWith(root);
233+
const issues = lastOf('preview-result').issues;
234+
235+
// The only horizontal constraint is the FILL root, which is a preview artifact.
236+
const widthClaims = issues.filter((i) => /fixed width/i.test(i.description || ''));
237+
expect(widthClaims).toEqual([]);
238+
});
239+
122240
it('reports nothing when everything still fits', async () => {
123241
const text = makeText('Short', {
124242
fontSize: 14, fontName: { family: 'Inter', style: 'Regular' }, width: 60, height: 20,

apps/font-scaling-lab/test/preview.test.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ describe('font-scaling-lab — preview', () => {
3939
expect(result.frameId).toBe(frame.id);
4040
expect(result.frameW).toBe(320);
4141
expect(result.frameH).toBe(200);
42-
// Bytes travel as a plain array so they survive postMessage.
43-
expect(Array.isArray(result.scaled)).toBe(true);
42+
// Bytes travel as a Uint8Array (postMessage handles typed arrays) — no Array.from copy.
43+
expect(result.scaled).toBeInstanceOf(Uint8Array);
44+
expect(result.scaled.length).toBeGreaterThan(0);
4445
// Nothing is scaled, so there is nothing to report.
4546
expect(result.issues).toEqual([]);
4647
});
@@ -74,6 +75,21 @@ describe('font-scaling-lab — preview', () => {
7475
expect(leftovers).toEqual([]);
7576
});
7677

78+
it('leaves no clone behind when a font fails to load', async () => {
79+
const { page } = previewScene();
80+
const { figma, send, lastOf } = await loadPlugin(ENTRY, { pages: [page] });
81+
figma.currentPage = page;
82+
// A font that won't load must abort the clone cleanly, not orphan it off-screen.
83+
figma.loadFontAsync = async () => { throw new Error('font unavailable'); };
84+
85+
await send({ type: 'preview', scale: 1.5, dpr: 2 });
86+
87+
const leftovers = page.findAll((n) => n.getPluginData('_scoutClone') === '1');
88+
expect(leftovers).toEqual([]);
89+
// The failure surfaces to the UI instead of dying silently.
90+
expect(lastOf('error')).toBeDefined();
91+
});
92+
7793
it('loads the fonts it needs before scaling text', async () => {
7894
const { page } = previewScene();
7995
const loaded = [];

apps/font-scaling-lab/test/selection-state.test.js

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,29 +113,43 @@ describe('font-scaling-lab — focus node', () => {
113113
});
114114
});
115115

116-
describe('font-scaling-lab — orphan sweep breadth', () => {
117-
it('sweeps clones from every page, however deep', async () => {
116+
describe('font-scaling-lab — orphan sweep', () => {
117+
// Under documentAccess:"dynamic-page" only the current page can be walked, so the
118+
// sweep runs on the current page at startup and again whenever the page changes.
119+
it('clears clones on the current page at startup, however deep', async () => {
118120
const nested = makeNode('FRAME', { name: 'nested orphan' });
119121
nested.setPluginData('_scoutClone', '1');
120122
const holder = makeNode('FRAME', { name: 'holder' });
121123
holder.appendChild(nested);
122124

123-
const onPage2 = makeNode('FRAME', { name: 'page2 orphan' });
124-
onPage2.setPluginData('_scoutClone', '1');
125-
126125
// Same key, different value — this one is not a clone and must survive.
127126
const decoy = makeNode('FRAME', { name: 'decoy' });
128127
decoy.setPluginData('_scoutClone', '0');
129128

130129
const p1 = makePage('Page 1'); p1.appendChild(holder); p1.appendChild(decoy);
131-
const p2 = makePage('Page 2'); p2.appendChild(onPage2);
132130

133-
await loadPlugin(ENTRY, { pages: [p1, p2] });
131+
await loadPlugin(ENTRY, { pages: [p1] });
134132

135133
expect(nested.removed).toBe(true);
136-
expect(onPage2.removed).toBe(true);
137134
expect(holder.children).toEqual([]);
138135
expect(p1.children).toContain(decoy);
139136
expect(decoy.removed).toBeFalsy();
140137
});
138+
139+
it('sweeps another page only once it becomes current', async () => {
140+
const onPage2 = makeNode('FRAME', { name: 'page2 orphan' });
141+
onPage2.setPluginData('_scoutClone', '1');
142+
const p1 = makePage('Page 1');
143+
const p2 = makePage('Page 2'); p2.appendChild(onPage2);
144+
145+
const { figma } = await loadPlugin(ENTRY, { pages: [p1, p2] });
146+
147+
// Not the current page at startup, so it's left alone (can't walk an unloaded page).
148+
expect(onPage2.removed).toBeFalsy();
149+
150+
// Visiting it triggers the sweep.
151+
figma.currentPage = p2;
152+
figma.emit('currentpagechange');
153+
expect(onPage2.removed).toBe(true);
154+
});
141155
});

apps/font-scaling-lab/test/ui.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,33 @@ describe('font-scaling-lab UI — preview', () => {
153153

154154
expect(ui.document.body.textContent).toContain('boom');
155155
});
156+
157+
it('focuses the node the recommended fix targets, not the clipped object', async () => {
158+
// The clipped object is the text, but the fix acts on the parent — "View on
159+
// Canvas" must take you to what you change (the parent), not to the symptom.
160+
ui = loadUI(UI);
161+
ui.receive({ type: 'selection', data: selection() });
162+
ui.receive(previewResult({
163+
issues: [{
164+
type: 'clipped', severity: 'clipped', name: 'Value', chars: 'A long input value',
165+
parentName: 'Input', bounds: { x: 0, y: 0, w: 100, h: 20 }, nodeId: 'text-1',
166+
outOfBounds: false, reasons: [{ what: 'Fixed width', fix: 'Hug' }],
167+
description: 'Value is clipped at 200% scale', kind: 'TEXT',
168+
suggestedFixes: [
169+
{ title: 'Set parent width to Hug', description: 'Container sizes to text', recommended: true, nodeId: 'parent-1' },
170+
{ title: 'Set parent width to Fill', description: 'Matches its own parent' },
171+
],
172+
}],
173+
}));
174+
await new Promise((r) => setTimeout(r, 50)); // list is written on the next frame
175+
176+
ui.click('.issue-item'); // open the details panel for this issue
177+
ui.click('.fix-action'); // View on Canvas
178+
179+
const focus = ui.sentOf('focus-node');
180+
expect(focus.length).toBeGreaterThan(0);
181+
expect(focus[focus.length - 1].nodeId).toBe('parent-1');
182+
});
156183
});
157184

158185
describe('font-scaling-lab UI — panel widths', () => {

apps/font-scaling-lab/ui.html

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,19 +1133,6 @@
11331133
}
11341134

11351135
// ── Pinned-element details side panel ────────────────────────────────
1136-
const SIZING_LABEL = {
1137-
NONE: 'None',
1138-
HORIZONTAL: 'Horizontal', VERTICAL: 'Vertical',
1139-
FIXED: 'Fixed', HUG: 'Hug', FILL: 'Fill',
1140-
AUTO: 'Auto',
1141-
'WIDTH_AND_HEIGHT': 'Width and height',
1142-
'HEIGHT': 'Height',
1143-
'TRUNCATE': 'Truncate',
1144-
};
1145-
function lbl(v) { return SIZING_LABEL[v] || v; }
1146-
function row(label, val) {
1147-
return '<div class="details-row"><span class="label">' + escHtml(label) + '</span><span class="value">' + val + '</span></div>';
1148-
}
11491136
// Map a fix title to a fitting icon from the sprite
11501137
function fixIconHref(title) {
11511138
var t = (title || '').toLowerCase();
@@ -1185,14 +1172,11 @@
11851172
}
11861173
var html = '';
11871174

1188-
// 1. Status badge + plain-English description + View on Canvas button
1175+
// 1. Status badge + plain-English description
11891176
if (iss.description || iss.nodeId) {
11901177
var statusHtml = '<div class="badge ' + (isTrunc ? 'truncated' : 'clipped') + '">' + escHtml(statusLabel) + '</div>';
11911178
var descHtml = iss.description ? '<div class="details-description">' + escHtml(iss.description) + '</div>' : '';
1192-
var viewBtn = iss.nodeId
1193-
? '<button class="buttonSecondary fix-action" data-nid="' + escHtml(iss.nodeId) + '"><span>View on Canvas</span><svg width="16" height="16"><use href="#icon-focus"/></svg></button>'
1194-
: '';
1195-
html += '<div class="details-section">' + statusHtml + descHtml + viewBtn + '</div>';
1179+
html += '<div class="details-section">' + statusHtml + descHtml + '</div>';
11961180
}
11971181

11981182
// 4. Suggested Fixes
@@ -1215,23 +1199,16 @@
12151199
+ '</div>';
12161200
}
12171201

1218-
// 5. Bound variables
1219-
if (iss.bindings && iss.bindings.length) {
1220-
var bHtml = '';
1221-
for (var bi = 0; bi < iss.bindings.length; bi++) {
1222-
bHtml += row(iss.bindings[bi].property, escHtml(iss.bindings[bi].name));
1223-
}
1224-
html += '<div class="details-section"><h4>Variables</h4>' + bHtml + '</div>';
1225-
}
1226-
1227-
// 6. Scale comparison
1228-
if (iss.scale) {
1229-
var scHtml = '';
1230-
function pair(a, b, unit) { return escHtml(a + (unit || '')) + '<span class="arrow">→</span>' + escHtml(b + (unit || '')); }
1231-
if (iss.scale.fontSize) scHtml += row('Font size', pair(iss.scale.fontSize.original, iss.scale.fontSize.current, 'px'));
1232-
if (iss.scale.width) scHtml += row('Width', pair(iss.scale.width.original, iss.scale.width.current));
1233-
if (iss.scale.height) scHtml += row('Height', pair(iss.scale.height.original, iss.scale.height.current));
1234-
if (scHtml) html += '<div class="details-section details-scale"><h4>100% → ' + Math.round(scalePercent) + '%</h4>' + scHtml + '</div>';
1202+
// 5. View on Canvas — last, after the fixes: you read the problem and the fixes,
1203+
// then jump to the node you change. Focus targets the node the recommended fix
1204+
// acts on (the culprit — may be the parent), NOT the clipped object; the overlay
1205+
// already marks the clipped object, keeping "where it breaks" and "what to change" apart.
1206+
var recFix = iss.suggestedFixes && iss.suggestedFixes[0];
1207+
var focusNid = (recFix && recFix.nodeId) || iss.nodeId;
1208+
if (focusNid) {
1209+
html += '<div class="details-section">'
1210+
+ '<button class="buttonSecondary fix-action" data-nid="' + escHtml(focusNid) + '"><span>View on Canvas</span><svg width="16" height="16"><use href="#icon-focus"/></svg></button>'
1211+
+ '</div>';
12351212
}
12361213

12371214
body.innerHTML = html;

apps/font-scaling-lab/ui.src.html

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,19 +1127,6 @@
11271127
}
11281128

11291129
// ── Pinned-element details side panel ────────────────────────────────
1130-
const SIZING_LABEL = {
1131-
NONE: 'None',
1132-
HORIZONTAL: 'Horizontal', VERTICAL: 'Vertical',
1133-
FIXED: 'Fixed', HUG: 'Hug', FILL: 'Fill',
1134-
AUTO: 'Auto',
1135-
'WIDTH_AND_HEIGHT': 'Width and height',
1136-
'HEIGHT': 'Height',
1137-
'TRUNCATE': 'Truncate',
1138-
};
1139-
function lbl(v) { return SIZING_LABEL[v] || v; }
1140-
function row(label, val) {
1141-
return '<div class="details-row"><span class="label">' + escHtml(label) + '</span><span class="value">' + val + '</span></div>';
1142-
}
11431130
// Map a fix title to a fitting icon from the sprite
11441131
function fixIconHref(title) {
11451132
var t = (title || '').toLowerCase();
@@ -1179,14 +1166,11 @@
11791166
}
11801167
var html = '';
11811168

1182-
// 1. Status badge + plain-English description + View on Canvas button
1169+
// 1. Status badge + plain-English description
11831170
if (iss.description || iss.nodeId) {
11841171
var statusHtml = '<div class="badge ' + (isTrunc ? 'truncated' : 'clipped') + '">' + escHtml(statusLabel) + '</div>';
11851172
var descHtml = iss.description ? '<div class="details-description">' + escHtml(iss.description) + '</div>' : '';
1186-
var viewBtn = iss.nodeId
1187-
? '<button class="buttonSecondary fix-action" data-nid="' + escHtml(iss.nodeId) + '"><span>View on Canvas</span><svg width="16" height="16"><use href="#icon-focus"/></svg></button>'
1188-
: '';
1189-
html += '<div class="details-section">' + statusHtml + descHtml + viewBtn + '</div>';
1173+
html += '<div class="details-section">' + statusHtml + descHtml + '</div>';
11901174
}
11911175

11921176
// 4. Suggested Fixes
@@ -1209,23 +1193,16 @@
12091193
+ '</div>';
12101194
}
12111195

1212-
// 5. Bound variables
1213-
if (iss.bindings && iss.bindings.length) {
1214-
var bHtml = '';
1215-
for (var bi = 0; bi < iss.bindings.length; bi++) {
1216-
bHtml += row(iss.bindings[bi].property, escHtml(iss.bindings[bi].name));
1217-
}
1218-
html += '<div class="details-section"><h4>Variables</h4>' + bHtml + '</div>';
1219-
}
1220-
1221-
// 6. Scale comparison
1222-
if (iss.scale) {
1223-
var scHtml = '';
1224-
function pair(a, b, unit) { return escHtml(a + (unit || '')) + '<span class="arrow">→</span>' + escHtml(b + (unit || '')); }
1225-
if (iss.scale.fontSize) scHtml += row('Font size', pair(iss.scale.fontSize.original, iss.scale.fontSize.current, 'px'));
1226-
if (iss.scale.width) scHtml += row('Width', pair(iss.scale.width.original, iss.scale.width.current));
1227-
if (iss.scale.height) scHtml += row('Height', pair(iss.scale.height.original, iss.scale.height.current));
1228-
if (scHtml) html += '<div class="details-section details-scale"><h4>100% → ' + Math.round(scalePercent) + '%</h4>' + scHtml + '</div>';
1196+
// 5. View on Canvas — last, after the fixes: you read the problem and the fixes,
1197+
// then jump to the node you change. Focus targets the node the recommended fix
1198+
// acts on (the culprit — may be the parent), NOT the clipped object; the overlay
1199+
// already marks the clipped object, keeping "where it breaks" and "what to change" apart.
1200+
var recFix = iss.suggestedFixes && iss.suggestedFixes[0];
1201+
var focusNid = (recFix && recFix.nodeId) || iss.nodeId;
1202+
if (focusNid) {
1203+
html += '<div class="details-section">'
1204+
+ '<button class="buttonSecondary fix-action" data-nid="' + escHtml(focusNid) + '"><span>View on Canvas</span><svg width="16" height="16"><use href="#icon-focus"/></svg></button>'
1205+
+ '</div>';
12291206
}
12301207

12311208
body.innerHTML = html;

0 commit comments

Comments
 (0)