Stop shrinking hand-written text widgets into illegibility

A person typing font-size:16px into the Text/HTML widget got 0.15vw — 2.8px on a 1080p screen,
1.9px at 1280 wide, smaller again on anything narrower. Not clipped, not hidden: rendered at a size
nobody can read, in the one widget whose entire purpose is hand-written HTML.

renderText converted every px font size to vw (px/108). That conversion exists to rescue LEGACY
Content Designer output, which used to publish absolute sizes as fontSize*10.8 px — dividing by 108
recovers the author's intended size and lets those widgets scale to any screen. Today's designer
emits cqw and no px at all (frontend/js/views/designer.js), so the conversion only ever needed to
apply to that legacy output. It was applied to everything.

Now it runs only on designer-authored markup, identified by its absolutely-positioned elements —
the same signal the dashboard already uses to decide whether a text widget can be reopened in the
designer. Hand-written markup keeps its px exactly as typed, and legacy designer widgets are
unchanged.

Found by looking at the screen. The rendered HTML and the widget URL both looked correct in every
check I ran; only a screenshot showed the text was microscopic.

5 tests covering both directions, including that a hand-written absolutely-positioned element
without the designer's left-first shape keeps its px. Verified on an Android screen: a 60px heading
and 24px body now render at their authored sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
Claude 2026-07-30 20:28:28 -05:00
parent e0bdd3b65c
commit 81f5d4f9f3
2 changed files with 123 additions and 5 deletions

View file

@ -434,12 +434,28 @@ load(); setInterval(load, 300000);
}
function renderText(c) {
// Designer preview uses fontSize/10 vw, but older published HTML used fontSize*10.8 px.
// Convert any px-based font sizes to vw so they scale to any viewport: px / 108 = vw
let html = c.html || '<p style="color:white;padding:20px">Empty text widget</p>';
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
});
// LEGACY DESIGNER RESCUE — deliberately narrow.
//
// The Content Designer used to publish absolute font sizes as fontSize*10.8 px; today it emits
// cqw (see designer.js). Converting px/108 back to vw restores the author's intended size and
// makes those old widgets scale to any screen.
//
// It must NOT touch hand-authored HTML. This regex used to run over EVERY text widget, so
// someone writing `font-size:16px` in the Text/HTML editor got 0.15vw — 2.8px on a 1080p
// screen, and smaller still on anything narrower. Their text was not clipped or hidden; it was
// rendered too small to read, in the one widget whose whole purpose is hand-written HTML.
//
// Designer output is identified by its absolutely-positioned elements, the same signal the
// dashboard uses to decide whether a text widget can be reopened in the designer. Hand-written
// markup keeps its px exactly as typed.
const isDesignerAuthored = /position:\s*absolute;\s*left:/.test(html);
if (isDesignerAuthored) {
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
});
}
// What to do when the text is taller than the screen. It used to be clipped in silence: the
// document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display

View file

@ -0,0 +1,102 @@
'use strict';
// Hand-written HTML in the Text/HTML widget was rendered far too small to read.
//
// The Content Designer used to publish absolute sizes as fontSize*10.8 px, and renderText converted
// px/108 back to vw to restore the intended size and let those widgets scale. That rescue is
// correct — but it ran over EVERY text widget, including markup a person typed themselves. So
// `font-size:16px` became 0.15vw: 2.8px on a 1080p screen, 1.9px on a 1280 one. Not clipped, not
// hidden — rendered at a size nobody can read, in the one widget whose entire purpose is
// hand-written HTML.
//
// Today's designer emits cqw, not px (frontend/js/views/designer.js), so the conversion only ever
// needed to apply to legacy designer output. That is identified by absolutely-positioned elements,
// the same signal the dashboard uses to decide whether a text widget can be reopened in the
// designer.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-textwidget-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-text-widget';
const express = require('express');
const { db } = require('../db/database');
const { requireAuth, generateToken } = require('../middleware/auth');
function seed() {
const u = 'u-tw', o = 'o-tw', ws = 'ws-tw';
db.prepare("INSERT OR IGNORE INTO users (id, email, password_hash, role) VALUES (?,?, 'x','user')").run(u, 'tw@test.local');
db.prepare('INSERT OR IGNORE INTO organizations (id, name, owner_user_id) VALUES (?,?,?)').run(o, 'org', u);
db.prepare('INSERT OR IGNORE INTO workspaces (id, organization_id, name) VALUES (?,?,?)').run(ws, o, 'ws');
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id, user_id, role) VALUES (?,?, 'org_owner')").run(o, u);
return { u, ws };
}
const { u, ws } = seed();
function makeWidget(id, html) {
db.prepare(`INSERT OR REPLACE INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at, updated_at)
VALUES (?, ?, ?, 'text', ?, ?, strftime('%s','now'), strftime('%s','now'))`)
.run(id, u, ws, id, JSON.stringify({ html, background: '#000' }));
return id;
}
const app = express();
app.use(express.json());
app.use('/api/widgets', requireAuth, require('../routes/widgets'));
const server = app.listen(0);
const token = generateToken(db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(u), ws);
async function render(id) {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/widgets/${id}/render`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.text();
}
test('THE BUG: hand-written px font sizes must survive untouched', async () => {
const id = makeWidget('w-hand', '<h1 style="color:#fff;font-size:40px">Notice</h1>');
const out = await render(id);
assert.match(out, /font-size:40px/, 'a hand-typed 40px must render as 40px');
assert.doesNotMatch(out, /font-size:0\.37vw/, '40px/108 = 0.37vw is ~7px on 1080p — unreadable');
});
test('a small hand-written size is not shrunk into invisibility', async () => {
const id = makeWidget('w-hand-small', '<p style="color:#fff;font-size:16px">Body copy</p>');
const out = await render(id);
assert.match(out, /font-size:16px/);
assert.doesNotMatch(out, /font-size:0\.15vw/, '0.15vw is 2.8px on a 1080p screen');
});
test('LEGACY designer output is still rescued, so old widgets keep scaling', async () => {
// Absolutely-positioned elements are the designer's signature. 54px was fontSize 5 * 10.8.
const id = makeWidget('w-designer',
'<div style="position:absolute;left:10%;top:20%;font-size:54px;color:#fff">Designed</div>');
const out = await render(id);
assert.match(out, /font-size:0\.50vw/, 'legacy designer px must still convert back to vw');
assert.doesNotMatch(out, /font-size:54px/);
});
test('a designer widget with several sizes converts all of them', async () => {
const id = makeWidget('w-designer-multi',
'<div style="position:absolute;left:0;font-size:108px">A</div>' +
'<div style="position:absolute;left:50%;font-size:21.6px">B</div>');
const out = await render(id);
assert.match(out, /font-size:1\.00vw/);
assert.match(out, /font-size:0\.20vw/);
});
test('hand-written markup that merely mentions absolute positioning elsewhere is not misread', async () => {
// The signal is `position:absolute` immediately followed by `left:` — the designer's own shape.
// A hand-written absolute element without that pairing keeps its px.
const id = makeWidget('w-hand-abs', '<div style="position:absolute;top:10px;font-size:32px">X</div>');
const out = await render(id);
assert.match(out, /font-size:32px/, 'only the designer\'s left-first shape triggers the rescue');
});
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });