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:
igor 2026-10-13 15:11:21 +13:00
parent 98c5461078
commit 4b9e1bba1c
12 changed files with 365 additions and 81 deletions

View file

@ -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`).

View file

@ -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>

View file

@ -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">&lsaquo; Back</a><b>Diagnostics &middot; ${shell}${iw ? ' + iw' : ''}${zoom ? ' + zoom' : ''}</b></div>
<div id="hdr"><a href="/settings?tab=about">&lsaquo; Back</a><b>Diagnostics &middot; ${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>

View file

@ -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" />

View file

@ -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" />

View file

@ -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,

View file

@ -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');

View file

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

View file

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

View file

@ -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
View 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/);
});

View file

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