diff --git a/e2e/reflow.spec.js b/e2e/reflow.spec.js index ee0b98ef..a793c0a5 100644 --- a/e2e/reflow.spec.js +++ b/e2e/reflow.spec.js @@ -98,11 +98,12 @@ test.describe('on a touch screen', () => { }); } - test('the icon colour swatches stay packed at 744px', async ({ page, request }) => { + test('the icon colour swatches stay packed on a wide phone', async ({ page, request }) => { await seedConfig(request, { items: [app('one', 'One')] }); await dismissSetupPrompt(request); - await page.setViewportSize({ width: 744, height: 900 }); + await page.setViewportSize({ width: 430, height: 932 }); await page.goto('/admin/#dashboard'); + await expect(page.locator('html')).toHaveClass(/\bis-mobile\b/); await page.locator('#al [data-act="edit"]').first().click(); await page.locator('#icon-col-style-btn').click(); await page.locator('#icon-col-style-list [data-val="color"]').click(); diff --git a/ui/admin/index.html b/ui/admin/index.html index b6248d85..7516f87e 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -10,9 +10,9 @@ Stackyard Settings - - - + + + diff --git a/ui/css/admin.css b/ui/css/admin.css index 7e4b19c8..a1d6861b 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -799,6 +799,7 @@ html.is-mobile .m-back{display:inline-flex;align-items:center;justify-content:ce html.is-mobile .m-back:active{opacity:.7} /* Under 16px, iOS zooms the page on focus and does not zoom back. */ html.is-mobile .kv-k,html.is-mobile .kv-v{font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body)} +@media (orientation:landscape) and (max-height:500px) and (pointer:coarse){.kv-k,.kv-v{font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body)}} html.is-mobile body.authed .mtabbar{ display:flex;position:fixed;left:21px;right:21px;bottom:21px; z-index:8000;justify-content:space-around;align-items:center;gap:2px; diff --git a/ui/css/tokens.css b/ui/css/tokens.css index 7fd5678f..e026465f 100644 --- a/ui/css/tokens.css +++ b/ui/css/tokens.css @@ -231,18 +231,20 @@ html[data-theme="light"] { Tracking is republished per step, not inherited. There is one absolute tracking per style, so the em that reproduces it at 17 does not at 15. */ -html.type-small:not(.is-mobile) { - --fs-large-title:32px; --lh-large-title:1.2188; --tr-large-title:0.0125em; /* 32/39 */ - --fs-title-1:26px; --lh-title-1:1.2308; --tr-title-1:0.0146em; /* 26/32 */ - --fs-title-2:20px; --lh-title-2:1.2; --tr-title-2:-0.013em; /* 20/24 */ - --fs-title-3:18px; --lh-title-3:1.2778; --tr-title-3:-0.025em; /* 18/23 */ - --fs-headline:15px; --lh-headline:1.3333; --tr-headline:-0.0287em; /* 15/20 */ - --fs-body:15px; --lh-body:1.3333; --tr-body:-0.0287em; /* 15/20 */ - --fs-callout:14px; --lh-callout:1.3571; --tr-callout:-0.0221em; /* 14/19 */ - --fs-subheadline:13px; --lh-subheadline:1.3846; --tr-subheadline:-0.0177em; /* 13/18 */ - --fs-footnote:12px; --lh-footnote:1.3333; --tr-footnote:-0.0067em; /* 12/16 */ - --fs-caption-1:11px; --lh-caption-1:1.1818; --tr-caption-1:0; /* 11/13 */ - --fs-caption-2:11px; --lh-caption-2:1.1818; --tr-caption-2:0.0055em; /* 11/13 */ +@media not all and (orientation:landscape) and (max-height:500px) and (pointer:coarse) { + html.type-small:not(.is-mobile) { + --fs-large-title:32px; --lh-large-title:1.2188; --tr-large-title:0.0125em; /* 32/39 */ + --fs-title-1:26px; --lh-title-1:1.2308; --tr-title-1:0.0146em; /* 26/32 */ + --fs-title-2:20px; --lh-title-2:1.2; --tr-title-2:-0.013em; /* 20/24 */ + --fs-title-3:18px; --lh-title-3:1.2778; --tr-title-3:-0.025em; /* 18/23 */ + --fs-headline:15px; --lh-headline:1.3333; --tr-headline:-0.0287em; /* 15/20 */ + --fs-body:15px; --lh-body:1.3333; --tr-body:-0.0287em; /* 15/20 */ + --fs-callout:14px; --lh-callout:1.3571; --tr-callout:-0.0221em; /* 14/19 */ + --fs-subheadline:13px; --lh-subheadline:1.3846; --tr-subheadline:-0.0177em; /* 13/18 */ + --fs-footnote:12px; --lh-footnote:1.3333; --tr-footnote:-0.0067em; /* 12/16 */ + --fs-caption-1:11px; --lh-caption-1:1.1818; --tr-caption-1:0; /* 11/13 */ + --fs-caption-2:11px; --lh-caption-2:1.1818; --tr-caption-2:0.0055em; /* 11/13 */ + } } /* ── Accessibility preferences ─────────────────────────────────────────────── diff --git a/ui/index.html b/ui/index.html index be90d3c3..715b0bd9 100644 --- a/ui/index.html +++ b/ui/index.html @@ -24,9 +24,9 @@ - + Stackyard - + diff --git a/ui/js/page-zoom.js b/ui/js/page-zoom.js index 78a39dcf..8630535c 100644 --- a/ui/js/page-zoom.js +++ b/ui/js/page-zoom.js @@ -6,14 +6,16 @@ if (!vv) return; const root = document.documentElement; let width = -1; - /* Read the scale only when the layout width changes. A pinch changes the - scale but not the width, and must not move the insets. */ + /* The scale is the page zoom only while the visual viewport spans the + layout width. A pinch narrows it, so a width change during a pinch is + read when the pinch ends. */ const read = () => { - if (root.clientWidth === width) return; + if (root.clientWidth === width || vv.width < root.clientWidth - 2) return; width = root.clientWidth; root.style.setProperty('--inset-scale', String(1 / vv.scale)); }; read(); window.addEventListener('resize', read); + vv.addEventListener('resize', read); } catch {} })(); diff --git a/ui/test/admin-touch.test.mjs b/ui/test/admin-touch.test.mjs index 5b80f2e4..b01c21b6 100644 --- a/ui/test/admin-touch.test.mjs +++ b/ui/test/admin-touch.test.mjs @@ -137,6 +137,22 @@ test('the tap highlight is cleared once, on the document', () => { test('the header fields are body size on a phone, so focusing one does not zoom', () => { assert.match(css, /html\.is-mobile \.kv-k,html\.is-mobile \.kv-v\{font-size:var\(--fs-body\)/); + assert.match( + css, + /@media \(orientation:landscape\) and \(max-height:500px\) and \(pointer:coarse\)\{\.kv-k,\.kv-v\{font-size:var\(--fs-body\)/, + 'a sideways phone keeps the desktop layout, so the phone rule alone misses it', + ); +}); + +test('a sideways phone keeps the default type step, so no field is under 16px', () => { + const tokens = read('css/tokens.css').replace(/\/\*[\s\S]*?\*\//g, ''); + const at = tokens.indexOf('html.type-small:not(.is-mobile)'); + const media = tokens.lastIndexOf('@media', at); + assert.ok(media >= 0 && tokens.indexOf('}', media) > at, 'the small step must sit inside a media query'); + assert.equal( + tokens.slice(media, tokens.indexOf('{', media)).trim(), + '@media not all and (orientation:landscape) and (max-height:500px) and (pointer:coarse)', + ); }); test('a long title wraps and the header buttons keep their size', () => { diff --git a/ui/test/page-zoom.test.mjs b/ui/test/page-zoom.test.mjs index 18e5a777..de483548 100644 --- a/ui/test/page-zoom.test.mjs +++ b/ui/test/page-zoom.test.mjs @@ -9,18 +9,23 @@ const read = rel => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), ' const script = read('../js/page-zoom.js'); /** Runs page-zoom.js against a stand-in page. */ +/** Runs page-zoom.js against a stand-in page. `pinch` is the zoom on top of the + page zoom: it raises the scale and narrows the visual viewport. */ function page({ width, scale }) { const props = new Map(); const listeners = []; const root = { clientWidth: width, style: { setProperty: (k, v) => props.set(k, v) } }; - const vv = { scale }; + const vv = { scale, width, addEventListener: (type, fn) => type === 'resize' && listeners.push(fn) }; const window = { visualViewport: vv, addEventListener: (type, fn) => type === 'resize' && listeners.push(fn) }; vm.runInNewContext(script, { window, document: { documentElement: root } }); + let zoom = scale; return { props, - resize({ width: w = root.clientWidth, scale: s = vv.scale }) { + resize({ width: w = root.clientWidth, scale: s = zoom, pinch = 1 }) { + zoom = s; root.clientWidth = w; - vv.scale = s; + vv.scale = s * pinch; + vv.width = w / pinch; for (const fn of listeners) fn(); }, }; @@ -40,10 +45,27 @@ test('at 150% page zoom each inset shrinks to two thirds', () => { test('a pinch leaves the insets alone', () => { const p = page({ width: 393, scale: 1 }); - p.resize({ scale: 2.5 }); + p.resize({ pinch: 2.5 }); assert.equal(p.props.get('--inset-scale'), '1'); }); +test('turning the phone while pinched waits for the pinch to end', () => { + const p = page({ width: 393, scale: 1 }); + p.resize({ pinch: 2 }); + p.resize({ width: 852, pinch: 2 }); + assert.equal(p.props.get('--inset-scale'), '1'); + p.resize({ width: 852, pinch: 1 }); + assert.equal(p.props.get('--inset-scale'), '1'); +}); + +test('turning the phone while pinched at 50% page zoom keeps the page zoom', () => { + const p = page({ width: 786, scale: 0.5 }); + p.resize({ width: 1704, pinch: 3 }); + assert.equal(p.props.get('--inset-scale'), '2'); + p.resize({ width: 1704, pinch: 1 }); + assert.equal(p.props.get('--inset-scale'), '2'); +}); + test('a page zoom change after load is picked up', () => { const p = page({ width: 393, scale: 1 }); p.resize({ width: 786, scale: 0.5 });