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);