Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to Previewer are listed here. The format follows

## [Unreleased]

### Changed

- The toolbar shows each button's shortcut: ⌘1–⌘4 on the devices, ⌘S on Screenshot, ⌘C on copy and Esc on close. The shortcut line under the phone is gone, so the phone gets a little more room.
- iPhone SE is black (Midnight) instead of white.
- iPad Pro 11″ is shown in landscape, 1210 × 834.

## [1.1.0] - 2026-10-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Everything about a device lives in [`extension/devices.json`](extension/devices.
| ------------- | ------------------------------------------------------------------------------- |
| `id` | Lower-case slug, used in screenshot file names. |
| `name` | What the toolbar shows. |
| `viewport` | CSS pixel size of the full screen in portrait (`screen.width × screen.height`). |
| `viewport` | CSS pixel size of the full screen in the orientation shown (`screen.width × screen.height`). |
| `pixelRatio` | The device's `devicePixelRatio`. Informational. |
| `userAgent` | The User-Agent the device's default browser sends. |
| `headers` | Extra request headers; `null` removes the header (Safari sends no client hints). |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ selected. Change the open shortcut at `chrome://extensions/shortcuts`.
| iPhone 18 Pro | 402 × 874 | The current iPhone. |
| iPhone SE | 375 × 667 | The smallest and shortest screen in common use: shows what falls below the fold. |
| Pixel 11 | 412 × 924 | The current Pixel, and the typical Android width. |
| iPad Pro 11″ | 834 × 1210 | Tablet layout. Like Safari on iPad, it asks for the desktop site. |
| iPad Pro 11″ | 1210 × 834 | Tablet layout, in landscape. Like Safari on iPad, it asks for the desktop site. |

Switching device keeps your place on the page. The page reloads only when you click the reload button.

