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