diff --git a/frontend/css/main.css b/frontend/css/main.css
index ddd6c6d..5956950 100644
--- a/frontend/css/main.css
+++ b/frontend/css/main.css
@@ -398,6 +398,23 @@ body {
transform: translateY(-2px);
}
+/* #238: a rotated display shown as its viewer sees it. The stage is the panel's face; the frame is
+ its framebuffer, turned back by the mount (js/lib/device-frame.js sizes and rotates it from the
+ players' shared rule). The frame is centred on its offset parent, so a stage without
+ `position: relative` would centre it on the page — hence both halves live here together. */
+.display-stage {
+ position: relative;
+ overflow: hidden;
+}
+
+.display-frame {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
.device-card-preview {
aspect-ratio: 16/9;
background: var(--bg-primary);
diff --git a/frontend/index.html b/frontend/index.html
index 9b592bf..d8bf22c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -15,6 +15,11 @@
+
+
diff --git a/frontend/js/lib/device-frame.js b/frontend/js/lib/device-frame.js
new file mode 100644
index 0000000..7cf5275
--- /dev/null
+++ b/frontend/js/lib/device-frame.js
@@ -0,0 +1,76 @@
+// #238: show a device's output the way a person standing in front of the panel sees it.
+//
+// Everywhere the dashboard showed a rotated display — the device preview modal, the Now Playing
+// screenshot, the device cards — it showed the framebuffer as captured/rendered, i.e. sideways,
+// while the panel on the wall was right. Designers use these surfaces to check their work, so a
+// sideways preview turned every anomaly on a portrait screen into "is that real?".
+//
+// The geometry itself is NOT here: it is the same rule the players rotate by
+// (server/lib/orientation-style.js, loaded as window.OrientationStyle), because a second copy of a
+// rotation rule is precisely how the dashboard and the panel came to disagree in the first place.
+// This file only measures the box and applies the answer.
+
+// stage element -> { inner, orientation }. Weak so a re-rendered dashboard doesn't pin dead nodes.
+const framed = new WeakMap();
+let observer = null;
+
+// Sizes are in px, so they are wrong the moment the stage resizes — and a stage inside an inactive
+// tab measures 0x0 until it is shown, which is the common case for Now Playing. One observer for
+// every stage: the callback re-measures whatever actually changed, including 0 -> visible.
+function ensureObserver() {
+ if (observer || typeof ResizeObserver === 'undefined') return observer;
+ observer = new ResizeObserver((entries) => { entries.forEach(e => applyFrame(e.target)); });
+ return observer;
+}
+
+function applyFrame(stage) {
+ const entry = framed.get(stage);
+ if (!entry) return;
+ if (!stage.isConnected) { // modal closed / list re-rendered
+ framed.delete(stage);
+ if (observer) observer.unobserve(stage);
+ return;
+ }
+ const OS = typeof window !== 'undefined' && window.OrientationStyle;
+ if (!OS || !OS.previewFrameStyle) return; // shared rule failed to load: leave today's rendering alone
+ const st = OS.previewFrameStyle(entry.orientation, { width: stage.clientWidth, height: stage.clientHeight });
+ const el = entry.inner;
+ if (!el) return;
+ el.style.width = st.width;
+ el.style.height = st.height;
+ el.style.top = st.top;
+ el.style.left = st.left;
+ el.style.transform = st.transform;
+ el.style.transformOrigin = st.transformOrigin;
+ // A rotated screenshot has to be letterboxed rather than cropped: the card's `object-fit: cover`
+ // applied to a frame whose axes are swapped fills the box by discarding most of the picture —
+ // a "preview" of the middle 30% of the screen.
+ el.style.objectFit = (st.transform && OS.swapsAxes(entry.orientation)) ? 'contain' : '';
+}
+
+/**
+ * Present `inner` (an iframe of the player, or a screenshot img) inside `stage` as the panel's
+ * face. Safe to call repeatedly — screenshot handlers replace the img element, and re-registering
+ * is how the new one gets framed.
+ *
+ * @param {Element} stage fixed box in the dashboard, sized for the AS-DISPLAYED aspect
+ * @param {Element} inner the device's output; positioned and rotated inside the stage
+ * @param {string} orientation the device row's orientation
+ */
+export function frameDeviceOutput(stage, inner, orientation) {
+ if (!stage || !inner) return;
+ // Applied here rather than left to each call site: the frame is absolutely positioned and centred
+ // on its offset parent, so a stage that forgets `position: relative` centres it on the PAGE.
+ stage.classList.add('display-stage');
+ inner.classList.add('display-frame');
+ framed.set(stage, { inner, orientation: orientation || 'landscape' });
+ applyFrame(stage);
+ const ro = ensureObserver();
+ if (ro) { ro.unobserve(stage); ro.observe(stage); }
+}
+
+/** Stage aspect for a device, as the viewer sees it ('9 / 16' for a portrait-hung 16:9 panel). */
+export function displayAspectRatio(orientation) {
+ const OS = typeof window !== 'undefined' && window.OrientationStyle;
+ return OS && OS.previewAspectRatio ? OS.previewAspectRatio(orientation) : '16 / 9';
+}
diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js
index e79ecfc..e095218 100644
--- a/frontend/js/views/dashboard.js
+++ b/frontend/js/views/dashboard.js
@@ -5,6 +5,7 @@ import { esc, livenessBadge } from '../utils.js';
import { t, tn } from '../i18n.js';
import * as gettingStarted from '../components/getting-started.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
+import { frameDeviceOutput } from '../lib/device-frame.js';
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
// Command types only — labels resolved through t('dashboard.cmd.')
@@ -78,6 +79,22 @@ function renderProgressFor(deviceId) {
});
}
+// #238: a screenshot is the panel's raw framebuffer, so a device set to 90/270 sends a landscape
+// image with the content lying on its side — the wall mount is what turns it upright, and the card
+// had no stand-in for the mount. Every portrait screen in the fleet therefore looked wrong at a
+// glance on the one screen people scan to check the fleet is fine.
+//
+// Re-run after any render that replaces card markup; the orientation rides on the card so the
+// socket handler can re-frame a single card without re-reading the device list.
+function frameCard(stage) {
+ const img = stage && stage.querySelector('img');
+ if (img) frameDeviceOutput(stage, img, stage.dataset.orientation);
+}
+
+function frameCardScreenshots(root) {
+ (root || document).querySelectorAll('.device-card-preview[data-orientation]').forEach(frameCard);
+}
+
function renderDeviceCard(device) {
const token = localStorage.getItem('token');
const screenshotUrl = device.screenshot_path
@@ -90,7 +107,7 @@ function renderDeviceCard(device) {
-
+
${screenshotUrl
? ``
: `
@@ -417,6 +434,7 @@ export function render(container) {
const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || '';
preview.innerHTML = `${statusHtml}`;
}
+ frameCard(preview); // the branch above can swap the img element out from under us
});
};
@@ -659,6 +677,7 @@ async function loadDashboard() {
}
main.innerHTML = html;
+ frameCardScreenshots();
attachGroupHandlers(groupsWithDevices, dashboardDevices);
// Drop any selections for devices that have since been absorbed into a
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index 974c787..1fa2a9a 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -4,6 +4,7 @@ import { showToast } from '../components/toast.js';
import { esc, livenessBadge, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
+import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js';
// The player distinguishes three cases for the Wi-Fi name, because "--" was hiding a real
// answer: Android 8.1+ refuses to reveal the SSID to an app without location permission, and a
@@ -15,6 +16,14 @@ function ssidLabel(ssid) {
return esc(ssid);
}
+// #238: turn the Now Playing screenshot the way the wall mount turns the panel. The placeholder
+// ("no screenshot yet") is deliberately left alone — it is dashboard chrome, not device output.
+function frameNowPlaying() {
+ const stage = document.getElementById('screenshotStage');
+ const img = document.getElementById('currentScreenshot');
+ if (stage && img && img.tagName === 'IMG') frameDeviceOutput(stage, img, currentDevice?.orientation);
+}
+
let currentDevice = null;
let statusHandler = null;
let screenshotHandler = null;
@@ -144,6 +153,10 @@ export function render(container, deviceId) {
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
screenshotEl.replaceWith(img);
}
+ // #238: a screenshot is the RAW framebuffer, so a portrait panel's arrives sideways — the
+ // player rotated the content into it and only the wall mount turns it back. Re-frame on every
+ // arrival, not just at render: the branch above swaps the element out from under us.
+ frameNowPlaying();
}
// Update remote canvas
const canvas = document.getElementById('remoteCanvas');
@@ -260,7 +273,7 @@ async function loadDevice(deviceId, activeTab = null) {
@@ -796,6 +814,7 @@ async function loadDevice(deviceId, activeTab = null) {
// offline→online transitions derived from the status log).
renderIncidents(device.deviceEvents || [], device.statusLog || []);
+ frameNowPlaying();
setupTabs();
setupActions(device);
setupRemote(device);
@@ -905,8 +924,13 @@ function setupTabs() {
// same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT
// playlist in the device's OWN layout/orientation (server payload). wall members
// preview full-frame (server forces wall_config:null in v1).
+//
+// #238: the iframe is the panel's FRAMEBUFFER, not its face. It used to be given the as-displayed
+// 9/16 shape directly, so on a portrait device the player rotated content a second time inside a
+// box that was already the finished picture and the preview came out sideways — while the panel
+// itself was right, which is the worst possible split for someone trying to verify their work.
+// The stage is the face; the frame is landscape underneath it and the mount turns it back.
function showDevicePreview(device) {
- const portrait = (device.orientation || '').includes('portrait');
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
@@ -916,10 +940,13 @@ function showDevicePreview(device) {
-
+
+
+
`;
document.body.appendChild(overlay);
+ frameDeviceOutput(overlay.querySelector('#dpvStage'), overlay.querySelector('#dpvStage iframe'), device.orientation);
const close = () => overlay.remove();
overlay.querySelector('#dpvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js
index ec3fc10..3241cd4 100644
--- a/frontend/js/views/playlists.js
+++ b/frontend/js/views/playlists.js
@@ -2,6 +2,7 @@ import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
+import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js';
function formatDate(ts) {
if (!ts) return '--';
@@ -210,9 +211,13 @@ async function renderDetail(container, playlistId) {
// /api/playlists/:id/preview-payload and renders with its unmodified renderer, so the
// preview is byte-identical to what a device shows. Orientation toggle just reloads
// the iframe with &orientation; the server passes it through.
+// #238: Portrait here had the same fault as the device preview — the iframe was given the
+// as-displayed 9/16 shape AND the player rotated inside it, so the portrait toggle showed sideways
+// content. The stage is the panel's face; the iframe is its landscape framebuffer, turned back by
+// the stand-in for the wall mount.
function showPlaylistPreview(playlist) {
let orientation = 'landscape';
- const aspect = () => (orientation.startsWith('portrait') ? '9 / 16' : '16 / 9');
+ const aspect = () => displayAspectRatio(orientation);
const frameSrc = () => `/player?preview=1&playlist=${encodeURIComponent(playlist.id)}&orientation=${orientation}&t=${Date.now()}`;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
@@ -227,16 +232,21 @@ function showPlaylistPreview(playlist) {
-
+
+
+
`;
document.body.appendChild(overlay);
+ const stage = overlay.querySelector('#pvpStage');
const frame = overlay.querySelector('#pvpFrame');
+ frameDeviceOutput(stage, frame, orientation);
const btnL = overlay.querySelector('#pvpLandscape');
const btnP = overlay.querySelector('#pvpPortrait');
const setOrientation = (o) => {
orientation = o;
- frame.style.aspectRatio = aspect();
+ stage.style.aspectRatio = aspect();
+ frameDeviceOutput(stage, frame, orientation);
frame.src = frameSrc();
btnL.className = 'btn btn-sm ' + (o === 'landscape' ? 'btn-primary' : 'btn-secondary');
btnP.className = 'btn btn-sm ' + (o.startsWith('portrait') ? 'btn-primary' : 'btn-secondary');
diff --git a/server/lib/orientation-style.js b/server/lib/orientation-style.js
index 07f0701..64936f4 100644
--- a/server/lib/orientation-style.js
+++ b/server/lib/orientation-style.js
@@ -1,7 +1,8 @@
'use strict';
/*
- * The CSS needed to rotate a full-screen player container.
+ * The CSS needed to rotate a full-screen player container, and (below) the CSS needed to show that
+ * rotated output back to a human in the dashboard.
*
* This looks trivial and is not, because rotating a box does NOT move it. The web player set
* `width:100vh; height:100vw` and `rotate(90deg)` on a container pinned `inset: 0`, which leaves
@@ -63,9 +64,72 @@ function orientationStyle(orientation) {
};
}
+/** Does this orientation put the panel's long edge vertical? 90 and 270 swap the axes; 0/180 don't. */
+function swapsAxes(orientation) {
+ const deg = ROTATION_DEG[orientation];
+ return deg === 90 || deg === 270;
+}
+
+/**
+ * The CSS needed to show a rotated display's OUTPUT — its framebuffer — inside a fixed dashboard
+ * box, as a person standing in front of the panel sees it.
+ *
+ * #238: the dashboard preview of a 90/270 device was sideways while the panel was right, and the
+ * reason is that the dashboard only did half the job. A portrait panel is a landscape framebuffer
+ * that the player rotates content INSIDE (+90), hung on the wall turned the other way (-90); the
+ * two cancel and the viewer sees upright portrait. The dashboard iframed the player into a box it
+ * had already made portrait-shaped, so the player rotated content a second time inside a box that
+ * was pretending to be the finished picture — one rotation applied, the mount's never modelled.
+ * Designers checking their work on a portrait screen saw sideways content and could not tell a
+ * real fault from a preview artefact, so every portrait anomaly became a support question.
+ *
+ * So the frame stands in for the physical mount and rotates by the INVERSE of the player's angle.
+ * Rotating it the SAME way instead is the tempting mistake and the worst kind of wrong: 90+90
+ * lands upside-down, which reads as "nearly right" and gets shipped.
+ *
+ * The dimension swap matters as much as the angle. Composing into a box the shape of the real
+ * FRAMEBUFFER (stage axes swapped) and turning that is not a no-op round trip — it is what makes
+ * the player lay the content out in the same portrait box the panel uses. Feed the player a
+ * portrait-shaped viewport instead and every zone, aspect and object-fit decision is computed for
+ * the wrong canvas.
+ *
+ * @param {string} orientation landscape | portrait | landscape-flipped | portrait-flipped
+ * @param {{width:number,height:number}} box the on-screen stage, in px, AS THE VIEWER SEES IT
+ * @returns {{transform:string,width:string,height:string,top:string,left:string,transformOrigin:string}}
+ * Values to assign directly onto element.style. Empty string means "clear it" — the
+ * landscape state must clear every property the rotated state sets, or a device switched
+ * back to landscape keeps a stale swapped size and looks broken until a reload.
+ */
+function previewFrameStyle(orientation, box) {
+ const deg = ROTATION_DEG[orientation];
+ const w = box && box.width, h = box && box.height;
+
+ // Unknown/landscape, or a stage that has not been laid out yet (a hidden tab measures 0x0):
+ // clear back to the base CSS rather than pinning a 0px frame nobody can see.
+ if (!deg || !(w > 0) || !(h > 0)) {
+ return { transform: '', width: '', height: '', top: '', left: '', transformOrigin: '' };
+ }
+
+ const swap = swapsAxes(orientation);
+ return {
+ transform: 'translate(-50%, -50%) rotate(' + ((360 - deg) % 360) + 'deg)',
+ width: (swap ? h : w) + 'px',
+ height: (swap ? w : h) + 'px',
+ top: '50%',
+ left: '50%',
+ transformOrigin: 'center center',
+ };
+}
+
+/** Stage aspect for a device, as the viewer sees it: '9 / 16' for a portrait-hung 16:9 panel. */
+function previewAspectRatio(orientation, panelW, panelH) {
+ const w = panelW || 16, h = panelH || 9;
+ return swapsAxes(orientation) ? (h + ' / ' + w) : (w + ' / ' + h);
+}
+
if (typeof module !== 'undefined' && module.exports) {
- module.exports = { orientationStyle, ROTATION_DEG };
+ module.exports = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG };
}
if (typeof window !== 'undefined') {
- window.OrientationStyle = { orientationStyle, ROTATION_DEG };
+ window.OrientationStyle = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG };
}
diff --git a/server/test/preview-frame.test.js b/server/test/preview-frame.test.js
new file mode 100644
index 0000000..09739a2
--- /dev/null
+++ b/server/test/preview-frame.test.js
@@ -0,0 +1,138 @@
+'use strict';
+
+// #238: the dashboard preview of a 90/270 display was sideways while the panel itself was right.
+//
+// A portrait panel is a landscape framebuffer that the player rotates content INSIDE (+90), hung on
+// the wall turned the other way (-90). The two cancel and a person in front of it sees upright
+// portrait. The dashboard modelled only the first half: it iframed the player into a box it had
+// already given the finished 9/16 shape, so the player rotated a second time inside it and the
+// preview came out at 90 degrees to the panel. Designers verify their work on these surfaces, so
+// every anomaly on a portrait screen became "is that the screen or the preview?".
+//
+// The frame therefore stands in for the mount and turns by the INVERSE angle. The tempting mistake
+// is to turn it the SAME way: 90+90 lands upside-down, which reads as nearly-right and ships.
+//
+// Geometry, not CSS strings: what matters is where the rotated box lands and which way the content
+// ends up pointing.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { previewFrameStyle, previewAspectRatio, orientationStyle, ROTATION_DEG } = require('../lib/orientation-style');
+
+const px = (v) => Number(String(v).replace('px', ''));
+const rotationOf = (style) => {
+ const m = /rotate\((-?\d+)deg\)/.exec(style.transform || '');
+ return m ? ((Number(m[1]) % 360) + 360) % 360 : 0;
+};
+
+/** Where does the frame land inside the stage, and is it centred on it? */
+function occupies(style, stageW, stageH) {
+ const bw = style.width ? px(style.width) : stageW;
+ const bh = style.height ? px(style.height) : stageH;
+ // top/left 50% put the box ORIGIN at the stage centre; translate(-50%,-50%) pulls it back by half
+ // its own size, so the box centre lands on the stage centre.
+ const centreX = style.left === '50%' ? stageW / 2 : bw / 2;
+ const centreY = style.top === '50%' ? stageH / 2 : bh / 2;
+ const deg = rotationOf(style);
+ const swap = deg === 90 || deg === 270;
+ const spanX = swap ? bh : bw;
+ const spanY = swap ? bw : bh;
+ return {
+ x0: centreX - spanX / 2, x1: centreX + spanX / 2,
+ y0: centreY - spanY / 2, y1: centreY + spanY / 2,
+ };
+}
+
+/**
+ * Which way the CONTENT points for a viewer of the dashboard: the player's own rotation inside the
+ * framebuffer, plus whatever the dashboard does to the frame. 0 means it matches the panel.
+ */
+const contentAngle = (orientation, frameStyle) =>
+ (ROTATION_DEG[orientation] + rotationOf(frameStyle)) % 360;
+
+// A portrait stage as the viewer sees it: a 16:9 panel hung on its side.
+const STAGE = { width: 405, height: 720 };
+
+test('THE BUG: portrait preview points the same way as the panel, not 90 degrees off', () => {
+ const style = previewFrameStyle('portrait', STAGE);
+ assert.equal(contentAngle('portrait', style), 0);
+
+ // What the dashboard used to do: give the iframe the as-displayed shape and no rotation of its
+ // own. The player still rotated inside it, so the content sat at 90 degrees to the panel.
+ assert.equal(contentAngle('portrait', { transform: '' }), 90);
+});
+
+test('the frame turns the OTHER way from the player — same way lands upside-down', () => {
+ const inverse = { 'portrait': 270, 'portrait-flipped': 90, 'landscape-flipped': 180 };
+ for (const [orientation, deg] of Object.entries(inverse)) {
+ const style = previewFrameStyle(orientation, STAGE);
+ assert.equal(rotationOf(style), deg, orientation);
+ assert.equal(contentAngle(orientation, style), 0, orientation);
+ }
+ // The near-miss this exists to catch: repeating the player's angle instead of cancelling it.
+ assert.equal(contentAngle('portrait', { transform: 'rotate(90deg)' }), 180);
+});
+
+test('the rotated frame covers the stage exactly — no bleed, no letterbox', () => {
+ for (const orientation of Object.keys(ROTATION_DEG)) {
+ const stage = orientation.includes('portrait') ? STAGE : { width: 720, height: 405 };
+ const box = occupies(previewFrameStyle(orientation, stage), stage.width, stage.height);
+ assert.deepEqual(box, { x0: 0, x1: stage.width, y0: 0, y1: stage.height }, orientation);
+ }
+});
+
+test('the frame is the FRAMEBUFFER shape, so the player composes into the panel\'s own box', () => {
+ // Not a cosmetic detail: the player lays zones, aspect and object-fit out against its viewport.
+ // Handing it the portrait 405x720 stage would compose the content for a canvas no panel has.
+ const style = previewFrameStyle('portrait', STAGE);
+ assert.equal(px(style.width), STAGE.height);
+ assert.equal(px(style.height), STAGE.width);
+ // ...and inside that landscape frame the player's own rule builds the portrait container back.
+ const player = orientationStyle('portrait');
+ assert.equal(player.width, '100vh'); // = frame height = 405, the stage width
+ assert.equal(player.height, '100vw'); // = frame width = 720, the stage height
+});
+
+test('180 does not swap the axes — the frame already fits, it just turns over', () => {
+ const stage = { width: 720, height: 405 };
+ const style = previewFrameStyle('landscape-flipped', stage);
+ assert.equal(px(style.width), 720);
+ assert.equal(px(style.height), 405);
+ assert.equal(rotationOf(style), 180);
+});
+
+test('the translate comes BEFORE the rotate, or the centring offset is rotated too', () => {
+ assert.match(previewFrameStyle('portrait', STAGE).transform, /^translate\(-50%, -50%\) rotate\(270deg\)$/);
+});
+
+test('landscape clears EVERY property the rotated state set', () => {
+ // A device switched back to landscape must not keep a stale swapped size: a half-reset leaves the
+ // frame pinned at the old height and the preview looks broken until a reload.
+ const rotated = previewFrameStyle('portrait', STAGE);
+ const landscape = previewFrameStyle('landscape', STAGE);
+ for (const key of Object.keys(rotated)) {
+ assert.equal(landscape[key], '', key + ' must be cleared');
+ }
+});
+
+test('an unmeasured stage (hidden tab) clears rather than pinning a 0px frame', () => {
+ // Now Playing lives in an inactive tab and measures 0x0 until it is shown. Sizing to that would
+ // render an invisible preview that never recovers; the resize observer re-applies when it is.
+ for (const box of [{ width: 0, height: 0 }, { width: 400, height: 0 }, null]) {
+ const style = previewFrameStyle('portrait', box);
+ assert.equal(style.width, '');
+ assert.equal(style.transform, '');
+ }
+});
+
+test('an unknown orientation falls back to no rotation, never to a blank frame', () => {
+ const style = previewFrameStyle('sideways-ish', STAGE);
+ assert.deepEqual(occupies(style, STAGE.width, STAGE.height), { x0: 0, x1: 405, y0: 0, y1: 720 });
+});
+
+test('stage aspect is the panel as the viewer sees it', () => {
+ assert.equal(previewAspectRatio('landscape'), '16 / 9');
+ assert.equal(previewAspectRatio('landscape-flipped'), '16 / 9');
+ assert.equal(previewAspectRatio('portrait'), '9 / 16');
+ assert.equal(previewAspectRatio('portrait-flipped'), '9 / 16');
+});