fix(ai): keep generated designs inside the canvas (#41)

Text could run off the edge (long/large headlines, nowrap) and shapes placed at
the far edge (e.g. a bottom band at y=100) spilled over.

- Server-side fit pass on every generated element: shrink text fontSize so it
  fits the canvas width (chars*fontSize*0.075, tuned for bold/uppercase
  headlines) and height (incl. line-height), then nudge x/y within 4% margins;
  clamp shapes so x+width<=100 and y+height<=100. Deterministic - doesn't rely on
  the model getting layout right.
- Designer preview: vw -> cqw (+ container-type on the canvas) so text scales to
  the canvas, not the browser window. The preview was overstating size vs what
  actually publishes; now it matches. Published widget keeps vw (scales on the
  player).

Verified: Playwright DOM check shows zero elements overflowing the canvas after
generation; unit test asserts long text is shrunk + repositioned in-bounds. 62/62.
This commit is contained in:
ScreenTinker 2026-06-09 12:51:23 -05:00
parent f7f78a7486
commit 4cc8ccb67e
3 changed files with 53 additions and 15 deletions

View file

@ -45,7 +45,7 @@ export function render(container) {
<!-- Preview -->
<div style="flex:1">
<div id="previewWrap" style="position:relative;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;background:#000;aspect-ratio:16/9">
<div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden"></div>
<div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden;container-type:inline-size"></div>
</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('designer.preview_hint')}</p>
</div>
@ -421,13 +421,13 @@ function redraw() {
switch (el.type) {
case 'text':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
break;
case 'clock':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
break;
case 'date':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
break;
case 'image':
html += `<img src="${el.src}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:contain;${border}${cursor}" data-idx="${i}" draggable="false">`;
@ -439,11 +439,11 @@ function redraw() {
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};border-radius:${el.radius || 0}px;${el.shape === 'circle' ? 'border-radius:50%;' : ''}${border}${cursor}" data-idx="${i}"></div>`;
break;
case 'weather':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">&#9925; ${t('common.loading')}</div>`;
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">&#9925; ${t('common.loading')}</div>`;
break;
case 'ticker':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center;${border}" data-idx="${i}">
<div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}vw;color:${el.color}" id="ticker_${i}">${t('designer.loading_news')}</div>
<div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}cqw;color:${el.color}" id="ticker_${i}">${t('designer.loading_news')}</div>
</div>`;
break;
case 'qr':
@ -454,8 +454,8 @@ function redraw() {
break;
case 'countdown':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
<div style="font-size:${el.fontSize / 15}vw;opacity:0.8">${el.label || ''}</div>
<div style="font-size:${el.fontSize / 10}vw;font-weight:bold" id="countdown_${i}"></div>
<div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${el.label || ''}</div>
<div style="font-size:${el.fontSize / 10}cqw;font-weight:bold" id="countdown_${i}"></div>
</div>`;
break;
case 'webpage':

View file

@ -42,8 +42,28 @@ const clampN = (n, lo, hi, d) => { n = Number(n); return Number.isFinite(n) ? Ma
const hex = (c, d) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c.trim())) ? c.trim() : d;
const cleanText = (s) => String(s == null ? '' : s).replace(/<[^>]*>/g, '').trim().slice(0, 200);
// Keep generated text on the canvas. The Designer renders text nowrap at
// ~fontSize/10 % of the canvas width per em, so long/large text runs off the
// edge. Estimate width = chars * fontSize * 0.06 (% of canvas width) and height
// = fontSize * 0.18 (% of canvas height); shrink fontSize to fit within 4%
// margins, then nudge x/y in-bounds. Deterministic, so it doesn't depend on the
// model getting layout right.
function fitText(el) {
// CW: width-% per (char * fontSize). 0.075 ~ bold/uppercase headlines (wider
// than mixed-case). CH: height-% per fontSize incl. line-height.
const M = 4, CW = 0.075, CH = 0.22;
const len = Math.max(1, el.text.length);
const maxByW = (100 - 2 * M) / (len * CW);
const maxByH = (100 - 2 * M) / CH;
el.fontSize = Math.floor(Math.max(8, Math.min(el.fontSize, maxByW, maxByH)));
const w = len * el.fontSize * CW;
const h = el.fontSize * CH;
el.x = Math.round(Math.min(Math.max(el.x, M), Math.max(M, 100 - M - w)) * 10) / 10;
el.y = Math.round(Math.min(Math.max(el.y, M), Math.max(M, 100 - M - h)) * 10) / 10;
}
// Never trust raw model output: cap count, clamp ranges, fix px-vs-% (models
// often emit pixels), strip any HTML from text, validate colors.
// often emit pixels), strip any HTML from text, validate colors, fit to canvas.
function normalizeDesign(raw) {
const out = { background: hex(raw && raw.background, '#111827'), elements: [] };
const els = Array.isArray(raw && raw.elements) ? raw.elements.slice(0, 20) : [];
@ -52,19 +72,25 @@ function normalizeDesign(raw) {
if (e.type === 'text') {
const text = cleanText(e.text);
if (!text) continue;
out.elements.push({
const el = {
type: 'text', x: clampN(e.x, 0, 95, 5), y: clampN(e.y, 0, 95, 5), text,
fontSize: clampN(e.fontSize, 12, 200, 48), fontFamily: 'Arial',
color: hex(e.color, '#FFFFFF'), bold: !!e.bold, shadow: !!e.shadow,
});
};
fitText(el);
out.elements.push(el);
} else if (e.type === 'shape') {
let w = Number(e.width), h = Number(e.height);
if (w > 100) w = w / 19.2; // px of 1920 -> %
if (h > 100) h = h / 10.8; // px of 1080 -> %
w = clampN(w, 1, 100, 30);
h = clampN(h, 1, 100, 20);
out.elements.push({
type: 'shape', shape: 'rect',
x: clampN(e.x, 0, 100, 0), y: clampN(e.y, 0, 100, 0),
width: clampN(w, 1, 100, 30), height: clampN(h, 1, 100, 20),
// keep the shape on-canvas: x+width <= 100, y+height <= 100
x: Math.min(clampN(e.x, 0, 100, 0), 100 - w),
y: Math.min(clampN(e.y, 0, 100, 0), 100 - h),
width: w, height: h,
color: hex(e.color, '#3b82f6'), opacity: clampN(e.opacity, 0, 1, 0.85), radius: 0,
});
}

View file

@ -35,8 +35,8 @@ test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => {
{ type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 },
]});
const s = d.elements[0];
assert.equal(s.x, 0, 'x clamped to 0');
assert.equal(s.y, 100, 'y clamped to 100');
assert.equal(s.x, 0, 'x clamped so full-width shape fits');
assert.equal(s.y, 0, 'y clamped so full-height shape fits (y+height<=100)');
assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%');
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
assert.equal(s.color, '#3b82f6', 'non-hex color -> default');
@ -74,3 +74,15 @@ test('endpointAllowed: blocks private/internal when hosted, allows public https'
assert.equal(endpointAllowed('ftp://example.com'), false, 'non-http blocked');
assert.equal(endpointAllowed('not a url'), false);
});
test('normalizeDesign: long/large text is shrunk + repositioned to fit canvas', () => {
const d = normalizeDesign({ elements: [
{ type: 'text', x: 30, y: 95, text: 'GRAND OPENING THIS FRIDAY EVERYONE WELCOME', fontSize: 160, color: '#fff' },
]});
const e = d.elements[0];
const w = e.text.length * e.fontSize * 0.075;
assert.ok(e.x + w <= 96.5, `fits horizontally (x=${e.x} w=${w.toFixed(1)})`);
assert.ok(e.y + e.fontSize * 0.22 <= 96.5, 'fits vertically');
assert.ok(e.fontSize < 160, 'fontSize was shrunk');
assert.ok(e.x >= 4 && e.y >= 4, 'within margins');
});