diff --git a/CHANGELOG.md b/CHANGELOG.md index c86b3a8..1aedee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 224ec67..e0b2ec0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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). | diff --git a/README.md b/README.md index 237288d..0e8f455 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/devices.png b/docs/devices.png index 7960938..34ac190 100644 Binary files a/docs/devices.png and b/docs/devices.png differ diff --git a/docs/overlay.png b/docs/overlay.png index 7802524..4ec314f 100644 Binary files a/docs/overlay.png and b/docs/overlay.png differ diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json index 268bee9..de42657 100644 --- a/extension/_locales/en/messages.json +++ b/extension/_locales/en/messages.json @@ -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", diff --git a/extension/_locales/nb/messages.json b/extension/_locales/nb/messages.json index 65dc99f..7431b1a 100644 --- a/extension/_locales/nb/messages.json +++ b/extension/_locales/nb/messages.json @@ -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": { diff --git a/extension/devices.json b/extension/devices.json index 5c72e01..b998874 100644 --- a/extension/devices.json +++ b/extension/devices.json @@ -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": [ @@ -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", @@ -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": [] } } ] diff --git a/extension/frame.js b/extension/frame.js index ddfbb2e..8c3428f 100644 --- a/extension/frame.js +++ b/extension/frame.js @@ -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', diff --git a/extension/overlay.css b/extension/overlay.css index 26c0cc5..fbc7af1 100644 --- a/extension/overlay.css +++ b/extension/overlay.css @@ -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; @@ -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; @@ -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; } } diff --git a/extension/overlay.js b/extension/overlay.js index 9ba8a60..3b86f0a 100644 --- a/extension/overlay.js +++ b/extension/overlay.js @@ -89,7 +89,6 @@
-
${shortcutHelp()}
@@ -128,14 +127,20 @@ const { el } = ui; el.seg.innerHTML = devices - .map((d, i) => ``) + .map((d, i) => ``) .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]; @@ -178,14 +183,14 @@ return isMac ? `⌘${key}` : `Ctrl+${key}`; } - function shortcutHelp() { - const k = (key) => `${keyLabel(key)}`; - return [ - `${k('1')}–${k('4')} ${t('keysDevices')}`, - `${k('S')} ${t('keysSave')}`, - `${k('C')} ${t('keysCopy')}`, - `Esc ${t('keysClose')}`, - ].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 ``; } // --- Rendering --------------------------------------------------------------- @@ -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})`; diff --git a/test/devices.test.js b/test/devices.test.js index 3fd8758..5c5a5a6 100644 --- a/test/devices.test.js +++ b/test/devices.test.js @@ -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) { diff --git a/test/e2e/preview.test.js b/test/e2e/preview.test.js index eaa943c..227527f 100644 --- a/test/e2e/preview.test.js +++ b/test/e2e/preview.test.js @@ -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`) }); } @@ -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'); @@ -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. @@ -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')]); @@ -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'); diff --git a/test/frame.test.js b/test/frame.test.js index dd13a90..c64cab9 100644 --- a/test/frame.test.js +++ b/test/frame.test.js @@ -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) {