Expand Down
Binary file modified docs/devices.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/overlay.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 0 additions & 16 deletions extension/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,6 @@
"message": "Soft shadow around the frame",
"description": "Setting hint"
},
"keysDevices": {
"message": "switch device",
"description": "Shortcut help"
},
"keysSave": {
"message": "save",
"description": "Shortcut help"
},
"keysCopy": {
"message": "copy",
"description": "Shortcut help"
},
"keysClose": {
"message": "close",
"description": "Shortcut help"
},
"saved": {
"message": "Saved $FILE$ to Downloads",
"description": "Toast after saving",
Expand Down
12 changes: 0 additions & 12 deletions extension/_locales/nb/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,6 @@
"shadowInScreenshotHint": {
"message": "Myk skygge rundt rammen"
},
"keysDevices": {
"message": "bytt enhet"
},
"keysSave": {
"message": "lagre"
},
"keysCopy": {
"message": "kopier"
},
"keysClose": {
"message": "lukk"
},
"saved": {
"message": "Lagret $FILE$ i Nedlastinger",
"placeholders": {
Expand Down
29 changes: 9 additions & 20 deletions extension/devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,21 @@
"bodyRadius": 64,
"screenRadius": 0,
"rim": 3,
"color": "#e6dccf",
"edge": "#c4b8aa",
"front": "#f6f3ee",
"color": "#2c2f35",
"edge": "#1a1c20",
"front": "#0b0b0c",
"statusBar": {
"height": 20,
"layout": "classic"
},
"homeButton": {
"radius": 33,
"ring": "#d3cdc4"
"ring": "#3a3d44"
},
"earpiece": {
"width": 52,
"height": 6,
"color": "#d9d4cc"
"color": "#24262b"
},
"homeIndicator": false,
"buttons": [
Expand Down Expand Up @@ -187,8 +187,8 @@
"id": "ipad-pro-11",
"name": "iPad Pro 11″",
"viewport": {
"width": 834,
"height": 1210
"width": 1210,
"height": 834
},
"pixelRatio": 2,
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
Expand All @@ -215,21 +215,10 @@
"layout": "tablet"
},
"camera": {
"edge": "right"
"edge": "top"
},
"homeIndicator": true,
"buttons": [
{
"side": "right",
"top": 70,
"length": 54
},
{
"side": "right",
"top": 136,
"length": 54
}
]
"buttons": []
}
}
]
Expand Down
9 changes: 6 additions & 3 deletions extension/frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@
shapes.push(rect(cx - e.width / 2, cy - e.height / 2, e.width, e.height, e.height / 2, e.color));
shapes.push({ type: 'circle', cx: cx - e.width / 2 - 26, cy, r: 5, fill: '#2b2f3a' });
}
if (f.camera?.edge === 'right') {
shapes.push({ type: 'circle', cx: x + g.bodyWidth - f.bezel.right / 2, cy: g.bodyHeight / 2, r: 3.5, fill: '#2b2f3a' });
}
// Tablets have the front camera on one edge, centred in the bezel.
const camera = {
top: [cx, f.bezel.top / 2],
right: [x + g.bodyWidth - f.bezel.right / 2, g.bodyHeight / 2],
}[f.camera?.edge];
if (camera) shapes.push({ type: 'circle', cx: camera[0], cy: camera[1], r: 3.5, fill: '#2b2f3a' });
if (f.homeButton) {
shapes.push({
type: 'circle',
Expand Down
54 changes: 33 additions & 21 deletions extension/overlay.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@ input:focus-visible {
.icon:hover,
.icon[aria-expanded="true"] { background: var(--bar-line); }

/* Icon buttons that also show their shortcut */
.icon.keyed {
width: auto;
padding: 0 10px 0 11px;
display: inline-flex;
gap: 7px;
}

/* Shortcut labels on the toolbar buttons */
.bar kbd {
font: 500 11px/1 var(--mono);
padding: 3px 5px;
border-radius: 4px;
background: var(--bar-line);
color: var(--bar-muted);
}

.seg kbd { margin-left: 7px; }
.seg button[aria-checked="true"] kbd { color: var(--bar-fg); }

.primary kbd {
background: rgba(255, 255, 255, 0.2);
color: var(--accent-fg);
}

svg.i {
width: 18px;
height: 18px;
Expand Down Expand Up @@ -192,27 +217,6 @@ svg.i {
font-size: 12px;
}

/* Shortcut line under the phone, on the dark backdrop */
.shortcuts {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 16px;
max-width: 100%;
color: var(--caption);
font-size: 12px;
}

.shortcuts span { white-space: nowrap; }

kbd {
font: 500 11px/1 var(--mono);
padding: 2px 5px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
}

.switch {
appearance: none;
flex: none;
Expand Down Expand Up @@ -356,6 +360,14 @@ iframe {

.toast[hidden] { display: none; }

/* With the shortcut labels the toolbar is about 940px wide on a Mac and 1100px
with Ctrl+ labels; below that they would wrap it onto two rows. The tooltips
still name the shortcuts. */
@media (max-width: 1140px) {
.bar kbd { display: none; }
.icon.keyed { width: 40px; padding: 0; }
}

@media (max-width: 720px) {
.seg button { padding: 0 9px; font-size: 12px; }
}
Expand Down
29 changes: 17 additions & 12 deletions extension/overlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@
</div>
</div>
<div class="caption"></div>
<div class="shortcuts">${shortcutHelp()}</div>
</div>
</div>
<div class="toast" role="status" hidden></div>
Expand Down Expand Up @@ -128,14 +127,20 @@
const { el } = ui;

el.seg.innerHTML = devices
.map((d, i) => `<button type="button" role="radio" data-id="${d.id}" title="${d.name} (${keyLabel(String(i + 1))})">${d.name}</button>`)
.map((d, i) => `<button type="button" role="radio" data-id="${d.id}" title="${d.name} (${keyLabel(String(i + 1))})" aria-keyshortcuts="${ariaKey(String(i + 1))}">${d.name}${keyHint(keyLabel(String(i + 1)))}</button>`)
.join('');
const shortcuts = { save: 'S', copy: 'C' };
for (const button of root.querySelectorAll('[data-action]')) {
const action = button.dataset.action; // also the i18n key
const shortcut = shortcuts[action];
button.title = shortcut ? `${t(action)} (${keyLabel(shortcut)})` : t(action);
const label = action === 'close' ? 'Esc' : shortcut && keyLabel(shortcut);
button.title = label ? `${t(action)} (${label})` : t(action);
if (button.classList.contains('icon')) button.setAttribute('aria-label', t(action));
if (label) {
button.setAttribute('aria-keyshortcuts', action === 'close' ? 'Escape' : ariaKey(shortcut));
button.insertAdjacentHTML('beforeend', keyHint(label));
button.classList.add('keyed');
}
}
for (const input of root.querySelectorAll('.switch')) input.checked = ui.settings[input.name];

Expand Down Expand Up @@ -178,14 +183,14 @@
return isMac ? `⌘${key}` : `Ctrl+${key}`;
}

function shortcutHelp() {
const k = (key) => `<kbd>${keyLabel(key)}</kbd>`;
return [
`<span>${k('1')}–${k('4')} ${t('keysDevices')}</span>`,
`<span>${k('S')} ${t('keysSave')}</span>`,
`<span>${k('C')} ${t('keysCopy')}</span>`,
`<span><kbd>Esc</kbd> ${t('keysClose')}</span>`,
].join('');
function ariaKey(key) {
return `${isMac ? 'Meta' : 'Control'}+${key}`;
}

// The shortcut printed on a toolbar button. Screen readers get it from
// aria-keyshortcuts instead, so the button's name stays just its name.
function keyHint(label) {
return `<kbd aria-hidden="true">${label}</kbd>`;
}

// --- Rendering ---------------------------------------------------------------
Expand Down Expand Up @@ -239,7 +244,7 @@
if (!ui) return;
const g = geometry(ui.device);
const stage = ui.el.stage.getBoundingClientRect();
const captionSpace = 64; // device name and the shortcut line under the phone
const captionSpace = 24; // device name under the phone
const scale = Math.min(1, (stage.height - 32 - captionSpace) / g.height, (stage.width - 32) / g.width);
ui.scale = Math.max(0.1, scale);
ui.el.phone.style.transform = `scale(${ui.scale})`;
Expand Down
1 change: 1 addition & 0 deletions test/devices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ for (const d of devices) {
assert.ok(LAYOUTS.includes(f.statusBar.layout), `statusBar.layout is one of ${LAYOUTS}`);
assert.ok(f.statusBar.height < d.viewport.height);
assert.ok(f.screenRadius <= f.bodyRadius);
if (f.camera) assert.ok(['top', 'right'].includes(f.camera.edge), 'camera.edge is top or right');

const g = geometry(d);
for (const b of f.buttons) {
Expand Down
22 changes: 19 additions & 3 deletions test/e2e/preview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ async function waitForPreviewFrame(page) {
throw new Error('preview iframe did not appear');
}

// The overlay lives in a closed shadow root, which only extension code can open.
async function toolbarKeys(page) {
return worker.evaluate(async (url) => {
const [tab] = await chrome.tabs.query({ url });
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => [...chrome.dom.openOrClosedShadowRoot(document.querySelector('previewer-root')).querySelectorAll('.bar kbd')].map((k) => k.textContent),
});
return result;
}, page.url());
}

async function shoot(page, name) {
if (shotsDir) await page.screenshot({ path: join(shotsDir, `${name}.png`) });
}
Expand All @@ -124,6 +136,10 @@ test('shows the page at the phone width with the phone user agent', async () =>
await page.waitForTimeout(300);
await shoot(page, 'iphone-18-pro');

// Each toolbar button shows its shortcut.
const mod = process.platform === 'darwin' ? '⌘' : 'Ctrl+';
assert.deepEqual(await toolbarKeys(page), [...['1', '2', '3', '4', 'S', 'C'].map((k) => mod + k), 'Esc']);

// ⌘2 / Ctrl+2 switches device without reloading the page.
await frame.evaluate(() => (window.__marker = 1));
await page.keyboard.press('ControlOrMeta+2');
Expand All @@ -139,7 +155,7 @@ test('shows the page at the phone width with the phone user agent', async () =>

await page.keyboard.press('ControlOrMeta+4');
await page.waitForTimeout(300);
assert.equal(await frame.evaluate(() => innerWidth), 834);
assert.equal(await frame.evaluate(() => innerWidth), 1210);
await shoot(page, 'ipad-pro-11');

// Back to the default for the next tests.
Expand Down Expand Up @@ -181,7 +197,7 @@ test('saves a transparent PNG with ⌘S / Ctrl+S', async () => {
assert.equal(pixels.center, 255, 'screen is opaque');
assert.ok(Math.abs(pixels.width / pixels.height - 432 / 898) < 0.01, 'PNG has the frame proportions');

// A second screenshot of the tallest device must not include the "Saved" toast.
// A second screenshot, of the iPad, must not include the "Saved" toast.
await page.keyboard.press('ControlOrMeta+4');
await page.waitForTimeout(300);
const [second] = await Promise.all([page.waitForEvent('download'), page.keyboard.press('ControlOrMeta+s')]);
Expand All @@ -191,7 +207,7 @@ test('saves a transparent PNG with ⌘S / Ctrl+S', async () => {
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
// Sample a row just above the screen's bottom edge, away from the home indicator.
const y = Math.round(bitmap.height * (1 - 30 / 1254));
const y = Math.round(bitmap.height * (1 - 30 / 878));
return [0.3, 0.7].map((fx) => [...ctx.getImageData(Math.round(bitmap.width * fx), y, 1, 1).data].slice(0, 3));
}, readFileSync(await second.path()).toString('base64'));
for (const rgb of bottom) assert.deepEqual(rgb, [251, 247, 242], 'page background, not the toast');
Expand Down
9 changes: 9 additions & 0 deletions test/frame.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ test('every shape stays inside the frame', () => {
}
});

test('the landscape iPad has its camera centred in the top bezel', () => {
const ipad = devices.find((d) => d.id === 'ipad-pro-11');
const g = geometry(ipad);
assert.ok(ipad.viewport.width > ipad.viewport.height, 'landscape');
const camera = frameShapes(ipad).find((s) => s.type === 'circle');
assert.equal(camera.cx, BUTTON_DEPTH + g.bodyWidth / 2);
assert.equal(camera.cy, ipad.frame.bezel.top / 2);
});

test('the shadow pass can use buttons + body as the silhouette', () => {
// overlay.js draws the first buttons.length + 1 shapes with a shadow.
for (const d of devices) {
Expand Down
Loading