From dab6c2671c2d37234659d850b0639d9e7c279a89 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Wed, 15 Jul 2026 22:40:43 -0400 Subject: [PATCH] feat(display): default HTML pages to fit-width, top-aligned The HTML display pages (/rotation for Visionect and the zero-config root /) fit the image to both viewport axes with object-fit: contain, so a paper whose aspect ratio differs from the panel shrinks to a letterboxed thumbnail with tiny, unreadable text. Default them instead to fitting the viewport width, keeping the aspect ratio, pinning the top of the page to the top of the viewport, and clipping whatever runs past the bottom. This keeps the masthead and lead story large and legible on any panel wider than the paper; where it would clip content, contain's alternative was already an unreadable thumbnail, so width-fit dominates in the one case they differ. - New ?fit= values on the HTML pages: default (width, top), ?fit=contain (previous letterboxed behavior), ?fit=cover (fill, center-crop). fit=width is client-side CSS only; /rotation.png and /api/display still frame with contain/cover server-side. - URL builder's Fit control offers all three for the Visionect target (defaulting to width) and contain/cover for the image/TRMNL targets, omitting fit from the URL when it matches the target default. Behavior change: existing HTML displays render fit-to-width on upgrade; add ?fit=contain to restore the letterboxed layout. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E2DKHiUNkAc1jo6gb4Ghi2 --- CHANGELOG.md | 19 +++++++++++++ cmd/broadsheet-server/main.go | 20 +++++++++----- cmd/broadsheet-server/rotation.go | 15 ++++++++--- .../web/templates/builder.html | 27 +++++++++++++++++-- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a7fae..93e0ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Changed: HTML display pages now fit to width, top-aligned + +The HTML display pages (`/rotation` for Visionect and the zero-config root `/`) +default to fitting the image to the viewport **width**, keeping the aspect ratio, +pinning the top of the page to the top of the viewport, and clipping whatever +runs past the bottom. This keeps the masthead and lead story large and legible on +panels whose aspect ratio differs from the paper's, instead of shrinking the whole +page to a letterboxed thumbnail. + +- New `?fit=` values on the HTML pages: default (width, top-aligned), + `?fit=contain` (the previous behavior — whole page, letterboxed), and + `?fit=cover` (fill, center-crop). Pair with `?margin=0` to pin the image flush + to the top-left corner. `fit=width` is client-side CSS only; the raw-image + endpoint (`/rotation.png`) still frames with `contain`/`cover` server-side. +- The URL builder's Fit control offers all three for the Visionect target + (defaulting to width) and `contain`/`cover` for the image/TRMNL targets. +- **Behavior change:** existing HTML displays render fit-to-width on upgrade. + Add `?fit=contain` to the page URL to restore the previous letterboxed layout. + ### Added: content-aware cropping Served pages are now trimmed to their content bounds before framing diff --git a/cmd/broadsheet-server/main.go b/cmd/broadsheet-server/main.go index b518f7a..65fded8 100644 --- a/cmd/broadsheet-server/main.go +++ b/cmd/broadsheet-server/main.go @@ -200,9 +200,14 @@ const displayHTML = ` @@ -213,12 +218,15 @@ const displayHTML = ` var q = new URLSearchParams(window.location.search); // Framing is done here in CSS, driven by the page URL: - // ?margin= -> padding, in vmin (default 3) - // ?fit=cover -> object-fit: cover (crop to fill) instead of contain + // ?margin= -> padding, in vmin (default 3) + // default -> fit to width, top-aligned, clipping the bottom overflow + // ?fit=contain -> scale the whole page to fit inside, letterboxed + // ?fit=cover -> fill the viewport, center-cropping the overflow if (q.has('margin')) { document.documentElement.style.setProperty('--pad', (parseFloat(q.get('margin')) || 0) + 'vmin'); } - if (q.get('fit') === 'cover') document.body.classList.add('cover'); + var fit = q.get('fit'); + if (fit === 'contain' || fit === 'cover') document.body.classList.add(fit); // Fetch the image exactly once per load — each fetch advances this device's // rotation cursor server-side, so one fetch == one advance. We ask only for a diff --git a/cmd/broadsheet-server/rotation.go b/cmd/broadsheet-server/rotation.go index b4dc9e5..41edc26 100644 --- a/cmd/broadsheet-server/rotation.go +++ b/cmd/broadsheet-server/rotation.go @@ -160,9 +160,14 @@ var rotationTmpl = template.Must(template.New("rotation").Parse(` @@ -180,7 +185,9 @@ var rotationTmpl = template.Must(template.New("rotation").Parse(` if (margin !== null) { document.documentElement.style.setProperty('--pad', (parseFloat(margin) || 0) + 'vmin'); } - if (qparam('fit') === 'cover') document.body.classList.add('cover'); + // Default is fit-to-width, top-aligned (CSS above); contain/cover opt out. + var fit = qparam('fit'); + if (fit === 'contain' || fit === 'cover') document.body.classList.add(fit); var I = {{.IntervalSec}}; // dwell seconds var phase = {{.Phase}}; diff --git a/cmd/broadsheet-server/web/templates/builder.html b/cmd/broadsheet-server/web/templates/builder.html index 8cee2d5..4bf8d9c 100644 --- a/cmd/broadsheet-server/web/templates/builder.html +++ b/cmd/broadsheet-server/web/templates/builder.html @@ -18,8 +18,8 @@

URL builder

- +

@@ -41,8 +41,12 @@

Preview

if (!forPage) { if ($('w').value) ps.push('w=' + $('w').value); if ($('h').value) ps.push('h=' + $('h').value); - if ($('fit').value) ps.push('fit=' + $('fit').value); } else if ($('sleep').value) ps.push('sleep=' + $('sleep').value); + // Fit applies to the HTML page (client-side CSS) and the raw image + // (server-side framing); emit it only when it differs from the target's + // default (width for the HTML page, contain for the image). + var fitDefault = $('target').value === 'visionect' ? 'width' : 'contain'; + if ($('fit').value && $('fit').value !== fitDefault) ps.push('fit=' + $('fit').value); return ps.length ? '?' + ps.join('&') : ''; } var HINTS = { @@ -62,8 +66,27 @@

Preview

ps.push('w=420'); return '?' + ps.join('&'); } + // The HTML page defaults to fit=width (fill width, top-align) and also offers + // contain/cover; the raw image and TRMNL envelope go through the server + // renderer, which frames with contain/cover alone. Reset to the target's + // default only when the target itself changes, so edits elsewhere on the form + // don't clobber a deliberate fit choice. + var lastTarget = null; + function setFitOptions(t) { + var sel = $('fit'); + var opts = [['contain', 'contain'], ['cover', 'cover']]; + if (t === 'visionect') opts.push(['width', 'width (top-align)']); + var def = t === 'visionect' ? 'width' : 'contain'; + var want = t === lastTarget ? sel.value : def; + lastTarget = t; + sel.innerHTML = opts.map(function (o) { + return ''; + }).join(''); + sel.value = opts.some(function (o) { return o[0] === want; }) ? want : def; + } function update() { var t = $('target').value, url; + setFitOptions(t); if (t === 'visionect') url = base + '/rotation' + params(true); else if (t === 'png') url = base + '/rotation.png' + params(false); else url = base + '/api/display' + params(false);