mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 10:13:51 +00:00
fix(mobile): allow zoom, stop sticky hover, calm touch and motion
- one viewport meta for every page: zoom is no longer blocked (user-scalable=no also stopped pinch zoom on Android; WCAG 1.4.4) and interactive-widget=resizes-content makes Android resize for the keyboard. iOS already ignored the old tags. - viewport.js does not pin the scroll while the page is pinch-zoomed, or it would fight the user's panning - 38 pure :hover rules moved into @media (hover: hover): iOS emulates hover on a tap and left the tapped toolbar button or row highlighted. Reveal-on-hover rules with touch fallbacks are untouched, and a test fails if a bare :hover rule is added - touch block: no tap flash, selection or callout on controls (long- pressing a row no longer opens the loupe), touch-action: manipulation so a quick second tap does not zoom; fields and the editor keep selection - prefers-reduced-motion block (loading ring keeps turning) - /diag defaults now mirror the app, with ?zoom=0 / ?iw=0 to compare Full mobile and desktop suites pass.
This commit is contained in:
parent
98c5461078
commit
4b9e1bba1c
12 changed files with 365 additions and 81 deletions
|
|
@ -523,6 +523,9 @@ Desktop and mobile do not have separate editor implementations. Both use the sam
|
|||
### PWA shell
|
||||
Cache shell/static assets only. Do not cache note/resource/API responses in ways that break shared-browser safety.
|
||||
|
||||
### Zoom, touch and hover CSS
|
||||
One `viewportMeta` constant (`app/templates/shared.js`) is used by every page: `width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content`. Zoom is allowed (WCAG 1.4.4; `user-scalable=no` also blocks pinch zoom on Android; iOS Safari ignores it anyway). Double-tap zoom on controls is off through `touch-action: manipulation`, and `public/viewport.js` does not pin the page scroll while the user is pinch-zoomed (`shouldPin`). `interactive-widget=resizes-content` only affects Android Chrome. The end of `styles.css` has the touch block (`@media (hover: none) and (pointer: coarse)`: no text-size inflation, tap flash, selection or callout on controls; selection stays on fields and `.cm-content`) and a `prefers-reduced-motion` block (the loading ring keeps turning). Pure `:hover` feedback rules must live inside `@media (hover: hover)`: iOS emulates hover on tap and leaves it stuck (a tapped toolbar button stayed highlighted). Reveal-on-hover affordances that mix `:hover` with another state have `(hover: none)` fallbacks and are exempt. `tests/touchCss.test.js` lints this. `/diag` can reproduce the old meta (`?zoom=0`, `?iw=0`) for comparison.
|
||||
|
||||
### Service worker policy (`public/service-worker.js`)
|
||||
Deliberately small. Navigations go to the network; if that fails the worker serves the static `/offline.html` (inline styles, a Try again button that checks `/health` and reloads, auto-retry on the `online` event). Versioned `.js`/`.css` URLs (`?v=<asset version>`, immutable by construction) are cache-first in a cache named `joplock-shell-<asset version>`; any other Joplock cache is deleted on activate, other apps' caches are left alone. Nothing else is intercepted: **no HTML page, API response, fragment, note or attachment is ever cached** (shared-browser safety; the app has no offline notes). The install step precaches only `/offline.html` and an icon. It used to precache bare URLs that the `?v=` requests never matched (so assets were stored twice) and never cached `app.js`/TinyMCE. Covered by `tests/serviceWorker.test.js` (the real file in a sandbox) and `playwright-tests/pwa-offline.spec.js` (real browser: cached assets, offline launch, recovery). Not done: an in-app "new version, reload" prompt; the new worker takes over immediately (`skipWaiting` + `claim`).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const { viewportMeta } = require('../templates/shared');
|
||||
const { send, sendJson, readRawBody, parseMultipart, contentDispositionFilename, shouldInlineResource } = require('./_helpers');
|
||||
|
||||
// Joplin Server rejects files over its formidable maxFileSize (200MB) with an
|
||||
|
|
@ -82,7 +83,7 @@ const handle = async (url, request, response, ctx) => {
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="${viewportMeta}" />
|
||||
<meta name="theme-color" content="#08110b" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -7,28 +7,32 @@
|
|||
// visualViewport while the keyboard is up, page scroll pinned at 0). No dependencies on app.js.
|
||||
'use strict';
|
||||
|
||||
const { viewportMeta } = require('./shared');
|
||||
|
||||
const SHELLS = ['current', 'vv'];
|
||||
|
||||
// Defaults mirror the real app (viewport meta from shared.js). The switches exist to compare:
|
||||
// ?iw=0 drops interactive-widget=resizes-content, ?zoom=0 blocks zoom as the app used to.
|
||||
const diagOptions = query => {
|
||||
const get = key => (query && typeof query.get === 'function' ? query.get(key) : (query || {})[key]);
|
||||
const shell = SHELLS.includes(get('shell')) ? get('shell') : 'current';
|
||||
return { shell, iw: get('iw') === '1', zoom: get('zoom') === '1' };
|
||||
return { shell, iw: get('iw') !== '0', zoom: get('zoom') !== '0' };
|
||||
};
|
||||
|
||||
const viewportContent = ({ iw, zoom }) => [
|
||||
const viewportContent = ({ iw, zoom }) => (iw && zoom ? viewportMeta : [
|
||||
'width=device-width',
|
||||
'initial-scale=1.0',
|
||||
zoom ? null : 'maximum-scale=1.0',
|
||||
zoom ? null : 'user-scalable=no',
|
||||
'viewport-fit=cover',
|
||||
iw ? 'interactive-widget=resizes-content' : null,
|
||||
].filter(Boolean).join(', ');
|
||||
].filter(Boolean).join(', '));
|
||||
|
||||
const diagPage = (options = {}) => {
|
||||
const { shell, iw, zoom } = diagOptions(options.query || options);
|
||||
const config = JSON.stringify({ shell, iw, zoom });
|
||||
const link = (label, params) => {
|
||||
const active = params.shell === shell && (params.iw === '1') === iw && (params.zoom === '1') === zoom;
|
||||
const active = params.shell === shell && (params.iw !== '0') === iw && (params.zoom !== '0') === zoom;
|
||||
const href = `/diag?${Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&')}`;
|
||||
return `<a href="${href}"${active ? ' class="on"' : ''}>${label}</a>`;
|
||||
};
|
||||
|
|
@ -84,9 +88,9 @@ details p{padding:2px 0}
|
|||
<i id="p-sl" style="height:env(safe-area-inset-left,0px)"></i><i id="p-sr" style="height:env(safe-area-inset-right,0px)"></i>
|
||||
</div>
|
||||
<div id="shell" class="shell-${shell}"${shell === 'vv' ? ' data-viewport-pin' : ''}>
|
||||
<div id="hdr"><a href="/settings?tab=about">‹ Back</a><b>Diagnostics · ${shell}${iw ? ' + iw' : ''}${zoom ? ' + zoom' : ''}</b></div>
|
||||
<div id="hdr"><a href="/settings?tab=about">‹ Back</a><b>Diagnostics · ${shell}${iw ? '' : ' - no iw'}${zoom ? '' : ' - no zoom'}</b></div>
|
||||
<div id="panel">
|
||||
<div id="variants">${link('current', { shell: 'current' })}${link('vv shell', { shell: 'vv' })}${link('vv + interactive-widget', { shell: 'vv', iw: '1' })}${link('current + zoom allowed', { shell: 'current', zoom: '1' })}</div>
|
||||
<div id="variants">${link('current shell', { shell: 'current' })}${link('vv shell (viewport.js)', { shell: 'vv' })}${link('current, no interactive-widget', { shell: 'current', iw: '0' })}${link('current, zoom blocked', { shell: 'current', zoom: '0' })}</div>
|
||||
<details><summary>How to test (tap)</summary>
|
||||
<p>Reports send themselves (two samples, 0.7s and 1.8s after each event) and are only logged on the server; nothing touches your notes. Run each variant link below; do it in the installed Home Screen app, and once in the browser tab.</p>
|
||||
<p>1. Open the page and leave it alone for 3s (<b>load</b>).</p>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
const {
|
||||
escapeHtml,
|
||||
appleSplashLinks,
|
||||
viewportMeta,
|
||||
themeOptions,
|
||||
passwordField,
|
||||
stripMarkdownForTitle,
|
||||
|
|
@ -51,7 +52,7 @@ const layoutPage = (options = {}) => {
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="${viewportMeta}" />
|
||||
<meta name="theme-color" content="${themeColor('dark-grey')}" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
|
@ -95,7 +96,7 @@ const layoutPage = (options = {}) => {
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="${viewportMeta}" />
|
||||
<meta name="theme-color" content="${themeColor(settings.theme)}" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
|
@ -375,7 +376,7 @@ const loggedOutPage = () => `<!DOCTYPE html>
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="${viewportMeta}" />
|
||||
<meta name="theme-color" content="${themeColor('dark-grey')}" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
|
@ -449,7 +450,7 @@ const recoveryPage = (options = {}) => {
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="${viewportMeta}" />
|
||||
<meta name="theme-color" content="${themeColor('dark-grey')}" />
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
|
|
@ -586,7 +587,7 @@ const mfaPage = (options = {}) => {
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="${viewportMeta}" />
|
||||
<meta name="theme-color" content="${themeColor('dark-grey')}" />
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const {
|
|||
escapeHtml,
|
||||
escapeJsString,
|
||||
appleSplashLinks,
|
||||
viewportMeta,
|
||||
themeOptions,
|
||||
validDateFormats,
|
||||
validDatetimeFormats,
|
||||
|
|
@ -145,7 +146,7 @@ const settingsPage = (options = {}) => {
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="${viewportMeta}" />
|
||||
<meta name="theme-color" content="${themeColor(settings.theme)}" />
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ const escapeJsString = value => `${value || ''}`
|
|||
.replaceAll('\n', '\\n')
|
||||
.replaceAll('</', '<\\/');
|
||||
|
||||
// One viewport for every page. Zoom stays available (WCAG 1.4.4; user-scalable=no also blocks
|
||||
// pinch zoom on Android). interactive-widget makes Android Chrome resize the layout viewport for
|
||||
// the soft keyboard like iOS-style layouts expect; iOS Safari ignores it, and public/viewport.js
|
||||
// handles the iOS keyboard. Double-tap zoom on controls is off through touch-action (styles.css).
|
||||
const viewportMeta = 'width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content';
|
||||
|
||||
const appleSplashLinks = [
|
||||
['1260x2736.png', 'screen and (device-width: 420px) and (device-height: 912px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
|
||||
['2736x1260.png', 'screen and (device-width: 420px) and (device-height: 912px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
|
||||
|
|
@ -148,6 +154,7 @@ const svgPin = '<svg class="pin-svg-icon" viewBox="0 0 24 24" fill="currentColor
|
|||
const svgLockOpen = '<svg class="vault-svg-icon" viewBox="0 0 24 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="15" width="18" height="11" rx="2"/><path d="M7 15V8a5 5 0 0 1 10 0"/></svg>';
|
||||
|
||||
module.exports = {
|
||||
viewportMeta,
|
||||
escapeHtml,
|
||||
escapeJsString,
|
||||
appleSplashLinks,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ test.describe('Mobile markdown toolbar and the keyboard', () => {
|
|||
|
||||
test('viewport.js puts the page scroll back at 0 when the browser scrolls it (what iOS does for a caret under the keyboard)', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile');
|
||||
await page.setContent('<!doctype html><html><body style="margin:0"><div data-viewport-pin style="height:3000px">tall page</div><input id="f"></body></html>');
|
||||
await page.setContent('<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body style="margin:0"><div data-viewport-pin style="height:3000px">tall page</div><input id="f"></body></html>');
|
||||
await page.addScriptTag({ path: path.join(__dirname, '..', 'public', 'viewport.js') });
|
||||
expect(await page.evaluate(() => window.JoplockViewport.enabled)).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 340));
|
||||
|
|
@ -96,6 +96,18 @@ test.describe('Mobile markdown toolbar and the keyboard', () => {
|
|||
expect(state.maxScroll).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
test('viewport.js leaves the scroll alone while the user is pinch-zoomed', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile');
|
||||
// no viewport meta: mobile Chromium lays the page out at 980px and scales it, a scale != 1
|
||||
await page.setContent('<!doctype html><html><body style="margin:0"><div data-viewport-pin style="height:3000px">tall page</div></body></html>');
|
||||
await page.addScriptTag({ path: path.join(__dirname, '..', 'public', 'viewport.js') });
|
||||
expect(await page.evaluate(() => Math.abs(window.visualViewport.scale - 1) > 0.01)).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 340));
|
||||
await page.waitForTimeout(400);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(300);
|
||||
expect((await page.evaluate(() => window.JoplockViewport.state())).pins).toBe(0);
|
||||
});
|
||||
|
||||
test('the localStorage kill switch turns viewport.js off', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile');
|
||||
await page.goto('/login');
|
||||
|
|
|
|||
|
|
@ -925,8 +925,10 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
.folder-context-item:hover {
|
||||
background: var(--bg-hover);
|
||||
@media (hover: hover) {
|
||||
.folder-context-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* display:block above beats the UA [hidden] rule; vault notebooks hide the
|
||||
|
|
@ -1015,7 +1017,9 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-close-btn:hover { color: var(--text); background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.upload-close-btn:hover { color: var(--text); background: var(--bg-hover); }
|
||||
}
|
||||
.upload-drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1119,7 +1123,9 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.upload-file-del:hover { color: var(--danger); background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.upload-file-del:hover { color: var(--danger); background: var(--bg-hover); }
|
||||
}
|
||||
|
||||
.trash-folder-empty {
|
||||
flex-shrink: 0;
|
||||
|
|
@ -1265,7 +1271,9 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
gap: 2px;
|
||||
}
|
||||
|
||||
.history-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
@media (hover: hover) {
|
||||
.history-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
}
|
||||
.history-item-active { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.history-item-title {
|
||||
|
|
@ -1336,7 +1344,9 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.notelist-load-more:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.notelist-load-more:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
}
|
||||
|
||||
.btn-icon-sm {
|
||||
width: 22px;
|
||||
|
|
@ -1350,7 +1360,9 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
color: var(--text-dim);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.btn-icon-sm:hover { background: var(--bg-hover); color: var(--text); }
|
||||
@media (hover: hover) {
|
||||
.btn-icon-sm:hover { background: var(--bg-hover); color: var(--text); }
|
||||
}
|
||||
|
||||
/* Sidebar items */
|
||||
.sidebar-item {
|
||||
|
|
@ -1364,7 +1376,9 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
transition: background 0.1s;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.sidebar-item:hover { background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.sidebar-item:hover { background: var(--bg-hover); }
|
||||
}
|
||||
.sidebar-item.active { background: var(--bg-active); }
|
||||
|
||||
.sidebar-item-icon {
|
||||
|
|
@ -1444,7 +1458,9 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.notelist-item:hover { background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.notelist-item:hover { background: var(--bg-hover); }
|
||||
}
|
||||
.notelist-item.active { background: var(--bg-active); }
|
||||
|
||||
.notelist-item-title {
|
||||
|
|
@ -1603,7 +1619,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
color: var(--text-dim);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.tb:hover { background: var(--bg-hover); color: var(--text); }
|
||||
@media (hover: hover) {
|
||||
.tb:hover { background: var(--bg-hover); color: var(--text); }
|
||||
}
|
||||
.tb:focus { outline: none; }
|
||||
|
||||
.tb-div {
|
||||
|
|
@ -1637,7 +1655,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
.export-menu button:hover { background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.export-menu button:hover { background: var(--bg-hover); }
|
||||
}
|
||||
.export-menu .export-menu-cancel { color: var(--text-muted); margin-top: 2px; border-top: 1px solid var(--border); border-radius: 0 0 4px 4px; }
|
||||
|
||||
/* Editor body (hidden textarea, used as sync target for form submission) */
|
||||
|
|
@ -1727,13 +1747,17 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
.tox .tox-tbtn svg {
|
||||
fill: var(--text-dim) !important;
|
||||
}
|
||||
.tox .tox-tbtn:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
background-color: var(--bg-hover) !important;
|
||||
color: var(--text) !important;
|
||||
@media (hover: hover) {
|
||||
.tox .tox-tbtn:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
background-color: var(--bg-hover) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
}
|
||||
.tox .tox-tbtn:hover svg {
|
||||
fill: var(--text) !important;
|
||||
@media (hover: hover) {
|
||||
.tox .tox-tbtn:hover svg {
|
||||
fill: var(--text) !important;
|
||||
}
|
||||
}
|
||||
.tox .tox-tbtn--active,
|
||||
.tox .tox-tbtn--enabled,
|
||||
|
|
@ -1792,11 +1816,13 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
.tox .tox-split-button__chevron svg {
|
||||
fill: var(--text-dim) !important;
|
||||
}
|
||||
.tox .tox-tbtn--select:hover .tox-tbtn__select-label,
|
||||
.tox .tox-split-button:hover .tox-tbtn__select-chevron svg,
|
||||
.tox .tox-tbtn--select:hover .tox-tbtn__select-chevron svg {
|
||||
color: var(--text) !important;
|
||||
fill: var(--text) !important;
|
||||
@media (hover: hover) {
|
||||
.tox .tox-tbtn--select:hover .tox-tbtn__select-label,
|
||||
.tox .tox-split-button:hover .tox-tbtn__select-chevron svg,
|
||||
.tox .tox-tbtn--select:hover .tox-tbtn__select-chevron svg {
|
||||
color: var(--text) !important;
|
||||
fill: var(--text) !important;
|
||||
}
|
||||
}
|
||||
/* Dropdown menus, context menus, popups */
|
||||
.tox .tox-menu,
|
||||
|
|
@ -1984,7 +2010,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
white-space: nowrap;
|
||||
max-width: 26em;
|
||||
}
|
||||
.cm-host .cm-jl-chip:hover { border-color: var(--accent); background: var(--bg-active); }
|
||||
@media (hover: hover) {
|
||||
.cm-host .cm-jl-chip:hover { border-color: var(--accent); background: var(--bg-active); }
|
||||
}
|
||||
.cm-host .cm-jl-chip-dl {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
|
@ -2002,7 +2030,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
cursor: pointer;
|
||||
}
|
||||
.cm-host .cm-jl-chip[data-kind="resource"] .cm-jl-chip-dl { display: inline-flex; }
|
||||
.cm-host .cm-jl-chip-dl:hover { color: #fff; background: var(--accent); }
|
||||
@media (hover: hover) {
|
||||
.cm-host .cm-jl-chip-dl:hover { color: #fff; background: var(--accent); }
|
||||
}
|
||||
.cm-host .cm-jl-chip-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
.cm-host .cm-jl-chip-icon::before { content: "\1F517"; font-size: 0.9em; }
|
||||
.cm-host .cm-jl-chip[data-kind="resource"] .cm-jl-chip-icon::before { content: "\1F4CE"; }
|
||||
|
|
@ -2021,7 +2051,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cm-host .cm-jl-check:hover { border-color: var(--accent); }
|
||||
@media (hover: hover) {
|
||||
.cm-host .cm-jl-check:hover { border-color: var(--accent); }
|
||||
}
|
||||
.cm-host .cm-jl-check.checked { background: var(--accent); border-color: var(--accent); }
|
||||
.cm-host .cm-jl-check.checked::after {
|
||||
content: "";
|
||||
|
|
@ -2063,7 +2095,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
.cm-host .cm-jl-fold-marker { opacity: 0; font-size: 0.8em; color: var(--text-dim); transition: opacity 0.12s; }
|
||||
.cm-host .cm-gutters:hover .cm-jl-fold-marker,
|
||||
.cm-host .cm-jl-fold-marker.closed { opacity: 0.9; }
|
||||
.cm-host .cm-jl-fold-marker:hover { color: var(--accent); }
|
||||
@media (hover: hover) {
|
||||
.cm-host .cm-jl-fold-marker:hover { color: var(--accent); }
|
||||
}
|
||||
.cm-host .cm-jl-fold-placeholder {
|
||||
margin: 0 4px;
|
||||
padding: 0 6px;
|
||||
|
|
@ -2103,7 +2137,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cm-host .cm-jl-outline-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
@media (hover: hover) {
|
||||
.cm-host .cm-jl-outline-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
}
|
||||
.cm-host .cm-jl-outline {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
|
|
@ -2136,7 +2172,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cm-host .cm-jl-outline-item:hover { background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.cm-host .cm-jl-outline-item:hover { background: var(--bg-hover); }
|
||||
}
|
||||
.cm-host .cm-jl-outline-empty { padding: 6px 12px; color: var(--text-dim); }
|
||||
|
||||
/* Link hover info */
|
||||
|
|
@ -2183,9 +2221,11 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
background: var(--bg-active);
|
||||
color: var(--text-heading);
|
||||
}
|
||||
.note-autocomplete-item:hover,
|
||||
.cm-tooltip-autocomplete ul > li:hover {
|
||||
background: var(--bg-hover);
|
||||
@media (hover: hover) {
|
||||
.note-autocomplete-item:hover,
|
||||
.cm-tooltip-autocomplete ul > li:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
.note-autocomplete-item + .note-autocomplete-item,
|
||||
.cm-tooltip-autocomplete ul > li + li {
|
||||
|
|
@ -2325,7 +2365,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
}
|
||||
.preview-img { display: block; max-width: 100%; height: auto; border-radius: 0; margin: 12px 0; cursor: default; }
|
||||
.editor-preview[contenteditable="true"] .preview-img { cursor: nwse-resize; border: 1px solid transparent; }
|
||||
.editor-preview[contenteditable="true"] .preview-img:hover { border-color: var(--accent); opacity: 0.95; }
|
||||
@media (hover: hover) {
|
||||
.editor-preview[contenteditable="true"] .preview-img:hover { border-color: var(--accent); opacity: 0.95; }
|
||||
}
|
||||
.editor-preview[contenteditable="true"] .preview-img:active { border-color: var(--accent); }
|
||||
|
||||
.preview-img-download-wrap {
|
||||
|
|
@ -2545,7 +2587,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.disconnected-retry:hover { background: #222; }
|
||||
@media (hover: hover) {
|
||||
.disconnected-retry:hover { background: #222; }
|
||||
}
|
||||
.disconnected-retry:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.disconnected-logout {
|
||||
padding: 8px 16px;
|
||||
|
|
@ -2558,7 +2602,9 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.disconnected-logout:hover { color: var(--danger); border-color: var(--danger); }
|
||||
@media (hover: hover) {
|
||||
.disconnected-logout:hover { color: var(--danger); border-color: var(--danger); }
|
||||
}
|
||||
.is-disconnected .app-layout { display: none; }
|
||||
.is-disconnected #mobile-app { display: none; }
|
||||
.autosave-conflict { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
|
@ -2667,17 +2713,21 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
white-space: nowrap;
|
||||
}
|
||||
.btn[hidden] { display: none; }
|
||||
.btn:hover { background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.btn:hover { background: var(--bg-hover); }
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: color-mix(in srgb, var(--accent) 88%, #fff);
|
||||
border-color: color-mix(in srgb, var(--accent) 88%, #fff);
|
||||
color: var(--text-on-accent);
|
||||
@media (hover: hover) {
|
||||
.btn-primary:hover {
|
||||
background: color-mix(in srgb, var(--accent) 88%, #fff);
|
||||
border-color: color-mix(in srgb, var(--accent) 88%, #fff);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
|
|
@ -2702,14 +2752,22 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
color: var(--text-dim);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.btn-icon:hover { background: var(--bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.btn-icon:hover { background: var(--bg-hover); }
|
||||
}
|
||||
#lock-toggle-btn { color: #FFC107; }
|
||||
#lock-toggle-btn:hover { color: #FFC107; }
|
||||
@media (hover: hover) {
|
||||
#lock-toggle-btn:hover { color: #FFC107; }
|
||||
}
|
||||
.btn-icon.active { color: var(--accent); background: var(--bg-active); }
|
||||
.btn-icon-compact { width: 24px; height: 24px; font-size: 13px; }
|
||||
.btn-danger-soft { color: var(--danger); }
|
||||
.btn-danger-soft:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
|
||||
.btn-danger:hover { color: var(--danger); }
|
||||
@media (hover: hover) {
|
||||
.btn-danger-soft:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.btn-danger:hover { color: var(--danger); }
|
||||
}
|
||||
.mode-toggle-btn {
|
||||
width: auto;
|
||||
min-width: 52px;
|
||||
|
|
@ -2872,8 +2930,10 @@ select.login-input option {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-link:hover {
|
||||
text-decoration: underline;
|
||||
@media (hover: hover) {
|
||||
.about-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
|
|
@ -3163,8 +3223,10 @@ select.login-input option {
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.login-eye:hover {
|
||||
opacity: 1;
|
||||
@media (hover: hover) {
|
||||
.login-eye:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-totp {
|
||||
|
|
@ -3880,7 +3942,9 @@ body.resizing .mobile-screen-right { transition: none !important; }
|
|||
transition: color 0.15s, border-color 0.15s;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.settings-tab:hover { color: var(--text); }
|
||||
@media (hover: hover) {
|
||||
.settings-tab:hover { color: var(--text); }
|
||||
}
|
||||
.settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
.settings-tab-panel { display: none; }
|
||||
.settings-tab-panel.active { display: block; }
|
||||
|
|
@ -3994,7 +4058,9 @@ body.resizing .mobile-screen-right { transition: none !important; }
|
|||
}
|
||||
.admin-table th { color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table tr:hover td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
@media (hover: hover) {
|
||||
.admin-table tr:hover td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
}
|
||||
.admin-actions-cell { white-space: nowrap; }
|
||||
.admin-inline-form { display: inline; margin: 0; }
|
||||
.admin-backup-actions { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
|
@ -4052,7 +4118,9 @@ body.resizing .mobile-screen-right { transition: none !important; }
|
|||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.admin-modal-close:hover { color: var(--text); }
|
||||
@media (hover: hover) {
|
||||
.admin-modal-close:hover { color: var(--text); }
|
||||
}
|
||||
.admin-modal-user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -4145,8 +4213,12 @@ body.resizing .mobile-screen-right { transition: none !important; }
|
|||
transition: opacity 0.15s, color 0.15s;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.editor-preview pre:hover .pre-copy-btn { opacity: 1; }
|
||||
.pre-copy-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||
@media (hover: hover) {
|
||||
.editor-preview pre:hover .pre-copy-btn { opacity: 1; }
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.pre-copy-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||
}
|
||||
|
||||
mark.search-highlight {
|
||||
background: #ffe066;
|
||||
|
|
@ -4178,7 +4250,9 @@ mark.search-highlight {
|
|||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.search-nav-btn:hover { border-color: #333; background: rgba(0,0,0,0.08); }
|
||||
@media (hover: hover) {
|
||||
.search-nav-btn:hover { border-color: #333; background: rgba(0,0,0,0.08); }
|
||||
}
|
||||
.search-nav-close { margin-left: auto; }
|
||||
/* Remote-update bar reuses .search-nav-bar layout; this rule lets the message
|
||||
take the remaining space so the action buttons pack to the right. */
|
||||
|
|
@ -4315,9 +4389,11 @@ mark.search-highlight-active {
|
|||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vault-folder-lock:hover {
|
||||
opacity: 1;
|
||||
color: #FFC107;
|
||||
@media (hover: hover) {
|
||||
.vault-folder-lock:hover {
|
||||
opacity: 1;
|
||||
color: #FFC107;
|
||||
}
|
||||
}
|
||||
|
||||
/* SVG lock icons */
|
||||
|
|
@ -4351,3 +4427,75 @@ mark.search-highlight-active {
|
|||
#link-copied-toast.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ===== Touch devices =====
|
||||
Controls are not text: a long press on a row, a tab or a button opens no loupe or link callout,
|
||||
and a quick second tap does not zoom the page (pinch zoom stays; WCAG 1.4.4). Note text, fields
|
||||
and the editors keep selection. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
summary,
|
||||
label,
|
||||
[role="button"],
|
||||
[role="tab"],
|
||||
.mobile-folder-row,
|
||||
.mobile-note-row,
|
||||
.nav-folder-row,
|
||||
.notelist-item {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-header,
|
||||
.mobile-folder-row,
|
||||
.mobile-note-row,
|
||||
.mobile-fab,
|
||||
.mobile-fab-menu,
|
||||
.mobile-ctx-sheet,
|
||||
.editor-toolbar,
|
||||
.nav-folder-row,
|
||||
.notelist-item,
|
||||
.settings-tab,
|
||||
button,
|
||||
[role="button"],
|
||||
[role="tab"],
|
||||
summary {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable],
|
||||
.cm-content {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: no slides, fades or pulses. The loading ring is progress, not decoration, so it
|
||||
keeps turning. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
transition-delay: 0s !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
*:not(.note-loading-ring) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
* What it does, only on a touch device (pointer: coarse):
|
||||
* - html[data-keyboard] while a soft keyboard is up, plus --app-height / --app-top (px) that
|
||||
* styles.css applies to .mobile-app only in that state; outside it nothing changes.
|
||||
* - while the mobile shell is showing, any document scroll is put back to (0, 0).
|
||||
* - while the mobile shell is showing (and the user has not pinch-zoomed), any document scroll is
|
||||
* put back to (0, 0).
|
||||
* - fires "joplock:viewport" on document when the keyboard state or geometry changes.
|
||||
*
|
||||
* Never guess a keyboard from innerHeight - visualViewport.height: an iOS home-screen app shrinks
|
||||
|
|
@ -60,7 +61,18 @@
|
|||
return reference > 0 && reference - s.vvHeight > reference * OCCLUSION;
|
||||
}
|
||||
|
||||
var pure = { OCCLUSION: OCCLUSION, isTypingElement: isTypingElement, keyboardVisible: keyboardVisible };
|
||||
/**
|
||||
* Whether a document scroll should be put back at (0,0): only while the mobile shell is showing
|
||||
* (nothing in it scrolls the document) and never while the user has pinch-zoomed, when panning
|
||||
* the zoomed page legitimately moves the scroll position.
|
||||
*/
|
||||
function shouldPin(s) {
|
||||
if (!s.shellShowing) return false;
|
||||
if (Math.abs((s.scale || 1) - 1) > 0.01) return false;
|
||||
return !!(s.scrollX || s.scrollY);
|
||||
}
|
||||
|
||||
var pure = { OCCLUSION: OCCLUSION, isTypingElement: isTypingElement, keyboardVisible: keyboardVisible, shouldPin: shouldPin };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = pure;
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
|
|
@ -122,7 +134,7 @@
|
|||
// iOS scrolls the document to reveal the caret. Nothing in the shell scrolls the document
|
||||
// (body is position:fixed), so put it back, whether or not we think a keyboard is up.
|
||||
var pinnedNow = false;
|
||||
if (shellShowing() && (window.scrollY || window.scrollX)) {
|
||||
if (shouldPin({ shellShowing: shellShowing(), scale: vv.scale, scrollX: window.scrollX, scrollY: window.scrollY })) {
|
||||
pins++;
|
||||
maxScroll = Math.max(maxScroll, Math.round(window.scrollY));
|
||||
window.scrollTo(0, 0);
|
||||
|
|
|
|||
|
|
@ -10,20 +10,22 @@ const routeDiag = require('../app/routes/diag');
|
|||
|
||||
const scriptOf = html => html.split('<script>')[1].split('</script>')[0];
|
||||
|
||||
test('diag options only accept known values', () => {
|
||||
assert.deepEqual(diagOptions(new URLSearchParams('')), { shell: 'current', iw: false, zoom: false });
|
||||
assert.deepEqual(diagOptions(new URLSearchParams('shell=vv&iw=1&zoom=1')), { shell: 'vv', iw: true, zoom: true });
|
||||
test('diag options only accept known values; defaults mirror the real app', () => {
|
||||
assert.deepEqual(diagOptions(new URLSearchParams('')), { shell: 'current', iw: true, zoom: true });
|
||||
assert.deepEqual(diagOptions(new URLSearchParams('shell=vv&iw=0&zoom=0')), { shell: 'vv', iw: false, zoom: false });
|
||||
assert.equal(diagOptions(new URLSearchParams('shell=<script>')).shell, 'current');
|
||||
});
|
||||
|
||||
test('viewport meta mirrors the app by default and varies only as requested', () => {
|
||||
test('viewport meta is the app\'s by default and varies only as requested', () => {
|
||||
const { viewportMeta } = require('../app/templates/shared');
|
||||
assert.equal(viewportContent({ iw: true, zoom: true }), viewportMeta);
|
||||
assert.equal(viewportContent({ iw: false, zoom: false }), 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover');
|
||||
assert.match(viewportContent({ iw: true, zoom: false }), /interactive-widget=resizes-content/);
|
||||
assert.doesNotMatch(viewportContent({ iw: false, zoom: true }), /user-scalable|maximum-scale/);
|
||||
assert.doesNotMatch(viewportContent({ iw: false, zoom: true }), /interactive-widget|user-scalable/);
|
||||
assert.match(viewportContent({ iw: true, zoom: false }), /interactive-widget=resizes-content.*|user-scalable=no/);
|
||||
});
|
||||
|
||||
test('diag page renders each variant and its script parses', () => {
|
||||
for (const q of ['', 'shell=vv', 'shell=vv&iw=1', 'shell=current&zoom=1']) {
|
||||
for (const q of ['', 'shell=vv', 'shell=vv&iw=0', 'shell=current&zoom=0']) {
|
||||
const html = templates.diagPage({ query: new URLSearchParams(q) });
|
||||
assert.match(html, /^<!DOCTYPE html>/);
|
||||
assert.doesNotThrow(() => new vm.Script(scriptOf(html)), `script for "${q}" must parse`);
|
||||
|
|
|
|||
84
tests/touchCss.test.js
Normal file
84
tests/touchCss.test.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
'use strict';
|
||||
|
||||
// iOS emulates :hover on tap and leaves it stuck until the next tap elsewhere, so a tapped
|
||||
// toolbar button or list row stayed highlighted. Pure visual hover feedback lives inside
|
||||
// @media (hover: hover). Reveal-on-hover affordances (a selector list that also names a
|
||||
// focus/active/class state) have their own touch fallbacks and are exempt.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'styles.css'), 'utf8');
|
||||
|
||||
// every rule with its selector list and the at-rules around it
|
||||
function rules(source) {
|
||||
const out = [];
|
||||
const stack = [];
|
||||
let buf = 0;
|
||||
for (const m of source.matchAll(/[{}]/g)) {
|
||||
if (m[0] === '{') {
|
||||
const selector = source.slice(buf, m.index).replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
stack.push(selector);
|
||||
buf = m.index + 1;
|
||||
} else {
|
||||
const selector = stack.pop();
|
||||
if (!selector.startsWith('@')) out.push({ selector, atRules: stack.filter(s => s.startsWith('@')) });
|
||||
buf = m.index + 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const all = rules(css);
|
||||
const pureHover = all.filter(r => r.selector.includes(':hover') && r.selector.split(',').every(part => part.includes(':hover')));
|
||||
|
||||
test('every pure :hover rule is inside @media (hover: hover)', () => {
|
||||
assert.ok(pureHover.length >= 38, `expected the wrapped hover rules, found ${pureHover.length}`);
|
||||
const ungated = pureHover.filter(r => !r.atRules.some(a => /\(hover:\s*hover\)/.test(a)));
|
||||
assert.deepEqual(ungated.map(r => r.selector), [], 'a bare :hover rule sticks on iOS after a tap: wrap it in @media (hover: hover)');
|
||||
});
|
||||
|
||||
test('reveal-on-hover rules that mix in another state are left alone (they have touch fallbacks)', () => {
|
||||
const reveal = all.find(r => r.selector.includes('.cm-jl-img:hover .cm-jl-img-handle'));
|
||||
assert.ok(reveal, 'the image handle reveal rule still exists');
|
||||
assert.equal(reveal.atRules.length, 0);
|
||||
assert.match(css, /@media \(hover: none\) \{ \.cm-host \.cm-jl-img-handle/);
|
||||
});
|
||||
|
||||
test('the touch block turns off double-tap zoom, callouts and tap flashes on controls, but not on text', () => {
|
||||
const touch = all.filter(r => r.atRules.some(a => /hover:\s*none\)\s*and\s*\(pointer:\s*coarse\)/.test(a)));
|
||||
const decls = selectorPart => touch.filter(r => r.selector.includes(selectorPart));
|
||||
assert.ok(decls('.mobile-note-row').length > 0);
|
||||
const manip = fs.readFileSync(path.join(__dirname, '..', 'public', 'styles.css'), 'utf8');
|
||||
assert.match(manip, /touch-action: manipulation;/);
|
||||
assert.match(manip, /-webkit-touch-callout: none;/);
|
||||
const textRule = touch.find(r => r.selector.includes('.cm-content'));
|
||||
assert.ok(textRule, 'selection stays on for fields and the editor');
|
||||
assert.ok(!touch.some(r => /^\s*(html|body)\b/.test(r.selector) && /user-select/.test(r.selector)));
|
||||
});
|
||||
|
||||
test('reduced motion stops transitions and animations except the loading ring', () => {
|
||||
const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)'));
|
||||
assert.match(block, /transition-duration: 0\.01ms !important/);
|
||||
assert.match(block, /\*:not\(\.note-loading-ring\)\s*\{\s*animation: none !important/);
|
||||
});
|
||||
|
||||
test('zoom is allowed everywhere and interactive-widget is set, from one shared meta', () => {
|
||||
const templates = require('../app/templates');
|
||||
const { viewportMeta } = require('../app/templates/shared');
|
||||
assert.match(viewportMeta, /viewport-fit=cover/);
|
||||
assert.match(viewportMeta, /interactive-widget=resizes-content/);
|
||||
assert.doesNotMatch(viewportMeta, /user-scalable|maximum-scale/);
|
||||
const user = { id: 'u', email: 'a@b.c' };
|
||||
const pages = [
|
||||
templates.layoutPage({ user: null }),
|
||||
templates.layoutPage({ user, navContent: '', editorContent: '', settings: {} }),
|
||||
templates.diagPage({}),
|
||||
templates.settingsPage({ user, settings: {} }),
|
||||
];
|
||||
for (const html of pages) assert.ok(html.includes(`<meta name="viewport" content="${viewportMeta}" />`));
|
||||
// and the diagnostics page can still reproduce the old blocked-zoom meta for comparison
|
||||
assert.match(templates.diagPage({ query: new URLSearchParams('zoom=0') }), /user-scalable=no/);
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ const assert = require('node:assert/strict');
|
|||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { keyboardVisible, isTypingElement } = require('../public/viewport.js');
|
||||
const { keyboardVisible, isTypingElement, shouldPin } = require('../public/viewport.js');
|
||||
|
||||
const base = { coarse: true, typing: true, wasVisible: false, apiHeight: 0, vvScale: 1, remembered: 0 };
|
||||
|
||||
|
|
@ -77,3 +77,12 @@ test('the stylesheet only changes .mobile-app while data-keyboard is set', () =>
|
|||
assert.match(css, /height: var\(--app-height\)/);
|
||||
assert.match(css, /top: var\(--app-top, 0px\)/);
|
||||
});
|
||||
|
||||
test('the page scroll is pinned only in the mobile shell, and never while pinch-zoomed', () => {
|
||||
const scrolled = { shellShowing: true, scale: 1, scrollX: 0, scrollY: 268 };
|
||||
assert.equal(shouldPin(scrolled), true);
|
||||
assert.equal(shouldPin({ ...scrolled, scrollY: 0 }), false, 'nothing to put back');
|
||||
assert.equal(shouldPin({ ...scrolled, shellShowing: false }), false, 'desktop shell / other pages scroll normally');
|
||||
assert.equal(shouldPin({ ...scrolled, scale: 1.8 }), false, 'a zoomed page pans by scrolling: do not fight it');
|
||||
assert.equal(shouldPin({ ...scrolled, scrollY: 0, scrollX: 40 }), true);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue