diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 08e1107..88f86ac 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -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 || '
Empty text widget
'; - 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 diff --git a/server/test/text-widget-font-size.test.js b/server/test/text-widget-font-size.test.js new file mode 100644 index 0000000..a5c51c2 --- /dev/null +++ b/server/test/text-widget-font-size.test.js @@ -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', 'Body copy
'); + 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', + '