Merge layout-improve: iOS home-screen layout, keyboard, landscape, PWA speed and offline, leaner agent docs
Some checks failed
Build and push Joplock image / build-and-push (push) Has been cancelled

iOS home-screen app
- stop using black-translucent (it left a dead band at the bottom and
  floated every bottom-anchored element); 100dvh fallbacks; iPhone Air
  and 16/17 Pro splash images
- keep the header and markdown toolbar on screen while the soft keyboard
  is up (public/viewport.js); keep the toolbar visible when editing
- list screens scroll clear of the FAB; the markdown word-count bar is
  desktop-only
- phones in landscape keep the mobile layout (decided by the physical
  screen, not the window), clear the notch, and no longer reload on
  rotation

PWA
- one computed asset version and per-theme theme-color; tidier manifest
- gzip/brotli, ETags and immutable versioned assets (cold start on a
  slow link 7.8s -> 2.6s); offline page and a service worker that
  caches only versioned scripts/styles
- zoom allowed, no sticky hover, touch and reduced-motion CSS

Tooling and docs
- /diag device diagnostics page, scripts/diag-reports.js, and
  rebuild-dev.sh archiving of device reports
- AGENT_GUIDE.md (33k tokens, read in full by every agent) split into a
  short AGENTS.md plus on-demand docs/agents/*.md, with a test that
  keeps those docs true and small

1,186 unit tests pass on Node 20 from a clean checkout; full mobile and
desktop Playwright suites pass.
This commit is contained in:
igor 2026-10-14 08:38:58 +13:00
commit 6bb71fa83f
69 changed files with 3753 additions and 1248 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ data/
docker-compose.dev.yml
plans/
playwright-report/
.diag-archive/
test-results/
# graft's local graph cache — regenerable, not committed (run `graft build`).

View file

@ -1,6 +1,55 @@
# Agents
Read [`AGENT_GUIDE.md`](./AGENT_GUIDE.md) first. It is the source of truth for repo conventions, architecture, editor model, mobile shell, settings, debugging workflow, and coding rules.
Joplock is a standalone web client for a stock, unmodified Joplin Server: Node (no framework), server-rendered HTML with htmx fragment swaps, and one large client file (`public/app.js`). It reads the shared Postgres directly and writes through Joplin Server's own API, so the same account keeps working from Joplin desktop, mobile and CLI.
This file is loaded on every task, so it is short on purpose. It gives the rules and says where the **reasons** live. It does not say where code lives: use the code index below (it cannot go stale). The topic files in `docs/agents/` hold the hard-won "why" and "do not regress" rules. **Read the one that matches what you are about to change, before you change it.**
## Before you touch ... read
| You are changing | Read first |
|---|---|
| the editor: CodeMirror/TinyMCE, mode switching, autosave, markdown round-trip, a phantom "Edited" | `docs/agents/editor.md` |
| any save path, vaults/encryption, note history/restore, anything reading the editor DOM across a note switch | `docs/agents/vaults-and-save-safety.md` |
| how notes/notebooks/resources are serialized or written (anything another Joplin client can see) | `docs/agents/joplin-fidelity.md` |
| the notebook tree, drag and drop, sort, pins, sharing, `user_items` | `docs/agents/notebooks-and-sharing.md` |
| upload, paste or drop of images/attachments | `docs/agents/editor-uploads.md` |
| `public/cm-extras.js` or the CodeMirror bundle | `docs/agents/editor-markdown-extras.md` |
| mobile layout, PWA, service worker, viewport/keyboard, static serving, anything iOS | `docs/agents/pwa-mobile.md` |
| text expander, AI autocomplete, `/ask` | `docs/agents/ai-expander-ask.md` |
| settings, themes, admin, backup/recovery, MFA, debug logging | `docs/agents/settings-and-admin.md` |
| server shape, htmx/fragment conventions, routes, data model, or you are unsure where something belongs | `docs/agents/architecture.md` |
| tests, Playwright, the dev stack, "my change does not show up" | `docs/agents/testing-and-dev.md` |
## Rules that always apply
1. Do not modify Joplin Server source for Joplock features unless explicitly approved.
2. The server is authoritative and the browser is ephemeral: no browser-local authoritative storage, and logout must clear client-visible state (shared machines).
3. Stay sync-compatible with normal Joplin clients. A Joplock write changes **only what the user edited** (`docs/agents/joplin-fidelity.md`).
4. Keep the sidecar API app-oriented; do not expose the raw sync/storage model to the frontend.
5. Keep changes minimal. Fix the cause, not the symptom, and add a test that fails without the fix. When you fix mobile, check desktop (and the reverse): they share the editor fragment.
## Workflow
- `npm test` before every commit (CI runs it on Node 20; this machine may run a newer Node).
- **Pushing to `master` publishes the Docker image**: GitHub Actions runs the tests, then builds and pushes `ghcr.io/abort-retry-ignore/joplock:latest`. Do not commit to `master` or push unless the user asks.
- Source edits need `./scripts/rebuild-dev.sh` (the image copies `app/`, `public/` and `server.js` at build time; a container restart is not enough). Browser tests (`npm run test:ui`) need that dev stack and `JOPLOCK_ADMIN_*` in the environment; see `docs/agents/testing-and-dev.md`.
- `plans/` is gitignored local history, **not documentation**; `plans/README.md` lists what is stale. Trust the code and these docs over any plan.
- Asset versions and the service worker cache name are computed (`app/assetVersion.js`): never hand-edit or "bump" them.
- After large changes run `graft build` so the code index matches.
## Traps that have each broken real data or a real phone
One line each; the topic file has the detail.
- **Inline `onclick="foo()"` only sees `window.foo`.** A function works in a unit test and is "not defined" in the browser unless `public/app.js` assigns it to `window` (`tests/inlineHandlersExposed.test.js`).
- **Never write one note's text into another.** Debounced saves, async fetches and TinyMCE are shared across note switches; every save path has an identity guard (`vaults-and-save-safety.md`).
- **The markdown <-> TinyMCE round trip must be idempotent**, or notes show a phantom "Edited" on open and get re-saved (`editor.md`).
- **Do not decode `items.content` for every item in SQL**: it also holds binary resources and one attachment aborts the statement (`notebooks-and-sharing.md`).
- **iOS home-screen apps**: never set `apple-mobile-web-app-status-bar-style: black-translucent`; never guess a keyboard from `innerHeight - visualViewport.height`; decide mobile vs desktop from the physical screen, never the window height (`pwa-mobile.md`). Chromium cannot reproduce any of this: measure on a device with `/diag`.
- **DOM ids and classes are a contract with inline JS and tests.** Rename them deliberately.
- **Floating menus need an opaque background and edge clamping**; pure `:hover` styles go inside `@media (hover: hover)` (`architecture.md`, `pwa-mobile.md`).
## Code index
<!-- graft:start -->
## Graft — repo context graph

File diff suppressed because it is too large Load diff

View file

@ -12,8 +12,8 @@ Joplock runs as a sidecar alongside an unmodified Joplin Server instance, sharin
- **Low Resource usage** -- minimal memory usage on the client, fast and responsive
- **Security-first design** -- no private data stored on the client; sessions are cleaned up on logout; per-user settings and admin controls for user management
- **Client-side encrypted vaults** -- turn any notebook into a vault; notes inside are AES-GCM encrypted in the browser with a PBKDF2-derived key. The server never sees vault passwords. Ciphertext is bound to a specific vault and note id, and the server rejects any write that would land a vault-encrypted body in the wrong note or the wrong vault
- **Dual-mode editor** -- CodeMirror 6 for markdown editing (the default) and TinyMCE 8 for rich rendered mode, switchable per note; on the mobile/tablet shell rendered mode opens read-only with a pencil toggle to edit
- **Rich markdown mode** -- inline image previews with drag-to-resize, attachment and note-link chips with hover info, clickable task checkboxes, code-block Copy buttons, Tab-aligned pipe tables, paste-as-markdown (web pages, Word, Docs) and URL-over-selection links, heading folding, a word-count status bar with a heading outline, and an optional live-preview mode that hides formatting marks off the line you are editing (each switchable under Settings → Markdown editor)
- **Dual-mode editor** -- CodeMirror 6 for markdown editing (the default) and TinyMCE 8 for rich rendered mode, switchable per note; on the mobile layout (phones and narrow windows) rendered mode opens read-only with a pencil toggle to edit
- **Rich markdown mode** -- inline image previews with drag-to-resize, attachment and note-link chips with hover info, clickable task checkboxes, code-block Copy buttons, Tab-aligned pipe tables, paste-as-markdown (web pages, Word, Docs) and URL-over-selection links, heading folding, a word-count status bar with a heading outline (desktop layout only), and an optional live-preview mode that hides formatting marks off the line you are editing (each switchable under Settings → Markdown editor)
- **Tables, code blocks, and lightbox** -- full-screen code editor modal with language picker, TinyMCE tables with markdown round-trip, a lightbox for images and attachments, and a per-image download button
- **Note export** -- export any note as Markdown, single-file HTML (images and links inlined), DOCX (server-side via pandoc with reference styles), or PDF (pandoc + weasyprint), with visible table gridlines in PDF/DOCX output
- **AI autocomplete** -- optional prose completion via user-configured provider profiles (OpenRouter and others), triggered by Ctrl/Cmd-Space or configurable Expander triggers; a `/ask <question>` slash command for direct Q&A; per-user text expanders for snippets
@ -24,7 +24,7 @@ Joplock runs as a sidecar alongside an unmodified Joplin Server instance, sharin
- **Multi-factor authentication** -- optional TOTP-based MFA on top of standard Joplin sessions
- **Fast search** -- searches titles and note bodies directly in Postgres; optional live-as-you-type search
- **Near-instant autosave** -- debounced saves with conflict detection, hash-based deduplication, and an undo ring buffer with full note history snapshots. Restoring a snapshot updates the open editor immediately without a page refresh
- **PWA support** -- installable as a home screen app on mobile and desktop with splash screens, offline indicator, and service worker shell
- **PWA support** -- installable as a home screen app on mobile and desktop, with splash screens, a mobile layout that follows the phone in both orientations (display-cutout safe), and compressed, cached static assets. Notes are never stored offline: with no connection you get an offline page, not a cached note
- **Server-side rendering** -- SSR with htmx for minimal client-side JavaScript
## Runtime Model
@ -142,8 +142,7 @@ The full example is meant as a working reference compose file. Adjust it for you
### Development
`AGENT_GUIDE.md` is the source of truth for repo conventions, architecture, the
editor model, and debugging workflow. `docs/joplock-db-schema.html` documents the
`AGENTS.md` holds the repo rules, and `docs/agents/` the reasons and "do not regress" contracts by topic (it is written for coding agents, short and on-demand). `docs/joplock-db-schema.html` documents the
four sidecar tables Joplock adds to the shared database.
For local work, keep a git-ignored `docker-compose.dev.yml` (Postgres + Joplin

44
app/assetVersion.js Normal file
View file

@ -0,0 +1,44 @@
'use strict';
// One version string for every shipped static asset, computed from the files themselves so that
// nobody has to remember to bump it. It is the ?v= on the script/stylesheet URLs (which the server
// then serves as immutable), the service worker's cache name, and what app.js registers the
// worker with. Any change to a shipped file changes it; a restart of an unchanged deployment
// does not.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const defaultPublicDir = path.join(__dirname, '..', 'public');
// Hashed: every file directly in public/ (app.js, styles.css, icons, manifest, the service worker
// template, ...) plus TinyMCE's entry point. The rest of tinymce/ is vendor code its own loader
// requests; fonts and splash images change with the files above or not at all.
const computeAssetVersion = (publicDir = defaultPublicDir) => {
const hash = crypto.createHash('sha1');
let names = [];
try {
names = fs.readdirSync(publicDir).sort();
} catch { /* a missing public dir hashes to the empty version */ }
const files = names.map(name => path.join(publicDir, name));
files.push(path.join(publicDir, 'tinymce', 'tinymce.min.js'));
for (const file of files) {
try {
if (!fs.statSync(file).isFile()) continue;
hash.update(path.relative(publicDir, file));
hash.update('\0');
hash.update(fs.readFileSync(file));
hash.update('\0');
} catch { /* unreadable file: skip */ }
}
return hash.digest('hex').slice(0, 10);
};
let cached = null;
const assetVersion = () => {
if (!cached) cached = computeAssetVersion();
return cached;
};
module.exports = { assetVersion, computeAssetVersion };

View file

@ -5,9 +5,10 @@ const path = require('path');
const { sessionIdFromHeaders } = require('./auth/cookies');
const { createRateLimitService } = require('./auth/rateLimitService');
const templates = require('./templates');
const { assetVersion } = require('./assetVersion');
const {
send, makeSendHtml, redirect, fileExists, serveFile,
send, makeSendHtml, redirect, fileExists, serveFile, serveServiceWorker, warmStaticCache,
ALL_NOTES_FOLDER_ID, TRASH_FOLDER_ID,
allNotesFolder, trashFolder, selectedFolderForNav, normalizeStoredFolderId,
} = require('./routes/_helpers');
@ -24,6 +25,7 @@ const routeResources = require('./routes/resources');
const routeFragments = require('./routes/fragments');
const routeMobile = require('./routes/mobile');
const routeShares = require('./routes/shares');
const routeDiag = require('./routes/diag');
const { createFolderOps } = require('./items/folderOps');
const { sortFoldersByRecent, applyPinnedFirst } = require('./items/folderTree');
const routeApi = require('./routes/api');
@ -335,6 +337,9 @@ Joplock can call an AI provider (OpenRouter, or any OpenAI-compatible API) to he
request.pipe(upstreamRequest);
};
// gzip/brotli the app's own assets once, off the event loop (see serveFile)
warmStaticCache(publicDir);
return http.createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
const reqStart = Date.now();
@ -421,6 +426,7 @@ Joplock can call an AI provider (OpenRouter, or any OpenAI-compatible API) to he
routeHistory,
routeResources,
routeMobile,
routeDiag,
routeFragments,
routeShares,
routeApi,
@ -557,7 +563,8 @@ Joplock can call an AI provider (OpenRouter, or any OpenAI-compatible API) to he
// Static files
const filePath = path.join(publicDir, relativePath.replace(/^\/+/, ''));
if (filePath.startsWith(publicDir) && fileExists(filePath) && !relativePath.endsWith('/')) {
serveFile(response, filePath);
if (relativePath === '/service-worker.js') serveServiceWorker(response, filePath, assetVersion());
else serveFile(response, filePath, request, { versioned: url.searchParams.has('v') });
return;
}

View file

@ -1,4 +1,5 @@
'use strict';
// Notebook rules and why: docs/agents/notebooks-and-sharing.md
// Notebook operations that must be consistent across every entry point (the
// htmx fragment routes, the JSON API and, on mobile, the same routes again).

View file

@ -1,4 +1,5 @@
'use strict';
// Tree/sort/pin semantics and why: docs/agents/notebooks-and-sharing.md
// Pure helpers for Joplin's nested notebooks (a notebook is nested when its
// `parent_id` points at another notebook).

View file

@ -1,3 +1,4 @@
// A write must change only what the user edited: docs/agents/joplin-fidelity.md (read before changing what is written)
const http = require('http');
const { randomBytes } = require('crypto');
const { serializeItem, MODEL_TYPE_NOTE, MODEL_TYPE_FOLDER, MODEL_TYPE_RESOURCE } = require('./joplinItem');

View file

@ -1,4 +1,5 @@
'use strict';
// A write must change only what the user edited: docs/agents/joplin-fidelity.md (read before changing serialization)
// Joplin item serialization, mirroring BaseItem.serialize / serialize_format from
// the Joplin client so what Joplock writes is indistinguishable from what a Joplin

View file

@ -1,4 +1,5 @@
'use strict';
// Sharing model and its known gaps: docs/agents/notebooks-and-sharing.md
const deriveShareFieldsForMove = (targetFolder) => {
const shareId = (targetFolder && targetFolder.shareId) ? targetFolder.shareId : '';

View file

@ -1,4 +1,5 @@
'use strict';
// Share write guards, and their limits: docs/agents/notebooks-and-sharing.md, docs/agents/joplin-fidelity.md
/**
* shareProxyGuard.js

View file

@ -1,4 +1,5 @@
'use strict';
// Vault boundary rules, and why this guard is inert for stock Joplin clients today: docs/agents/vaults-and-save-safety.md, docs/agents/joplin-fidelity.md
/**
* vaultProxyGuard.js

View file

@ -1,9 +1,11 @@
'use strict';
// Static serving (serveFile) and the service worker template: docs/agents/pwa-mobile.md; vault write guard: docs/agents/vaults-and-save-safety.md
const http = require('http');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const crypto = require('crypto');
const { isEncryptedBody } = require('../items/itemService');
// ─── Content types ────────────────────────────────────────────────────────────
@ -134,16 +136,157 @@ const fileExists = filePath => {
}
};
const serveFile = (response, filePath) => {
// Compressible text assets are served gzip/brotli (negotiated), with an ETag so a stale copy
// revalidates with a 304 instead of re-downloading, and Vary: Accept-Encoding. Measured: the
// shipped JS+CSS is 2.5 MB raw, 0.77 MB gzipped, and the reverse proxy in front does not compress
// JS/CSS. A request carrying ?v=<asset version> (app/assetVersion.js changes it with any shipped
// file) is immutable for a year; everything else revalidates after five minutes.
const COMPRESSIBLE = new Set(['.css', '.js', '.json', '.svg', '.html', '.webmanifest']);
const MIN_COMPRESS_BYTES = 1024;
const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
const staticCache = new Map(); // filePath -> { mtimeMs, size, etag, body, gzip, br }
const cacheEntry = (filePath, stat) => {
const cached = staticCache.get(filePath);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
const body = fs.readFileSync(filePath);
const entry = {
mtimeMs: stat.mtimeMs,
size: stat.size,
etag: `"${crypto.createHash('sha1').update(body).digest('hex').slice(0, 20)}"`,
body,
gzip: null,
br: null,
};
staticCache.set(filePath, entry);
return entry;
};
const encoded = (entry, encoding) => {
if (!entry[encoding]) {
entry[encoding] = new Promise((resolve, reject) => {
const done = (error, result) => (error ? reject(error) : resolve(result));
if (encoding === 'br') {
zlib.brotliCompress(entry.body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: entry.body.length } }, done);
} else {
zlib.gzip(entry.body, { level: 9 }, done);
}
});
}
return entry[encoding];
};
const chooseEncoding = acceptEncoding => {
const accepted = `${acceptEncoding || ''}`.toLowerCase();
// "name" or "name;q=<non-zero>" as one list item; "name;q=0" means refused
const allows = name => new RegExp(`(^|,)\\s*${name}\\s*(;\\s*q=(?!0(\\.0*)?\\s*(,|$))[0-9.]+)?\\s*(,|$)`).test(accepted);
if (allows('br')) return 'br';
if (allows('gzip')) return 'gzip';
return null;
};
const matchesEtag = (ifNoneMatch, etag) => {
if (!ifNoneMatch) return false;
if (ifNoneMatch.trim() === '*') return true;
const bare = etag.replace(/^W\//, '');
return ifNoneMatch.split(',').some(candidate => candidate.trim().replace(/^W\//, '') === bare);
};
const cacheControlFor = (filePath, versioned) => {
const extension = path.extname(filePath).toLowerCase();
const name = path.basename(filePath);
if (extension === '.html') return 'no-store';
if (extension === '.woff2') return 'public, max-age=31536000, immutable';
if (name === 'service-worker.js' || extension === '.webmanifest') return 'no-cache';
if (versioned) return 'public, max-age=31536000, immutable';
return 'public, max-age=300';
};
// request is optional (without it the file is sent as is); versioned says the URL carried ?v=
const serveFile = (response, filePath, request = null, { versioned = false } = {}) => {
const extension = path.extname(filePath).toLowerCase();
const contentType = contentTypes[extension] || 'application/octet-stream';
const stat = fs.statSync(filePath);
response.writeHead(200, {
'Cache-Control': extension === '.html' ? 'no-store' : (extension === '.woff2' ? 'public, max-age=31536000, immutable' : 'public, max-age=300'),
'Content-Length': stat.size,
const headers = {
'Cache-Control': cacheControlFor(filePath, versioned),
'Content-Type': contentType,
};
const buffered = COMPRESSIBLE.has(extension) && stat.size <= MAX_BUFFERED_BYTES;
if (!buffered) {
// images, fonts, big files: stream as is, with a cheap validator
headers['ETag'] = `W/"${stat.size.toString(16)}-${Math.round(stat.mtimeMs).toString(16)}"`;
if (request && matchesEtag(request.headers['if-none-match'], headers['ETag'])) {
response.writeHead(304, headers);
response.end();
return;
}
headers['Content-Length'] = stat.size;
response.writeHead(200, headers);
fs.createReadStream(filePath).pipe(response);
return;
}
const entry = cacheEntry(filePath, stat);
headers['Vary'] = 'Accept-Encoding';
const encoding = request && stat.size >= MIN_COMPRESS_BYTES ? chooseEncoding(request.headers['accept-encoding']) : null;
headers['ETag'] = encoding ? entry.etag.replace(/"$/, `-${encoding}"`) : entry.etag;
if (request && matchesEtag(request.headers['if-none-match'], headers['ETag'])) {
response.writeHead(304, headers);
response.end();
return;
}
const sendBody = (body, encodingName) => {
if (encodingName) headers['Content-Encoding'] = encodingName;
headers['Content-Length'] = body.length;
response.writeHead(200, headers);
response.end(body);
};
if (!encoding) {
sendBody(entry.body, null);
return;
}
encoded(entry, encoding).then(body => sendBody(body, encoding), () => {
entry[encoding] = null;
delete headers['ETag'];
headers['ETag'] = entry.etag;
sendBody(entry.body, null);
});
fs.createReadStream(filePath).pipe(response);
};
// Compress the app's own root-level assets once at startup (off the event loop), so the first
// visitor does not pay brotli level 11 on a 800 KB file.
const warmStaticCache = publicDir => {
let names = [];
try {
names = fs.readdirSync(publicDir);
} catch {
return;
}
for (const name of names) {
const filePath = path.join(publicDir, name);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || !COMPRESSIBLE.has(path.extname(name).toLowerCase()) || stat.size < MIN_COMPRESS_BYTES || stat.size > MAX_BUFFERED_BYTES) continue;
const entry = cacheEntry(filePath, stat);
encoded(entry, 'br').catch(() => { entry.br = null; });
encoded(entry, 'gzip').catch(() => { entry.gzip = null; });
} catch { /* skip */ }
}
};
// The service worker is a template: its cache name carries the asset version (app/assetVersion.js)
// so a changed deployment is a byte-different worker the browser installs, with no hand-bumped
// string. It is never cached by the browser or any proxy.
const serveServiceWorker = (response, filePath, version) => {
const source = fs.readFileSync(filePath, 'utf8').replaceAll('__ASSET_VERSION__', version);
response.writeHead(200, {
'Cache-Control': 'no-cache',
'Content-Type': contentTypes['.js'],
'Content-Length': Buffer.byteLength(source),
'Service-Worker-Allowed': '/',
});
response.end(source);
};
// ─── Domain helpers ───────────────────────────────────────────────────────────
@ -294,6 +437,8 @@ const assertShareWriteAccess = async (itemService, userId, itemId) => {
};
module.exports = {
chooseEncoding,
matchesEtag,
publicItem,
publicItems,
// response
@ -309,7 +454,9 @@ module.exports = {
parseBody,
// files
fileExists,
serveServiceWorker,
serveFile,
warmStaticCache,
// domain
TRASH_FOLDER_ID,
ALL_NOTES_FOLDER_ID,

18
app/routes/diag.js Normal file
View file

@ -0,0 +1,18 @@
'use strict';
const { redirect } = require('./_helpers');
// GET /diag: device diagnostics page (see app/templates/diag.js). Debug mode only, and only for
// a signed-in user (its reports go through the authenticated /api/web/client-log endpoint).
// With debug off the route does not exist (falls through to the normal 404).
const handle = async (url, request, response, ctx) => {
if (url.pathname !== '/diag' || request.method !== 'GET') return false;
const { sendHtml, authenticatedUser, templates, isDebug } = ctx;
if (!(typeof isDebug === 'function' ? isDebug() : ctx.debug)) return false;
const auth = await authenticatedUser(request);
if (auth.error || !auth.user) { redirect(response, '/login'); return true; }
sendHtml(response, 200, templates.diagPage({ query: url.searchParams }));
return true;
};
module.exports = { handle };

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,19 +83,19 @@ 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>
:root { color-scheme: dark; }
body { margin: 0; font-family: system-ui, sans-serif; background: #000; color: #fff; }
.resource-viewer-page { min-height: 100vh; display: flex; flex-direction: column; }
.resource-viewer-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }
.resource-viewer-page-bar { display: flex; align-items: center; gap: 10px; padding: calc(env(safe-area-inset-top, 0px) + 10px) 12px 10px; background: #111827; border-bottom: 1px solid rgba(255,255,255,0.12); }
.resource-viewer-page-btn { appearance: none; border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); color: #fff; border-radius: 10px; padding: 8px 14px; font: inherit; }
.resource-viewer-page-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; }
.resource-viewer-page-body { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; background: #000; }
.resource-viewer-page-img { max-width: 100%; max-height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); object-fit: contain; }
.resource-viewer-page-frame { width: 100%; height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); border: 0; background: #fff; }
.resource-viewer-page-img { max-width: 100%; max-height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); max-height: calc(100dvh - 64px - env(safe-area-inset-top, 0px)); object-fit: contain; }
.resource-viewer-page-frame { width: 100%; height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); height: calc(100dvh - 64px - env(safe-area-inset-top, 0px)); border: 0; background: #fff; }
.resource-viewer-page-text { max-width: 100%; width: 100%; box-sizing: border-box; padding: 16px; margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, monospace; font-size: 14px; color: #e5e7eb; background: #111; overflow: auto; max-height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); }
.resource-viewer-page-unsupported { display: flex; flex-direction: column; align-items: center; gap: 16px; color: #9ca3af; padding: 32px; text-align: center; }
</style>

View file

@ -1,4 +1,5 @@
'use strict';
// Sharing model, and why share SQL must never decode items.content for every item: docs/agents/notebooks-and-sharing.md
const { randomBytes } = require('crypto');
const { sendJson, parseBody } = require('./_helpers');

View file

@ -1,3 +1,4 @@
// A setting persists only if normalized here AND allow-listed in app/routes/api.js: docs/agents/settings-and-admin.md
const validThemes = ['aritim-dark','dark','matrix-amber','matrix-blue','dark-fruit','dark-grey','matrix-orange','matrix-purple','dark-red','dracula','earth','fireball','fruit','grey','light','matrix','nord','oled-dark','solarized-dark','solarized-light','swamp-thing'];
// Provider templates — preset URL and default model per service.

200
app/templates/diag.js Normal file
View file

@ -0,0 +1,200 @@
// Device diagnostics page (GET /diag, debug mode only). A mock of the mobile shell that measures
// viewport / safe-area / soft-keyboard behaviour on a REAL phone (installed PWA or browser) and
// posts flat snapshots to /api/web/client-log, where they appear in `docker logs` as
// "[joplock client] diag.viewport {...}". It exists so layout work can be compared on real
// hardware before the real editor is changed: `?shell=current` is today's fixed-inset shell,
// `?shell=vv` loads /viewport.js, the module the real app uses (shell height follows
// 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') !== '0', zoom: get('zoom') !== '0' };
};
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(', '));
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 !== '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>`;
};
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="${viewportContent({ iw, zoom })}" />
<meta name="theme-color" content="#2a3446" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Joplock device diagnostics</title>
${shell === 'vv' ? '<script src="/viewport.js"></script>' : ''}
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;overscroll-behavior:none;background:#7a1010;color:#e8ecf2;font:13px/1.35 -apple-system,system-ui,sans-serif}
body{position:fixed;inset:0;width:100%}
#shell{position:fixed;left:0;right:0;top:0;display:flex;flex-direction:column;background:#1b2230;overflow:hidden}
#shell::after{content:'';position:absolute;left:0;right:0;bottom:0;height:5px;background:#ff2bd6;z-index:5;pointer-events:none}
#shell::before{content:'';position:absolute;left:0;right:0;top:0;height:3px;background:#19e6ff;z-index:5;pointer-events:none}
#shell.shell-current{bottom:0}
#shell.shell-vv{bottom:0}
html[data-keyboard] #shell.shell-vv{top:var(--app-top,0px);bottom:auto;height:var(--app-height)}
#hdr{flex:none;height:calc(44px + env(safe-area-inset-top,0px));padding:env(safe-area-inset-top,0px) 8px 0;display:flex;align-items:center;gap:8px;background:#2a3446;border-bottom:1px solid #3a475e}
#hdr b{flex:1;font-size:15px}
#hdr a,#bar a{color:#8fd3e6;text-decoration:none;padding:8px 6px}
#panel{flex:none;max-height:36%;overflow:auto;padding:6px 10px;background:#161c28;border-bottom:1px solid #3a475e;-webkit-overflow-scrolling:touch}
#out{font:10.5px/1.3 ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-all}
#variants{display:flex;flex-wrap:wrap;gap:4px 10px;padding:4px 0}
#variants a{color:#8fd3e6;padding:4px 0}
#variants a.on{color:#fff;font-weight:700;text-decoration:underline}
details{margin:4px 0;color:#b9c3d3}
summary{padding:4px 0}
details p{padding:2px 0}
#edit{flex:1;min-height:0;display:flex;flex-direction:column;gap:6px;padding:8px}
#edit iframe{flex:none;height:64px;width:100%;border:1px solid #3a475e;border-radius:6px;background:#10151f}
#edit input,#edit textarea{width:100%;font-size:16px;color:#e8ecf2;background:#10151f;border:1px solid #3a475e;border-radius:6px;padding:8px;font-family:inherit}
#edit input{flex:none}
#edit textarea{flex:1;min-height:60px;resize:none}
#bar{flex:none;display:flex;align-items:center;gap:6px;padding:6px 8px calc(6px + env(safe-area-inset-bottom,0px));background:#2a3446;border-top:1px solid #3a475e}
#bar select,#bar button{font-size:14px;padding:6px 8px;color:#e8ecf2;background:#10151f;border:1px solid #3a475e;border-radius:6px}
#bar select{flex:1;min-width:0}
#status{flex:none;font-size:11px;padding:0 10px 4px;color:#9fb0c8;background:#2a3446}
#probes{position:fixed;left:0;top:0;width:0;visibility:hidden;pointer-events:none}
#probes i{display:block;width:0}
</style>
</head>
<body>
<div id="probes" aria-hidden="true">
<i id="p-vh" style="height:100vh"></i><i id="p-dvh" style="height:100dvh"></i><i id="p-svh" style="height:100svh"></i><i id="p-lvh" style="height:100lvh"></i>
<i id="p-st" style="height:env(safe-area-inset-top,0px)"></i><i id="p-sb" style="height:env(safe-area-inset-bottom,0px)"></i>
<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 ? '' : ' - no iw'}${zoom ? '' : ' - no zoom'}</b></div>
<div id="panel">
<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>
<p>2. Tap the top box (iframe, like the rich editor), wait 3s. Dismiss the keyboard (Done / tap outside), wait 3s. Repeat for the single-line field and the big box. While the keyboard is up, look: does the header stay at the top? Is the bottom bar right above the keyboard?</p>
<p>3. Rotate to landscape, wait 3s; tap a field, wait 3s; rotate back.</p>
<p>4. Background the app 10s and return (<b>visible</b>).</p>
<p>5. Colours help in screenshots: <b>magenta</b> line = bottom edge of the app area, <b>cyan</b> line = top edge, <b>dark red</b> = page background showing through where the app does NOT reach. Please screenshot (a) idle and (b) keyboard up, and paste them in chat.</p>
<p>6. Optional: type a note in the label menu and tap Send, e.g. when something looks wrong.</p>
</details>
<pre id="out">measuring&hellip;</pre>
</div>
<div id="edit">
<iframe id="f-iframe" title="editor-like iframe" srcdoc="&lt;!doctype html&gt;&lt;html&gt;&lt;body contenteditable style='margin:8px;font:16px -apple-system,system-ui,sans-serif;color:#e8ecf2;background:#10151f;min-height:40px'&gt;Tap here (iframe, like TinyMCE)&lt;/body&gt;&lt;/html&gt;"></iframe>
<input id="f-input" type="text" placeholder="single-line field (like the title)" autocomplete="off" />
<textarea id="f-area" placeholder="big box (like the markdown editor)"></textarea>
</div>
<div id="status">&nbsp;</div>
<div id="bar">
<select id="label" aria-label="report label"><option>idle</option><option>kb-open-iframe</option><option>kb-open-input</option><option>kb-open-textarea</option><option>kb-closed</option><option>landscape</option><option>landscape-kb-open</option><option>after-bg-return</option><option>other</option></select>
<button id="send" type="button">Send</button><button id="copy" type="button">Copy</button>
</div>
</div>
<script>
(function(){
var CONFIG=${config};
var $=function(id){return document.getElementById(id)};
var vv=window.visualViewport,root=document.documentElement;
var coarse=window.matchMedia('(pointer: coarse)');
var remembered={},rid=Math.random().toString(36).slice(2,8),seq=0,autoSent=0,last=null,events=[];
function typing(el){return !!el&&(el.tagName==='TEXTAREA'||el.tagName==='IFRAME'||(el.tagName==='INPUT'&&!/^(button|checkbox|radio|range|submit|reset|file|color)$/.test(el.type))||el.isContentEditable===true)}
function h(id){return $(id).getBoundingClientRect().height}
function kbVisible(){
var w=window.innerWidth,height=vv?vv.height:window.innerHeight,large=h('p-lvh');
var ref=Math.max(large,remembered[w]||0,window.innerHeight);
var api=navigator.virtualKeyboard&&navigator.virtualKeyboard.boundingRect&&navigator.virtualKeyboard.boundingRect.height>0;
var visible=coarse.matches&&typing(document.activeElement)&&(api||(!!vv&&vv.scale===1&&ref-height>ref*0.2));
if(!visible)remembered[w]=Math.max(ref,height);
return visible;
}
function snap(){
var o={},put=function(k,v){o[k]=v};
var visible=kbVisible();
var sh=$('shell').getBoundingClientRect(),hd=$('hdr').getBoundingClientRect(),bar=$('bar').getBoundingClientRect(),ed=$('edit').getBoundingClientRect();
var vTop=vv?vv.offsetTop:0,vH=vv?vv.height:window.innerHeight,vBottom=vTop+vH,ae=document.activeElement;
put('shell',CONFIG.shell);put('iw',CONFIG.iw);put('zoom',CONFIG.zoom);
put('standalone',window.matchMedia('(display-mode: standalone)').matches||navigator.standalone===true);
put('navStandalone',navigator.standalone===true);
put('dpr',window.devicePixelRatio);put('screenW',screen.width);put('screenH',screen.height);
put('innerW',window.innerWidth);put('innerH',window.innerHeight);put('outerH',window.outerHeight);put('clientH',root.clientHeight);
put('vvW',vv?Math.round(vv.width):-1);put('vvH',Math.round(vH));put('vvTop',Math.round(vTop));put('vvLeft',vv?Math.round(vv.offsetLeft):-1);put('vvScale',vv?Math.round(vv.scale*100)/100:-1);
put('p100vh',Math.round(h('p-vh')));put('p100dvh',Math.round(h('p-dvh')));put('p100svh',Math.round(h('p-svh')));put('p100lvh',Math.round(h('p-lvh')));
put('safeT',Math.round(h('p-st')));put('safeB',Math.round(h('p-sb')));put('safeL',Math.round(h('p-sl')));put('safeR',Math.round(h('p-sr')));
put('scrollY',Math.round(window.scrollY));put('docScrollY',Math.round(root.scrollTop));
put('shellTop',Math.round(sh.top));put('shellH',Math.round(sh.height));put('shellBottom',Math.round(sh.bottom));
// header's position on the visible screen (0 = pinned at the top; negative = panned off)
put('hdrScreenY',Math.round(hd.top-vTop));
// gap between the bottom bar and the bottom of the visible area (>=0 = bar visible; negative = under the keyboard)
put('barGap',Math.round(vBottom-bar.bottom));put('editH',Math.round(ed.height));
put('hdrTop',Math.round(hd.top));put('barBottom',Math.round(bar.bottom));put('vvBottom',Math.round(vBottom));
put('hdrVisible',hd.bottom>vTop&&hd.top<vBottom);put('barVisible',bar.top>=vTop-1&&bar.bottom<=vBottom+1);
// portrait, no keyboard: how far short of the window's bottom the shell ends (the iOS dead band)
put('bandBottom',Math.round(window.outerHeight-sh.bottom));
put('kbAttr',root.hasAttribute('data-keyboard'));
put('appTopVar',(getComputedStyle(root).getPropertyValue('--app-top')||'').trim()||'none');
put('vpPins',window.JoplockViewport&&window.JoplockViewport.state?window.JoplockViewport.state().pins:-1);
put('vpMax',window.JoplockViewport&&window.JoplockViewport.state?window.JoplockViewport.state().maxScroll:-1);
put('appHeightVar',(getComputedStyle(root).getPropertyValue('--app-height')||'').trim()||'none');
put('focus',ae?(ae.id||ae.tagName):'none');put('kbGuess',visible);
put('kbApi',!!navigator.virtualKeyboard);put('kbApiH',navigator.virtualKeyboard&&navigator.virtualKeyboard.boundingRect?Math.round(navigator.virtualKeyboard.boundingRect.height):-1);
put('orient',(screen.orientation&&screen.orientation.type)||'');
put('hoverHover',window.matchMedia('(hover: hover)').matches);put('coarse',coarse.matches);
return o;
}
function render(){
last=snap();
var lines=Object.keys(last).map(function(k){return k+'='+last[k]});
$('out').textContent=lines.join(' ')+'\\n\\nevents:\\n'+events.slice(-6).join('\\n');
}
var raf=0;function schedule(){if(!raf)raf=requestAnimationFrame(function(){raf=0;render()})}
function note(ev){events.push(new Date().toISOString().slice(14,23)+' '+ev+' vvH='+(vv?Math.round(vv.height):'-')+' vvTop='+(vv?Math.round(vv.offsetTop):'-')+' scrollY='+Math.round(window.scrollY));if(events.length>40)events.shift();schedule()}
function send(label,auto){
var snapshot=snap();snapshot.label=label;snapshot.rid=rid;snapshot.n=++seq;snapshot.auto=!!auto;last=snapshot;
var safe={};Object.keys(snapshot).forEach(function(k){var v=snapshot[k];if(typeof v==='string')safe[k]=v.slice(0,120);else if(typeof v==='number'||typeof v==='boolean'||v===null)safe[k]=v});
$('status').textContent='sending #'+snapshot.n+' ('+label+')…';
fetch('/api/web/client-log',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},credentials:'same-origin',body:'event='+encodeURIComponent('diag.viewport')+'&data='+encodeURIComponent(JSON.stringify(safe))})
.then(function(r){$('status').textContent=(r.ok||r.status===204)?'sent #'+snapshot.n+' ('+label+')':'FAILED #'+snapshot.n+' HTTP '+r.status+' (session expired? log in again)'})
.catch(function(e){$('status').textContent='FAILED #'+snapshot.n+' '+e.message});
}
function auto(ev){note(ev);if(autoSent>=80)return;autoSent+=2;setTimeout(function(){send('auto:'+ev+'@0.7s',true)},700);setTimeout(function(){send('auto:'+ev+'@1.8s',true)},1800)}
['send','copy'].forEach(function(id){['pointerdown','mousedown'].forEach(function(t){$(id).addEventListener(t,function(e){e.preventDefault()})})});
$('send').addEventListener('click',function(){send($('label').value,false)});
$('copy').addEventListener('click',function(){var s=JSON.stringify(last||snap());try{navigator.clipboard.writeText(s).then(function(){$('status').textContent='copied'},function(){$('status').textContent='copy blocked'})}catch(e){$('status').textContent='copy blocked'}});
if(vv){vv.addEventListener('resize',function(){note('vv.resize')});vv.addEventListener('scroll',function(){note('vv.scroll')})}
window.addEventListener('resize',function(){note('win.resize')});
window.addEventListener('orientationchange',function(){auto('orientation')});
document.addEventListener('focusin',function(){auto('focusin')});
document.addEventListener('focusout',function(){setTimeout(function(){auto('focusout')},0)});
document.addEventListener('visibilitychange',function(){if(!document.hidden)auto('visible')});
window.addEventListener('scroll',function(){note('win.scroll')});
render();setTimeout(function(){auto('load')},600);
})();
</script>
</body>
</html>`;
};
module.exports = { diagPage, diagOptions, viewportContent };

View file

@ -7,6 +7,7 @@ const settings = require('./settings');
const pages = require('./pages');
const mobile = require('./mobile');
const shares = require('./shares');
const diag = require('./diag');
module.exports = {
// shared
@ -16,6 +17,9 @@ module.exports = {
renderMarkdown: shared.renderMarkdown,
passwordField: shared.passwordField,
// diagnostics (debug only)
diagPage: diag.diagPage,
// shares
shareDialog: shares.shareDialog,
shareInviteesList: shares.shareInviteesList,

View file

@ -4,13 +4,18 @@
const {
escapeHtml,
appleSplashLinks,
viewportMeta,
themeOptions,
passwordField,
stripMarkdownForTitle,
} = require('./shared');
const { noteMetaFragment } = require('./fragments');
const ASSET_VERSION = '20261010opaquemenu';
const { assetVersion } = require('../assetVersion');
const { themeColor } = require('../themeColors');
// computed from the shipped files (app/assetVersion.js): never bump by hand
const ASSET_VERSION = assetVersion();
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';
@ -47,11 +52,10 @@ 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="theme-color" content="#0b0b0b" />
<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" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Joplock" />
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
@ -92,29 +96,29 @@ 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="theme-color" content="#08110b" />
<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" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Joplock" />
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
${appleSplashLinks}
<link rel="stylesheet" href="/styles.css?v=${ASSET_VERSION}" />
<script src="/htmx.min.js"></script>
<script src="/turndown.min.js"></script>
<script src="/htmx.min.js?v=${ASSET_VERSION}"></script>
<script src="/turndown.min.js?v=${ASSET_VERSION}"></script>
<script src="/codemirror.min.js?v=${ASSET_VERSION}"></script>
<script src="/cm-extras.js?v=${ASSET_VERSION}"></script>
<script src="/prism.min.js"></script>
<script src="/tinymce/tinymce.min.js"></script>
<script src="/hljs.min.js"></script>
<script src="/html-docx.js"></script>
<script src="/prism.min.js?v=${ASSET_VERSION}"></script>
<script src="/tinymce/tinymce.min.js?v=${ASSET_VERSION}"></script>
<script src="/hljs.min.js?v=${ASSET_VERSION}"></script>
<script src="/html-docx.js?v=${ASSET_VERSION}"></script>
<script>window.joplockStripNoteTitle=${stripMarkdownForTitle.toString()};</script>
<script>
window._joplockConfig={
debug:${debug ? 'true' : 'false'},
assetVersion:${JSON.stringify(ASSET_VERSION)},
noteOpenMode:${JSON.stringify(settings.noteOpenMode || 'markdown')},
folderSort:${JSON.stringify(settings.folderSort === 'recent' ? 'recent' : 'alpha')},
mobileStartup:${JSON.stringify(mobileStartup || null)},
@ -136,6 +140,7 @@ const layoutPage = (options = {}) => {
openRouterEnabled:${(Array.isArray(settings.aiProfiles) ? settings.aiProfiles.some(p => p.apiKey) : !!settings.openRouterApiKey) ? 'true' : 'false'}
};
</script>
<script src="/viewport.js?v=${ASSET_VERSION}"></script>
<script src="/app.js?v=${ASSET_VERSION}" defer></script>
<title>Joplock</title>
</head>
@ -371,11 +376,10 @@ 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="theme-color" content="#08110b" />
<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" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Joplock" />
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
@ -446,8 +450,8 @@ 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="theme-color" content="#0b0b0b" />
<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" />
<link rel="stylesheet" href="/styles.css?v=${ASSET_VERSION}" />
@ -583,8 +587,8 @@ 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="theme-color" content="#0b0b0b" />
<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" />
<link rel="stylesheet" href="/styles.css?v=${ASSET_VERSION}" />

View file

@ -5,6 +5,7 @@ const {
escapeHtml,
escapeJsString,
appleSplashLinks,
viewportMeta,
themeOptions,
validDateFormats,
validDatetimeFormats,
@ -12,7 +13,11 @@ const {
} = require('./shared');
const { AI_PROVIDERS } = require('../settingsService');
const ASSET_VERSION = '20260519pwa22';
const { assetVersion } = require('../assetVersion');
const { themeColor } = require('../themeColors');
// computed from the shipped files (app/assetVersion.js): never bump by hand
const ASSET_VERSION = assetVersion();
const formatBytes = value => {
const bytes = Number(value || 0);
@ -141,8 +146,8 @@ 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="theme-color" content="#08110b" />
<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" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
@ -271,7 +276,7 @@ const settingsPage = (options = {}) => {
<label><input type="checkbox" id="settings-highlight-active-line" onchange="saveSetting('highlightActiveLine',this.checked?'1':'0')"${settings.highlightActiveLine !== false ? ' checked' : ''} /> Highlight current line in editor</label>
<label><input type="checkbox" id="settings-md-inline-widgets" onchange="saveSetting('mdInlineWidgets',this.checked?'1':'0')"${settings.mdInlineWidgets !== false ? ' checked' : ''} /> Show images, attachments, checkboxes and code Copy buttons inline</label>
<label><input type="checkbox" id="settings-md-live-preview" onchange="saveSetting('mdLivePreview',this.checked?'1':'0')"${settings.mdLivePreview === true ? ' checked' : ''} /> Hide formatting marks (#, **, links) on lines you are not editing</label>
<label><input type="checkbox" id="settings-md-status-bar" onchange="saveSetting('mdStatusBar',this.checked?'1':'0')"${settings.mdStatusBar !== false ? ' checked' : ''} /> Show word count and heading outline bar</label>
<label><input type="checkbox" id="settings-md-status-bar" onchange="saveSetting('mdStatusBar',this.checked?'1':'0')"${settings.mdStatusBar !== false ? ' checked' : ''} /> Show word count and heading outline bar (desktop; not shown on phones)</label>
<label><input type="checkbox" id="settings-md-folding" onchange="saveSetting('mdFolding',this.checked?'1':'0')"${settings.mdFolding !== false ? ' checked' : ''} /> Show fold arrows for headings, lists and code blocks</label>
</div>
</div>
@ -466,6 +471,7 @@ const settingsPage = (options = {}) => {
<div class="about-info">
${joplockVersion ? `<div class="about-row"><span class="about-label">Joplock version</span><span class="about-value">${escapeHtml(joplockVersion)}</span></div>` : ''}
${joplinServerVersion ? `<div class="about-row"><span class="about-label">Joplin Server</span><span class="about-value">${escapeHtml(joplinServerVersion)}</span></div>` : ''}
${debug ? '<div class="about-row"><span class="about-label">Diagnostics</span><span class="about-value"><a href="/diag" class="about-link">Device &amp; viewport diagnostics</a></span></div>' : ''}
<div class="about-row"><span class="about-label">Author</span><span class="about-value">Igor Divjak</span></div>
<div class="about-row"><span class="about-label">Repository</span><span class="about-value"><a href="https://github.com/abort-retry-ignore/joplock" target="_blank" rel="noopener" class="about-link">github.com/abort-retry-ignore/joplock</a></span></div>
</div>

View file

@ -26,7 +26,17 @@ 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)'],
['1206x2622.png', 'screen and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
['2622x1206.png', 'screen and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
['1320x2868.png', 'screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
['2868x1320.png', 'screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
['1290x2796.png', 'screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
@ -144,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,

37
app/themeColors.js Normal file
View file

@ -0,0 +1,37 @@
'use strict';
// <meta name="theme-color"> per theme, read from the same --theme-color custom properties the
// stylesheet declares (app.js syncThemeColor() applies them at runtime). The server uses this so
// the first paint of the status bar / title bar already matches the user's theme instead of a
// fixed green until JavaScript runs.
const fs = require('fs');
const path = require('path');
const DEFAULT_THEME = 'earth';
const defaultStylesPath = path.join(__dirname, '..', 'public', 'styles.css');
const parseThemeColors = css => {
const colors = {};
for (const match of `${css}`.matchAll(/^\.theme-([a-z0-9-]+)\s*\{([^}]*)\}/gm)) {
const color = /--theme-color:\s*(#[0-9a-fA-F]{3,8})\s*;/.exec(match[2]);
if (color) colors[match[1]] = color[1].toLowerCase();
}
return colors;
};
let cached = null;
const loadThemeColors = (stylesPath = defaultStylesPath) => {
try {
return parseThemeColors(fs.readFileSync(stylesPath, 'utf8'));
} catch {
return {};
}
};
const themeColor = theme => {
if (!cached) cached = loadThemeColors();
return cached[theme] || cached[DEFAULT_THEME] || '#1e1a12';
};
module.exports = { themeColor, parseThemeColors, loadThemeColors, DEFAULT_THEME };

View file

@ -0,0 +1,49 @@
# Text expander, AI autocomplete and /ask
> Read this before: changing text expansion, AI provider profiles, prose autocomplete or the `/ask` slash command.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Expander / AI Autocomplete
- Expander entries live in per-user `textExpanders` settings and are configured in Settings -> Expander.
- Expander triggers are always on; there is no global autocomplete enable/disable toggle.
- Trigger strings are normalized in `app/settingsService.js`, must be non-empty, deduplicated, and are capped at 15 characters.
- Expander entry shape is `{ id, trigger, action, profileId, text }`.
- `action: 'text'` replaces the trigger with `text`; empty text entries are discarded during normalization.
- `action: 'ai'` removes the trigger and launches prose autocomplete. `profileId` selects an AI profile, or falls back to the active profile when blank.
- AI autocomplete triggers are not configured in the AI tab. The AI tab owns provider profiles and sentence count; the Expander tab owns trigger strings.
- Legacy manual suffix triggers (`double-q`, `triple-space`, `ellipsis`) and robot toggle UI were removed. Do not reintroduce `proseAutocompleteManualTrigger`, `proseAutocompleteManualTriggerOptions`, or `autocompleteEnabled`.
- `Ctrl-Space` / `Mod-Space` remains a keyboard shortcut path for manual prose completion, separate from Expander suffix triggers.
- Note-link autocomplete (`[[...`) remains separate from AI prose autocomplete.
## AI Provider Profiles
- AI provider profiles live in `aiProfiles` and are normalized in `app/settingsService.js`.
- Profiles are user-defined; `defaultAiProfiles` is intentionally empty.
- Each profile can specify provider, API URL/model, API key, temperature, and active state.
- Legacy `openRouterApiKey` / `openRouterModel` are still migrated into an OpenRouter profile for backward compatibility.
- `/api/web/ai/prose-complete` accepts optional `profileId` and falls back to the active profile or first keyed profile.
- Autocomplete provider requests include `reasoning: { enabled: false }` in the chat completion payload.
- Server-side completion post-processing strips repeated prompt prefixes/suffixes, collapses adjacent repeated phrases, limits sentence count, and reports empty-completion diagnostics with `emptyReason`.
- Empty completion reasons are `provider-empty`, `provider-repeated-existing-text`, and `trimmed-no-complete-sentence`.
## `/ask` Slash Command
- A line starting with `/ask <question>` (must be at column 0, non-empty question after the space/tab) fires a direct Q&A request when Enter is pressed with the caret at the end of that line. Works in both CM6 and TinyMCE.
- Detection: `detectAskCommand(lineText)` in `public/app.js`. Trigger wiring: the `Enter` binding in the CM6 `keymap.of([...])` inside `initCM()`, and an `e.key==='Enter'` check at the top of the TinyMCE `editor.on('keydown', ...)` handler (same handler that wires `Ctrl/Cmd-Space`).
- Flow: `/ask …` line/block is replaced with a `⏳ Asking…` placeholder immediately, `requestAskCompletion(question, context, profileId)` calls `POST /api/web/ai/ask`, and the placeholder is swapped for the answer (`handleAskInCM`/`handleAskInTinyMCE`). Empty/failed responses restore the original `/ask …` text. There is no accept/dismiss popup for `/ask` — Ctrl+Z is the reject path.
- **TinyMCE soft-line detection**: TinyMCE runs with `newline_behavior:'linebreak'`, so pressing Enter inside a paragraph inserts `<br>` rather than starting a new `<p>`. The `/ask` handler therefore looks for the "soft line" between the last `<br>` (or block start) and the caret — not the whole block's text. Firefox `Range.toString()` does NOT emit `\n` for `<br>` (Chromium/WebKit do), so the handler walks the DOM manually collecting text and resetting on each `<br>`. Answer replacement is limited to that soft-line range (`isSoftLine` branch in `handleAskInTinyMCE`) so earlier soft-lines in the same `<p>` are preserved.
- Identity guard: both handlers capture `activeEditorForm()` + `_formNoteId(form)` before firing the request and re-check them (plus placeholder presence) before writing the answer, so a note switch during the request drops the result instead of writing into the wrong note.
- Disabled entirely inside vault/encrypted notes via `askDisabledForActiveNote()` (checks `form.dataset.encrypted` / `form.dataset.vaultId`) — plaintext context must never be sent to a third-party AI provider from an encrypted note. When the user types `/ask …` and hits Enter in a vault note, `_notifyAskDisabledInVault()` shows a one-shot per-note alert explaining the reason (previously Enter silently fell through to a newline, which looked like a bug).
- Server endpoint `POST /api/web/ai/ask` (`app/routes/api.js`, next to `/api/web/ai/prose-complete`) uses a direct-answer system prompt (not the continuation/style-inference prompt used by prose autocomplete), caps `context` at 4000 chars, and uses `max_tokens: 512`. Shares `getActiveProfileFromSettings`/profile resolution with prose autocomplete; no new settings.
- Coverage: `tests/askCommand.test.js` (client detection + CM6 handler flow), `tests/createServer.test.js` (endpoint request/response, profile selection, error forwarding).
- Expander runtime (`_feedRingBuffer`, `consumePendingTextExpansion`, `runTextExpanderAction`) is in `public/app.js`. Its wiring points are `beforeinput`/`input` listeners inside `initCM()` (source `'cm'`), the old contenteditable-preview activation code (source `'preview'`, now dead), and TinyMCE's `editor.on('keyup')` (rendered mode).
- **Markdown mode (CM6) expander is live** (`initCM()` is invoked for real). **Rendered mode (TinyMCE) expander is live for BOTH text and AI triggers**: text triggers via `maybeExpandTextFromTinyMCE()`/`replaceTinyMCETextExpansion()`; AI triggers via `removeTinyMCETriggerForAction()` + `requestTinyMCEProseCompletion()` (prompt from `getTextBeforeCaretTinyMCE()`, insertion via `insertProseCompletionTinyMCE()`). `Ctrl/Cmd-Space` manual AI completion is wired on `editor.on('keydown')` inside TinyMCE too. The `'preview'` contenteditable path is dead (TinyMCE replaced that host). Coverage: `tests/expanderRuntime.test.js` (runtime) + `playwright-tests/ai-rendered.spec.js` (live provider E2E, skips without creds/AI profile).
- CodeMirror-mode expansion should inspect the current document suffix rather than relying only on raw DOM input events.
- (Historical, if re-wired to TinyMCE's iframe body) triggers can split typed text across text nodes, especially on iOS Safari. Prefer robust text-position/range logic.
- The input ring buffer is only for detecting Expander suffix triggers; keep per-keystroke logging minimal.
- Client diagnostic logging goes through `POST /api/web/client-log`; it redacts sensitive fields matching text/body/content/password/key/secret/token.
- If iPhone behavior differs from desktop, use Docker-visible client logs and remember stale service worker/cache can hide client JS changes.

194
docs/agents/architecture.md Normal file
View file

@ -0,0 +1,194 @@
# Architecture and conventions
> Read this before: changing how the server, htmx fragments, routes or the data model fit together, or when you are unsure where something belongs. Also holds the small cross-cutting contracts that have each broken before.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Product Direction
- Joplin Server stays unmodified
- Joplock stays separate project and separate repo
- Reuses existing Joplin Server auth/session/user model through sidecar logic
- Keeps compatibility with desktop/mobile/CLI clients on same server and same DB
- Browser stays thin and untrusted
- Shared-browser safety matters: logout should clear client-visible state/cache as much as platform allows
- Installable PWA shell, no offline notes/editing
- Uses same Postgres database as Joplin Server, no separate app DB
## Architecture Overview
### Stack
- **Server**: Node.js HTTP server, no framework
- **Client**: SSR HTML + htmx fragment swaps + shared browser logic in `public/app.js`
- **Editor**: Dual-mode. Markdown mode = CodeMirror 6 (mounted into `#cm-host`); rendered mode = TinyMCE 8. The `#note-body` textarea is the hidden form/sync target for both.
- **Code blocks**: Full-screen code modal with a CM6 editor and language picker. Highlighting differs by mode: **markdown mode (CM6)** uses CM6's own `syntaxHighlighting(joplockHighlight)` with the language parsers bundled from `cm-build/`; **rendered mode (TinyMCE)** uses the native `codesample` plugin (PrismJS `.token` spans). highlight.js (`public/hljs.min.js`) is loaded on the page but is only reachable from the dead `#note-preview` path — it highlights nothing live (see the dead-code note below). Rendered mode points TinyMCE's codesample plugin at full `window.Prism` bundle (`public/prism.min.js`) so every language offered by modal has grammar support. Prism token colors are injected into the TinyMCE iframe via `content_style` in `_tinyMCEContentFontStyle()` (the oxide dark content skin ships no `.token` CSS). `codemirror.min.js` and `prism.min.js` are loaded on page before TinyMCE/app.js.
- **Autosave**: htmx delayed PUT after typing pause (deferred while modals are open)
- **Markdown**: server-side `renderMarkdown()`, client-side Turndown `htmlToMarkdown()`
- **Auth**: reuses Joplin Server `sessionId` cookie
- **DB access**: reads direct from shared Postgres; writes go through stock Joplin Server API
- **Exports**: `POST /api/export/{docx,pdf,html}` in `app/routes/api.js` — DOCX and PDF are rendered server-side with `pandoc` (PDF via `weasyprint` plus pandoc's print CSS), HTML is a single self-contained file with inlined theme CSS, base64 images and base64 attachment links. `public/html-docx.js` is the legacy client-side DOCX path.
- Exports work in BOTH editor modes. Rendered mode posts TinyMCE HTML (`format:'html'`); markdown mode posts the markdown (`format:'markdown'`, via `_exportSource()`) and all three endpoints render it with `renderMarkdown()` before the shared strip-links / inline-images pipeline, so images and attachments come out the same in either mode. DOCX with no `format` keeps the legacy pandoc-native markdown input. Pinned by `tests/exportMarkdownMode.test.js` (pandoc stubbed) and the real-container download test in `playwright-tests/md-extras.spec.js`.
- **Ops surface**: admin user management (`/admin/users`), `pg_dump` backup/restore (`/admin/backups`, `/admin/restore`), break-glass recovery mode (`/recovery`), and login rate limiting — all served by the same sidecar process.
- **Script load order** (`app/templates/pages.js`): `htmx` → `turndown` → `codemirror` → `cm-extras` → `prism` → `tinymce` → `hljs` → `html-docx` → inline config → `app.js` (`defer`). `viewport.js` sits between the inline config and `app.js` (it reads `_joplockConfig.debug`). Every local script and stylesheet carries `?v=<asset version>` (`app/assetVersion.js`, computed from the shipped files; `tests/pwaHead.test.js` enforces it), which is what lets the server serve them immutable.
### Runtime Shape
- Initial page load is full SSR HTML from `layoutPage()` in `app/templates/pages.js`
- After load, most interactions are fragment-driven via htmx
- The browser is intentionally thin: most state is DOM state, form state, or small client-only UI state in `public/app.js`
- There is no frontend router and no SPA store
- Desktop and mobile share the same server routes and most of the same editor code; mobile is a different screen shell around the same editor fragment
### Request Flow
1. Browser hits Joplock
2. Joplock validates `sessionId` against Joplin session/user tables
3. Fragment endpoints return HTML chunks; htmx swaps DOM
4. Writes serialize note/folder/resource and send upstream to stock Joplin Server API
### Main UI Flow
1. `GET /` renders the full shell
2. Navigation / notes / editor content is loaded from fragment endpoints
3. Selecting a folder swaps the notes list or nav tree fragment
4. Selecting a note swaps in `editorFragment()`
5. Autosave sends `PUT /fragments/editor/:id` with the current form state
6. Preview rendering uses `POST /fragments/preview`
### Fragment Conventions
- `app/templates/*.js` returns raw HTML strings, not JSX/templates/components
- htmx targets are mostly `#nav-panel`, `#notelist-panel`, `#editor-panel`, and mobile-specific targets like `#mobile-editor-body`
- Out-of-band swaps are used sparingly; note metadata is one example
- Client logic often relies on stable IDs, so be careful renaming DOM IDs used by inline JS
## Service Responsibilities
### Stock Joplin Server
Owns:
- login/session/auth source of truth
- sync endpoints
- canonical storage rules
- existing user/session tables
### Joplock
Owns:
- thin-client UI
- sidecar API endpoints
- session validation against shared DB
- markdown rendering and editor behavior
- resource upload/serving
- note history snapshots (`joplock_history`)
- per-user + admin settings and TOTP seeds (`joplock_settings`)
- vault metadata (`joplock_vaults`) and its own session mirror (`joplock_sessions`)
- admin user management, DB backup/restore, break-glass recovery
- note export (DOCX/PDF/HTML) and the AI provider proxy
- PWA shell/assets
Does not own:
- canonical note/folder/resource persistence rules
- sync protocol semantics
- auth/session source of truth — Joplin's `sessions` table is authoritative; `joplock_sessions` only mirrors `last_seen` for logout/heartbeat
- offline-first storage
### Sidecar Tables (four, no Joplin FKs)
Joplock owns exactly four tables in the shared database; every cross-reference is
an application-level pointer, never an SQL foreign key:
| Table | Purpose |
|---|---|
| `joplock_sessions` | `session_id` → `last_seen` mirror of Joplin sessions (logout/heartbeat only) |
| `joplock_settings` | per-user settings JSONB, `updated_time`, `totp_seed`; `user_id = '__app__'` is the admin row |
| `joplock_vaults` | `user_id` + `folder_id` (unique) with `salt` and `verify` |
| `joplock_history` | note snapshots: `note_id`, `user_id`, `title`, `body`, `body_hash`, `saved_time`, indexed by `joplock_history_note_time` |
Physical schema reference: `docs/joplock-db-schema.html`. Keep it in sync when a
column changes — tables are created lazily with `CREATE TABLE IF NOT EXISTS` in
the service that owns them (e.g. `app/settingsService.js` also carries the
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS totp_seed` migration).
### How Reads vs Writes Work
- Reads come from the shared Postgres DB for speed and to match the current server state
- Writes do not write directly to Joplin tables; they go through stock Joplin Server APIs
- That split is intentional: Joplock can stay lightweight while preserving compatibility with normal Joplin clients
- If behavior looks inconsistent after a write, inspect both the sidecar request path and the upstream Joplin API call path
## Design Decisions
### Separate repo
Joplock lives outside Joplin monorepo. Keep standalone build, test, docs, Docker flow working without Joplin source tree.
### Shared Postgres database
Joplock reads same Postgres database as Joplin Server. No data duplication. Writes still go through Joplin Server API for compatibility and validation.
### Configurable open mode
Notes can open in rendered mode or markdown mode based on the per-user `noteOpenMode` setting (default **markdown**). The stored value may be `preview` (the pre-TinyMCE name, still accepted and preserved by `normalizeSettings`), but note the vocabulary mismatch: `preferredEditorMode()` returns only `'markdown'` or `'rich'`, and `_editorMode` is never `'preview'`. So `noteOpenMode: 'preview'` means "open rendered", and any `_editorMode === 'preview'` branch is dead. Desktop and mobile both respect the same setting. `initEditorPanel` / `_completeUnlock` must call `preferredEditorMode()` (live `_joplockConfig.noteOpenMode`). Do not let `_tinymceReadonlyDefault()` / mobile-shell read-only force rendered mode — that override made Settings → "Open notes in: Markdown" a no-op on tablet/narrow windows. Mobile read-only still applies, but only when the note actually opens in rendered mode. `_joplockConfig` is inlined in `<head>` *before* `app.js`.
### Shared editor fragment
Desktop and mobile do not have separate editor implementations. Both use the same `editorFragment()` and client editor logic; mobile wraps it in a mobile-specific shell and screen navigation layer.
### Mobile-first navigation without SPA rewrite
Mobile uses a folders screen, notes screen, and editor screen implemented in SSR + htmx + inline JS. Do not introduce a client router or framework state layer to solve mobile flow problems.
## Routes and response contracts
`app/createServer.js` assembles the server and delegates to the handlers in
`app/routes/*.js`, each of which exports a `handle(url, request, response, ctx)`
returning `true` when it consumed the request (`app/routes/api.js` additionally
exports the export handlers and CSS/URL helpers for tests).
Routes are not listed here (grep `handle(` in `app/routes/`, or ask the code index); the contracts below are what is not obvious from them.
A few contracts worth remembering:
- The editor autosave `PUT /fragments/editor/:id` also sets the
`X-Note-Updated-Time` / `X-Note-Conflict` response headers the client relies on
(see "Plaintext save identity guard" (`docs/agents/vaults-and-save-safety.md`)).
- Mobile note creation is driven by response headers such as `X-Mobile-Note-Id`,
consumed in `htmx:afterRequest`.
- History restore returns `editorFragment` **inline** (target = the editor
container) with OOB swaps, not just the status bar.
If a UI action appears broken, check:
1. Which endpoint it hits
2. Which htmx target it swaps
3. Which client event handler expects to run after swap/request
4. Whether the response includes headers or OOB fragments the client depends on
## Coding Guidance
- Keep changes minimal
- Preserve sidecar/frontend boundary
- `public/app.js` is DOM-contract fragile; validate escaping-heavy changes and stable IDs carefully
- **Floating menus must be opaque and stay on screen.** `--bg-elevated` is a ~2% tint in most themes, so it is a *panel lift*, never a popup background: put popups on a solid colour (`--bg-side` / `--bg-editor`; the notebook context menu layers the tint over `--bg-side`). A `position:fixed` popup also cannot be scrolled to, so place it with `positionFolderContextMenu()`-style clamping instead of raw pointer coordinates, or items near the window edge become unreachable. Guarded by `tests/popupBackgrounds.test.js` and `playwright-tests/folder-context-menu.spec.js`.
- **Inline handlers only see what `public/app.js` assigns to `window`.** A template `onclick="foo()"` needs `window.foo=foo;`; a function that works in a unit test can still be "not defined" in the browser. `tests/inlineHandlersExposed.test.js` scans every inline handler the templates emit and fails if a called function is not exposed
- The code modal lives in `loggedInLayout`, not inside `navigationFragment` or `editorFragment`, so it survives htmx OOB swaps
- Be careful with checkbox text handling, `\n`, regex escaping, and DOM-to-markdown round trips
- Keep standalone repo paths/docs/scripts correct; avoid reintroducing monorepo assumptions
- Prefer changing existing inline helpers over introducing a new abstraction unless there is clear reuse
- When fixing mobile behavior, verify desktop is unchanged
- When fixing desktop editor behavior, verify mobile still works because both use the same editor fragment
- Be cautious with `htmx:afterRequest` assumptions; in htmx 2.x, response headers are often more reliable than old event-property assumptions
- If changing vault behavior, verify desktop + mobile, locked + unlocked, existing note + newly-created note, and refresh/restart behavior
## Debugging: If startup/resume behavior is wrong
- Check the `/` render path in `app/createServer.js`
- Check `resumeLastNote`, `lastNoteId`, and `lastNoteFolderId`
- Refresh/restart must not reopen encrypted notes or notes inside vault notebooks
## Small Contracts That Are Easy To Regress
Behaviours that are documented nowhere else and have each broken before (verified against the code; the larger contracts have their own sections above).
- **Vault chrome follows the folder.** `_syncEditorVaultChrome(noteId, inVault, unlocked)` (`public/app.js`) injects/removes the lock-toggle button in `.editor-titlebar` when a note is moved into a vault (called from `_doEncryptNoteInVault`). Vault notes cannot be moved out (see Vault / Encryption Model in `docs/agents/vaults-and-save-safety.md`).
- **Live search keeps what you typed.** The sidebar search value is saved around the htmx swap (`_navSearchSavedValue`, captured at `htmx:beforeSwap`) so characters typed while a request is in flight are not lost.
- **Search and note lists are paginated.** Desktop and mobile lists end in a "Load more" button (`.notelist-load-more`), not infinite scroll; search is backed by the `pg_trgm` index `ensureIndexes()` creates.
- **TinyMCE autosave sync has a fast path and a debug path.** By default `onEdit` only calls `markEdited()` + `scheduleSave()`, and `_lazyTinyMCESyncBeforeSave()` runs once before `formHash()` in `scheduleSave` / `scheduleSaveTitle` / `_activeEditorIsDirty`; with admin debug logging on, every event syncs and logs. `ExecCommand` + `SetContent` are wired so the blocks dropdown and `insertContent` mutations still sync. `flushSave`'s hash-unchanged branch sets `Saved` (without that a phantom `Edited` blocked the sidebar's notebook "+" button).
- **Read-only is one function.** `_applyFormReadonly()` locks the title contenteditable, folder select, toolbar buttons/inputs and the CM6 `contentDOM`; `.editor-readonly` in `styles.css` is the visual. On the mobile shell a note that opens in rendered mode is read-only until the pencil (`jop_edit`) is tapped; `_tinymceReadonlyDefault()` and `_tinyMCEToolbarSpec()` read the cached `isMobileShellMode()`.
- **The mobile editor body shows an inline spinner** (`.mobile-loading-note`) while a note loads. A `position: fixed` overlay does not work there (it ends up under the shell's fixed layers).
- **`renderMarkdown()` strips `hx-*` attributes** from user HTML so a note cannot inject htmx behaviour.

View file

@ -0,0 +1,31 @@
# Markdown-mode extras (`public/cm-extras.js`)
> Read this before: changing inline image previews, chips, task checkboxes, folding, tables, paste-as-markdown or the CodeMirror bundle.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Markdown-mode extras (`public/cm-extras.js`)
`initCM()` spreads `_cmExtraExtensions(C)` (try/catch-guarded: a failure must never stop the editor mounting) into the CM6 extension list. The extras need extra symbols from the bundle (`Decoration`, `WidgetType`, `StateField`, `ViewPlugin`, `showPanel`, `hoverTooltip`, fold APIs, `syntaxTree`, `Prec`, ...) — they are exported from `cm-build/index.js`; **if you add an extra that needs another CM symbol, export it there and rebuild with `npm run build:cm`**.
User settings (Settings → Markdown editor; `app/settingsService.js`, whitelisted in `app/routes/api.js`, exposed through `_joplockConfig` in `pages.js`, read in `app.js` as `_mdInlineWidgets` / `_mdLivePreview` / `_mdStatusBar` / `_mdFolding`):
- `mdInlineWidgets` (default ON): image previews, attachment/note-link chips, task checkboxes, code-block background + Copy button.
- `mdLivePreview` (default OFF): hide `#`, `**`, `*`, `~~`, backticks and `[](url)` marks except on the heading line / inside the construct the caret touches. Only when the editor has focus.
- `mdStatusBar` (default ON, **desktop shell only**: never created in the mobile shell): bottom `showPanel` with word count, reading time, Ln/Col (or selection word count) and an Outline popup.
- `mdFolding` (default ON): fold gutter (headings, lists, fenced code) — the theme hides `.cm-gutters` entirely when OFF.
- Always on: paste-as-markdown, URL-over-selection → link, table helper, hover info on `:/id` links, and the file-drop claim below.
How it fits together (details that bit us — keep them):
- One `StateField` (`modelField`) holds `scanDoc(doc)` output (images, links, tasks, headings, fences; nothing inside fenced code is ever scanned) and provides the **block** image widgets (block decorations cannot come from a `ViewPlugin`). A `ViewPlugin` builds the inline decorations (chips, checkboxes, code lines, live-preview hides) for `view.visibleRanges` only. Docs over ~2 MB skip scanning.
- Image rows are block widgets *under* the `![](:/id)` / `<img src=":/id">` line; the source line stays visible and editable. Previews load through `hooks.fetchResourceBlob` into a refcounted blob-URL cache because `/resources/:id` is `Cache-Control: no-store`. Click selects the source reference (Delete removes the image); double-click opens the lightbox.
- **Resize writes `<img src=":/id" alt=".." width="N" />`** — byte-identical to the Turndown `joplinImg` rule and understood by `renderMarkdown()`, so rendered mode honours the width and round-trips it. Double-clicking the handle resets to plain `![](:/id)`. Widget positions go stale as text above changes, so `locateImage()` re-finds the line from `view.posAtDOM()` before every edit.
- Resize/checkbox edits are ignored when the editor is read-only (`canEdit()` checks `contenteditable="false"`, which is what `_applyFormReadonly` sets).
- **CM6 does not repaint its cursor/selection layers when a block widget changes height** (image finishing its load). `relayout()` re-asserts the selection (`addToHistory:false`) after each load; `requestMeasure()` alone leaves the caret painted at a stale y.
- **CodeMirror's built-in drop handler reads dropped text-like files (.txt, .svg, .json, ...) and inserts their contents.** The app's own listener uploads them, so the extras claim file drops with a `domEventHandlers({drop})` returning `true` (handlers from extensions run before the built-in). Without it a dropped text file is both pasted AND uploaded.
- Paste: the app's capture-phase `paste` listener (`initCM`) uploads image/file clipboard items; the extras' `paste` handler converts rich HTML via `hooks.htmlToMarkdown` = `_pasteHtmlToMarkdown()` (the shared Turndown instance) only when the HTML has real structure (`shouldConvertHtml`), never inside fenced code, never for VS Code clipboards, never on Ctrl/Cmd+Shift+V. (`lang-markdown` also wraps URL-over-selection natively; the extras' version just guarantees it and escapes brackets.)
- Download: image previews get a top-right ⬇ button and attachment chips an inline ⬇ (shown once the target resolves to a resource). Both call `hooks.downloadResource` = `downloadResource()`, so behaviour matches rendered mode (action sheet with View/Save on desktop/PWA, direct download on mobile web). The buttons stop propagation so they neither open the lightbox nor select the image source.
- Chips replace `[text](:/id)`; the kind (note vs attachment) is resolved lazily via `_resolveInternalLink()` (note headers cache, then `HEAD /resources/:id`). Notes open through the note-list row (`_openNoteById()`), attachments through `_openResourceLightbox()`. Rendered mode still treats every `:/id` link as an attachment — note-link navigation exists only in markdown mode.
- Table helper (`Prec.high` keymap): Tab/Shift-Tab align the whole pipe table and move between cells (Tab at the last cell appends a row). Tables inside fences are ignored. **Do not bind Enter**: people type pipe tables row by row, and an "Enter adds a formatted row" handler corrupts what they type next (it broke `table-regression.spec.js`).
- No regex lookbehind anywhere in `cm-extras.js` (a parse-time SyntaxError on iOS < 16.4 would take the whole file down) — pinned by a test.
- Coverage: `tests/cmExtras.test.js` (pure helpers + real CM6 in jsdom), `playwright-tests/md-extras.spec.js` (real browser: preview/resize/round-trip, chips + hover, checkboxes, tables, paste, status bar/outline/folding, Copy button, live preview).

View file

@ -0,0 +1,29 @@
# Uploads, paste and drop
> Read this before: changing note/image/attachment upload, clipboard paste, drag and drop, or the upload size limit.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Upload behavior
- The upload modal (`openUploadModal()` → `uploadModalFiles()` → `insertUploadedFiles()`) is the primary picker/drag-drop path; it uploads to `/fragments/upload` and inserts into the live TinyMCE document when in rich mode (or the CM6/textarea target when not). On success (all files upload, no errors) the modal auto-dismisses; if any file errors it stays open showing per-file errors.
- Drag-and-drop directly onto TinyMCE works via `_uploadFileToTinyMCE()` (inserts `<img data-resource-id>` / `<a data-resource-id>` into the live editor). Markdown-mode drops route through `_uploadFileToCM()` (inserts `![](:/id)` at the CM cursor).
- **The upload-modal/picker insert must go through the live editor in rich mode** (it used to fall through to the hidden textarea, which lost the insert). `_captureUploadInsertTarget()` now returns `{ mode: 'tinymce', rng }` when rich mode is active, the TinyMCE host is visible, and the editor is not read-only; `_insertUploadedMarkdown()` then routes to `_insertResourceIntoTinyMCEFromMarkdown(markdown, rng)`, which re-inserts a saved caret range, builds the same `<img>`/`<a>` HTML as the drop path, and inserts it through `_tinyMCEBlockAttachmentHtml()` (so blank-line padding is identical). Read-only editors (mobile rendered mode) still fall through to the textarea/CM branches.
- **Programmatic rich-mode inserts must call `_endTinyMCEPostLoadWindow()` first** (`_uploadFileToTinyMCE`, `insertUploadedFiles`, `_insertResourceIntoTinyMCEFromMarkdown`). An insert is a real edit, not load echo: it clears `_tinymcePostLoad`/`_tinymcePostLoadUntil` and sets `_tinymceUserTypedSinceLoad`. Without this, `onEdit` classifies the insert's own events as round-trip echo, the post-load reconcile re-baselines `_savedHash` to the inserted content, and the debounced save then sees "hash unchanged" — the upload never reaches the server.
- **Dropped/pasted attachments (image AND document) are padded with a blank line before and after** so a single attachment stays easy to delete even when several are stacked. This padding is *only* about spacing around the inserted resource — it does not change how surrounding typed text is handled.
- Markdown mode (CM6): `_uploadFileToCM()` inserts `<pad>` + ref + `\n\n`, where `<pad>` is `''` at the very start of the doc, `\n` if the char before the cursor is already a newline, else `\n\n`. Both images and documents get this. (Plain source blank line; no `md-blank-line` marker needed because the user edits raw text here.)
- Rendered mode (TinyMCE): `_tinyMCEBlockAttachmentHtml(editor,inner)` wraps the image/link in its own `<p>` and adds a `<p class="md-blank-line"><br></p>` (the renderer's canonical deletable blank line — see `injectBlankLineBlocks`) before and after, for both images and documents. **Smart**: it skips the leading and/or trailing marker when the caret block is already empty or already adjacent to an existing blank-line paragraph (via `editor.selection.getNode()`), falling back to adding both when the selection API is unavailable (unit tests).
- Round-trip safety (rendered mode): `md-blank-line` paragraphs are pre-normalized in `tinymceToMarkdown()` and matched by the `blankLine`/`emptyP` Turndown rules → `\x00BL\x00` sentinel → `\n\n\n` (one extra newline = one blank line that re-renders as an `md-blank-line` `<p>`). Do NOT switch these separators to bare `<div><br></div>` or plain empty `<p></p>` — those get merged/dropped around block-level images and swallow the spacing after a few round-trips.
- `_buildMarkdownInsert()` (used by the upload-modal picker's textarea/CM targets) is unchanged — it still adds a single `\n` on each side as needed. It is deliberately NOT part of the blank-line padding change.
- Coverage: `tests/cm6MarkdownMode.test.js` (CM padding for image + document; TinyMCE `_tinyMCEBlockAttachmentHtml` markers + smart skip), `tests/previewRoundTrip.test.js` (blank line between stacked image/image, image/doc, doc/image survives render⇄markdown), `tests/appRuntime.test.js` (mode-switch round-trip, no mangling).
- Markdown-mode clipboard paste: `initCM()` registers a CAPTURE-phase `paste` listener on `_cmView.dom` that uploads image/file clipboard items via `_uploadFileToCM()` (`_clipboardFilesToUpload()` decides: non-image files always, images unless the clipboard is a text+HTML rich copy such as a spreadsheet). It must be capture-phase on the editor root: CM6's own paste handler only reads `text/plain` and, for an image-only clipboard, would *replace the selection with an empty string*.
- Clipboard paste: images are uploaded by TinyMCE's built-in pipeline (`paste_data_images:true` + `automatic_uploads:true` + `images_upload_handler`); non-image clipboard files are handled by an explicit `editor.on('paste', ...)` handler that routes through `_uploadFileToTinyMCE()`.
- The Image/Media dialogs' Browse button is wired via `file_picker_callback` to `/fragments/upload`, returning a `/resources/<id>` URL.
- All upload paths produce `src="/resources/<id>"` / `href="/resources/<id>"`, which `tinymceToMarkdown()` (the `joplinImg`/`joplinLink` Turndown rules) converts to Joplin `![](:/id)` / `[](:/id)` on save. `data-resource-id` is added by drop/paste/upload-modal paths but is not required for the round-trip (matching is by `src`/`href`).
- `uploadFiles()`/`handleFilePicker()` (the older `#file-upload` input path) still exist and batch multi-file selections; image-only uploads must not promote the image filename into the note title.
## Upload size limit
- `appSettings.maxUploadMb` (admin setting, default 200, clamped 1–2000) caps upload size. Joplin Server's formidable `maxFileSize` is a hard 200MB ceiling — exceeding it produced opaque 500s after buffering; the limit prevents that.
- Server: `app/routes/resources.js` `resolveMaxUploadBytes()` does a fast 413 via `Content-Length` pre-check (before buffering) plus a post-parse guard.
- Client: `_maxUploadBytes()` reads `_joplockConfig.maxUploadMb`; `_fileTooLarge()` guards every upload entry point (modal, drop, paste, picker) with a friendly message instead of a failed request.
- Admin field: Settings → Admin → Login Security, saved via `/admin/security`.

105
docs/agents/editor.md Normal file
View file

@ -0,0 +1,105 @@
# Editor model
> Read this before: changing the editor (CodeMirror 6 markdown mode, TinyMCE rendered mode, mode switching, autosave, markdown round-trip) or debugging a phantom "Edited" state.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Architecture (dual-mode: CM6 markdown + TinyMCE rendered)
The editor supports two modes, both backed by the hidden `<textarea id="note-body">` form field:
- **Rendered mode = TinyMCE 8.** Persistent singleton (`initPersistentTinyMCE()` in `public/app.js`) mounted on a hidden `<textarea id="tinymce-editor">` that lives outside `.app`/`#mobile-app` in `pages.js` so htmx swaps don't destroy it; a `position:fixed` `#tinymce-host` div is repositioned via `positionTinyMCEHost()` to sit over the `#tinymce-slot` placeholder in `editorFragment()`. TinyMCE content is converted back to markdown via `tinymceToMarkdown()` (Turndown) into `#note-body` on `input`/`change`.
- **Markdown mode = CodeMirror 6.** Mounted into `#cm-host` by `mountMarkdownEditor()` → `initCM()`, seeded from `#note-body`. CM6 edits sync into `#note-body` via `cmSyncToTA()` (called from initCM's update listener). `getCM()` returns the live `EditorView`; `cmSetVal()` replaces the CM document.
- `codemirror.min.js` is loaded on the page before `app.js` (built from `cm-build/`, `npm run build:cm`), so `window.CM` is available for both the markdown editor and the code-block modal.
Text-expander is now wired for BOTH modes:
- **Markdown mode (CM6)**: `maybeExpandTextFromCM()` on `initCM()` contentDOM input listeners (text + AI triggers).
- **Rendered mode (TinyMCE)**: `maybeExpandTextFromTinyMCE()` on `editor.on('keyup')`; inspects the caret text node suffix in the iframe and replaces the trigger via `replaceTinyMCETextExpansion()` (multi-line → `<br>`, then `tinyMCESyncToTA()`). Both `action:'text'` AND `action:'ai'` triggers now fire in rendered mode: AI triggers call `removeTinyMCETriggerForAction()` then `requestTinyMCEProseCompletion()` (builds a prompt from the iframe caret via `getTextBeforeCaretTinyMCE()`, calls `requestProseCompletion()`). The completion is offered in the SAME `note-autocomplete-popup` used by markdown mode — kind `'tinymce-prose'`, accept with Enter/Tab inserts via `insertProseCompletionTinyMCE()` (DOM text nodes, restores a caret bookmark first), Esc discards. `Ctrl/Cmd-Space` inside the iframe is wired on `editor.on('keydown')` (the global `document` keydown can't see iframe keystrokes) and also shows the popup. Popup keys are forwarded from the iframe keydown via `handleRenderPopupKey()` because iframe key events never reach the outer-document listener; popup coords come from `tinyMCECaretCoords()` (iframe caret rect offset by the iframe element rect).
## Dead code and legacy paths (verified still present)
- **Dead `getPV()` / `#note-preview` contenteditable code** still exists in `public/app.js` (superseded by TinyMCE). `getPV()` returns null (the element is never rendered by any template), so every `if(pv){...}` branch in the formatting helpers (`wrapSel`, `insertPfx`, `clearFormat`, `openCodeModal`, `submitCode`, `syncPV`, `replacePVTextExpansion`, etc.) is dead and always falls through to the CM/textarea branch. Harmless. NOT removed because it is threaded through ~30 functions and ripping it out risks regressing the live CM path; do it as a dedicated, well-tested cleanup pass, not a drive-by.
- **Image download buttons live in the TinyMCE body, not the dead PV path.** `initResourceImageDownloadButtons`/`activatePV` are legacy-only (PV never renders). The restored button is `initTinyMCEImageDownloadButtons(editor)`, called from `_setTinyMCEContent`'s post-load hook next to `initTinyMCECodeCopyButtons`. Serialization contract (same as the code-copy button): the button is `textContent=''` + `data-mce-bogus="all"` (label via CSS `::after` in `_tinyMCEContentFontStyle()`), so `getContent()` drops it — it can never reach saved markdown, HTML/DOCX/PDF exports. Only the positioning wrap `<span class="preview-img-download-wrap">` survives `getContent()`; `_stripTinymceDownloadChrome()` unwraps it in `tinyMCEContent()` and `tinymceToMarkdown()` so every serialization boundary sees the pre-injection DOM (keeps round-trip hashes stable — no phantom edits). Because the anchor can now sit inside the iframe, `_positionResourceActions` resolves anchor rects via `_anchorRectInHostDoc()` (iframe-viewport rect offset by the iframe element's rect). Coverage: `tests/tinymceImageDownload.test.js`.
- **`highlightTinyMCECodeBlocks()` is unreferenced dead code.** It was the hljs pass over the TinyMCE body before Prism/`codesample` took over; every call site was removed but the function is still defined (`public/app.js`). It is the only hljs consumer besides the dead PV path, so removing it (with `highlightCodeBlocks()`) is a safe, self-contained slice of the PV cleanup.
## Two modes
- **Markdown mode**: CodeMirror 6 mounted in `#cm-host` is the visible editor; `#note-body` is the hidden sync target. If the CM6 bundle fails to load, `mountMarkdownEditor()` falls back to showing the raw textarea.
- **Rendered mode**: TinyMCE (persistent instance, positioned over `#tinymce-slot`) is visible; `tinymceToMarkdown()` converts edited HTML back to markdown on `input`/`change`.
## Source of truth during editing
- The hidden textarea `#note-body` is the form field used for saves.
- In markdown mode, CM6 changes sync into `#note-body` via `cmSyncToTA()`.
- In rendered mode, TinyMCE's `getContent()` is converted via `tinymceToMarkdown()` into `#note-body`.
- Switching modes: markdown→rich calls `cmSyncToTA()` then POSTs the markdown to `/fragments/preview` and loads the rendered HTML into TinyMCE; rich→markdown calls `tinyMCESyncToTA()` then mounts CM6 from the textarea. There is no client-side markdown→TinyMCE-HTML converter — that direction round-trips through the server.
- File/image uploads should alter markdown first, then refresh rendered preview from markdown; do not treat preview-only DOM insertion as authoritative state.
- The title is mirrored between `.editor-title`, hidden title input, and mobile title header when applicable.
## Save lifecycle
- `markEdited()` updates UI state to `Edited`
- `scheduleSave()` triggers delayed autosave for body/form changes
- `scheduleSaveTitle()` is a shorter timer for title changes
- If `scheduleSave()` or `scheduleSaveTitle()` sees the same form hash as `_savedHash`, the visible save state should return to `Saved`, not remain `Edited`
- `flushSave()` is the forced-save path used before leaving a dirty note; it must also handle vault-note encryption before navigation proceeds
- `htmx:afterRequest` on the editor save path transitions UI state back to `Saved`
- Offline/request failure paths set status to `Offline`
## Important fragility points
- DOM IDs and class names are part of the editor contract with inline JS (`#cm-host`, `#note-body`, `#tinymce-slot`, `#tinymce-host`, `#editor-toolbar`).
- Rendered-mode HTML (TinyMCE content) must remain convertible back to markdown with acceptable fidelity via `tinymceToMarkdown()`/Turndown.
- Checkbox, code block, and blank-line handling are easy to regress.
- **Blank-line markers between blocks are `<p class="md-blank-line"><br></p>`, NOT bare `<div><br></div>`.** `injectBlankLineBlocks()` (`app/markdownRenderer.js`) emits extra blank lines as empty paragraphs because TinyMCE's schema preserves empty `<p>` natively; a bare `<div><br></div>` got normalised/merged/dropped around block-level images, which swallowed spacing between images after a few markdown⇄render round-trips. The Turndown `blankLine` rule (`public/app.js`, and the preview-path copy in `tests/previewRoundTrip.test.js`) matches `P|DIV.md-blank-line`; in `getTurndown()` both `blankLine` and `emptyP` emit the same `\x00BL\x00` sentinel so precedence is moot. **Two TinyMCE quirks made image spacing collapse anyway (both fixed):** (1) **TinyMCE strips the `<br>`** from the marker on `setContent`, leaving an empty `<p class="md-blank-line"></p>` that Turndown drops — so `tinymceToMarkdown()` pre-normalises a **blank** `md-blank-line` paragraph (empty, whitespace/`&nbsp;`-only, or just `<br>`) to the `❤BR❤` sentinel shape before Turndown. **This normalisation MUST stay conditional.** A marker is a real, focusable paragraph in the iframe, so clicking the gap between two blocks puts the caret inside it and typing puts the new text there; rewriting markers unconditionally (the old "empty or not" behaviour) replaced that text with the sentinel and **silently destroyed it** — type a line after a checklist, leave the note, come back, gone. When a marker holds real content, leave the paragraph verbatim: with text present neither the `blankLine` nor the `emptyP` rule matches, so it converts as an ordinary paragraph. Coverage: `tests/appRuntime.test.js` "keeps text typed INTO a blank-line marker (data-loss regression)" + "still collapses a genuinely blank blank-line marker". (2) **Sized/raw-HTML images** (Turndown emits `<img … width=… />` for resized images) are markdown-it *HTML blocks* rendered OUTSIDE any `<p>`; a loose block `<img>` next to markers gets absorbed into an adjacent paragraph by TinyMCE, so `postProcess()` wraps any line that is a lone `<img>` in its own `<p>`. Regression coverage: `tests/appRuntime.test.js` "image spacing …", "sized … survive 6 mode switches", "br-stripped … marker".
- The code modal is outside the fragment-swapped editor so it survives swaps; it uses CM6 (`_initCodeModalCM`) which requires `window.CM` (now loaded).
- Both markdown mode (CM6) AND rendered mode (TinyMCE) open this same custom full-screen CM6 code modal (`openCodeModal`/`submitCode`) for *editing* the code text/language, NOT TinyMCE's built-in `codesample` dialog. The toolbar uses a custom `jop_code` button; clicking an existing `<pre>` in rendered mode routes through `tinyMCEInsertCodeBlock()` → `openCodeModal()`. On submit in rendered mode, `submitCode()` (TinyMCE branch, `_codeTinyMCE`/`_codeTinyMCEBookmark`) inserts `<pre class="language-x">code</pre>` via `ed.insertContent()` — the `codesample` plugin's `SetContent` handler then highlights it with Prism. Do NOT reintroduce hljs highlighting of rendered-mode blocks; Prism owns rendered-mode coloring. `highlightTinyMCECodeBlocks()` (the old hljs pass over the TinyMCE body) has **no call sites left** — the function body is dead, not deleted, so grep hits are misleading. Do not reintroduce `ed.execCommand('mceCodeSample')` (that opens the built-in dialog).
- On htmx editor-panel swap, `_cmView` is destroyed in `htmx:afterSwap` and re-mounted by `initEditorPanel()` (via `mountMarkdownEditor`) on `htmx:afterSettle` when the note opens in markdown mode. Keep that destroy/remount ordering intact.
- `#tinymce-host` is `position:fixed` and repositioned via `positionTinyMCEHost()`; if it looks detached, check that function and the `#tinymce-slot` rect, not CSS alone.
- **Turndown expels "flanking" whitespace, and it gets that wrong next to atomic children.** `flankingWhitespace()` derives an element's edge whitespace from `node.textContent`, which skips `<img>`/`<br>` (they contribute no text). For `<a><img/>&nbsp;Label</a>` it reports *leading* whitespace even though the whitespace is interior to the produced markdown (`![alt](:/id) Label`), so `replacementForNode()`'s `content.trim()` cannot remove it — yet it is still prepended. The space was therefore **duplicated on every round-trip and grew one character per note open**, corrupting the stored body. `tinymceToMarkdown()` hides such whitespace behind a sentinel (`_protectInlineLeadingSpace` → `_restoreProtectedSpace`) that **encodes the character code**: these runs are frequently NBSP, and restoring a generic `' '` would itself change the body and keep the note permanently dirty. Do not "simplify" that sentinel back to a plain space, and do not narrow its character class to `[ \t]` — Turndown's `edgeWhitespace` uses `\s`, which matches NBSP.
- **Soft breaks inside a blockquote must re-apply the `>` prefix.** `> b\n> c` is ONE quoted paragraph with a soft break, so the renderer emits `<blockquote><p>b<br>c</p></blockquote>`. The `❤BR❤` sentinel is restored to `\n` *after* Turndown has prefixed the lines it emitted, so a naive global `split/join` produced `> b\nc` — the second line escaping the blockquote entirely (real content corruption plus a permanent dirty-on-open diff). The restore is line-aware and carries the leading `>` run across the break; keep it that way. Bare `> ` lines also get their insignificant trailing space stripped.
## Debugging
### If toolbar/mode behavior is inconsistent
- Verify whether the current editor is actually inside `#mobile-editor-body`
- Check `syncEditorModeButtons()` and `setEditorMode()`
- Check whether the note was initialized with the expected `noteOpenMode`
- If switching modes marks the note `Edited`, confirm the current form hash differs from `_savedHash`; unchanged hashes should show `Saved`
### If title UI drifts
- Check `.editor-title`
- Check hidden input `.editor-title-hidden`
- Check `#mobile-editor-title`
- Check `autoTitle()` and `syncTitle()`
### If save-state UI drifts
- Check `setSaveState()`
- Check `#autosave-status`
- Check `#mobile-editor-status`
- Check htmx save success/failure handlers and upload progress handlers
### If a note shows "Edited" (or autosaves) immediately on open
`_savedHash` is snapshotted in `initEditorPanel()` from the **raw server body**, before CM6/TinyMCE has loaded. 820ms after `setContent`, the reconcile timer in `_setTinyMCEContent()` syncs the editor back to markdown and compares hashes. So **any** non-idempotent markdown⇄HTML round-trip looks like a user edit.
With `debugLogging` on, the reconcile prints exactly which side it took — this is the fastest way to triage:
| Log line | Meaning |
|---|---|
| `post-load reconcile: form clean, nothing to do` | Round-trip is idempotent. Save state is not the problem; look elsewhere. |
| `post-load reconcile: load normalisation only, re-baselining hash` | Round-trip is lossy but nobody typed. Hash is re-baselined: no `markEdited`, no save. Expected for notes with constructs HTML cannot represent. |
| `post-load reconcile: user typed during window, marking edited` | A real edit landed inside the 800ms quiet window and is being saved. Correct behaviour. |
If you see `user typed during window` when the note was untouched, the `_tinymceUserTypedSinceLoad` flag is being set spuriously — it must only be wired to `keydown`/`paste`/`cut`/`drop`. Never wire it to `input` or `SetContent`: `editor.setContent()` fires both, which reintroduces the phantom edit. Navigation-only keys (arrows, modifiers) must stay filtered out.
To find out *why* a note is non-idempotent, round-trip its stored body offline:
`renderMarkdown(body)` → `tinymceToMarkdown(html)` → diff against `body`. `tests/appRuntime.test.js` has the harness (`makeTurndownCtx()` + `runWithDeps()`) for running the real `public/app.js` functions under JSDOM.
Known-lossy constructs that are **by design** and will always re-baseline (do not "fix" them by adding markers):
- an indented ` ``` ` fence loses its indent (HTML cannot carry fence indentation)
- encrypted/vault note wrappers strip HTML comments — irrelevant in practice, since locked notes never load plaintext into TinyMCE
Heading-gap blank lines around ATX headings are no longer lossy: `tinymceToMarkdown(html, prevMd)` keeps Turndown's natural spacing and only falls back to the authored markdown (`prevMd`, what `#note-body` already held for the note) when the fresh conversion differs from it purely in heading-gap shape — collapsing both with `_applyHeadingSpacing()` yields the same text. Authored blank lines around headings survive the switch; untouched compact notes don't gain a phantom "Edited"/save; a real edit always wins (and one-time re-spaces a compact heading note). Do not reintroduce the unconditional `headingGapRe`/`headingLeadRe` collapse in `tinymceToMarkdown()` — it ate blank lines the user typed around headings (reported: add a blank line under a heading in markdown mode, switch to rendered and back, it was gone, and the collapsed body was saved). `_applyHeadingSpacing()` remains for the authored-gap comparator and the legacy preview path (`htmlToMarkdown`). Coverage: `tests/appRuntime.test.js` heading-gap round-trip tests + `playwright-tests/heading-gap-roundtrip.spec.js`.

View file

@ -0,0 +1,36 @@
# Joplin field fidelity
> Read this before: writing or serializing notes, notebooks or resources, or anything another Joplin client can see. A Joplock write must change only what the user edited.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Joplin Field Fidelity (do not regress)
Joplock shares one account with stock Joplin clients, so **a Joplock write must change only what the user edited**. Originally `serializeNote`/`serializeFolder` rebuilt every item from hard-coded values: a single body edit turned a to-do into a plain note, wiped author/source URL/location, flipped an HTML note to markdown, reset `order` and overwrote `source_application`. Measured with the real Joplin CLI synced through Joplock's proxy, then fixed.
### How it works
- **Pass-through serializer** (`app/items/joplinItem.js`): reproduces Joplin's `serialize` / `serialize_format` (canonical key order, ISO times, `\n` escaping, no empty body block). Values come from the item's **stored fields** and only the fields Joplock owns are overridden.
- **Items carry their stored fields.** `mapNoteRow` / `mapFolderRow` attach `fields` (the parsed `items.content` JSON). Every write path passes `existing`, so `serializeNote({ fields: existing.fields, ... })` writes everything else back untouched, including fields Joplock has never heard of (`is_locked`, `extracted_resource_ids`, `user_data`, `application_data`, ...). Defaults (`order = now`, zero geo strings, `markup_language 1`, `source_application net.cozic.joplock-web`) apply **only to brand-new items**; edited items keep their original `source_application`.
- **Joplock-owned fields:** title, body, parent, deleted time, `is_shared`/`share_id`, `updated_time` (always now, sync needs it). `user_updated_time` moves **only on a real title/body edit**; move, trash, restore and share changes preserve it (Joplin's `moveToFolder` semantics).
- **`fields` is internal.** `publicItem()` / `publicItems()` (`routes/_helpers.js`) strip it from JSON API responses. Keep using them on any new endpoint that returns a note or notebook.
- **The server only stores fields its own Joplin library knows** (it drops the rest on upload and when serving), so "unknown to Joplock" fields that can persist are ones a current Joplin writes. Joplin clients themselves also drop unknown fields on read.
- **Share id lives in a column.** Joplin Server moves `share_id` out of the item JSON into `items.jop_share_id` on the first API save. Always read it via `shareIdOfRow()` (column first, JSON fallback) / `shareIdOf()` in SQL. Reading only the JSON made a shared note look unshared after its first edit, so the *second* Joplock edit wrote `share_id: ''` and silently took the note away from every recipient.
### Items Joplock must treat carefully
| Item | Behaviour |
|---|---|
| `markup_language = 2` (HTML note) | Shown **rendered and read-only** (`app/htmlNoteRenderer.js`, allowlist sanitizer on untrusted HTML + TinyMCE's own sanitizing). Form is `data-html-note="1"`: no markdown/rendered switch, no toolbar, title not editable, `tinyMCESyncToTA` is a no-op. The server refuses any body change (403) but still allows move/trash/restore. Never convert it to markdown. |
| Joplin end-to-end encrypted (`jop_encryption_applied = 1`) | **Locked placeholder only.** `e2ee: true`, title/body blanked, no editor form at all. `updateNote`/`updateFolder` throw 403 (`assertNotE2ee`); a live one cannot be hard-deleted; a notebook holding any cannot be deleted (409, checked **before** any write so a delete never half-completes). |
| `is_conflict = 1` (conflict copy) | **Hidden** from lists, counts, headers and search (`conflictFilterSql`), like Joplin's "Conflicts" notebook. Stays untouched, and travels with its notebook when that is deleted (`includeConflicts`). |
| Attachments | `serializeResource` writes Joplin 3.x's OCR fields; `ocr_driver_id` is `1` (Joplin's default; `0` is a legacy quirk). |
### Real-client integration tests (opt-in)
`npm run setup:joplin-cli` installs the real Joplin terminal client under `~/.cache` (not in the repo). `npm run test:joplin` (dev stack up, admin env vars set) drives it headless through Joplock's `/joplin` proxy using a dedicated `compat-client@joplock.test` account (`integration-tests/joplin/harness.js`): `joplin-fidelity`, `joplin-guards`, `joplin-nested`, `joplin-sharing`. They create items in the client, sync, edit/move/trash in Joplock, sync back and compare **field by field** from the client's own SQLite. Raw uploads authenticate with `X-API-AUTH`, like a real client. Joplin Server applies share changes on a 10 s task, so the sharing tests wait ~13 s per step. One test is a tracked `todo` (below).
### Known issues found with the real client (not fixed)
- **Sync-proxy guards do not apply to stock Joplin clients.** `vaultProxyGuard` and `shareProxyGuard` identify the user with `authenticatedUser()`, which reads only Joplock's `sessionId` **cookie**. Joplin apps authenticate with an `X-API-AUTH` header and send no cookie, so both guards "stream through". Verified: a real client wrote a **plaintext note into a vault notebook and created a notebook under a vault**, and nothing stopped it. The vault/share/"no notebook under a vault" proxy protections described elsewhere in this guide therefore only hold for browser-origin traffic. Making them live needs `authenticatedUser` to accept `X-API-AUTH` **and** a fix for a second bug: when a request does authenticate, both guards call `bufferRequest()` on the same stream, so the second read never completes and the request hangs (seen as a hung cookie-authenticated PUT to `/joplin/...`). Behaviour will change for existing clients (403s on vault writes), so treat it as its own change.
- **Collaborator content is invisible to the share owner in Joplock.** Access checks use Joplock's own `user_items` rows keyed by `jop_id`; Joplin Server keys them by `items.id`. A note a collaborator creates in a Joplin app never shows for the owner in Joplock (it does in the owner's Joplin apps). Tracked as the `todo` test in `joplin-sharing.test.js`. Fixing it means changing the access model together with Joplock's own removal paths (otherwise removed users keep access).
- Notes created in a share after sharing do not reach a **Joplock** recipient, and a note moved out of a share stays visible to a Joplock recipient (the note write paths never grant/revoke Joplock's `jop_id` rows; only folder operations do). Stock-client recipients are handled correctly by the server.

View file

@ -0,0 +1,134 @@
# Notebooks, nesting and sharing
> Read this before: touching the notebook tree (nesting, drag and drop, sort, pins), notebook create/move/delete rules, or anything about sharing / `user_items`.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Sharing Ownership Model
- One authoritative tree owned by sharer (`owner_id` never transferred).
- Recipients gain access via Joplin `user_items` + accepted `share_users` (auto-accept on invite).
- **Vault notebooks cannot be shared**: `POST /api/web/shares` rejects them with 400 `Vault notebooks cannot be shared` (`app/routes/shares.js`).
- `share_users.can_write` controls whether recipients can edit shared notes (default `1` — editable). Only the owner can move, delete, or stop-share.
- Move into a shared notebook sets `share_id`; move out clears it and drops recipient access to that item.
- Revoke/stop sharing removes recipient access; owner keeps folders/notes in place.
- Recipients can leave a shared notebook via the share dialog ("Leave notebook" button) → removes their `share_users` row + `user_items` entries.
- Only the share owner sees invite/remove/stop controls in the share dialog. Recipients see only their own access status and the Leave button.
### Shared-note guard layers
- **Route layer** (`app/routes/fragments.js`, `app/routes/api.js`): `resolveItemShareAccess` → `assertCanWrite`/`assertOwnerForDestructive` on create/update/delete/restore/move. `canWrite` respects `share_users.can_write` for recipients. Owner-only for move/delete regardless of `can_write`. Recipient creates in shared folder blocked with 403.
- **Proxy layer** (`app/proxy/shareProxyGuard.js`): inspects PUT and DELETE sync-proxy requests. PUT checks `resolveItemShareAccess` → `canWrite`. DELETE is owner-only. Inherits `noteIdFromItemPath`/`bufferRequest` from vault proxy guard.
- **Editor UI** (`app/templates/fragments.js`, `public/app.js`): `editorFragment` accepts `canWrite` param from route handler (queries `share_users.can_write`). When `canWrite=false` renders read-only banner, disables folder select, hides delete button, sets `contenteditable="false"` on title. When `canWrite=true` the editor is fully interactive.
- **Share-id propagation** (`app/items/shareAccess.js` for the field derivation, `app/routes/shares.js` for the write): `deriveShareFieldsForMove(targetFolder)` returns `{ shareId, isShared }` from the target folder, so a move sets/clears `share_id`. `ensureShareIdsOnNotebook()` writes `share_id` directly to the items DB content JSON. `createNote`/`updateNote` serialize these fields into Joplin note metadata.
- **Share dialog API** (`app/routes/shares.js`): uses Joplin Server's `/api/shares/:id/users` endpoints (not deprecated `/api/share_users`). PATCH/ACCEPT/REJECT/DELETE operations use DB-only writes since Joplin Server's newer API doesn't support mutations on individual share_users. `can_write` column managed via direct `share_users` table updates. The module also exports helpers reused elsewhere: `autoAcceptShareUser`, `populateUserItems`, `ensureShareIdsOnNotebook`, `STATUS_ACCEPTED`/`STATUS_WAITING`.
- **Never decode `items.content` for every item in share SQL** (`app/routes/shares.js`): `items` also holds binary resource blobs (`jop_type 0`, every uploaded image/PDF). `convert_from(content,'UTF8')` raises on them and aborts the whole statement — and the routes swallow DB errors, so invites silently stopped fanning out `user_items` (shared notebook never appeared) and revoke/stop-sharing silently left access behind as soon as the owner had one attachment. Read `share_id` only through `shareIdOf()` (a `CASE WHEN jop_type IN (1, 2)` guard; SQL gives no AND/OR evaluation-order guarantee). Pinned by `tests/sharesBinaryItems.test.js`.
### File map
| Layer | File |
|-------|------|
| Access helpers | `app/items/shareAccess.js` |
| Share dialog API routes | `app/routes/shares.js` |
| Proxy write guard | `app/proxy/shareProxyGuard.js` |
| Fragment write gates | `app/routes/fragments.js` |
| API write gates | `app/routes/api.js` |
| Share dialog template | `app/templates/shares.js` |
| Share dialog client | `public/app.js` (openShareDialog, inviteToShare, toggleShareWrite, leaveShareNotebook, etc.) |
| Read-only editor | `app/templates/fragments.js` (editorFragment) |
| Unit tests | `tests/shareAccess.test.js`, `tests/shareProxyGuard.test.js`, `tests/shareWriteGuards.test.js` |
| Playwright tests | `playwright-tests/share-modal.spec.js`, `playwright-tests/share-access.spec.js`, `playwright-tests/share-revoke-move.spec.js` |
## Nested Notebooks
Joplin notebooks nest via the folder's `parent_id`. Joplock renders, creates, moves and deletes them on desktop and mobile. There is **no schema change** and no sidecar table: the tree is just `jop_parent_id` on the shared `items` rows, so Joplin desktop/mobile/CLI see exactly the same hierarchy.
### Where the logic lives
| Concern | File |
|---|---|
| Pure tree helpers (build/flatten/descendants/ancestors/roll-up counts/`canNestUnder`/option labels) | `app/items/folderTree.js` |
| Create / move / delete / share-eligibility rules, shared by fragment + JSON routes | `app/items/folderOps.js` (`ctx.folderOps`, built in `createServer.js`) |
| Subtree share stamping, recipient grant/revoke | `app/routes/shares.js` (`collectSubtreeItems`, `setShareOnItems`, `createShareSync`) |
| Sync-proxy guard (no notebooks under a vault) | `app/proxy/vaultProxyGuard.js` (`enforceFolderWrite`) |
| Desktop tree markup | `navigationFragment` in `app/templates/fragments.js` |
| Mobile tree markup | `mobileFoldersFragment` in `app/templates/mobile.js` |
| Pickers | `notebookOptionsHtml` (editor select), `folderPickerOptions` + `GET /fragments/folder-options` (move / new-notebook modals) |
| Tests | `tests/folderTree.test.js`, `tests/folderOps.test.js`, `tests/folderRoutes.test.js`, `tests/nestedFoldersUi.test.js`, `playwright-tests/nested-folders.spec.js`, `playwright-tests/nested-share.spec.js` |
### Tree semantics (keep them)
- **Siblings keep input order.** `foldersByUserId` already orders by case-folded title; `folderTree` never re-sorts, so flat lists and the virtual "All Notes"/"Trash" rows come out unchanged.
- **Defensive like Joplin's `buildTree`:** a folder whose parent is not visible (not shared with this user, deleted) is shown at the top level; a parent cycle never loops (first folder of the cycle in list order becomes a root); depth is capped at `MAX_DEPTH`. Every folder always appears.
- **Counts:** the nav and mobile list show **rolled-up** counts (a collapsed parent is honest). `counts` from `folderNoteCountsByUserId` stays the **direct** count and still drives note pagination (`/fragments/folder-notes`), `data-note-count`, and "does this notebook have notes to fetch". Selecting a parent lists only its own notes; "All Notes" is the aggregate.
- Sub-notebooks sort/render **before** the notebook's own notes.
### Desktop DOM contract (easy to break)
```
.nav-folder[data-folder-id][data-parent-id][data-depth][style=--nav-depth:N]
> .nav-folder-row
> .nav-folder-children (only when it has sub-notebooks; contains more .nav-folder)
> .nav-folder-notes[data-folder-id] (this notebook's OWN notes, lazy loaded)
```
- **Never** use `el.querySelector('.nav-folder-notes…')` or descendant CSS (`.nav-folder.collapsed .x`) on a notebook: sub-notebooks are inside it and would match first / leak collapse state. Use `navFolderNotesDiv(el)` (direct child) and the child combinator in CSS (`.nav-folder.collapsed > .nav-folder-notes`).
- **Multi-expand, no accordion.** Any number of notebooks can be open. Opening one also opens the notebooks above it (`toggleNavFolder`). `initNavPanel` trusts saved state; with no saved state the selected notebook **and its ancestors** open. Notes are lazy-loaded only for notebooks that are open **and visible** (`navFolderVisible`); expanding a parent loads already-open descendants (`navLoadOpenDescendants`). A notebook with 0 direct notes never requests a notes page.
- Saved state is `localStorage['joplock-nav-folders']` (notes-list open).
### Drag and drop (desktop)
Drag a notebook onto another to nest it; drop it on the **"Drop here to move to the top level"** strip (only shown while dragging a *nested* notebook) to un-nest it. Hovering a closed notebook that has sub-notebooks for 700 ms opens it so deeper targets are reachable.
- The template decides who takes part: `navigationFragment` puts `draggable="true" data-dnd="1"` on the row of ordinary notebooks only (not All Notes, Trash, vaults or Joplin-encrypted notebooks). The script reads those attributes; the **server re-validates every move** (`PUT /fragments/folders/:id`), and a refusal is shown with `alert()` (e.g. "Stop sharing this notebook before moving it into another notebook").
- Handlers (`navDragStart/Over/Leave/Drop/End`, `navDropAllowed`, `navMoveFolder` in `public/app.js`) are **delegated on `document`**, so they survive htmx re-renders. `dragenter` and `dragover` share one handler. `dragleave` must be judged by position when `relatedTarget` is null (Chromium fires it, with a null target, when the pointer crosses a row's own child elements); clearing the highlight on every `dragleave` made it flicker off. Visual changes happen a tick after `dragstart` because changing layout synchronously there cancels the drag in some browsers.
- Desktop only: touch has no HTML5 drag-and-drop, mobile uses the "Move notebook..." sheet. Real-mouse coverage: `playwright-tests/nested-folders.spec.js` ("drag and drop"); pure rules and lifecycle: `tests/nestedFoldersUi.test.js`.
### Notebook order (A-Z / most recently updated)
Per-user setting `folderSort` (`alpha` default | `recent`; Settings -> Appearance, plus a quick switch in the sidebar header `#nav-sort-btn` and the mobile folders header `#mobile-sort-btn`, both calling `toggleFolderSort()`; `<body data-folder-sort>` picks which label shows).
- **What "recent" means** (`sortFoldersByRecent` in `app/items/folderTree.js`, data from `itemService.folderActivityByUserId`): a notebook's rank is the newest note activity **anywhere in its subtree** (so a parent rises with its busiest child, like Joplin's own "updated date" notebook sort), using the note's `user_updated_time` (falls back to the row time) and the notebook's creation time when nothing is in it. Trashed and conflict notes do not count. Because `user_updated_time` only moves on a real title/body edit (see Joplin Field Fidelity in `docs/agents/joplin-fidelity.md`), **moving or trashing a note does not reorder notebooks**.
- **Parents always show.** The order is applied to the FLAT list before the tree is built, so reordering can never separate a notebook from its parent; siblings simply sort by their own rolled-up recency, ties keep alphabetical order.
- **Where it applies:** the sidebar (`navData` -> `orderFoldersForUser`) and the mobile folders screen. **Pickers are always alphabetical** (editor notebook `<select>`, move / new-notebook dialogs): `sortFoldersByRecent` tags each folder with `alphaRank` and `inAlphabeticalOrder()` restores it, so the database's collation is never re-derived. The activity query only runs for users who chose "recent".
- Switching order re-renders the nav via `/fragments/nav`; lazily loaded note lists now receive the open note's id (`navLoadFolderNotes`) so it stays highlighted.
- Tests: `tests/folderTree.test.js` (ordering), `tests/folderSort.test.js` (SQL, setting, routes, pickers), `playwright-tests/folder-sort.spec.js`.
### Pinned notebooks
Pin a notebook to the top of the list: **Pin to top / Unpin from top** in the desktop context menu (`#folder-ctx-pin`) and the mobile long-press sheet (`#mobile-folder-ctx-pin`); a pin icon marks pinned rows (`.nav-pin-icon`, `.mobile-pin-icon`, `data-pinned="1"`).
- **Joplock-only, per user.** Stored as `pinnedFolders` in `joplock_settings` (max 100 ids, distinct); changed only through `PUT` / `DELETE /api/web/pinned-folders/:id`. **Nothing is written to the Joplin notebook** (Joplin has no pin concept), so Joplin apps are unaffected, other users do not see your pins, and a recipient can pin a notebook that was shared with them. Pinning a notebook that does not exist is a 404; a successful pin also forgets pins of notebooks that no longer exist (stale ids are otherwise ignored on render). Guarded by a byte-for-byte check of the Joplin item in `playwright-tests/folder-pin.spec.js` and a "no Joplin write" unit test.
- **Ordering** (`applyPinnedFirst` in `app/items/folderTree.js`, applied in `orderFoldersForUser` after the A-Z / recent order): pinned notebooks come first among their siblings; among themselves they follow the active order. **Pinning a nested notebook pulls its ancestors up** (rank 1 = "contains a pinned notebook") so it stays near the top and is still drawn under its parents; ancestors are not themselves marked pinned. Like every ordering here it runs on the FLAT list, so a notebook can never be separated from its parent.
- **Pickers ignore pins** (editor notebook select, move / new-notebook dialogs): `applyPinnedFirst` keeps/assigns `alphaRank` so `inAlphabeticalOrder()` restores the alphabetical order.
- Tests: `tests/folderTree.test.js` (rules), `tests/folderPin.test.js` (setting, endpoints, routes, pickers, client helpers), `playwright-tests/folder-pin.spec.js`.
### Mobile contract
- The folders screen is an **inline expandable tree** (not drill-down), so `mobileBack` and the 3-screen stack are untouched. Rows carry `data-folder-id/-parent-id/-depth` and `--m-depth`; rows below the top level render `hidden` and `mobileApplyFolderTree()` reveals them from `localStorage['joplock-mobile-folders']` (sub-notebooks shown; separate from the desktop key). `.mobile-folder-row[hidden]` needs its own CSS rule because `display:flex` beats the UA `[hidden]`.
- The chevron (`.mobile-folder-toggle`) is its own tap target and stops propagation; the row tap still calls `mobilePushNotes(id,title)`. **Keep the `mobilePushNotes("id","title")` onclick format**: `wireFolderRowLongPress` parses it. The chevron gutter is only rendered when something is nested.
- Long-press sheet gained "New sub-notebook" and "Move notebook…". The note-move sheet (`mobileCtxMove`) reuses the editor `<select>`'s option text, so the tree indentation carries over.
- Both expansion keys are cleared on logout (`pages.js`).
### Rules enforced server side (`folderOps`)
- **Structure:** not into itself or a descendant (mirrors Joplin's `canNestUnder`). Same-parent move is a no-op.
- **Delete = promote.** Sub-notebooks move up to the deleted notebook's parent; its notes move to that parent (top level: the existing top-level "General", created if missing). Deleting never deletes notes or sub-notebooks. (Old behaviour moved every note to General and orphaned sub-notebooks.)
- **Vaults are top-level leaves.** Cannot contain notebooks, cannot be moved, cannot be created nested (`POST /api/web/vaults` refuses a nested notebook or one with children), cannot end up in a share. The new-notebook modal disables the parent picker when "vault" is ticked. `vaultProxyGuard` also rejects a notebook PUT whose parent is a vault, **but see "Sync-proxy guards do not apply to stock Joplin clients" in `docs/agents/joplin-fidelity.md`: that guard is currently inert for real clients.**
- **Sharing:**
- Only the owner creates/moves/deletes inside a share (recipients get 403).
- A **share root stays top level** (Joplin's rule): moving one under anything is refused ("Stop sharing…"); sharing a nested notebook **lifts it to the top level first** (`prepareForShare`; the share dialog says so); a notebook already inside a share cannot be shared again; a subtree containing a vault cannot be shared.
- `ensureShareIdsOnNotebook` now stamps the **whole subtree** (every sub-notebook and the notes directly in each), writing `share_id`/`is_shared` into the item JSON **and** the `jop_share_id` column (falls back to JSON only if a server lacks the column). Creating a sub-notebook in a share inherits it; moving a subtree into/out of a share re-stamps it and grants/revokes recipient `user_items` (`createShareSync.setSubtreeShare`).
- **Subtree share changes go through the API.** `createShareSync.setSubtreeShare` re-saves every notebook and note of the subtree with the new share fields (`itemWriteService`, lossless thanks to pass-through) so Joplin Server emits change events. Silent database writes are invisible to stock clients: a subtree moved **out** of a share used to stay in the recipient's Joplin app. Items the API cannot rewrite (Joplin-encrypted) get the database sweep only. Verified with a real client recipient in `integration-tests/joplin-sharing.test.js`.
- **Stopping a share clears the dead `share_id` from the owner's whole tree** (notebook, sub-notebooks, notes; JSON and `jop_share_id`), like Joplin's own `unshareFolder`. Left in place, Joplin Server answers any later write of those items with `share not found` (422 — the note could no longer be edited or trashed) and **silently refuses to delete a root folder that still carries a share id** (returns 200, does nothing). Pinned by `playwright-tests/nested-share.spec.js` step 6.
- Recipient access keeps the existing Joplock convention: `user_items.item_id` holds the **`jop_id`** (Joplin Server's own fan-out uses `items.id`, which `itemAccessExpression` does not read).
- `updateFolder` now preserves `created_time`, `user_created_time`, `icon` and `master_key_id` (a rename/move used to reset them).
### Endpoints
- `PUT /fragments/folders/:id` and `PUT /api/web/folders/:id` accept `title` and/or `parentId` (`''` = top level; both in one write). `POST /fragments/folders` and `POST /api/web/folders` accept `parentId`. `DELETE` promotes.
- `GET /fragments/folder-options?exclude=<id>&selected=<id>` → `<option>`s in tree order; the excluded subtree, vaults and foreign shares are `disabled`.
- `GET /fragments/nav?folderId=<id>&withSelect=1` highlights/opens a notebook and refreshes the open note's notebook `<select>` (used after a move).
### Known pre-existing sharing gaps (not caused by nesting; not fixed here)
Verified live while building this: a **note created in a shared notebook after the share** is not visible to a Joplock recipient, and a **note moved out of a share stays visible** to the recipient (the note write paths never call grant/revoke; only folder operations do). Fixing means calling `createShareSync.setItemsShare` from the note create/move paths.

77
docs/agents/pwa-mobile.md Normal file
View file

@ -0,0 +1,77 @@
# PWA and mobile platform
> Read this before: touching the mobile layout or its screens, the viewport / soft keyboard, the service worker, static serving, iOS behaviour, or `/diag`. iOS findings here come from a real device, not an emulator.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## PWA & Mobile Platform
Everything about being an installed phone app lives here: which layout a device gets, how the iOS home-screen viewport and soft keyboard behave, what is cached, and how to measure it. **iOS behaviour here was found on a real iPhone Air home-screen install, not in an emulator**: Chromium cannot reproduce the iOS soft keyboard scrolling the document, the status-bar viewport bug, display cutouts or the home indicator. Before changing shell, viewport or keyboard layout, measure on a device with `/diag` (below) and compare numbers, not impressions. The app is an installable PWA with no offline notes: the only thing available offline is a static "you're offline" page.
### Which shell (mobile vs desktop)
`isMobileShellMode()` (`_computeMobileShell`, `public/app.js`) is true for uiMode `mobile`, or in `auto` when the window is <=768px wide, **or** the device is a phone: `(pointer: coarse)` and a physical screen whose short side is <=500 CSS px (`_isPhoneScreen`). The second clause keeps a phone in landscape (812-956px wide) on the mobile shell; it used to get the desktop layout in ~390px of height and rotating reloaded the page (a shell flip reloads). The CSS mirror is the media query `(max-width: 768px), (pointer: coarse) and (max-device-width: 500px), (pointer: coarse) and (max-device-height: 500px)` on the four former `max-width: 768px` blocks in `styles.css`; iOS reports `device-width` as the portrait width in both orientations and Android rotates it, hence both. **Never use the window height** to decide: a soft keyboard shrinks the window (on iOS home-screen apps `innerHeight` shrinks with it), which would flip an iPad to the phone layout and reload the page while typing. Tablets (screen short side >=600) and mouse-driven windows keep the width rule only. The mobile shell is inset by `env(safe-area-inset-left/right)` (landscape notch / Dynamic Island), and `@media (max-height: 500px)` tightens the title bar and toolbar; in landscape with the keyboard up the editor's title bar (`#mobile-editor-header` only, never the search header) steps aside. Covered by `tests/landscapeShell.test.js` and `playwright-tests/mobile-landscape.spec.js` (the iPad case sets the physical screen apart from the window through CDP, because `page.setViewportSize` shrinks the emulated screen too). Mobile/tablet editor behavior should be reasoned about by editor container context, not just viewport width.
### iOS home-screen status bar (do NOT use black-translucent)
Pages must not set `apple-mobile-web-app-status-bar-style` (leave it absent, as herdr-web-ui does). With `black-translucent`, an iOS standalone app sizes `innerHeight`/`100dvh`/`100svh` as *screen height minus the top safe-area inset*, anchored at the top, while `100lvh`/`100vh` still report the full screen. The shell then ends that many points above the bottom edge (a dead band painted in the page background) and every `position: fixed; bottom: …` element (FAB, bottom sheets, backdrops) floats too high. Measured on an iPhone Air: screen 812, `innerHeight` 751, top inset 61. Absent meta = content starts below the status bar, bottom edge lands on the hardware, safe-area-top is 0. iOS bakes the value in at install time, so after changing it the icon must be removed and re-added. `tests/pwaHead.test.js` guards this, that every `apple-touch-startup-image` file exists and matches its media query, and that each `100vh` has a `100dvh` fallback (WebKit reports `100vh`/`100lvh` overflowing in default-style standalone installs). Newer iPhones need their own splash entry in `app/templates/shared.js` + `scripts/generatePwaAssets.mjs` (iPhone Air 420×912 @3x, 16/17 Pro 402×874 @3x are included).
### Soft keyboard / viewport (`public/viewport.js`)
Loaded in `<head>` after the inline config and before `app.js`. On a touch device it decides whether a soft keyboard is up (`keyboardVisible`: a typing element is focused, or the keyboard was already up, AND the visual viewport has lost more than 20% of the largest unobstructed height seen; pinch zoom and non-touch never count) and then: sets `html[data-keyboard]` plus `--app-height` / `--app-top`, which `styles.css` applies to `.mobile-app` only in that state (outside it the shell is `inset:0` exactly as before); and, while the mobile shell is showing, puts any document scroll back to (0,0). Reason: iOS scrolls the document to reveal the caret (measured on an iPhone Air home-screen app: `scrollY` 268-342px even with `body{position:fixed}`), which carries the header and the markdown toolbar off the top of the screen; sizing the shell to the visible area and pinning the scroll leaves nothing to scroll. It fires `joplock:viewport` on `document`; `positionTinyMCEHost` uses `#mobile-app`'s bottom (not an `innerHeight` guess) and re-anchors on that event. Never reintroduce `innerHeight - visualViewport.height` keyboard guesses (an iOS home-screen app shrinks `innerHeight` with the keyboard). Kill switch on a device: `localStorage['joplock-viewport'] = 'off'`. With the admin debug flag on it posts `viewport.kb` events (keyboard state, visual viewport, scroll, how often it had to pin, header/toolbar positions on screen) to `/api/web/client-log`; read them with `node scripts/diag-reports.js --event viewport.kb` (flags `HDR-OFF`, `TB-OFF`, `TB-HIDDEN`, `PANNED`, `NOT-PINNED`). `./scripts/rebuild-dev.sh` archives device reports into `.diag-archive/` before recreating the container (a recreate wipes its logs). `/diag?shell=vv` runs this same module. Covered by `tests/viewport.test.js` (pure logic, real iPhone numbers) and `playwright-tests/mobile-toolbar-keyboard.spec.js`.
### 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.
### Static asset serving (`serveFile` in `app/routes/_helpers.js`)
Compressible text assets (.js .css .json .svg .html .webmanifest, 1 KB to 4 MB) are served brotli/gzip, negotiated from `Accept-Encoding` (q-values respected), with `Vary: Accept-Encoding` and a content-hash `ETag` per encoding (a stale copy revalidates with a 304, not a re-download). Brotli (level 11) and gzip are computed once, asynchronously, and cached in memory per file+mtime; `warmStaticCache(publicDir)` does the root-level files at startup so the first visitor does not wait. A URL carrying `?v=<asset version>` is `immutable` for a year (every script and stylesheet in the app head carries it; `tests/pwaHead.test.js` enforces that); other files are `max-age=300` plus ETag; `service-worker.js` and the manifest are `no-cache`; html is `no-store`; images and fonts stream with a weak validator. Measured on the dev stack: shipped JS+CSS 2.5 MB raw → 0.77 MB (gzip) / 0.67 MB (brotli); cold relaunch with an expired cache on a slow 4G link 7.8s → 2.6s. The reverse proxy in front (Nginx Proxy Manager defaults) does not compress JS/CSS, so this must stay in the app. Covered by `tests/staticServing.test.js`. HTML responses are gzipped separately by `makeSendHtml`.
### 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`).
### Device diagnostics (`/diag`)
Debug-mode-only page (`app/routes/diag.js`, `app/templates/diag.js`) for measuring viewport, safe-area and soft-keyboard behaviour on a real phone. Linked from Settings → About when debug logging is on. It mocks the mobile shell (header, iframe + input + textarea like the real editors, bottom bar) and posts flat snapshots to `/api/web/client-log`, which land in the container log as `[joplock client] diag.viewport {…}`. Read them with `node scripts/diag-reports.js [--since 30m] [--event diag.viewport|viewport.kb] [--rid <id>] [--json]` (one compact line per report, flags `HDR-OFF` / `BAR-HID` / `BAR-GAP` / `NO-SAFE`). Variants: `?shell=current` (the plain fixed-inset shell, i.e. what iOS does with no help), `?shell=vv` (loads `public/viewport.js`, the production module: shell height follows `visualViewport` while the keyboard is up, page scroll pinned). The viewport meta defaults to the app's own (`viewportMeta`); `?iw=0` drops `interactive-widget=resizes-content` and `?zoom=0` blocks zoom (the old meta) for comparison. Report field names must not match the client-log key filter (`text|body|content|password|key|secret|token`); `tests/diagPage.test.js` enforces that. Use it before changing shell/keyboard layout so the change is compared against real-device numbers, not Chromium emulation (which cannot reproduce the iOS keyboard or notch).
## Mobile UI Model
### Shell structure
- `#mobile-folders-screen`
- `#mobile-notes-screen`
- `#mobile-editor-screen`
These screens are shown/hidden by inline JS in `layoutPage()` using class changes, not route changes.
### Mobile navigation behavior
- Folder-first flow: folders -> notes -> editor (the folders screen is an inline expandable tree for nested notebooks; see Nested Notebooks in `docs/agents/notebooks-and-sharing.md`)
- Search has its own mobile header state
- Mobile note creation uses dedicated fragment endpoints and server headers to drive the next UI step
- The floating action button is only a mobile affordance; desktop should stay unaffected
- FAB visibility should follow screen state directly (`folders` / `notes` visible, `editor` hidden), not only htmx swap side effects
- In markdown mode the mobile formatting toolbar (`#editor-toolbar`) is permanent: `setMobileToolbarVisible(false)` is ignored there (only a locked vault hides it). Callers ask to hide it when the viewport handler thinks the keyboard went away, which is wrong on iOS home-screen apps (`innerHeight` shrinks with the visual viewport, so an open keyboard looks closed), and from the rich editor's blur handler after a switch to markdown. Do not reintroduce `innerHeight - visualViewport.height` keyboard guesses; see `/diag` for real-device numbers. Covered by `tests/mobileToolbarVisibility.test.js` and `playwright-tests/mobile-toolbar-keyboard.spec.js`.
- The markdown editor's word-count / heading-outline bar (`.cm-jl-status`, `mdStatusBar` setting) is desktop-only: `_cmExtraExtensions` passes `statusBar: _mdStatusBar && !isMobileShellMode()`, so it is never created in the mobile shell (it costs a row under the editor and recounts words on every keystroke). The Outline jump-to-heading popup lives in that bar, so it is not available on mobile either. Covered by `tests/mobileStatusBar.test.js` and `playwright-tests/md-extras.spec.js`.
- `#mobile-folders-body` / `#mobile-notes-body` carry `padding-bottom: calc(96px + env(safe-area-inset-bottom))` so the last row (or the notes list's "Load more" button) scrolls clear of the FAB (56px + 20px offset) and the home indicator. Scope it to those two ids: `#mobile-editor-body` shares `.mobile-screen-body` and must not get it. Covered by `playwright-tests/mobile-list-clearance.spec.js`.
- Mobile folder rows can include a vault lock button and it must stay inline with the row actions
### Mobile editor behavior
- Mobile hides the desktop title bar and uses the mobile header instead
- Mobile header mirrors note title and save state
- Mode buttons should remain visible and clearly indicate the active mode
- Toolbar visibility should be keyed to being inside the mobile editor container, not only screen width
- Newly-created empty mobile notes may be discarded on back if still blank/untitled
- Locked mobile notes should not reveal plaintext/editor surfaces until unlock
### Tablet expectations
- A tablet window wider than 768px gets the **desktop** shell (see "Which shell"); only a narrow window (<=768px) or a phone-sized touch screen gets the mobile shell
- Existing note open path and new note open path should behave the same with respect to default open mode, toolbar visibility, and title/save-state UI
- When debugging tablet issues, compare the exact htmx target and after-settle path used by new-note vs existing-note opens
## Debugging
### If mobile note creation/opening misbehaves
- Check whether the server response includes the expected mobile header such as `X-Mobile-Note-Id`
- Check the `htmx:afterRequest` handler that consumes that header
- Compare new-note path vs existing-note path
- Check whether the note is in a vault and whether the editor was initialized in locked vs unlocked state

View file

@ -0,0 +1,99 @@
# Settings, themes, admin and ops
> Read this before: adding a setting or theme, changing the settings page, admin/backup/recovery, MFA, or debug logging.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Storage
- Settings are stored per-user in `joplock_settings.settings` as JSONB
- `app/settingsService.js` owns defaults and normalization
- Unknown or invalid values should normalize back to safe defaults
## What persists a setting
A setting only persists if it is (a) defaulted/normalized in `app/settingsService.js` and
(b) listed in the `allowedKeys` array in `app/routes/api.js` (or handled by a `/admin/*` route
for admin settings). Adding a key to only one of the two silently does nothing.
The list of settings and their defaults is `defaultSettings` / `normalizeSettings` in `app/settingsService.js`; it is not copied here.
`noteMonospace` is not a separate UI switch: it is derived from `noteFontFamily === 'mono'`
(via the `note-body-monospace` class on `<body>`).
## Debug Logging (runtime-toggleable)
- `appSettings.debugLogging` is tri-state (`normalizeTristate()` in `app/settingsService.js`): `null` = inherit env `DEBUG`; `true`/`false` = explicit admin override persisted in DB.
- `app/createServer.js` keeps `effectiveDebug` in memory; `refreshDebugLogging()` re-reads the DB on startup and after an admin save. `isDebug()` is read dynamically by the request logger and passed into ctx; `_joplockConfig.debug` and the settings-page inline `DEBUG` flag both reflect the effective state.
- Toggled via the "Enable debug logging" checkbox in Settings → Admin → Login Security. Applied immediately, no restart. `DEBUG` env in the compose files is only the startup default.
## The settings page is a full page, not a modal
- `/settings` is a standalone SSR page (full navigation away from the app), not an htmx fragment or modal. It has its own inline `<script>` and does NOT load `app.js`.
- Esc dismisses it (returns to `/`) via `<body onkeydown="...">` — an HTML attribute so it fires before any JS and can't be killed by a script error. It flashes the page background briefly as visual confirmation. Do not move this back into an `addEventListener` inside the IIFE; that proved unreliable (IIFE errors / event-propagation quirks swallowed Esc).
- Settings auto-save, so Esc has no unsaved-text concern.
## Adding a new setting
1. Add default + normalization in `app/settingsService.js`
2. Allow the key in the `allowedKeys` array of `PUT /api/web/settings` in `app/routes/api.js` (or in the matching `/admin/*` route for admin-only settings)
3. Add the UI in `settingsPage()` in `app/templates/settings.js`
4. If needed, inject the normalized setting into `layoutPage()` / `_joplockConfig` in `app/templates/pages.js` for `public/app.js`
5. Nothing to bump: the asset version and service worker cache name are computed from the shipped files (`app/assetVersion.js`)
6. Rebuild with `./scripts/rebuild-dev.sh`
## Adding or editing a theme
Themes are CSS-only. Each theme is a class on `<body>` that sets a shared set of CSS custom properties.
**Files to touch:**
1. **`public/styles.css`** — Add/edit a `.theme-<slug>` block that defines the same set of custom properties used by every other theme.
- Minimum properties that must be defined: `--bg`, `--theme-color`, `--bg-side`, `--bg-list`, `--bg-editor`, `--bg-elevated`, `--bg-input`, `--bg-hover`, `--bg-active`, `--overlay`, `--shadow`, `--text`, `--text-dim`, `--text-muted`, `--text-heading`, `--text-on-accent`, `--accent`, `--border`, `--border-focus`, `--danger`, `--toolbar-bg`, `--scrollbar`, `--statusbar-bg`, and `color-scheme` (`light` or `dark`).
- `--bg-elevated` in particular is not optional: it is forwarded into the TinyMCE iframe by `_syncTinyMCEThemeVars()` and backs the image-download button background. A theme missing it renders that button transparent.
- Keep numbers and hover/active states neutral unless the theme intentionally uses color.
- The markdown toolbar and the TinyMCE toolbar both share `color-mix(in srgb, var(--accent) 10%, var(--bg))`. Setting a sensible `--accent` and `--bg` is enough; no extra toolbar work needed.
2. **`app/settingsService.js`** — Add the theme slug to the `validThemes` array at the top of the file (it is the allowlist that keeps a persisted slug from being normalized back to the default).
3. **`app/templates/shared.js`** — Add `[<slug>, <displayName>]` to `themeOptions` so it appears in the status bar picker and the settings page.
4. **`tests/settingsService.test.js`** — Add an assertion that `normalizeSettings({ theme: '<slug>' }).theme` is preserved and not normalized back to the default.
5. **Service worker / asset version** — nothing to do: `app/assetVersion.js` hashes the shipped files, so new theme CSS changes the version, the `?v=` URLs and the worker's cache name automatically. Add the theme's `--theme-color` in its CSS block (`app/themeColors.js` reads it for the page head; `tests/themeColors.test.js` fails if a selectable theme lacks one).
6. Rebuild with `./scripts/rebuild-dev.sh`.
No changes are needed in `pages.js`, `app.js`, or `settings.js`: those all read `themeOptions` or apply `theme-${settings.theme}` dynamically.
## MFA Notes
- MFA is per-user, managed via Settings → Security → Two-Factor Authentication.
- Each user's TOTP seed is stored in `joplock_settings.totp_seed` in the shared Postgres DB.
- No global/shared TOTP seed. The old `JOPLOCK_TOTP_SEED` / `JOPLOCK_TOTP_ISSUER` env vars are removed.
- `IGNORE_ADMIN_MFA=true` skips the per-user MFA check at login for the docker-defined admin account (`JOPLOCK_ADMIN_EMAIL`). Other users are unaffected.
- Admin can force-enable/disable MFA for any user via the Admin tab (no code required).
## Admin & Ops Surface
### Who is "admin"
- Admin is **not** a role grant inside Joplock. `isJoplockAdmin()` in `app/createServer.js` returns true only for the single account whose email equals `JOPLOCK_ADMIN_EMAIL` **and** whose Joplin `users.is_admin` flag is set (`app/auth/sessionService.js`). If `JOPLOCK_ADMIN_EMAIL` is unset, `adminService` is `null` and there is no admin at all — so the Admin tab and the `/admin/*` routes disappear entirely.
- Only that account sees the Admin tab on `/settings`; `appSettings` (the `__app__` row) is read only for admins (`app/routes/settings.js`).
- `server.js` calls `adminService.ensureAdminUser()` (best-effort, non-blocking) so the account exists in Joplin with the configured password on startup.
### Backup / restore
- `app/backupService.js` shells out to `pg_dump`/`pg_restore` for the **entire shared database** (Joplin's and Joplock's tables together), writing `<name>.dump` files under `JOPLOCK_BACKUP_DIR` with path-containment checks. Compression is `JOPLOCK_BACKUP_COMPRESSION` (default `zstd:19`) or `JOPLOCK_BACKUP_COMPRESSION_LEVEL` (default `9`).
- A restore **replaces the whole database**, Joplin data included. `README.md` documents the operational order (stop/quiesce Joplin Server, stop sync clients).
- Backups are only durable if `JOPLOCK_BACKUP_DIR` is on persistent storage.
### Break-glass recovery
- `JOPLOCK_RECOVERY_ENABLED=true` + `JOPLOCK_RECOVERY_PASSWORD` expose `/recovery` (`app/routes/recovery.js`, `app/recoveryService.js`): its own login, its own short-lived session (`JOPLOCK_RECOVERY_SESSION_TTL_MINUTES`, default 30), and backup/restore without normal Joplin auth. It is for backup/restore only, not note editing.
- Recovery is separate from MFA: it does not consult `joplock_settings.totp_seed`.
### Login rate limiting
- `app/auth/rateLimitService.js` is an in-memory, per-credential-attempt limiter; the ceiling is the admin `authRateLimitAttempts` setting (default 20 per 15 minutes). It is not persisted — a Joplock restart clears the counters.

View file

@ -0,0 +1,116 @@
# Testing and the dev stack
> Read this before: running or writing tests (unit, Playwright), rebuilding the dev stack, or when a code change does not show up.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Verification
- Run tests: `npm test`
- Browser E2E: `npm run test:ui` (needs a live dev stack + admin env vars, see below)
- Build image: `npm run docker:build`
- Sidecar-only compose: `npm run docker:up`
- Full example compose: `npm run docker:up:full`
- Full example compose built from source: `npm run docker:up:build` (uses `docker-compose.example-full-build.yml`)
- Dev stack: `npm run docker:up:dev` / `./scripts/rebuild-dev.sh` (see "Development Stack")
- Rebuild PWA assets: `npm run generate:pwa-assets`
### Playwright credentials
- Tests NEVER hardcode credentials. `playwright-tests/helpers.js` resolves the admin account from the environment in this order: `PLAYWRIGHT_ADMIN_EMAIL` → `PLAYWRIGHT_EMAIL` → `JOPLOCK_ADMIN_EMAIL` (and the `*_PASSWORD` equivalents). The dev container sets `JOPLOCK_ADMIN_*`, so the useful tests work against it out of the box.
- `login()` calls `requireCredentials()` and fails loudly if none are set. Admin-only specs (`resource-lifecycle`, `auth-rate-limit`) use `hasAdminCredentials()` to `test.skip` when unset. `helpers.js` exports `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`hasAdminCredentials` so specs share one source of truth.
### Playwright helper robustness (do not simplify these away)
The shared helpers absorb races in the app's two-phase htmx settling, not test flakiness:
- `setNoteBody()` clicks whichever markdown toggle is **visible** (`#editor-panel #markdown-toggle:visible`, `#mobile-editor-body #markdown-toggle:visible`, `#mobile-md-toggle:visible`). The mobile shell hides the editor fragment's own MD toggle and shows the header `#mobile-md-toggle` from `pages.js`; a locator that does not filter on `:visible` can click a hidden element and silently inject into a textarea TinyMCE then overwrites.
- `setNoteTitle()` waits for the note-creation swaps to land, then sets the title and **verifies it stuck**, retrying up to 8 times. The creation response re-renders the editor with server state (`Untitled note`) after the form first appears, wiping an early title injection.
- `deleteNotebook()` waits for the `#nav-panel` swap to settle and retries the delete while a stale/duplicated folder row is still present.
- Specs must not depend on a user's editor settings: `md-extras.spec.js` pins the markdown-editor settings in `beforeAll` and restores the account's real values in `afterAll`; other specs read the CodeMirror *document* (`getCM().state.doc`), not `.cm-content` text, because "hide formatting marks" removes `## ` from the DOM of lines the caret is not on. Specs whose flow needs a native `confirm()` (e.g. `deleteNotebook()`) must call `acceptDialogs(page)`; the two-account share specs are desktop-only.
- When adding a helper, prefer a shared one in `helpers.js` over a per-spec local; delete helpers only after confirming no spec imports them.
### Playwright data cleanup (do not leave notes/notebooks/resources behind)
- **Tests must not leave data in the shared Joplin DB.** Every spec that creates notebooks/notes/resources must clean them up.
- Use `teardownTestData(page, { folders, folderPrefixes, titlePrefixes, noteIds })` from `helpers.js` in a `finally` block. It permanently removes the matching notes (trash + purge via `DELETE /fragments/notes/:id` twice), deletes the folders, empties trash, and cleans orphaned resources. It is best-effort (never throws), so it is safe in `finally` even after a failed assertion.
- Prefer `{ folders: [folder] }` — purges every note inside the notebook *then* deletes the notebook. (Plain `deleteNotebook()` is NOT enough on its own: deleting a notebook never deletes its notes — they move to the parent notebook, or **General** at the top level — so they leak.)
- For notes created outside a dedicated notebook (e.g. mobile "New note" in **All Notes**), capture the id with `getActiveNoteId(page)` and pass `{ noteIds: [id] }`.
- A suite-wide safety net runs automatically: `playwright.config.js` `globalTeardown` (`playwright-tests/global-teardown.js`) logs in once after the whole run and purges any leftover test-prefixed folders/notes + orphaned resources (folder name prefixes like `pw-`, `dnd-`, `esc-`, `search-`, `upload-`, `res-lifecycle-`, and known test note-title prefixes). Keep those prefix lists in sync when you add new test naming.
- Cleanup relies on `GET /api/web/notes/headers` returning `parentId` (added for this), `DELETE /api/web/folders/:id`, `DELETE /fragments/notes/:id` (trash then purge), `POST /fragments/trash/empty`, and `POST /admin/orphaned-resources/cleanup`.
- Verify a change doesn't leak by running a data-creating spec twice and confirming the DB item counts are identical before/after (they must be stable, i.e. zero accumulation).
### Playwright screenshots
- Screenshots are captured on every test run (pass or fail) under `test-results/`, named `{testTitle}-{project}-{browser}-{retry}.png`. Configured via `use.screenshot: 'on'` in `playwright.config.js`.
- Videos are still `retain-on-failure`, traces `on-first-retry`, and `test-results/` is already git-ignored.
### Playwright share tests
Share tests require the admin account and two browser contexts (owner + recipient). The shared reader user is created automatically by `ensureShareTestUsers()` via the admin API.
```
# All share tests (requires live dev stack)
npx playwright test playwright-tests/share-*.spec.js --project=desktop
# Individual specs
npx playwright test playwright-tests/share-modal.spec.js --project=desktop
npx playwright test playwright-tests/share-access.spec.js --project=desktop
npx playwright test playwright-tests/share-revoke-move.spec.js --project=desktop
```
Env vars (same credential chain as other Playwright tests via `JOPLOCK_ADMIN_*`):
```
JOPLOCK_ADMIN_EMAIL="admin@example.com" JOPLOCK_ADMIN_PASSWORD="..." npx playwright test ...
```
Multi-user fixtures use `test.extend` with per-test `ownerPage`/`readerPage` (separate browser pages). All tests are `desktop`-only and skip on mobile (right-click context menu). Dialogs are auto-accepted by `acceptDialogs()` in `beforeEach`.
Test data uses `slug('share-...')` prefixes and is cleaned via `teardownTestData` per test. The global teardown also purges `share-*` prefixed folders (add `'share-'` to the prefix list in `global-teardown.js` if missing).
## Development Stack
Use the dev compose stack for all development work. It includes Postgres, Joplin Server, and Joplock together.
- Rebuild Joplock app container after code changes: `./scripts/rebuild-dev.sh`
- Start / restart full dev stack: `docker compose -f docker-compose.dev.yml up -d --build`
- Stop dev stack: `docker compose -f docker-compose.dev.yml down`
Do not use the sidecar-only `docker-compose.yml` for development.
Important:
- `docker compose ... restart joplock` is not enough after source edits because the Docker image copies `app/`, `public/`, and `server.js` at build time.
- For app code changes, use `./scripts/rebuild-dev.sh` from now on.
Recommended inner loop:
1. Edit source
2. Rebuild with `./scripts/rebuild-dev.sh`
3. Refresh the app
4. Check `docker compose -f docker-compose.dev.yml logs --tail=... joplock` if something looks wrong
## Reference Material
- Mobile UX reference, only if you have the Joplin monorepo checked out next to this repo: `~/dev/joplin/packages/app-mobile/`
- Use it for interaction ideas and behavior parity targets, not as a copy-paste implementation source
- Joplock must still fit the SSR + htmx sidecar architecture
## Debugging: If a code change does not appear in the app
- Rebuild with `./scripts/rebuild-dev.sh`
- Do not rely on `docker compose ... restart joplock` after source edits
- If still stale, inspect the built container logs and confirm the right compose stack is running
## Tests
- `tests/*.test.js` — unit/integration tests (`node:test`; several suites extract real functions out of `public/app.js` and run them under a JSDOM harness)
- Run: `npm test` (i.e. `node --test tests/*.test.js`)
- Real Joplin client compatibility (opt-in, needs the dev stack): `npm run setup:joplin-cli` once, then `npm run test:joplin` (`integration-tests/*.test.js`)
- `playwright-tests/*.spec.js` — browser E2E against a live dev stack; run with `npm run test:ui`
## Configuration, compose files and built bundles
Facts that are not obvious from the repo layout:
- **Configuration is inline environment variables in the compose files** (they are commented); there is no committed `.env.example`, and `.env` is git-ignored. The variables themselves are read in `server.js` and the services; do not copy a list of them into docs.
- `docker-compose.dev.yml` is the per-developer dev stack (Postgres + Joplin Server + Joplock from source). It is **git-ignored**; if it is missing, recreate it from `docker-compose.example-full-build.yml`. `./scripts/rebuild-dev.sh` and `npm run docker:up:dev` use it. Do not use the sidecar-only `docker-compose.yml` for development.
- **Bundles are built, never hand-edited:** `cm-build/` -> `public/codemirror.min.js` (`npm run build:cm`; the markdown extras need extra CodeMirror symbols exported from `cm-build/index.js`), `hljs-build/` -> `public/hljs.min.js` (`npm run build:hljs`), `prism-build/` -> `public/prism.min.js` (`npm run build:prism`). `public/tinymce/` is the TinyMCE 8 npm package copied in. `public/html-docx.js` is the legacy client-side DOCX path. `public/reference.docx` is the pandoc reference document for DOCX styling (regenerate with `scripts/build-reference-docx.sh`).
- Icons and splash images are generated: `npm run generate:pwa-assets` (see `docs/agents/pwa-mobile.md`).

View file

@ -0,0 +1,72 @@
# Vaults, encryption and save safety
> Read this before: touching vaults/encryption, ANY autosave or save path, note history/restore, or code that reads the editor DOM during a note switch. These guards each exist because a real note was overwritten.
> This file records *why* and *what not to regress*. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
## Vault / Encryption Model
- Vaults are notebooks/folders with metadata stored in `joplock_vaults`
- Titles stay plaintext
- Notebook names stay plaintext
- Note body ciphertext is stored in normal Joplin note bodies using Joplock markers for compatibility
- Browser crypto stays client-side only; server never receives vault passwords
- A note inside a vault notebook must be treated as protected even if its stored body is still plaintext during transition states
- Locked vault notes render the lock overlay plus hidden editor shells; do not remove the hidden editor DOM because unlock logic depends on it
- Unlocking a vault note with markdown as the preferred open mode must seed CM6 from the decrypted `plaintext` argument. Do not call `setEditorMode('markdown')` on unlock: that path `tinyMCESyncToTA()`s leftover/empty TinyMCE content over the just-decrypted body (locked notes skip editor init, so TinyMCE never held this note). `tinyMCESyncToTA()` must also refuse to write when `#tinymce-host` is not visible.
- Clicking a vault lock while unlocked should lock immediately and close the open note if it belongs to that vault
- Startup/refresh must never auto-resume an encrypted note or a note inside a vault notebook
### Encrypted-save identity guard (do not remove)
Encrypted-note autosave is debounced 2s. A hard-won bug was that the timer callback captured the outgoing note's `form`/`noteId`/`vaultId`, but read plaintext from the live DOM (`getTA()`). If the user switched notes during the debounce, plaintext of note B was encrypted with note A's vault key and PUT to note A's URL, silently overwriting A's ciphertext with an encryption of B's body. On next unlock, note A "decrypted" to note B's plaintext.
Client (`public/app.js`) rules:
- The encrypted `scheduleSave` override and `buildFlushRequest` MUST read `ta` from the captured form (`form.querySelector('textarea[name="body"], textarea.editor-body')`), never from `getTA()`.
- Every encrypted-save path calls `_encryptedSaveIdentityOk(form, expectedNoteId, expectedVaultId)` before hashing, before encrypting, after every `await`, and inside `_triggerEncryptedSave`. Identity checks compare captured `noteId`/`vaultId` against `_formNoteId(form)`, `form.dataset.vaultId`, `form.dataset.encrypted`, `form.isConnected`, `activeEditorForm()`, and `_activeEditorNoteId()`. Abort with a log line if any mismatch.
- `encryptForVault(plaintext, vaultId, key, salt, noteId)` embeds `noteId` in the ciphertext blob. Every call passes it.
- `htmx:beforeSwap` for `#editor-panel` / `#mobile-editor-body` cancels `_saveTimer` and `_saveTitleTimer` so stale timers can't fire against a fresh note (defence-in-depth alongside the identity guard).
Server (`app/routes/_helpers.js`) rules:
- `assertVaultNoteBodyEncrypted(vaultService, userId, existingParentId, targetParentId, body, noteId, opts)` parses the ciphertext blob and rejects the write when `meta.vault` mismatches the target folder's vault, or `meta.noteId` (if present) mismatches the target note.
- **Vault boundary enforcement** (three layers):
1. **ParentId immutability / no conflict copies**: if a note currently lives in a vault folder, the server rejects any PUT that changes its `parentId` (400: `Vault notes cannot be moved to a different folder`). Vault notes cannot change folder — the folder select is disabled client-side and the server enforces this on every write path. Conflict `createCopy` is also rejected for vault notes: ciphertext is bound to source note id, while copying unlocked DOM content would make a plaintext duplicate.
2. **Ciphertext required inside vaults**: saving to a vault folder always requires an encrypted body. Same-folder saves are covered; sync-proxy writes are covered **only for cookie-authenticated (browser) traffic, not stock Joplin clients** (see "Known issues found with the real client" (`docs/agents/joplin-fidelity.md`)). `enforceExistingVault=true` (sync-proxy only) extends this to prevent external Joplin clients from stripping ciphertext by re-parenting.
3. **Metadata integrity**: encrypted blobs carry `vault` (folder id) and `noteId` (bound target); both are validated against the destination to prevent cross-vault or cross-note ciphertext smuggling.
- **Client-side**: the folder select (`#editor-folder-select`) is rendered `disabled` for vault-protected notes and stays disabled after unlock — vault notes cannot change parent folder through the UI. The only reachable folder-change path is plain→vault (encrypt on move).
- Every note-write path passes the target `noteId`: `app/routes/api.js` (PUT), `app/routes/fragments.js` (autosave PUT), `app/routes/history.js` (restore), `app/proxy/vaultProxyGuard.js` (sync proxy).
- Legacy blobs without `noteId` still pass (backwards compatible); new writes are note-id-bound.
Tests must not regress this: an encrypted note's ciphertext blob's `noteId` field must equal the note id it is stored under; a write with a mismatched blob must be rejected with 400 (or 403 via the proxy guard).
### Plaintext save identity guard (do not remove)
Plaintext (non-encrypted) autosave has its own cross-note contamination race, fixed after a user report of "note B's body replaced by note A's content". Root cause: the persistent TinyMCE singleton is shared by all rich-mode notes, and several async `/fragments/preview` fetches wrote their result into it with no check that the active note was still the one the fetch was started for. A late preview response for note A landed while note B's form was active; the next TinyMCE→textarea sync copied A's markdown into B's `#note-body`, and the 2s autosave PUT it to `/fragments/editor/B`.
Client (`public/app.js`) rules:
- **Provenance stamps**: `_displayedNoteId` (note whose content the user last saw) and `_tinymceContentNoteId` (note whose rendered HTML TinyMCE last loaded) are stamped by `initEditorPanel` and `_setTinyMCEContent(html, noteId)`; `htmx:beforeSwap` for the editor containers clears both. Empty stamps are permissive (defence-in-depth only), non-empty mismatching stamps are hard blocks.
- **Guard**: `_plaintextSaveIdentityOk(form)` must pass before any plaintext body PUT: form connected, `hx-put` note id === `data-note-id` === `_displayedNoteId`, form is `activeEditorForm()`, and — in rich mode with the TinyMCE host visible — `_tinymceContentNoteId` matches too. It is enforced in `scheduleSave`, `scheduleSaveTitle`, `buildFlushRequest`, and an `htmx:configRequest` choke point that blocks *any* `hx-put=/fragments/editor/…` request (manual, conflict-button, or `joplock:save`-triggered) fired from a non-active form. The choke point skips `dataset.encrypted==='1'` forms (those are covered by the encrypted guard above).
- **Async fetch discipline**: every `/fragments/preview` fetch that writes into the shared editor (`setEditorMode('rich')`, `refreshTinyMCEForActiveNote`) captures the note id before the request and discards the response if the note, mode, or active form changed mid-flight. `tinyMCESyncToTA` and `_lazyTinyMCESyncBeforeSave` refuse to copy TinyMCE content into a textarea whose note doesn't match `_tinymceContentNoteId`.
- **Other guarded paths**: `_completeUnlock` aborts if the unlocked note is no longer the active note; late `htmx:afterRequest` save responses from detached/replaced forms don't stamp `snapshotHash` (would mark a switched-to note as "Saved" while dropping its pending edits); `flushSave` success only updates save state when the flushed form is still active.
- **flushSave baseUpdatedTime sync (do not remove)**: `flushSave` saves via a raw `fetch()` whose OOB-carrying response body is discarded — unlike the htmx autosave path, nothing would refresh the form's hidden `baseUpdatedTime`. A flush save advances the server clock while the form keeps the old base, so the NEXT autosave PUT trips the server conflict guard and the user sees "A newer version of this note exists on the server" after merely switching tabs/views (visibilitychange → flushSave). Fix: the editor PUT sets an `X-Note-Updated-Time` response header (mirror of the `#editor-sync-state` OOB), and flushSave reads it to refresh the form's `baseUpdatedTime`. flushSave also detects the `X-Note-Conflict` header and surfaces the conflict fragment + banner instead of wrongly marking the editor "Saved".
- **flushSave must fetch with `keepalive: true` (do not remove)**: the flush usually fires from `visibilitychange` while the document is being torn down (tab close, note switch via navigation). A plain `fetch()` is aborted mid-flight, Chromium sends a truncated request whose `Cookie` header never reaches the server → 401, and the pending title/body edits are silently lost. `keepalive` lets the request outlive the document. Pinned by `tests/saveIdentityGuard.test.js`.
- **Mobile shell conflict participation (do not remove)**: `mobileEditorFragment` re-adds `#editor-sync-state` (with `baseUpdatedTime`) after the titlebar-stripping transform. Without it, mobile saves carry no base → the server skips the conflict check (silent last-write-wins) and `checkNoteFreshness` early-returns (base 0) → the desktop↔mobile switch never detects concurrent changes.
- **Shell-scoped banner**: both shells render `#remote-update-bar` with duplicate ids; `showRemoteUpdateBanner`/`dismissRemoteUpdateBanner` resolve the bar via `queryActiveEditor('#remote-update-bar')` first — `getElementById` alone returns the desktop shell's bar, which is `display:none` in the mobile shell (banner invisible exactly when mobile users need it).
Tests: `tests/saveIdentityGuard.test.js` locks the guard wiring, the flushSave header handling, and the shell-scoped banner; `tests/createServer.test.js` asserts the `X-Note-Updated-Time` header; `tests/templates.test.js` asserts the mobile sync-state; `tests/tinymceOnEditSync.test.js` has a provenance test for the sync refusal.
### Note-history restore
- `POST /fragments/history/:noteId/restore/:snapshotId` returns `editorFragment` **inline** (target = `#editor-panel` / `#mobile-editor-body`) with `#autosave-status` and `#nav-panel` as OOB swaps. Do not go back to swapping only `#autosave-status` with an OOB editor-panel — the editor-swap lifecycle (`htmx:afterSwap` destroy CM6, `htmx:afterSettle` reinit) only runs when the request target is the editor container, and without it the restored body doesn't appear until a page refresh.
- The client `restoreHistorySnapshot()` cancels `_saveTimer` / `_saveTitleTimer` and clears `_savedHash` before firing the request so a stale autosave from pre-restore edits can't overwrite the restored body.
## Debugging
### If vault behavior misbehaves
- Check whether the folder is marked with `isVault`
- Check whether the note is marked with `inVault` / `isEncrypted` / `vaultId`
- Check `toggleVaultLock()`, `unlockNote()`, `_completeUnlock()`, and `flushSave()` in `public/app.js`
- Check whether the hidden editor shells still exist in locked editor HTML

View file

@ -30,7 +30,8 @@ const getRaw = async (page, request, id) => (await request.get(`/joplin/api/item
const delRaw = async (page, request, id) => request.delete(`/joplin/api/items/root:/${id}.md:`, { headers: { 'X-API-AUTH': await sessionId(page) } }).catch(() => null);
const api = (page, method, url, body) => page.evaluate(async ([m, u, b]) => {
const res = await fetch(u, { method: m, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: b === undefined ? undefined : JSON.stringify(b) });
// bounded: an unanswered request must fail here, by name, not as a bare 90s test timeout
const res = await fetch(u, { method: m, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: b === undefined ? undefined : JSON.stringify(b), signal: AbortSignal.timeout(20000) });
const text = await res.text();
let data = null; try { data = JSON.parse(text); } catch { /* html */ }
return { status: res.status, data, text };
@ -65,7 +66,7 @@ test.describe('HTML notes', () => {
const folderId = (await api(page, 'POST', '/api/web/folders', { title: folder, parentId: '' })).data.item.id;
await putRaw(page, request, noteId, `${title}\n\n${html}\n\n${noteProps(noteId, folderId, ['markup_language: 2'])}`);
const before = await getRaw(page, request, noteId);
await page.reload();
await page.reload({ timeout: 30000 });
await openNote(page, folder, title);
const form = page.locator('#editor-panel #note-editor-form[data-html-note="1"]');
@ -111,7 +112,7 @@ test.describe('HTML notes', () => {
try {
const folderId = (await api(page, 'POST', '/api/web/folders', { title: folder, parentId: '' })).data.item.id;
await putRaw(page, request, noteId, `${title}\n\n<h3>Mobile heading</h3><p>body</p>\n\n${noteProps(noteId, folderId, ['markup_language: 2'])}`);
await page.reload();
await page.reload({ timeout: 30000 });
await expect(page.locator('#mobile-app[aria-hidden="false"]')).toBeVisible();
await page.locator('#mobile-folders-body .mobile-folder-row', { hasText: folder }).first().click();
await page.locator('#mobile-notes-body .mobile-note-row', { hasText: title }).first().click();
@ -136,7 +137,7 @@ test.describe('HTML notes', () => {
const folderId = (await api(page, 'POST', '/api/web/folders', { title: folder, parentId: '' })).data.item.id;
await putRaw(page, request, htmlId, `An html note\n\n<p>html</p>\n\n${noteProps(htmlId, folderId, ['markup_language: 2'])}`);
await api(page, 'POST', '/api/web/notes', { title: 'A markdown note', body: '# md body', parentId: folderId });
await page.reload();
await page.reload({ timeout: 30000 });
await openNote(page, folder, 'An html note');
await expect(page.locator('#editor-panel #note-editor-form[data-html-note="1"]')).toBeVisible({ timeout: 15000 });
await page.locator('#nav-panel .notelist-item', { hasText: 'A markdown note' }).first().click();
@ -163,7 +164,7 @@ test.describe('Joplin end-to-end encrypted notes', () => {
const folderId = (await api(page, 'POST', '/api/web/folders', { title: folder, parentId: '' })).data.item.id;
await putRaw(page, request, noteId, [`id: ${noteId}`, `parent_id: ${folderId}`, 'encryption_cipher_text: JED01000007fakeciphertextfakeciphertext', 'encryption_applied: 1', 'type_: 1'].join('\n'));
const before = await getRaw(page, request, noteId);
await page.reload();
await page.reload({ timeout: 30000 });
const f = page.locator(`#nav-panel .nav-folder[data-folder-title="${folder}"]`).first();
await expect(f).toBeVisible({ timeout: 15000 });
await f.locator(':scope > .nav-folder-row .nav-folder-toggle').click();

View file

@ -511,10 +511,10 @@ test.describe('Markdown mode extras (mobile shell)', () => {
await page.locator('#mobile-editor-body .cm-jl-check').first().tap();
await expect.poll(() => getDoc(page)).toContain('- [x] task one');
const status = page.locator('#mobile-editor-body .cm-jl-status');
await expect(status).toBeVisible();
const sb = await status.boundingBox();
expect(sb.y + sb.height).toBeLessThanOrEqual(page.viewportSize().height + 1);
// The word-count / outline bar is desktop-only: the mobile editor gives that row to the text.
await expect(page.locator('#mobile-editor-body .cm-jl-status')).toHaveCount(0);
const host = await page.locator('#mobile-editor-body #cm-host').boundingBox();
expect(host.y + host.height).toBeGreaterThan(page.viewportSize().height - 4);
// Export is reachable from the mobile editor menu in markdown mode too.
await page.locator('#mobile-editor-menu-btn').click();
await expect(page.locator('#mobile-ctx-export')).toBeVisible();

View file

@ -0,0 +1,76 @@
'use strict';
const { test, expect, devices } = require('@playwright/test');
const { login } = require('./helpers');
// A phone in landscape is 812-956px wide, past the 768px breakpoint. It used to get the desktop
// layout in ~390px of height and rotating reloaded the page. The mobile shell now follows the
// physical screen of a touch device, so rotation keeps it, and a tablet whose window the keyboard
// squashes does not flip to it. Read-only: logs in and resizes the window.
test.describe('Landscape and the shell', () => {
test('a phone in landscape keeps the mobile shell, and rotating does not reload the page', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
await login(page);
await expect(page.locator('#mobile-app[aria-hidden="false"]')).toBeVisible();
const portrait = page.viewportSize();
await page.evaluate(() => { window.__notReloaded = true; });
await page.setViewportSize({ width: portrait.height, height: portrait.width }); // landscape
await page.waitForTimeout(1200);
const state = await page.evaluate(() => ({
marker: window.__notReloaded === true,
mobileShell: isMobileShellMode(),
w: innerWidth,
h: innerHeight,
mobileApp: getComputedStyle(document.getElementById('mobile-app')).display,
desktopApp: getComputedStyle(document.querySelector('.app')).display,
headerH: Math.round(document.querySelector('.mobile-screen-active .mobile-header').getBoundingClientRect().height),
overflowX: document.documentElement.scrollWidth - innerWidth,
}));
expect(state.w).toBeGreaterThan(768);
expect(state.marker, 'the page must not reload when the phone rotates').toBe(true);
expect(state.mobileShell).toBe(true);
expect(state.mobileApp).toBe('flex');
expect(state.desktopApp).toBe('none');
expect(state.headerH, 'the title bar tightens in a short window').toBe(36);
expect(state.overflowX).toBeLessThanOrEqual(0);
// and back
await page.setViewportSize(portrait);
await page.waitForTimeout(800);
expect(await page.evaluate(() => window.__notReloaded === true && isMobileShellMode())).toBe(true);
});
test('a tablet whose window the keyboard squashes stays on the desktop shell', async ({ browser }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
const context = await browser.newContext({ ...devices['iPad Pro 11 landscape'], baseURL: testInfo.project.use.baseURL });
try {
const page = await context.newPage();
await login(page);
await page.evaluate(() => { window.__notReloaded = true; });
expect(await page.evaluate(() => isMobileShellMode())).toBe(false);
// the soft keyboard: the window loses over half its height, the physical screen does not.
// page.setViewportSize() would shrink the emulated screen with the window, so set them apart.
const cdp = await context.newCDPSession(page);
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 1194, height: 380, deviceScaleFactor: 2, mobile: true, screenWidth: 1194, screenHeight: 834 });
await page.waitForTimeout(1200);
expect(await page.evaluate(() => [innerHeight, screen.height])).toEqual([380, 834]);
const state = await page.evaluate(() => ({ marker: window.__notReloaded === true, mobileShell: isMobileShellMode(), screen: [screen.width, screen.height] }));
expect(state.marker, 'no reload while typing').toBe(true);
expect(state.mobileShell).toBe(false);
expect(Math.min(...state.screen)).toBeGreaterThan(500);
} finally {
await context.close();
}
});
test('a mouse-driven desktop window that is short stays on the desktop shell', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
await page.setViewportSize({ width: 1000, height: 400 });
await login(page);
expect(await page.evaluate(() => isMobileShellMode())).toBe(false);
await expect(page.locator('.app')).toBeVisible();
});
});

View file

@ -0,0 +1,69 @@
'use strict';
const { test, expect } = require('@playwright/test');
const { ensureMobileFoldersScreen, login, openMobileFolder } = require('./helpers');
// The floating "+" button sits over the bottom-right of the folder and note lists. The lists
// must end with enough padding that the last row can be scrolled out from beneath it, otherwise
// that row's count / "+" / chevron (or the "Load more" button) can never be tapped.
// Read-only: navigates and measures, creates nothing.
async function measureAtEnd(page, bodySelector) {
// Scroll and measure in one step: htmx can still be swapping a list in (which resets its
// scroll position), so callers poll this until the list has settled at its end.
return page.evaluate(selector => {
const body = document.querySelector(selector);
body.scrollTop = body.scrollHeight;
const fab = document.getElementById('mobile-fab').getBoundingClientRect();
const last = body.lastElementChild;
const lastRect = last.getBoundingClientRect();
const covered = [];
for (const el of [last, ...last.querySelectorAll('button, a, [role="button"]')]) {
const r = el.getBoundingClientRect();
if (!r.width || !r.height) continue;
// Probe points on the right half of the row, where the FAB lives.
for (const [fx, fy] of [[0.5, 0.5], [0.9, 0.5], [0.9, 0.2], [0.9, 0.8]]) {
const top = document.elementFromPoint(r.x + r.width * fx, r.y + r.height * fy);
if (top && top.closest('#mobile-fab')) covered.push(`${el.className || el.tagName}@${fx},${fy}`);
}
}
return {
padBottom: parseFloat(getComputedStyle(body).paddingBottom),
lastBottom: Math.round(lastRect.bottom),
fabTop: Math.round(fab.top),
covered,
};
}, bodySelector);
}
async function expectLastRowClearOfFab(page, bodySelector) {
await expect.poll(async () => {
const r = await measureAtEnd(page, bodySelector);
return r.lastBottom <= r.fabTop;
}, { message: `${bodySelector} last row bottom must settle above the FAB top`, timeout: 10000 }).toBe(true);
const result = await measureAtEnd(page, bodySelector);
expect(result.padBottom, `${bodySelector} bottom padding`).toBeGreaterThanOrEqual(96);
expect(result.covered, `${bodySelector} last row points under the FAB`).toEqual([]);
}
test.describe('Mobile list bottom clearance', () => {
test('folder and note lists scroll their last row clear of the FAB', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
await login(page);
await ensureMobileFoldersScreen(page);
await expect(page.locator('#mobile-folders-body .mobile-folder-row').first()).toBeVisible({ timeout: 15000 });
await expectLastRowClearOfFab(page, '#mobile-folders-body');
await openMobileFolder(page, 'All Notes');
await expect(page.locator('#mobile-notes-body > *').first()).toBeVisible({ timeout: 15000 });
await expectLastRowClearOfFab(page, '#mobile-notes-body');
});
test('the editor body does not get list padding', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
await login(page);
await expect(page.locator('#mobile-app[aria-hidden="false"]')).toBeVisible();
const pad = await page.evaluate(() => parseFloat(getComputedStyle(document.getElementById('mobile-editor-body')).paddingBottom));
expect(pad).toBe(0);
});
});

View file

@ -0,0 +1,120 @@
'use strict';
const path = require('node:path');
const { test, expect } = require('@playwright/test');
const { ensureMobileFoldersScreen, login, openMobileFolder } = require('./helpers');
// In markdown mode the mobile formatting toolbar (B I U S H3 ...) is shown when a note opens and
// must stay while the soft keyboard is up. It used to disappear the moment a line was tapped:
// the viewport handler decided "keyboard gone" when innerHeight - visualViewport.height < 80,
// and iOS home-screen apps shrink innerHeight together with the visual viewport, so an open
// keyboard looked closed. Chromium reproduces that by shrinking the window, which resizes both.
// Read-only: opens a note and focuses the editor, types nothing.
test.describe('Mobile markdown toolbar and the keyboard', () => {
test('the toolbar stays visible when the keyboard opens and closes', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
await login(page);
await ensureMobileFoldersScreen(page);
await openMobileFolder(page, 'All Notes');
await page.locator('#mobile-notes-body .mobile-note-row').first().click();
await expect(page.locator('#mobile-editor-screen.mobile-screen-active')).toBeVisible();
const content = page.locator('#mobile-editor-body #cm-host .cm-content');
const toolbar = page.locator('#mobile-editor-body #editor-toolbar');
// the editor mounts a moment after the screen switches; a note that opens rendered has no #cm-host
const markdown = await content.waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false);
test.skip(!markdown, 'note opened in rendered mode, not markdown');
await expect(toolbar).toBeVisible();
const size = page.viewportSize();
await content.click();
// the keyboard: both innerHeight and the visual viewport shrink, as in an iOS home-screen app
await page.setViewportSize({ width: size.width, height: Math.round(size.height * 0.55) });
await page.waitForTimeout(700);
await expect(toolbar, 'toolbar while the keyboard is up').toBeVisible();
expect(await toolbar.evaluate(el => getComputedStyle(el).display)).not.toBe('none');
await page.setViewportSize(size);
await page.waitForTimeout(700);
await expect(toolbar, 'toolbar after the keyboard closes').toBeVisible();
});
test('the shell follows the visible area while the keyboard is up, and the header and toolbar stay on screen', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
await login(page);
await ensureMobileFoldersScreen(page);
await openMobileFolder(page, 'All Notes');
await page.locator('#mobile-notes-body .mobile-note-row').first().click();
const content = page.locator('#mobile-editor-body #cm-host .cm-content');
const markdown = await content.waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false);
test.skip(!markdown, 'note opened in rendered mode, not markdown');
const geometry = () => page.evaluate(() => {
const r = sel => { const e = document.querySelector(sel); const b = e.getBoundingClientRect(); return { top: Math.round(b.top), bottom: Math.round(b.bottom) }; };
return {
kb: document.documentElement.hasAttribute('data-keyboard'),
appHeightVar: document.documentElement.style.getPropertyValue('--app-height'),
vv: Math.round(window.visualViewport.height),
app: r('#mobile-app'), header: r('.mobile-screen-active .mobile-header'), toolbar: r('#mobile-editor-body #editor-toolbar'),
};
});
const before = await geometry();
expect(before.kb).toBe(false);
expect(before.appHeightVar).toBe('');
const size = page.viewportSize();
await content.click();
await page.setViewportSize({ width: size.width, height: Math.round(size.height * 0.55) });
await expect.poll(async () => (await geometry()).kb, { message: 'keyboard detected', timeout: 5000 }).toBe(true);
const up = await geometry();
expect(up.appHeightVar).toBe(`${up.vv}px`);
expect(up.app.top).toBe(0);
expect(up.app.bottom).toBe(up.vv);
expect(up.header.top).toBe(0);
expect(up.toolbar.top).toBeGreaterThanOrEqual(0);
expect(up.toolbar.bottom).toBeLessThanOrEqual(up.vv);
// keyboard dismissed: blur and restore the window; the shell goes back to inset:0
await page.evaluate(() => document.activeElement && document.activeElement.blur());
await page.setViewportSize(size);
await expect.poll(async () => (await geometry()).kb, { message: 'keyboard gone', timeout: 5000 }).toBe(false);
const down = await geometry();
expect(down.appHeightVar).toBe('');
expect(down.app.bottom).toBe(size.height);
});
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><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));
await expect.poll(() => page.evaluate(() => window.scrollY), { message: 'scroll pinned back to 0' }).toBe(0);
const state = await page.evaluate(() => window.JoplockViewport.state());
expect(state.pins).toBeGreaterThanOrEqual(1);
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');
await page.evaluate(() => localStorage.setItem('joplock-viewport', 'off'));
await page.setContent('<!doctype html><html><body><div data-viewport-pin style="height:3000px"></div></body></html>');
await page.addScriptTag({ path: path.join(__dirname, '..', 'public', 'viewport.js') });
expect(await page.evaluate(() => window.JoplockViewport.enabled)).toBe(false);
await page.evaluate(() => localStorage.removeItem('joplock-viewport'));
});
});

View file

@ -0,0 +1,46 @@
'use strict';
const { test, expect } = require('@playwright/test');
const { login } = require('./helpers');
// The service worker (public/service-worker.js) caches only versioned scripts/stylesheets and an
// offline page. A launch with no network must land on that page, never on the browser's error
// page and never on a cached signed-in page. Read-only: logs in, goes offline, comes back.
test.describe('PWA offline launch', () => {
test('the worker caches versioned assets, an offline launch shows the offline page, and it recovers', async ({ page, context }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
await login(page);
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload(); // now controlled by the worker
await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
const version = await page.evaluate(() => window._joplockConfig.assetVersion);
expect(version).toMatch(/^[0-9a-f]{10}$/);
const cached = await page.evaluate(async () => {
const names = await caches.keys();
const entries = {};
for (const name of names) entries[name] = (await (await caches.open(name)).keys()).map(r => new URL(r.url).pathname + new URL(r.url).search);
return entries;
});
expect(Object.keys(cached)).toEqual([`joplock-shell-${version}`]);
const entries = cached[`joplock-shell-${version}`];
expect(entries).toContain('/offline.html');
await expect.poll(async () => (await page.evaluate(async name => (await (await caches.open(name)).keys()).map(r => new URL(r.url).pathname + new URL(r.url).search), `joplock-shell-${version}`)).includes(`/app.js?v=${version}`)).toBe(true);
// never a page, never an API response
expect(entries.filter(e => e === '/' || e.startsWith('/api') || e.startsWith('/fragments'))).toEqual([]);
await context.setOffline(true);
await page.goto('/');
await expect(page.getByRole('heading', { name: "You're offline" })).toBeVisible();
await expect(page.locator('#retry')).toBeVisible();
// while still offline the button says so instead of reloading into an error
await page.locator('#retry').click();
await expect(page.locator('#status')).toHaveText('Still offline.');
// back online: the page notices (online event) and reloads into the app by itself
await context.setOffline(false);
await expect(page.locator('body.app-shell')).toBeVisible({ timeout: 15000 });
});
});

View file

@ -1,9 +1,10 @@
/* joplock client application — extracted from templates.js */
/* Server config is passed via window._joplockConfig set inline before this script loads */
/* Contracts for this file (editor, saves, vaults, uploads, mobile shell, AI) are in docs/agents/: editor.md, vaults-and-save-safety.md, editor-uploads.md, pwa-mobile.md, ai-expander-ask.md. Read the one that matches your change first. */
(function(){
var _cfg=window._joplockConfig||{};
var _dbg=_cfg.debug||false;
var _assetVersion='20260519pwa22';
var _assetVersion=_cfg.assetVersion||'';
var _openRouterEnabled=!!_cfg.openRouterEnabled;
var _textExpanders=Array.isArray(_cfg.textExpanders)?_cfg.textExpanders.filter(function(e){return e&&e.trigger&&(e.action==='ai'||e.text!=null)}).map(function(e){return{id:String(e.id||''),trigger:String(e.trigger),action:e.action==='ai'?'ai':'text',profileId:String(e.profileId||''),text:String(e.text||'')}}):[];
function _log(){if(!_dbg)return;var a=Array.prototype.slice.call(arguments);a.unshift('[joplock]');console.log.apply(console,a)}
@ -298,7 +299,14 @@ var _traceKey='joplock-debug-trace';
// recomputed it inline; after a resize they could disagree and cause the
// toolbar/readonly-default to be out of sync with the shell layout.
var _mobileShellCached=null;
function _computeMobileShell(){if(_uiMode==='mobile')return true;if(_uiMode==='desktop')return false;return viewportWidth()<=_mobileShellMaxWidth}
// A phone is a touch device whose PHYSICAL screen has a short side <=500 CSS px (iPhone Pro Max 440,
// Pixel 412; tablets start around 600-744). The screen, never the window: a soft keyboard shrinks the
// window (iOS home-screen apps even shrink innerHeight), so a window-height test would flip an iPad to
// the phone layout and reload the page while typing. This is what keeps a phone in LANDSCAPE
// (812-956px wide) on the mobile shell. Must stay in step with the @media query in styles.css.
var _phoneScreenMaxShortSide=500;
function _isPhoneScreen(){try{return window.matchMedia('(pointer: coarse)').matches&&Math.min(window.screen.width,window.screen.height)<=_phoneScreenMaxShortSide}catch(e){return false}}
function _computeMobileShell(){if(_uiMode==='mobile')return true;if(_uiMode==='desktop')return false;return viewportWidth()<=_mobileShellMaxWidth||_isPhoneScreen()}
function isMobileShellMode(recompute){if(recompute||_mobileShellCached===null)_mobileShellCached=_computeMobileShell();return _mobileShellCached}
function isDesktopMode(recompute){return !isMobileShellMode(recompute)}
function _trace(){if(!_dbg)return;try{var line='['+new Date().toISOString().slice(11,23)+'] '+Array.prototype.slice.call(arguments).map(function(v){return typeof v==='string'?v:JSON.stringify(v)}).join(' ');var arr=JSON.parse(sessionStorage.getItem(_traceKey)||'[]');arr.push(line);if(arr.length>80)arr=arr.slice(arr.length-80);sessionStorage.setItem(_traceKey,JSON.stringify(arr));console.log('[trace]',line)}catch(_e){}}
@ -2444,17 +2452,13 @@ function positionTinyMCEHost(){
top=slot.getBoundingClientRect().top;
}
var mobileApp=document.getElementById('mobile-app');
var mar=mobileApp?mobileApp.getBoundingClientRect():{left:0,width:window.innerWidth};
var mar=mobileApp?mobileApp.getBoundingClientRect():{left:0,width:window.innerWidth,bottom:window.innerHeight};
left=mar.left;
width=mar.width;
// Keep the host locked to the visible viewport while the keyboard is open,
// otherwise iOS may pan the outer page (dual-scroll effect).
// When keyboard is closed, prefer innerHeight to avoid rubber-band gaps.
if(vv&&window.innerHeight-vv.height>80){
vpH=vv.height+vv.offsetTop;
}else{
vpH=window.innerHeight;
}
// The host's bottom is the shell's bottom. public/viewport.js sizes .mobile-app to the
// visible area while the keyboard is up, so no innerHeight/visualViewport guess here
// (iOS home-screen apps shrink innerHeight with the keyboard, which fooled the old one).
vpH=mar.bottom;
}else{
var rect=slot.getBoundingClientRect();
if(rect.width===0||rect.height===0){
@ -2664,7 +2668,7 @@ function askDisabledForActiveNote(){
}
// User-visible explanation for why /ask silently no-ops in vault notes. Shown
// once per note-open so users don't keep re-typing hoping it will work. The
// underlying policy (see AGENT_GUIDE "Ask Slash Command") is that plaintext
// underlying policy (see docs/agents/ai-expander-ask.md) is that plaintext
// context from an encrypted note must never leave the browser.
var _askDisabledNotified={};
function _notifyAskDisabledInVault(){
@ -3620,9 +3624,11 @@ function _cmExtraHooks(){
htmlToMarkdown:_pasteHtmlToMarkdown
};
}
// The word-count / heading-outline bar is desktop-only: on the mobile shell it costs a row of
// screen under the editor and recounts words on every keystroke, so it is not created at all.
function _cmExtraExtensions(C){
if(!window.JoplockMd)return [];
try{return window.JoplockMd.createExtensions(C,_cmExtraHooks(),{inlineWidgets:_mdInlineWidgets,livePreview:_mdLivePreview,statusBar:_mdStatusBar,folding:_mdFolding})}
try{return window.JoplockMd.createExtensions(C,_cmExtraHooks(),{inlineWidgets:_mdInlineWidgets,livePreview:_mdLivePreview,statusBar:_mdStatusBar&&!isMobileShellMode(),folding:_mdFolding})}
catch(err){console.error('[joplock] markdown extras failed to initialise',err);return []}
}
function initCM(host,content){
@ -5948,8 +5954,14 @@ function confirmLogout(event){
positionTinyMCEHost();
return;
}
// Markdown mode: this is the formatting bar (B I U S H3 ...). It is up when a note opens and
// stays up while editing, so "hide" requests are ignored: the viewport handler asks for one
// when it thinks the keyboard went away (wrong on iOS home-screen apps, where innerHeight
// shrinks together with the visual viewport, so an OPEN keyboard looks closed), and the rich
// editor's blur handler asks for one 200ms after a switch to markdown. Only a locked vault hides it.
var form=activeEditorForm();
if(form&&form.dataset.encrypted==='1'&&form.dataset.vaultUnlocked!=='1')show=false;
var locked=!!(form&&form.dataset.encrypted==='1'&&form.dataset.vaultUnlocked!=='1');
show=!locked;
tb.style.display=show?'flex':'none';
// Reposition immediately, then again after a tick so the toolbar has a
// rendered height before positionTinyMCEHost measures the anchor bottom.
@ -5971,21 +5983,17 @@ function confirmLogout(event){
// Show/hide toolbar on TinyMCE focus/blur.
(function initMobileToolbarGlobalListeners(){
function onVpChange(){
// Keyboard state comes from public/viewport.js (joplock:viewport); it also resizes the
// shell, so this only has to re-anchor the TinyMCE host. The markdown toolbar is permanent
// (setMobileToolbarVisible ignores hide in markdown mode), so no keyboard-gone logic.
stabilizeTinyMCEHostPosition();
// When the visual viewport returns to full height the keyboard has gone away.
// Hide the toolbar — user is no longer actively editing.
if(!inMobileEditor())return;
var vv=window.visualViewport;
var keyboardGone=!vv||(window.innerHeight-vv.height<80);
if(keyboardGone&&!(_tinymceEditor&&_tinymceEditor.hasFocus&&_tinymceEditor.hasFocus())){
setMobileToolbarVisible(false);
}
}
if(window.visualViewport){
window.visualViewport.addEventListener('resize',onVpChange);
window.visualViewport.addEventListener('scroll',positionTinyMCEHost);
}
window.addEventListener('resize',positionTinyMCEHost);
document.addEventListener('joplock:viewport',onVpChange);
// TinyMCE lives in an iframe; relay focus/blur via custom events from setup().
document.body.addEventListener('joplock:editor-focus',function(){
if(_tinymceSuppressEdits)return;

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

View file

@ -1,4 +1,5 @@
/* Joplock markdown-mode (CodeMirror 6) extras.
* Contracts and reasons: docs/agents/editor-markdown-extras.md
*
* Loaded after codemirror.min.js and before app.js. Exposes `window.JoplockMd`:
*

View file

@ -1,12 +1,12 @@
{
"id": "/",
"name": "Joplock",
"short_name": "Joplock",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#08110b",
"theme_color": "#08110b",
"background_color": "#1a1610",
"theme_color": "#1e1a12",
"description": "Thin-client web frontend for Joplin Server.",
"icons": [
{
@ -39,5 +39,6 @@
"type": "image/png",
"purpose": "any"
}
]
],
"lang": "en"
}

65
public/offline.html Normal file
View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1e1a12" />
<title>Joplock is offline</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: calc(24px + env(safe-area-inset-top, 0px)) calc(24px + env(safe-area-inset-right, 0px)) calc(24px + env(safe-area-inset-bottom, 0px)) calc(24px + env(safe-area-inset-left, 0px));
background: #1a1610;
color: #e8e0d0;
font: 16px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-align: center;
}
main { max-width: 340px; }
img { width: 72px; height: 72px; border-radius: 16px; margin-bottom: 20px; }
h1 { font-size: 22px; margin-bottom: 8px; }
p { color: #b8ad98; margin-bottom: 22px; }
button {
min-height: 48px;
padding: 0 28px;
font: inherit;
font-weight: 600;
color: #1a1610;
background: #d9a441;
border: 0;
border-radius: 10px;
}
button:active { opacity: 0.8; }
#status { margin-top: 16px; min-height: 1.5em; font-size: 14px; color: #8f8573; }
</style>
</head>
<body>
<main>
<img src="/icon-192.png" alt="" width="72" height="72" />
<h1>You're offline</h1>
<p>Joplock needs a connection to your notes. Nothing is kept on this device, so there is nothing to open until you're back online.</p>
<button id="retry" type="button">Try again</button>
<div id="status" role="status"></div>
</main>
<script>
(function () {
// not "status": a global of that name is window.status, a string the browser owns
var statusEl = document.getElementById('status');
function retry() {
statusEl.textContent = 'Checking\u2026';
fetch('/health', { cache: 'no-store' }).then(function (r) {
if (r.ok) location.reload();
else statusEl.textContent = 'Still not reachable.';
}).catch(function () { statusEl.textContent = 'Still offline.'; });
}
document.getElementById('retry').addEventListener('click', retry);
window.addEventListener('online', retry);
})();
</script>
</body>
</html>

View file

@ -1,11 +1,22 @@
const CACHE_NAME = 'joplock-shell-v85-20261010opaquemenu';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/cm-extras.js', '/prism.min.js', '/hljs.min.js', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png', '/maskable-icon-192.png', '/maskable-icon-512.png', '/apple-touch-icon.png', '/fonts/CascadiaMono-Regular.woff2', '/fonts/CascadiaMono-Bold.woff2'];
// Joplock service worker. Policy (docs/agents/pwa-mobile.md, "Service worker policy"): never cache anything user-specific.
// - navigations: network, and when the network is down a static /offline.html (never a cached
// signed-in page; the app has no offline notes)
// - versioned scripts/stylesheets (?v=<asset version>, immutable by construction): cache-first, so a
// relaunch does not wait on the network even if the browser's own cache was evicted
// - everything else (API, notes, attachments, TinyMCE's own files, fonts, icons): not intercepted
// __ASSET_VERSION__ is replaced by the server with app/assetVersion.js's value when this file is
// served, so a changed deployment is a byte-different worker and gets its own cache.
const VERSION = '__ASSET_VERSION__';
const CACHE_NAME = 'joplock-shell-__ASSET_VERSION__';
const CACHE_PREFIX = 'joplock-shell-';
const OFFLINE_URL = '/offline.html';
const PRECACHE = [OFFLINE_URL, '/icon-192.png'];
self.addEventListener('install', event => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(STATIC_ASSETS);
await cache.addAll(PRECACHE);
})(),
);
self.skipWaiting();
@ -14,35 +25,49 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => {
event.waitUntil(
(async () => {
// only this app's caches: other apps on the same origin keep theirs
const keys = await caches.keys();
await Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)));
await Promise.all(keys.filter(key => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME).map(key => caches.delete(key)));
await self.clients.claim();
})(),
);
self.clients.claim();
});
const isVersionedAsset = url => url.searchParams.has('v') && /\.(?:js|css)$/.test(url.pathname) && url.pathname !== '/service-worker.js';
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
const url = new URL(event.request.url);
// Only serve static assets from cache; everything else goes straight to network
if (!STATIC_ASSETS.includes(url.pathname)) return;
event.respondWith(
(async () => {
// Network-first for static assets, cache fallback for offline
try {
const networkResponse = await fetch(event.request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
if (request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
return await fetch(request);
} catch (error) {
const offline = await caches.match(OFFLINE_URL);
return offline || Response.error();
}
return networkResponse;
} catch (e) {
const cached = await caches.match(event.request);
if (cached) return cached;
throw e;
}
})(),
);
})(),
);
return;
}
if (isVersionedAsset(url)) {
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok && response.type === 'basic') {
// not awaited: the page gets the file as it arrives
event.waitUntil(cache.put(request, response.clone()).catch(() => undefined));
}
return response;
})(),
);
}
});

View file

@ -905,6 +905,7 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
box-shadow: 0 10px 30px var(--shadow);
/* a very short window: scroll the menu instead of letting items fall off the screen */
max-height: calc(100vh - 16px);
max-height: calc(100dvh - 16px);
overflow-y: auto;
}
@ -924,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
@ -1014,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;
@ -1118,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;
@ -1264,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 {
@ -1335,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;
@ -1349,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 {
@ -1363,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 {
@ -1443,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 {
@ -1602,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 {
@ -1636,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) */
@ -1726,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,
@ -1791,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,
@ -1983,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;
@ -2001,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"; }
@ -2020,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: "";
@ -2062,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;
@ -2102,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;
@ -2135,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 */
@ -2182,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 {
@ -2324,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 {
@ -2544,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;
@ -2557,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; }
@ -2666,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 {
@ -2701,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;
@ -2743,6 +2802,7 @@ body.editor-markdown-mode .editor-toolbar {
justify-content: center;
min-height: 100vh;
font-family: 'Cascadia Mono', monospace;
min-height: 100dvh;
}
.login-card { text-align: center; max-width: 360px; width: 100%; }
@ -2826,6 +2886,7 @@ select.login-input option {
.settings-page {
min-height: 100vh;
padding: calc(28px + env(safe-area-inset-top, 0px)) 20px calc(28px + env(safe-area-inset-bottom, 0px));
min-height: 100dvh;
background: var(--bg);
}
@ -2869,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 {
@ -3160,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 {
@ -3182,7 +3247,14 @@ body { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 0; }
/* Responsive */
@media (max-width: 768px) {
/* The mobile shell. "Phone" = narrow window (<=768px) OR a touch device whose physical screen has a
short side <=500px. The second part is what makes a phone in LANDSCAPE (812-956px wide) keep the
mobile layout. It must be the screen, never the window height: a soft keyboard shrinks the window
(on iOS home-screen apps innerHeight shrinks with it), so a height test would flip an iPad to the
phone layout and reload the page while typing. iOS reports device-width as the portrait width in
both orientations and Android rotates it, hence both device-width and device-height.
public/app.js _computeMobileShell() must stay in step; tests/landscapeShell.test.js checks both. */
@media (max-width: 768px), (pointer: coarse) and (max-device-width: 500px), (pointer: coarse) and (max-device-height: 500px) {
body { font-size: 12px; }
body { --statusbar-height: 36px; }
/* Prevent iOS auto-zoom: all inputs must be >= 16px */
@ -3336,7 +3408,7 @@ body { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
display: none;
}
@media (max-width: 768px) {
@media (max-width: 768px), (pointer: coarse) and (max-device-width: 500px), (pointer: coarse) and (max-device-height: 500px) {
/* Hide desktop layout on mobile */
.app, .nav-reopen-btn, .mobile-nav-backdrop { display: none !important; }
.app-statusbar { display: none; }
@ -3354,8 +3426,7 @@ body { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
.mobile-app {
display: flex;
position: fixed;
inset: 0;
top: env(safe-area-inset-top, 0px);
inset: 0 env(safe-area-inset-right, 0px) 0 env(safe-area-inset-left, 0px);
overflow: hidden;
background: var(--bg);
}
@ -3514,6 +3585,15 @@ body { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* The folder and note lists end clear of the floating "+" (56px tall, 20px above the
bottom edge, so 76px) plus breathing room and the home indicator. Without this the last
row's count / "+" / chevron (or "Load more") sit under the button and cannot be scrolled
out from beneath it. Scoped to the two list bodies: the editor body shares
.mobile-screen-body and must not get padding. */
#mobile-folders-body,
#mobile-notes-body {
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
}
/* Editor body must not scroll at the outer level — TinyMCE scrolls internally.
overflow:hidden + overscroll-behavior:none kills iOS rubber-band on the shell. */
.mobile-editor-body {
@ -3648,7 +3728,7 @@ body { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
.mobile-fab {
position: fixed;
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
right: 20px;
right: calc(20px + env(safe-area-inset-right, 0px));
width: 56px;
height: 56px;
border-radius: 50%;
@ -3672,7 +3752,7 @@ body { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
}
.mobile-fab-menu {
position: fixed;
right: 20px;
right: calc(20px + env(safe-area-inset-right, 0px));
bottom: calc(88px + env(safe-area-inset-bottom, 0px));
background: var(--bg-side);
border: 1px solid var(--border);
@ -3712,7 +3792,7 @@ body { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
left: 0; right: 0; bottom: 0;
background: var(--bg-side);
border-radius: 0;
padding: 8px 0 calc(16px + env(safe-area-inset-bottom, 0px));
padding: 8px env(safe-area-inset-right, 0px) calc(16px + env(safe-area-inset-bottom, 0px)) env(safe-area-inset-left, 0px);
z-index: 201;
box-shadow: 0 -4px 24px var(--shadow);
animation: mobileCtxSlideUp 0.2s cubic-bezier(0.4,0,0.2,1);
@ -3802,12 +3882,24 @@ body.force-mobile .mobile-editor-body > form {
body.force-mobile .mobile-app {
display: flex !important;
position: fixed;
inset: 0;
/* left/right keep clear of a landscape notch or Dynamic Island (0 in portrait and in browsers) */
inset: 0 env(safe-area-inset-right, 0px) 0 env(safe-area-inset-left, 0px);
overflow: hidden;
background: var(--bg);
}
body.force-mobile .mobile-fab { display: flex; }
/* While a soft keyboard is up, public/viewport.js sizes the shell to the visible area and keeps it
at the top of that area (and pins the page scroll at 0). iOS then has nothing to scroll into
view, so the header and the markdown toolbar stay on screen. Outside that state .mobile-app is
inset:0 exactly as before. */
html[data-keyboard] .mobile-app,
html[data-keyboard] body.force-mobile .mobile-app {
top: var(--app-top, 0px);
bottom: auto;
height: var(--app-height);
}
/* Hide the custom toolbar in mobile rich (TinyMCE) mode — applies at any viewport width.
TinyMCE's own built-in toolbar is rendered inside the editor host instead.
In markdown/preview mode body.mobile-markdown-mode is set, restoring it. */
@ -3830,7 +3922,7 @@ body.resizing .mobile-screen-left,
body.resizing .mobile-screen-right { transition: none !important; }
/* Mobile locked editor */
@media (max-width: 768px) {
@media (max-width: 768px), (pointer: coarse) and (max-device-width: 500px), (pointer: coarse) and (max-device-height: 500px) {
.editor-locked {
padding: 60px 20px;
}
@ -3858,12 +3950,14 @@ 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; }
@media (max-width: 768px) {
@media (max-width: 768px), (pointer: coarse) and (max-device-width: 500px), (pointer: coarse) and (max-device-height: 500px) {
.settings-page {
padding: calc(16px + env(safe-area-inset-top, 0px)) 12px calc(20px + env(safe-area-inset-bottom, 0px));
}
@ -3972,7 +4066,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; }
@ -3994,6 +4090,7 @@ body.resizing .mobile-screen-right { transition: none !important; }
max-width: 420px;
width: calc(100% - 32px);
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
@ -4005,6 +4102,7 @@ body.resizing .mobile-screen-right { transition: none !important; }
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
overflow-y: auto;
}
.admin-modal-header {
@ -4028,7 +4126,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;
@ -4121,8 +4221,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;
@ -4154,7 +4258,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. */
@ -4291,9 +4397,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 */
@ -4327,3 +4435,102 @@ 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;
}
}
/* Short windows: a phone in landscape (about 390px tall), or any phone with the keyboard up. The
shell's fixed chrome (title bar 44px + toolbar 67px) is most of that, so tighten it. These
elements exist only in the mobile shell, so a tablet's desktop layout is unaffected. */
@media (max-height: 500px) {
.mobile-header {
height: calc(36px + env(safe-area-inset-top, 0px));
}
.mobile-app .editor-toolbar {
padding: 4px 10px;
}
.mobile-app .editor-toolbar .tb {
min-width: 40px;
height: 34px;
font-size: 16px;
}
}
/* Landscape with the keyboard up leaves room for little more than the toolbar and a few lines of
text: the editor's title bar (back, search, menu) steps aside until the keyboard is dismissed. */
@media (max-height: 500px) and (orientation: landscape) {
html[data-keyboard] #mobile-editor-header {
display: none;
}
}

236
public/viewport.js Normal file
View file

@ -0,0 +1,236 @@
/*
* Joplock viewport sync: keeps the mobile shell on the part of the screen that is actually visible.
*
* Why: when a soft keyboard opens, iOS shrinks the visual viewport and then SCROLLS THE DOCUMENT to
* bring the caret into view (measured on an iPhone Air home-screen app: scrollY 268-342px, with
* body{position:fixed}). Everything pinned to the top of the shell, the header and the markdown
* toolbar, goes off the top of the screen. Chrome on Android instead overlays the keyboard on a
* layout viewport that did not change. The cure (the one herdr-web-ui ships) is to size the shell
* to the visual viewport while the keyboard is up, keep it at the top of what is visible, and pin
* the page scroll at 0, so there is nothing for the browser to scroll into view.
*
* 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 (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
* innerHeight together with the visual viewport, so an open keyboard looks closed (that guess hid
* the markdown toolbar). Occlusion is measured against the largest height seen without a keyboard.
*
* Debug: with the admin debug flag on, state changes are posted to /api/web/client-log as
* "viewport.kb" (read them with scripts/diag-reports.js --event viewport.kb). A real device can
* switch the whole thing off with localStorage['joplock-viewport'] = 'off'.
*
* Pure helpers are exported for node tests (module.exports), like cm-extras.js.
* Why this exists and how it is verified: docs/agents/pwa-mobile.md ("Soft keyboard / viewport").
*/
(function (root) {
'use strict';
// How much of the unobstructed height must be gone before we call it a keyboard. A phone
// keyboard takes 35-45%; browser chrome and status bars take well under 15%.
var OCCLUSION = 0.2;
var TEXT_INPUT_BLOCKED = /^(button|checkbox|radio|range|submit|reset|file|color|image)$/;
// An element whose focus raises the soft keyboard. TinyMCE edits inside an iframe, so the
// document's activeElement is the IFRAME while it has focus.
function isTypingElement(el) {
if (!el) return false;
var tag = el.tagName;
if (tag === 'TEXTAREA' || tag === 'IFRAME') return true;
if (tag === 'INPUT') return !TEXT_INPUT_BLOCKED.test(el.type || 'text');
return el.isContentEditable === true;
}
/**
* s: { coarse, typing, wasVisible, apiHeight, vvHeight, vvScale, large, inner, remembered }
* wasVisible keeps the state up while the visual viewport is still shrunk after focus moved
* to a toolbar button (a tap there can blur the editor while the keyboard stays), and through
* the dismiss animation; it ends when the viewport comes back.
*/
function keyboardVisible(s) {
if (!s.coarse) return false;
if (s.apiHeight > 0) return true;
if (!(s.typing || s.wasVisible)) return false;
// pinch zoom shrinks the visual viewport with no keyboard
if (Math.abs((s.vvScale || 1) - 1) > 0.01) return false;
var reference = Math.max(s.large || 0, s.remembered || 0, s.inner || 0);
return reference > 0 && reference - s.vvHeight > reference * OCCLUSION;
}
/**
* 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;
var doc = document;
var html = doc.documentElement;
var vv = window.visualViewport;
var api = { pure: pure, enabled: false };
root.JoplockViewport = api;
try { if (window.localStorage.getItem('joplock-viewport') === 'off') return; } catch (e) { /* storage blocked */ }
if (!vv) return;
api.enabled = true;
var coarse = window.matchMedia('(pointer: coarse)');
var keyboard = navigator.virtualKeyboard;
var visible = false;
var remembered = {}; // unobstructed height seen per window width (portrait/landscape)
var lastKey = '';
var pins = 0; // corrective scrolls: how often the browser scrolled the page under us
var maxScroll = 0; // largest scroll offset the browser applied before we put it back
var settleTimer = null;
// the large viewport: what the screen would be with the browser chrome retracted. The probe is
// created once <body> exists (this script loads in <head>).
var probe = null;
function largeHeight() {
if (!probe && doc.body) {
probe = doc.createElement('div');
probe.setAttribute('aria-hidden', 'true');
probe.style.cssText = 'position:fixed;top:0;left:0;width:0;height:100lvh;visibility:hidden;pointer-events:none;contain:strict';
doc.body.appendChild(probe);
}
return probe ? probe.getBoundingClientRect().height : 0;
}
function shellShowing() {
return !!(doc.body && (doc.body.classList.contains('mobile-shell-active') || doc.querySelector('[data-viewport-pin]')));
}
function sync() {
var width = window.innerWidth;
var height = vv.height;
var large = largeHeight();
var remember = remembered[width] || 0;
var apiHeight = keyboard && keyboard.boundingRect ? keyboard.boundingRect.height : 0;
var next = keyboardVisible({
coarse: coarse.matches,
typing: isTypingElement(doc.activeElement),
wasVisible: visible,
apiHeight: apiHeight,
vvHeight: height,
vvScale: vv.scale,
large: large,
inner: window.innerHeight,
remembered: remember,
});
if (!next) remembered[width] = Math.max(remember, large, window.innerHeight, height);
// 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 (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);
pinnedNow = true;
}
visible = next;
if (visible) {
// right after scrolling back, offsetTop has not settled: read it on the next frame
var top = pinnedNow ? 0 : Math.round(vv.offsetTop);
html.setAttribute('data-keyboard', '');
html.style.setProperty('--app-height', Math.round(height) + 'px');
html.style.setProperty('--app-top', top + 'px');
} else {
html.removeAttribute('data-keyboard');
html.style.removeProperty('--app-height');
html.style.removeProperty('--app-top');
}
if (pinnedNow) window.requestAnimationFrame(sync);
var key = [visible, Math.round(height), Math.round(vv.offsetTop)].join('|');
if (key !== lastKey) {
lastKey = key;
try { doc.dispatchEvent(new CustomEvent('joplock:viewport', { detail: { keyboard: visible, height: Math.round(height), top: Math.round(vv.offsetTop) } })); } catch (e) { /* old engine */ }
}
scheduleReport();
}
// ---- debug reporting (admin debug flag only) --------------------------------------------
var reports = 0;
var lastReport = '';
function debugOn() { return !!(root._joplockConfig && root._joplockConfig.debug); }
function rectTop(selector) {
var el = doc.querySelector(selector);
if (!el) return -9999;
var r = el.getBoundingClientRect();
return r.width && r.height ? Math.round(r.top) : -9999;
}
function appBottom() {
var app = doc.getElementById('mobile-app');
return app && app.offsetWidth ? Math.round(app.getBoundingClientRect().bottom) : -9999;
}
function scheduleReport() {
if (!debugOn() || reports >= 150) return;
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(report, 700); // after iOS has finished panning
}
function report() {
settleTimer = null;
if (!debugOn()) return;
var toolbar = doc.querySelector('#mobile-editor-body #editor-toolbar');
var data = {
kb: visible,
vvH: Math.round(vv.height), vvTop: Math.round(vv.offsetTop), vvScale: Math.round(vv.scale * 100) / 100,
innerH: window.innerHeight, lvh: Math.round(largeHeight()),
scrollY: Math.round(window.scrollY), pins: pins, maxScr: maxScroll,
appH: (html.style.getPropertyValue('--app-height') || 'none'), appTop: (html.style.getPropertyValue('--app-top') || 'none'),
// where the pieces are on screen: negative = scrolled off the top, -9999 = not shown
hdrTop: rectTop('.mobile-screen-active .mobile-header'),
tbTop: rectTop('#mobile-editor-body #editor-toolbar'),
tbDisplay: toolbar ? (toolbar.style.display || 'css') : 'none',
appBottom: appBottom(),
focus: doc.activeElement ? doc.activeElement.tagName : 'none',
standalone: !!(window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true),
};
var key = JSON.stringify(data);
if (key === lastReport) return;
lastReport = key;
reports++;
try {
window.fetch('/api/web/client-log', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'event=' + encodeURIComponent('viewport.kb') + '&data=' + encodeURIComponent(JSON.stringify(data)),
}).catch(function () { /* logging only */ });
} catch (e) { /* logging only */ }
}
// ---- wiring ----------------------------------------------------------------------------
vv.addEventListener('resize', sync);
vv.addEventListener('scroll', sync);
window.addEventListener('scroll', sync, { passive: true });
window.addEventListener('resize', sync);
window.addEventListener('orientationchange', sync);
if (coarse.addEventListener) coarse.addEventListener('change', sync);
if (keyboard && keyboard.addEventListener) keyboard.addEventListener('geometrychange', sync);
doc.addEventListener('focusin', sync);
// focus moving field to field blurs first: look again once it has landed
doc.addEventListener('focusout', function () { setTimeout(sync, 0); });
doc.addEventListener('selectionchange', function () { if (visible) scheduleReport(); });
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', sync);
api.sync = sync;
api.state = function () { return { keyboard: visible, pins: pins, maxScroll: maxScroll }; };
sync();
})(typeof window !== 'undefined' ? window : this);

128
scripts/diag-reports.js Normal file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env node
'use strict';
// Reads real-device reports out of the dev container's logs (and .diag-archive/, where
// scripts/rebuild-dev.sh keeps them across rebuilds) and prints one compact line per report.
//
// node scripts/diag-reports.js [--since 30m] [--container joplock-dev-joplock-1]
// [--event diag.viewport|viewport.kb] [--rid abc123] [--json] [--no-archive]
//
// diag.viewport (the /diag page): flags
// HDR-OFF header not at the top of the visible screen (page panned)
// BAR-HID bottom bar below the visible area (under the keyboard)
// BAR-GAP more than 2px of empty space under the bottom bar (keyboard down)
// DEAD-BAND shell ends above the window's bottom edge (iOS black-translucent viewport bug)
// NO-SAFE standalone but every safe-area inset is 0
// viewport.kb (the real app, admin debug on; public/viewport.js): flags
// HDR-OFF / TB-OFF header / markdown toolbar scrolled off the top of the screen
// TB-HIDDEN markdown toolbar display:none
// PANNED iOS scrolled the page under us (pins>0 means we put it back)
// NOT-PINNED page scrollY != 0 while the shell is showing
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const vm = require('node:vm');
const args = process.argv.slice(2);
const opt = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; };
const container = opt('container', 'joplock-dev-joplock-1');
const since = opt('since', '1h');
const ridFilter = opt('rid', '');
const eventFilter = opt('event', '');
const asJson = args.includes('--json');
const useArchive = !args.includes('--no-archive');
const sources = [];
if (useArchive) {
const dir = path.join(__dirname, '..', '.diag-archive');
if (fs.existsSync(dir)) for (const f of fs.readdirSync(dir).sort()) if (f.endsWith('.log')) sources.push(fs.readFileSync(path.join(dir, f), 'utf8'));
}
const logs = spawnSync('docker', ['logs', '-t', '--since', since, container], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (logs.error) console.error(`docker logs failed: ${logs.error.message}`);
else sources.push(`${logs.stdout}\n${logs.stderr}`);
const stamp = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)\.\d+Z /;
const reports = [];
const seen = new Set();
for (const source of sources) {
const lines = source.split('\n');
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\S+) \[joplock client\] (diag\.viewport|viewport\.kb) \{$/);
if (!m) continue;
const body = ['{'];
let j = i + 1;
for (; j < lines.length; j++) {
const text = lines[j].replace(stamp, '');
body.push(text);
if (text === '}') break;
}
try {
const data = vm.runInNewContext(`(${body.join('\n')})`, {}, { timeout: 200 });
data.event = m[2];
data.at = m[1].slice(0, 19).replace('T', ' ');
const key = `${data.event}|${data.at}|${data.rid || ''}|${data.n || ''}|${JSON.stringify(data).length}`;
if (!seen.has(key)) { seen.add(key); reports.push(data); }
} catch { /* a truncated block: skip */ }
i = j;
}
}
reports.sort((a, b) => a.at.localeCompare(b.at));
const diagFlags = r => {
const out = [];
if (Math.abs(r.hdrScreenY) > 1) out.push('HDR-OFF');
if (r.barGap < 0) out.push('BAR-HID');
if (r.barGap > 2 && !r.kbGuess) out.push('BAR-GAP');
if (r.bandBottom > 2 && !r.kbGuess) out.push('DEAD-BAND');
if (r.standalone && !(r.safeT || r.safeB || r.safeL || r.safeR)) out.push('NO-SAFE');
return out;
};
const kbFlags = r => {
const out = [];
if (r.hdrTop < 0 && r.hdrTop !== -9999) out.push('HDR-OFF');
if (r.tbTop < 0 && r.tbTop !== -9999) out.push('TB-OFF');
if (r.tbDisplay === 'none') out.push('TB-HIDDEN');
if (r.pins > 0) out.push(`PANNED(max ${r.maxScr}px, pinned ${r.pins}x)`);
if (r.scrollY !== 0 && r.appBottom !== -9999) out.push('NOT-PINNED');
return out;
};
const flags = r => (r.event === 'viewport.kb' ? kbFlags(r) : diagFlags(r));
const selected = reports.filter(r => (!ridFilter || r.rid === ridFilter) && (!eventFilter || r.event === eventFilter));
if (asJson) {
console.log(JSON.stringify(selected.map(r => ({ ...r, flags: flags(r) })), null, 1));
} else if (!selected.length) {
console.log(`no device reports in the last ${since} (container ${container}${useArchive ? ' + archive' : ''})`);
} else {
for (const r of selected) {
if (r.event === 'viewport.kb') {
console.log([
r.at.slice(5), 'app ', r.standalone ? 'PWA' : 'tab', `kb=${r.kb ? 'Y' : 'n'}`,
`vv ${r.vvH}@${r.vvTop} s=${r.vvScale}`, `inner ${r.innerH} lvh=${r.lvh}`, `scrollY=${r.scrollY}`,
`appH=${r.appH} appTop=${r.appTop} appBottom=${r.appBottom}`,
`hdrTop=${r.hdrTop} tbTop=${r.tbTop}(${r.tbDisplay})`, `focus=${r.focus}`,
flags(r).join(',') || 'ok',
].join(' | '));
continue;
}
const variant = `${r.shell}${r.iw ? '+iw' : ''}${r.zoom ? '+zoom' : ''}`;
console.log([
r.at.slice(11),
`${r.rid}#${r.n}`.padEnd(9),
String(r.label).padEnd(18),
variant.padEnd(10),
r.standalone ? 'PWA' : 'tab',
`inner ${r.innerW}x${r.innerH}`,
`vv ${r.vvW}x${r.vvH} top=${r.vvTop} s=${r.vvScale}`,
`lvh=${r.p100lvh} dvh=${r.p100dvh} svh=${r.p100svh}`,
`safe T${r.safeT} B${r.safeB} L${r.safeL} R${r.safeR}`,
`hdrY=${r.hdrScreenY} barGap=${r.barGap} band=${r.bandBottom ?? '?'} shellH=${r.shellH} outerH=${r.outerH}`,
`scr ${r.screenW}x${r.screenH}`,
`appH=${r.appHeightVar}${r.kbAttr !== undefined ? ` kbAttr=${r.kbAttr ? 'Y' : 'n'} top=${r.appTopVar} pins=${r.vpPins}` : ''}`,
`focus=${r.focus} kb=${r.kbGuess ? 'Y' : 'n'}${r.kbApi ? `/api${r.kbApiH}` : ''}`,
flags(r).join(',') || 'ok',
].join(' | '));
}
console.log(`\n${selected.length} report(s); latest UA: ${selected[selected.length - 1].ua}`);
}

View file

@ -9,6 +9,11 @@ const publicDir = path.join(rootDir, 'public');
const splashDir = path.join(publicDir, 'apple-splash');
const splashScreens = [
// iPhone Air (420x912 @3x) and iPhone 16/17 Pro (402x874 @3x)
{ width: 1260, height: 2736 },
{ width: 2736, height: 1260 },
{ width: 1206, height: 2622 },
{ width: 2622, height: 1206 },
{ width: 1320, height: 2868 },
{ width: 2868, height: 1320 },
{ width: 1290, height: 2796 },

View file

@ -10,6 +10,22 @@ if [[ ! -f "$COMPOSE_FILE" ]]; then
exit 1
fi
# Recreating the container discards its logs, and /diag + viewport.kb device reports only live
# there: keep them in .diag-archive/ (gitignored; scripts/diag-reports.js reads it too). Best
# effort: this must never stop a rebuild.
CONTAINER="joplock-dev-joplock-1"
ARCHIVE_DIR="$ROOT_DIR/.diag-archive"
archive_reports() {
docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER" || return 0
mkdir -p "$ARCHIVE_DIR" || return 0
local file
file="$ARCHIVE_DIR/$(date +%Y%m%d-%H%M%S).log"
docker logs -t "$CONTAINER" 2>&1 \
| awk '/\[joplock client\] (diag\.viewport|viewport\.kb) \{$/ {p=1} p {print} p && / \}$/ {p=0}' > "$file" || true
[[ -s "$file" ]] || rm -f "$file"
}
archive_reports || echo "[joplock] (could not archive device reports; continuing)"
echo "[joplock] Rebuilding dev app container..."
docker compose -f "$COMPOSE_FILE" build joplock
docker compose -f "$COMPOSE_FILE" up -d --no-deps joplock

133
tests/agentDocs.test.js Normal file
View file

@ -0,0 +1,133 @@
'use strict';
// The agent docs are only useful while they are small and true. AGENTS.md is loaded on every task,
// so it has a size budget; the topic files in docs/agents/ are read on demand, so each must be
// linked from AGENTS.md; and any file, function or npm script a doc names must still exist (a stale
// reference actively misleads an agent, which is worse than no doc).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = rel => fs.readFileSync(path.join(root, rel), 'utf8');
const agents = read('AGENTS.md');
const topicDir = path.join(root, 'docs', 'agents');
const topics = fs.readdirSync(topicDir).filter(f => f.endsWith('.md')).sort();
const docs = { 'AGENTS.md': agents, ...Object.fromEntries(topics.map(f => [`docs/agents/${f}`, read(`docs/agents/${f}`)])) };
const ROOTS = '(?:app|public|tests|playwright-tests|integration-tests|scripts|docs|cm-build|hljs-build|prism-build)';
const globToRegExp = glob => new RegExp(`^${glob.split('*').map(p => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`);
const exists = rel => {
if (!rel.includes('*')) return fs.existsSync(path.join(root, rel));
const dir = path.join(root, path.dirname(rel));
return fs.existsSync(dir) && fs.readdirSync(dir).some(name => globToRegExp(path.basename(rel)).test(name));
};
test('AGENTS.md is small enough to load on every task', () => {
const lines = agents.split('\n').length;
assert.ok(lines <= 130, `AGENTS.md is ${lines} lines (budget 130): move detail into docs/agents/`);
assert.ok(agents.length <= 14000, `AGENTS.md is ${agents.length} chars (budget 14000)`);
});
test('no topic file grows into the next monolith', () => {
for (const f of topics) assert.ok(docs[`docs/agents/${f}`].length <= 24000, `docs/agents/${f} is over 24000 chars: split it`);
});
test('every topic file is linked from AGENTS.md, and every docs/agents link resolves', () => {
for (const f of topics) assert.ok(agents.includes(`docs/agents/${f}`), `docs/agents/${f} is not linked from AGENTS.md`);
for (const [name, text] of Object.entries(docs)) {
for (const [, link] of text.matchAll(/docs\/agents\/([\w-]+\.md)/g)) assert.ok(topics.includes(link), `${name} links to docs/agents/${link}, which does not exist`);
}
});
test('every topic file says when to read it', () => {
for (const f of topics) {
const text = docs[`docs/agents/${f}`];
assert.match(text, /^# .+\n\n> Read this before: /, `docs/agents/${f} must start with a title and a "Read this before" line`);
}
});
test('every repo path a doc names exists', () => {
const missing = [];
for (const [name, text] of Object.entries(docs)) {
for (const [, token] of text.matchAll(/`([^`\n]+)`/g)) {
const m = new RegExp(`^(${ROOTS}/[\\w./@*\\-]+?)(?::\\d+)?$`).exec(token.trim());
if (m && !exists(m[1])) missing.push(`${name}: ${token}`);
}
}
assert.deepEqual(missing, []);
});
test('every npm script a doc names exists', () => {
const scripts = Object.keys(JSON.parse(read('package.json')).scripts);
const missing = [];
for (const [name, text] of Object.entries(docs)) {
for (const [, script] of text.matchAll(/npm run ([\w:-]+)/g)) if (!scripts.includes(script)) missing.push(`${name}: npm run ${script}`);
}
assert.deepEqual(missing, []);
});
// Source files to look identifiers up in (not the docs themselves, not vendored bundles)
const sourceText = (() => {
const out = [];
const walk = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (['node_modules', 'tinymce', 'plans', 'graft', 'test-results', 'playwright-report', 'data', 'vendor'].includes(entry.name) || entry.name.startsWith('.')) continue;
walk(full);
} else if (/\.(js|mjs|json|css|html)$/.test(entry.name) && !/\.min\.js$/.test(entry.name) && !['html-docx.js'].includes(entry.name)) {
out.push(fs.readFileSync(full, 'utf8'));
}
}
};
for (const dir of ['app', 'public', 'tests', 'playwright-tests', 'integration-tests', 'scripts']) walk(path.join(root, dir));
out.push(read('server.js'), read('package.json'));
return out.join('\n');
})();
// Names that are other people's APIs (TinyMCE, CodeMirror, htmx, the browser, Postgres, Node), not ours.
const EXTERNAL = new Set([
'fetch', 'require', 'handle', 'alert', 'confirm', 'getContent', 'setContent', 'insertContent', 'execCommand', 'mceCodeSample',
'convert_from', 'toString', 'focus', 'blur', 'dispatch', 'setSelection', 'scrollIntoView', 'getSelection', 'getBoundingClientRect',
'posAtDOM', 'posAtCoords', 'requestMeasure', 'getNode', 'elementFromPoint', 'scrollTo', 'matchMedia', 'getComputedStyle',
'addEventListener', 'setDeviceMetricsOverride', 'setViewportSize', 'setOffline', 'newCDPSession', 'newContext', 'toBeVisible',
'dragstart', 'dragenter', 'dragover', 'dragleave', 'getElementById', 'querySelector', 'querySelectorAll', 'getAttribute',
'pg_dump', 'pg_restore', 'CREATE', 'renderMarkdown', 'markdown', 'htmlToMarkdown',
'flankingWhitespace', // Turndown internal (vendored public/turndown.min.js)
]);
test('every function a doc names still exists somewhere in the source', () => {
const missing = [];
for (const [name, text] of Object.entries(docs)) {
for (const [, token] of text.matchAll(/`([A-Za-z_$][\w$.]*)\([^`\n]*\)`/g)) {
const fn = token.split('.').pop();
if (EXTERNAL.has(fn) || fn.length < 4) continue;
if (!new RegExp(`\\b${fn.replace(/\$/g, '\\$')}\\b`).test(sourceText)) missing.push(`${name}: ${fn}()`);
}
}
assert.deepEqual([...new Set(missing)], []);
});
test('source files that point at a topic doc point at one that exists', () => {
const bad = [];
const scan = rel => {
for (const f of fs.readdirSync(path.join(root, rel))) {
if (!/\.js$/.test(f) || /\.min\.js$/.test(f)) continue;
const text = read(`${rel}/${f}`);
for (const [, link] of text.matchAll(/docs\/agents\/([\w-]+\.md)/g)) if (!topics.includes(link)) bad.push(`${rel}/${f}: ${link}`);
}
};
['app', 'app/items', 'app/routes', 'app/proxy', 'app/templates', 'public'].forEach(scan);
assert.deepEqual(bad, []);
});
test('nothing still points at the removed AGENT_GUIDE.md', () => {
const hits = [];
for (const [name, text] of Object.entries({ ...docs, 'README.md': read('README.md') })) if (text.includes('AGENT_GUIDE')) hits.push(name);
for (const rel of ['public/app.js', 'public/service-worker.js', 'server.js']) if (read(rel).includes('AGENT_GUIDE')) hits.push(rel);
assert.deepEqual(hits, []);
});

View file

@ -0,0 +1,70 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { computeAssetVersion, assetVersion } = require('../app/assetVersion');
const { serveServiceWorker } = require('../app/routes/_helpers');
const tempPublic = files => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-assets-'));
for (const [name, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
fs.writeFileSync(path.join(dir, name), content);
}
return dir;
};
test('the asset version is stable for unchanged files and changes with any shipped file', () => {
const dir = tempPublic({ 'app.js': 'a', 'styles.css': 'b', 'tinymce/tinymce.min.js': 't', 'tinymce/plugins/x.js': 'p' });
const v1 = computeAssetVersion(dir);
assert.match(v1, /^[0-9a-f]{10}$/);
assert.equal(computeAssetVersion(dir), v1);
fs.writeFileSync(path.join(dir, 'app.js'), 'a2');
const v2 = computeAssetVersion(dir);
assert.notEqual(v2, v1);
fs.writeFileSync(path.join(dir, 'tinymce/tinymce.min.js'), 't2');
assert.notEqual(computeAssetVersion(dir), v2);
// vendor files other than the entry point are not hashed
const v3 = computeAssetVersion(dir);
fs.writeFileSync(path.join(dir, 'tinymce/plugins/x.js'), 'p2');
assert.equal(computeAssetVersion(dir), v3);
});
test('a file added to public/ changes the version, and a missing directory does not throw', () => {
const dir = tempPublic({ 'app.js': 'a' });
const before = computeAssetVersion(dir);
fs.writeFileSync(path.join(dir, 'viewport.js'), 'v');
assert.notEqual(computeAssetVersion(dir), before);
assert.match(computeAssetVersion(path.join(dir, 'nope')), /^[0-9a-f]{10}$/);
});
test('the real public/ yields one version, and every template uses it', () => {
const v = assetVersion();
assert.match(v, /^[0-9a-f]{10}$/);
const templates = require('../app/templates');
assert.ok(templates.layoutPage({ user: null }).includes(`/styles.css?v=${v}`));
assert.ok(templates.layoutPage({ user: { id: 'u', email: 'a@b.c' }, navContent: '', editorContent: '', settings: {} }).includes(`assetVersion:"${v}"`));
});
test('the served service worker carries the version in its cache name and is never cached', () => {
const dir = tempPublic({ 'service-worker.js': "const CACHE_NAME = 'joplock-shell-__ASSET_VERSION__'; // __ASSET_VERSION__\n" });
let status; let headers; let body;
const response = { writeHead: (s, h) => { status = s; headers = h; }, end: b => { body = b; } };
serveServiceWorker(response, path.join(dir, 'service-worker.js'), 'abc123');
assert.equal(status, 200);
assert.equal(body, "const CACHE_NAME = 'joplock-shell-abc123'; // abc123\n");
assert.equal(headers['Cache-Control'], 'no-cache');
assert.equal(headers['Service-Worker-Allowed'], '/');
assert.match(headers['Content-Type'], /javascript/);
assert.equal(headers['Content-Length'], Buffer.byteLength(body));
});
test('the shipped service worker is a template, not a hand-versioned file', () => {
const sw = fs.readFileSync(path.join(__dirname, '..', 'public', 'service-worker.js'), 'utf8');
assert.match(sw, /const CACHE_NAME = 'joplock-shell-__ASSET_VERSION__'/);
assert.doesNotMatch(sw, /joplock-shell-v\d+/);
});

77
tests/diagPage.test.js Normal file
View file

@ -0,0 +1,77 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const templates = require('../app/templates');
const { diagOptions, viewportContent } = require('../app/templates/diag');
const routeDiag = require('../app/routes/diag');
const scriptOf = html => html.split('<script>')[1].split('</script>')[0];
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 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.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=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`);
assert.doesNotMatch(html, /\$\{/, 'no unrendered template placeholder');
}
assert.match(templates.diagPage({ query: new URLSearchParams('shell=vv') }), /id="shell" class="shell-vv"/);
});
test('every reported field survives the server-side client-log key filter', () => {
// /api/web/client-log drops keys matching this pattern, so a field named like one is lost silently.
const dropped = /text|body|content|password|key|secret|token/i;
const keys = [...scriptOf(templates.diagPage({})).matchAll(/put\('([A-Za-z0-9_]+)'/g)].map(m => m[1]);
assert.ok(keys.length > 30, 'expected the snapshot to report many fields');
for (const extra of ['label', 'rid', 'n', 'auto']) keys.push(extra);
for (const key of keys) assert.doesNotMatch(key, dropped, `report field "${key}" would be dropped by the server`);
});
const run = async ({ pathname = '/diag', method = 'GET', debug = true, user = { id: 'u1' } } = {}) => {
const out = { html: null, redirect: null };
const ctx = {
isDebug: () => debug,
templates,
sendHtml: (_res, status, html) => { out.status = status; out.html = html; },
authenticatedUser: async () => (user ? { user } : { error: 'no' }),
};
const response = { writeHead: (status, headers) => { out.status = status; out.redirect = headers && headers.Location; }, end() {} };
out.handled = await routeDiag.handle(new URL(`http://x${pathname}`), { method, headers: {} }, response, ctx);
return out;
};
test('/diag is served to a signed-in user in debug mode', async () => {
const out = await run({ pathname: '/diag?shell=vv' });
assert.equal(out.handled, true);
assert.equal(out.status, 200);
assert.match(out.html, /shell-vv/);
});
test('/diag does not exist when debug is off, or for other paths and methods', async () => {
assert.equal((await run({ debug: false })).handled, false);
assert.equal((await run({ pathname: '/diagnostics' })).handled, false);
assert.equal((await run({ method: 'POST' })).handled, false);
});
test('/diag redirects an anonymous visitor to the login page', async () => {
const out = await run({ user: null });
assert.equal(out.handled, true);
assert.equal(out.status, 302);
assert.equal(out.redirect, '/login');
});

View file

@ -0,0 +1,107 @@
'use strict';
// A phone in landscape is 812-956px wide, past the 768px breakpoint, so it used to get the desktop
// layout (sidebar and all) in about 390px of height, and rotating reloaded the page. The mobile
// shell now also covers any touch device whose PHYSICAL screen has a short side <= 500px. It must
// not use the window height: a soft keyboard shrinks the window (on iOS home-screen apps innerHeight
// shrinks with it), which would flip an iPad to the phone layout while typing.
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in app.js`);
let depth = 0;
for (let i = start; i < appSrc.length; i++) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
}
throw new Error(`no closing brace for ${name}`);
}
const constantLine = name => {
const m = new RegExp(`var ${name}=[^;]+;`).exec(appSrc);
assert.ok(m, `${name} not found`);
return m[0];
};
// innerWidth/innerHeight are the window (keyboard changes them); screen is the physical display
function shell({ uiMode = 'auto', innerWidth, innerHeight = 800, screen: [sw, sh], coarse }) {
const ctx = vm.createContext({
_uiMode: uiMode,
viewportWidth: () => innerWidth,
viewportHeight: () => innerHeight, // available, but the decision must not use it
innerHeight,
window: { matchMedia: q => ({ matches: q === '(pointer: coarse)' ? coarse : false }), screen: { width: sw, height: sh } },
});
vm.runInContext(`${constantLine('_mobileShellMaxWidth')} ${constantLine('_phoneScreenMaxShortSide')} ${extractFn('_isPhoneScreen')} ${extractFn('_computeMobileShell')} var result=_computeMobileShell();`, ctx);
return ctx.result;
}
test('phones keep the mobile shell in both orientations', () => {
// iPhone Air home-screen app: screen reports 375x812 and does not rotate on iOS
assert.equal(shell({ innerWidth: 375, screen: [375, 812], coarse: true }), true, 'portrait');
assert.equal(shell({ innerWidth: 812, screen: [375, 812], coarse: true }), true, 'landscape');
// Pixel 7: screen rotates on Android
assert.equal(shell({ innerWidth: 412, screen: [412, 915], coarse: true }), true);
assert.equal(shell({ innerWidth: 915, screen: [915, 412], coarse: true }), true);
// largest phones
assert.equal(shell({ innerWidth: 956, screen: [956, 440], coarse: true }), true);
});
test('tablets keep the desktop shell in every orientation, whatever the keyboard does to the window', () => {
assert.equal(shell({ innerWidth: 820, screen: [820, 1180], coarse: true }), false, 'iPad Air portrait');
assert.equal(shell({ innerWidth: 1180, screen: [820, 1180], coarse: true }), false, 'iPad Air landscape');
// the keyboard shrinks the window, not the screen: a window-height test would have got these wrong
assert.equal(shell({ innerWidth: 1180, innerHeight: 400, screen: [820, 1180], coarse: true }), false, 'iPad landscape, keyboard up (innerHeight ~400)');
assert.equal(shell({ innerWidth: 1180, innerHeight: 120, screen: [820, 1180], coarse: true }), false, 'even a nearly flat window');
assert.equal(shell({ innerWidth: 744, screen: [744, 1133], coarse: true }), true, 'a narrow iPad mini window <= 768 is mobile, as before');
});
test('mouse-driven windows only use the width rule', () => {
assert.equal(shell({ innerWidth: 700, screen: [1920, 1080], coarse: false }), true, 'narrow desktop window');
assert.equal(shell({ innerWidth: 900, screen: [1920, 1080], coarse: false }), false);
assert.equal(shell({ innerWidth: 900, screen: [412, 915], coarse: false }), false, 'a phone-sized screen without a touch pointer (e.g. DevTools mouse) is not a phone');
});
test('the uiMode setting still wins', () => {
assert.equal(shell({ uiMode: 'desktop', innerWidth: 375, screen: [375, 812], coarse: true }), false);
assert.equal(shell({ uiMode: 'mobile', innerWidth: 1600, screen: [1920, 1080], coarse: false }), true);
});
test('all four mobile-shell media blocks use the same phone query, in step with app.js', () => {
const phoneQuery = '@media (max-width: 768px), (pointer: coarse) and (max-device-width: 500px), (pointer: coarse) and (max-device-height: 500px) {';
assert.equal(css.split(phoneQuery).length - 1, 4, 'the four former max-width: 768px blocks');
assert.equal(css.includes('@media (max-width: 768px) {'), false, 'no block may keep the width-only query');
assert.match(constantLine('_phoneScreenMaxShortSide'), /=500;/);
assert.match(constantLine('_mobileShellMaxWidth'), /=768;/);
});
test('the phone query never looks at the window height (the keyboard changes that)', () => {
const phoneQuery = css.match(/@media \(max-width: 768px\), [^{]+\{/)[0];
assert.doesNotMatch(phoneQuery, /(?<!device-)(max|min)-height/);
});
test('the mobile shell keeps clear of landscape display cutouts', () => {
assert.match(css, /\.mobile-app \{[^}]*inset: 0 env\(safe-area-inset-right, 0px\) 0 env\(safe-area-inset-left, 0px\);/);
assert.match(css, /body\.force-mobile \.mobile-app \{[^}]*inset: 0 env\(safe-area-inset-right, 0px\) 0 env\(safe-area-inset-left, 0px\);/);
assert.match(css, /\.mobile-fab \{[^}]*right: calc\(20px \+ env\(safe-area-inset-right, 0px\)\);/);
assert.match(css, /\.mobile-fab-menu \{[^}]*right: calc\(20px \+ env\(safe-area-inset-right, 0px\)\);/);
assert.match(css, /\.mobile-ctx-sheet \{[^}]*padding: 8px env\(safe-area-inset-right, 0px\) calc\(16px \+ env\(safe-area-inset-bottom, 0px\)\) env\(safe-area-inset-left, 0px\);/);
});
test('short windows tighten the shell chrome, and landscape typing hides only the editor title bar', () => {
const short = css.slice(css.indexOf('@media (max-height: 500px) {'));
assert.match(short, /\.mobile-header \{\s*height: calc\(36px/);
assert.match(short, /\.mobile-app \.editor-toolbar \.tb \{[^}]*height: 34px/);
assert.match(css, /@media \(max-height: 500px\) and \(orientation: landscape\) \{\s*html\[data-keyboard\] #mobile-editor-header \{\s*display: none;/);
// the search header holds a text field: it must never be hidden while typing
assert.doesNotMatch(css, /data-keyboard\][^{]*mobile-editor-search-header[^{]*\{\s*display: none/);
});

View file

@ -0,0 +1,59 @@
'use strict';
// The markdown editor's word-count / heading-outline bar is a desktop feature. In the mobile
// shell it is not created at all (it cost a row of screen under the editor and recounted words
// on every keystroke), while the user's mdStatusBar setting still governs the desktop shell.
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in app.js`);
let depth = 0;
for (let i = start; i < appSrc.length; i++) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
}
throw new Error(`no closing brace for ${name}`);
}
function optionsPassed({ mobile, setting }) {
let captured = null;
const ctx = vm.createContext({
window: { JoplockMd: { createExtensions: (_C, _hooks, opts) => { captured = opts; return []; } } },
console,
_mdStatusBar: setting,
_mdInlineWidgets: true,
_mdLivePreview: false,
_mdFolding: true,
isMobileShellMode: () => mobile,
_cmExtraHooks: () => ({}),
});
vm.runInContext(`${extractFn('_cmExtraExtensions')}; _cmExtraExtensions({});`, ctx);
return captured;
}
test('the desktop shell follows the mdStatusBar setting', () => {
assert.equal(optionsPassed({ mobile: false, setting: true }).statusBar, true);
assert.equal(optionsPassed({ mobile: false, setting: false }).statusBar, false);
});
test('the mobile shell never gets the status bar, whatever the setting says', () => {
assert.equal(optionsPassed({ mobile: true, setting: true }).statusBar, false);
assert.equal(optionsPassed({ mobile: true, setting: false }).statusBar, false);
});
test('other markdown extras are unaffected by the shell', () => {
for (const mobile of [true, false]) {
const o = optionsPassed({ mobile, setting: true });
assert.equal(o.inlineWidgets, true);
assert.equal(o.folding, true);
assert.equal(o.livePreview, false);
}
});

View file

@ -0,0 +1,62 @@
'use strict';
// setMobileToolbarVisible(false) is called when the viewport handler thinks the keyboard went
// away and 200ms after the rich editor blurs. In markdown mode that used to hide the formatting
// toolbar for good (nothing re-showed it), so it vanished the moment a line was tapped. It must
// ignore "hide" in markdown mode; only a locked vault hides it.
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in app.js`);
let depth = 0;
for (let i = start; i < appSrc.length; i++) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
}
throw new Error(`no closing brace for ${name}`);
}
function run({ mode, show, form }) {
const tb = { style: { display: '' } };
let repositioned = 0;
const ctx = vm.createContext({
_editorMode: mode,
mobileEditorToolbar: () => tb,
activeEditorForm: () => form || null,
positionTinyMCEHost: () => { repositioned++; },
setTimeout: fn => fn(),
});
vm.runInContext(`${extractFn('setMobileToolbarVisible')}; setMobileToolbarVisible(${show});`, ctx);
return { display: tb.style.display, repositioned };
}
test('markdown mode keeps the toolbar when asked to hide it', () => {
for (const mode of ['markdown', 'md']) {
assert.equal(run({ mode, show: false }).display, 'flex');
assert.equal(run({ mode, show: true }).display, 'flex');
}
});
test('a locked vault hides the markdown toolbar whatever is asked', () => {
const locked = { dataset: { encrypted: '1', vaultUnlocked: '0' } };
assert.equal(run({ mode: 'markdown', show: true, form: locked }).display, 'none');
assert.equal(run({ mode: 'markdown', show: false, form: locked }).display, 'none');
const unlocked = { dataset: { encrypted: '1', vaultUnlocked: '1' } };
assert.equal(run({ mode: 'markdown', show: false, form: unlocked }).display, 'flex');
});
test('rich mode leaves the toolbar to CSS (TinyMCE has its own) and just repositions the host', () => {
for (const show of [true, false]) {
const r = run({ mode: 'rich', show });
assert.equal(r.display, '');
assert.equal(r.repositioned, 1);
}
});

80
tests/pwaHead.test.js Normal file
View file

@ -0,0 +1,80 @@
'use strict';
// Why these exist: with apple-mobile-web-app-status-bar-style=black-translucent, an iOS
// home-screen app sizes innerHeight / 100dvh / 100svh as (screen height - top safe-area inset),
// anchored at the top, while 100lvh/100vh still report the real screen. The app stops that
// many points above the bottom edge (a dead band under it) and every `position: fixed;
// bottom: ...` element floats too high. Measured on an iPhone Air: screen 812, innerHeight
// 751, top inset 61. Leaving the meta tag out gives the normal layout (content below the
// status bar, bottom edge on the hardware), which is what herdr-web-ui ships. iOS bakes the
// value in at install time, so changing it needs the icon removed and added again.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const templates = require('../app/templates');
const publicDir = path.join(__dirname, '..', 'public');
const stylesSrc = fs.readFileSync(path.join(publicDir, 'styles.css'), 'utf8');
const user = { id: 'u1', email: 'a@example.com', isAdmin: false };
test('no real page head asks iOS for the black-translucent status bar', () => {
const pages = {
login: templates.layoutPage({ user: null }),
app: templates.layoutPage({ user, navContent: '', editorContent: '', settings: {} }),
diag: templates.diagPage({}),
};
for (const [name, html] of Object.entries(pages)) {
assert.doesNotMatch(html, /apple-mobile-web-app-status-bar-style/, `${name} page must not set a status bar style`);
assert.doesNotMatch(html, /black-translucent/, `${name} page must not use black-translucent`);
}
});
test('home-screen app meta that must stay is still present', () => {
const html = templates.layoutPage({ user: null });
assert.match(html, /<meta name="apple-mobile-web-app-capable" content="yes"/);
assert.match(html, /<meta name="mobile-web-app-capable" content="yes"/);
assert.match(html, /viewport-fit=cover/);
assert.match(html, /<link rel="manifest"/);
});
test('every apple-touch-startup-image link points at a real file with matching pixel size', () => {
const html = templates.layoutPage({ user: null });
const links = [...html.matchAll(/<link rel="apple-touch-startup-image" href="\/apple-splash\/(\d+)x(\d+)\.png" media="([^"]+)"/g)];
assert.ok(links.length >= 24, `expected the splash set, got ${links.length}`);
for (const [, w, h, media] of links) {
const file = path.join(publicDir, 'apple-splash', `${w}x${h}.png`);
assert.ok(fs.existsSync(file), `${w}x${h}.png is referenced but missing`);
const dw = Number(/device-width: (\d+)px/.exec(media)[1]);
const dh = Number(/device-height: (\d+)px/.exec(media)[1]);
const dpr = Number(/pixel-ratio: (\d+)/.exec(media)[1]);
const portrait = /portrait/.test(media);
assert.deepEqual([Number(w), Number(h)], portrait ? [dw * dpr, dh * dpr] : [dh * dpr, dw * dpr], `${w}x${h} does not match its media query: ${media}`);
}
const sizes = links.map(l => `${l[1]}x${l[2]}`);
assert.ok(sizes.includes('1260x2736'), 'iPhone Air portrait splash (420x912 @3x)');
assert.ok(sizes.includes('1206x2622'), 'iPhone 16/17 Pro portrait splash (402x874 @3x)');
});
test('every 100vh in the stylesheet has a 100dvh fallback line next to it (iOS home-screen apps overflow on 100vh)', () => {
const lines = stylesSrc.split('\n');
const missing = [];
lines.forEach((line, i) => {
if (!/100vh/.test(line)) return;
if (/^\s*\.app \{/.test(line)) return; // desktop shell, display:none in the mobile shell
const near = lines.slice(Math.max(0, i - 1), i + 3).join('\n');
if (!/100dvh/.test(near)) missing.push(`${i + 1}: ${line.trim()}`);
});
assert.deepEqual(missing, []);
});
test('every local script and stylesheet in the app head is versioned, so the server can serve it immutable', () => {
const { assetVersion } = require('../app/assetVersion');
const html = templates.layoutPage({ user, navContent: '', editorContent: '', settings: {} });
const refs = [...html.matchAll(/<(?:script src|link rel="stylesheet" href)="(\/[^"]+)"/g)].map(m => m[1]);
assert.ok(refs.length >= 9, `expected the full script set, got ${refs.length}`);
for (const ref of refs) assert.ok(ref.endsWith(`?v=${assetVersion()}`), `${ref} must carry ?v=<asset version>`);
});

157
tests/serviceWorker.test.js Normal file
View file

@ -0,0 +1,157 @@
'use strict';
// Runs the real public/service-worker.js in a sandbox with fake caches/fetch/events.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ORIGIN = 'https://joplock.test';
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'service-worker.js'), 'utf8').replaceAll('__ASSET_VERSION__', 'v123');
const abs = input => (typeof input === 'string' ? new URL(input, ORIGIN).href : input.url);
function makeWorker({ network = async () => { throw new Error('offline'); }, existingCaches = {} } = {}) {
const handlers = {};
const store = new Map(Object.entries(existingCaches).map(([name, entries]) => [name, new Map(Object.entries(entries))]));
const fetched = [];
let claimed = false;
let skipped = false;
const cacheApi = name => {
if (!store.has(name)) store.set(name, new Map());
const entries = store.get(name);
return {
addAll: async urls => { for (const u of urls) entries.set(abs(u), { ok: true, type: 'basic', url: abs(u), clone() { return this; } }); },
match: async req => entries.get(abs(req)),
put: async (req, res) => { entries.set(abs(req), res); },
};
};
const caches = {
open: async name => cacheApi(name),
keys: async () => [...store.keys()],
delete: async name => store.delete(name),
match: async req => { for (const entries of store.values()) if (entries.has(abs(req))) return entries.get(abs(req)); return undefined; },
};
const sandbox = {
self: {
addEventListener: (type, fn) => { handlers[type] = fn; },
skipWaiting: () => { skipped = true; },
clients: { claim: async () => { claimed = true; } },
location: { origin: ORIGIN },
},
caches,
URL,
Response: { error: () => ({ isError: true }) },
fetch: async request => { fetched.push(request.url); return network(request); },
};
vm.runInNewContext(source, sandbox);
const dispatch = (type, extra = {}) => {
const event = { waitUntil: p => { event.waited = (event.waited || []).concat(p); }, respondWith: p => { event.response = p; }, ...extra };
handlers[type](event);
return event;
};
const request = (url, { method = 'GET', mode = 'no-cors' } = {}) => ({ url: abs(url), method, mode });
return { dispatch, request, store, fetched, flags: () => ({ claimed, skipped }) };
}
const ok = (body = 'ok') => ({ ok: true, type: 'basic', body, clone() { return this; } });
test('install precaches only the offline page and an icon, in a cache named for the version, and activates at once', async () => {
const w = makeWorker();
const e = w.dispatch('install');
await Promise.all(e.waited);
assert.deepEqual([...w.store.keys()], ['joplock-shell-v123']);
assert.deepEqual([...w.store.get('joplock-shell-v123').keys()].sort(), [`${ORIGIN}/icon-192.png`, `${ORIGIN}/offline.html`]);
assert.equal(w.flags().skipped, true);
});
test('activate removes older Joplock caches only, then claims the page', async () => {
const w = makeWorker({ existingCaches: { 'joplock-shell-v1': {}, 'joplock-shell-v123': {}, 'someone-elses-cache': {} } });
const e = w.dispatch('activate');
await Promise.all(e.waited);
assert.deepEqual([...w.store.keys()].sort(), ['joplock-shell-v123', 'someone-elses-cache']);
assert.equal(w.flags().claimed, true);
});
test('navigations go to the network; a down network gets the offline page; nothing signed-in is ever stored', async () => {
let down = false;
const w = makeWorker({ network: async () => { if (down) throw new Error('offline'); return ok('<html>signed-in notes</html>'); } });
await Promise.all(w.dispatch('install').waited);
const online = w.dispatch('fetch', { request: w.request('/', { mode: 'navigate' }) });
assert.equal((await online.response).body, '<html>signed-in notes</html>');
assert.equal([...w.store.get('joplock-shell-v123').keys()].some(k => k === `${ORIGIN}/`), false, 'a page must not be cached');
down = true;
const offline = w.dispatch('fetch', { request: w.request('/', { mode: 'navigate' }) });
assert.equal((await offline.response).url, `${ORIGIN}/offline.html`);
});
test('a navigation with no network and no cached offline page ends as a network error', async () => {
const w = makeWorker();
const e = w.dispatch('fetch', { request: w.request('/', { mode: 'navigate' }) });
assert.deepEqual(await e.response, { isError: true });
});
test('versioned scripts and stylesheets are cache-first', async () => {
let calls = 0;
const w = makeWorker({ network: async () => { calls++; return ok('js body'); } });
const first = w.dispatch('fetch', { request: w.request('/app.js?v=abc') });
assert.equal((await first.response).body, 'js body');
await Promise.all(first.waited);
assert.equal(calls, 1);
const second = w.dispatch('fetch', { request: w.request('/app.js?v=abc') });
assert.equal((await second.response).body, 'js body');
assert.equal(calls, 1, 'second request must not touch the network');
// a different version is a different entry
const other = w.dispatch('fetch', { request: w.request('/app.js?v=def') });
await other.response;
assert.equal(calls, 2);
// styles too
const css = w.dispatch('fetch', { request: w.request('/styles.css?v=abc') });
await css.response;
assert.equal(calls, 3);
});
test('failed and opaque responses are not cached', async () => {
let kind = 'bad';
const w = makeWorker({ network: async () => (kind === 'bad' ? { ok: false, type: 'basic', clone() { return this; } } : { ok: true, type: 'opaque', clone() { return this; } }) });
for (kind of ['bad', 'opaque']) {
const e = w.dispatch('fetch', { request: w.request('/app.js?v=abc') });
await e.response;
if (e.waited) await Promise.all(e.waited);
}
assert.equal(w.store.get('joplock-shell-v123').has(`${ORIGIN}/app.js?v=abc`), false);
});
test('everything else is left to the browser', () => {
const w = makeWorker();
const untouched = [
w.request('/app.js'), // unversioned
w.request('/api/web/settings'),
w.request('/fragments/editor/abc'),
w.request('/resources/abc?v=1'), // versioned but not js/css
w.request('/icon-192.png?v=1'),
w.request('/service-worker.js?v=abc'), // the worker itself
w.request('/manifest.webmanifest?v=abc'),
w.request('/app.js?v=abc', { method: 'POST' }),
{ url: 'https://cdn.example.com/lib.js?v=1', method: 'GET', mode: 'no-cors' }, // cross-origin
w.request('/tinymce/plugins/link/plugin.min.js'),
];
for (const request of untouched) {
const e = w.dispatch('fetch', { request });
assert.equal(e.response, undefined, `${request.method} ${request.url} must not be intercepted`);
}
assert.equal(w.fetched.length, 0);
});
test('the offline page ships and does not need anything but the precached icon', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'offline.html'), 'utf8');
assert.match(html, /You're offline/);
assert.match(html, /id="retry"/);
assert.match(html, /<style>/, 'inline styles: its stylesheet cannot be fetched offline');
const external = [...html.matchAll(/(?:src|href)="(\/[^"]+)"/g)].map(m => m[1]);
assert.deepEqual(external, ['/icon-192.png']);
});

View file

@ -60,10 +60,12 @@ function makeShellCtx(opts = {}) {
document: dom.window.document,
_uiMode: opts.uiMode || 'auto',
_mobileShellMaxWidth: 768,
_phoneScreenMaxShortSide: 500,
_mobileShellCached: null,
});
// Inject the same helpers app.js defines.
vm.runInContext(extractFn('viewportWidth'), ctx);
vm.runInContext(extractFn('_isPhoneScreen'), ctx); // jsdom has no matchMedia: reports false, i.e. width rule only
vm.runInContext(extractFn('_computeMobileShell'), ctx);
vm.runInContext(extractFn('isMobileShellMode'), ctx);
vm.runInContext(extractFn('isDesktopMode'), ctx);

142
tests/staticServing.test.js Normal file
View file

@ -0,0 +1,142 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const zlib = require('node:zlib');
const { serveFile, warmStaticCache, chooseEncoding, matchesEtag } = require('../app/routes/_helpers');
const big = `${'function joplock(){return "static asset"}\n'.repeat(400)}`; // compressible, > 1 KB
const withServer = async (files, fn) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-static-'));
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
const server = http.createServer((request, response) => {
const url = new URL(request.url, 'http://x');
serveFile(response, path.join(dir, url.pathname.slice(1)), request, { versioned: url.searchParams.has('v') });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const get = (pathname, headers = {}) => new Promise((resolve, reject) => {
http.get({ host: '127.0.0.1', port: server.address().port, path: pathname, headers }, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
}).on('error', reject);
});
try { await fn({ dir, get }); } finally { server.close(); }
};
test('Accept-Encoding negotiation: br over gzip over identity, q=0 refuses', () => {
assert.equal(chooseEncoding('gzip, deflate, br'), 'br');
assert.equal(chooseEncoding('gzip'), 'gzip');
assert.equal(chooseEncoding('br;q=0, gzip'), 'gzip');
assert.equal(chooseEncoding('br;q=0.5, gzip'), 'br');
assert.equal(chooseEncoding('identity'), null);
assert.equal(chooseEncoding(''), null);
assert.equal(chooseEncoding(undefined), null);
});
test('If-None-Match matching handles lists, weak validators and *', () => {
assert.equal(matchesEtag('"a", "b"', '"b"'), true);
assert.equal(matchesEtag('W/"b"', '"b"'), true);
assert.equal(matchesEtag('*', '"b"'), true);
assert.equal(matchesEtag('"c"', '"b"'), false);
assert.equal(matchesEtag(undefined, '"b"'), false);
});
test('serves gzip and brotli that decompress to the original, with Vary and a per-encoding ETag', async () => {
await withServer({ 'app.js': big }, async ({ get }) => {
const gz = await get('/app.js', { 'accept-encoding': 'gzip' });
assert.equal(gz.status, 200);
assert.equal(gz.headers['content-encoding'], 'gzip');
assert.equal(gz.headers['vary'], 'Accept-Encoding');
assert.equal(zlib.gunzipSync(gz.body).toString(), big);
assert.ok(gz.body.length < big.length / 5, 'repetitive source must shrink a lot');
assert.equal(Number(gz.headers['content-length']), gz.body.length);
assert.match(gz.headers['etag'], /-gzip"$/);
const br = await get('/app.js', { 'accept-encoding': 'gzip, br' });
assert.equal(br.headers['content-encoding'], 'br');
assert.equal(zlib.brotliDecompressSync(br.body).toString(), big);
assert.match(br.headers['etag'], /-br"$/);
const plain = await get('/app.js');
assert.equal(plain.headers['content-encoding'], undefined);
assert.equal(plain.body.toString(), big);
assert.equal(plain.headers['vary'], 'Accept-Encoding');
assert.doesNotMatch(plain.headers['etag'], /-(gzip|br)"$/);
});
});
test('a matching If-None-Match gets a 304 with the validators, a stale one the full body', async () => {
await withServer({ 'styles.css': big.replace(/function/g, '.rule') }, async ({ get }) => {
const first = await get('/styles.css', { 'accept-encoding': 'br' });
const etag = first.headers['etag'];
const cached = await get('/styles.css', { 'accept-encoding': 'br', 'if-none-match': etag });
assert.equal(cached.status, 304);
assert.equal(cached.body.length, 0);
assert.equal(cached.headers['etag'], etag);
assert.ok(cached.headers['cache-control']);
const stale = await get('/styles.css', { 'accept-encoding': 'br', 'if-none-match': '"old"' });
assert.equal(stale.status, 200);
// an ETag from another encoding is not a match for this one
const other = await get('/styles.css', { 'accept-encoding': 'gzip', 'if-none-match': etag });
assert.equal(other.status, 200);
});
});
test('cache headers: versioned URLs are immutable, the rest revalidate; worker, manifest and html are never reused blindly', async () => {
await withServer({ 'app.js': big, 'service-worker.js': big, 'manifest.webmanifest': big, 'page.html': big, 'font.woff2': 'x'.repeat(2000) }, async ({ get }) => {
assert.equal((await get('/app.js?v=abc123')).headers['cache-control'], 'public, max-age=31536000, immutable');
assert.equal((await get('/app.js')).headers['cache-control'], 'public, max-age=300');
assert.equal((await get('/service-worker.js?v=abc123')).headers['cache-control'], 'no-cache');
assert.equal((await get('/manifest.webmanifest?v=abc123')).headers['cache-control'], 'no-cache');
assert.equal((await get('/page.html?v=1')).headers['cache-control'], 'no-store');
assert.equal((await get('/font.woff2')).headers['cache-control'], 'public, max-age=31536000, immutable');
});
});
test('content types are right for compressed responses', async () => {
await withServer({ 'app.js': big, 'styles.css': big }, async ({ get }) => {
assert.match((await get('/app.js', { 'accept-encoding': 'gzip' })).headers['content-type'], /^text\/javascript/);
assert.match((await get('/styles.css', { 'accept-encoding': 'gzip' })).headers['content-type'], /^text\/css/);
});
});
test('small files and already-compressed types are not compressed; images stream with a weak validator and 304', async () => {
await withServer({ 'tiny.js': 'var a=1;', 'icon.png': Buffer.alloc(5000, 7) }, async ({ get }) => {
const tiny = await get('/tiny.js', { 'accept-encoding': 'gzip, br' });
assert.equal(tiny.headers['content-encoding'], undefined);
assert.equal(tiny.body.toString(), 'var a=1;');
const png = await get('/icon.png', { 'accept-encoding': 'gzip, br' });
assert.equal(png.headers['content-encoding'], undefined);
assert.equal(png.body.length, 5000);
assert.match(png.headers['etag'], /^W\//);
const again = await get('/icon.png', { 'if-none-match': png.headers['etag'] });
assert.equal(again.status, 304);
});
});
test('editing a file invalidates its cached body, compression and ETag', async () => {
await withServer({ 'app.js': big }, async ({ dir, get }) => {
const a = await get('/app.js', { 'accept-encoding': 'gzip' });
const next = `${big}// changed\n`;
fs.writeFileSync(path.join(dir, 'app.js'), next);
const future = new Date(Date.now() + 5000);
fs.utimesSync(path.join(dir, 'app.js'), future, future);
const b = await get('/app.js', { 'accept-encoding': 'gzip' });
assert.notEqual(b.headers['etag'], a.headers['etag']);
assert.equal(zlib.gunzipSync(b.body).toString(), next);
});
});
test('warming the cache tolerates a missing directory and compresses real files without error', async () => {
assert.doesNotThrow(() => warmStaticCache(path.join(os.tmpdir(), 'joplock-does-not-exist')));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-warm-'));
fs.writeFileSync(path.join(dir, 'app.js'), big);
assert.doesNotThrow(() => warmStaticCache(dir));
});

View file

@ -765,7 +765,7 @@ test('styles define ordered list spacing and matrix note text token', () => {
assert.ok(css.includes('.ai-profiles-bar {'));
assert.ok(css.includes('.ai-profile-field .settings-field-hint,'));
assert.ok(css.includes('overflow-wrap: anywhere;'));
assert.ok(css.includes('@media (max-width: 768px) {'));
assert.ok(css.includes('@media (max-width: 768px), (pointer: coarse)'));
assert.ok(css.includes('.settings-tabs {\n\t\tdisplay: grid;'));
assert.ok(css.includes('grid-template-columns: 1fr;'));
assert.ok(css.includes('.ai-profiles-bar {\n\t\tdisplay: grid;'));

40
tests/themeColors.test.js Normal file
View file

@ -0,0 +1,40 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { themeColor, parseThemeColors, DEFAULT_THEME } = require('../app/themeColors');
const { themeOptions } = require('../app/templates/shared');
const templates = require('../app/templates');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'styles.css'), 'utf8');
test('every selectable theme has a theme-color in the stylesheet', () => {
const colors = parseThemeColors(css);
for (const [slug] of themeOptions) assert.match(colors[slug] || '', /^#[0-9a-f]{3,8}$/, `theme ${slug} has no --theme-color`);
});
test('themeColor reads the stylesheet value and falls back to the default theme', () => {
assert.equal(themeColor('nord'), parseThemeColors(css).nord);
assert.equal(themeColor('not-a-theme'), parseThemeColors(css)[DEFAULT_THEME]);
assert.equal(themeColor(undefined), parseThemeColors(css)[DEFAULT_THEME]);
});
test('pages put the user\'s theme colour in the head, and the login page the login theme\'s', () => {
const app = templates.layoutPage({ user: { id: 'u', email: 'a@b.c' }, navContent: '', editorContent: '', settings: { theme: 'nord' } });
assert.match(app, new RegExp(`<meta name="theme-color" content="${themeColor('nord')}"`));
const login = templates.layoutPage({ user: null });
assert.match(login, new RegExp(`<meta name="theme-color" content="${themeColor('dark-grey')}"`));
assert.match(login, /class="theme-dark-grey/);
});
test('the manifest matches the default theme, has a stable id and does not lock the orientation', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'manifest.webmanifest'), 'utf8'));
assert.equal(manifest.id, '/');
assert.equal(manifest.orientation, undefined);
assert.equal(manifest.theme_color, themeColor(DEFAULT_THEME));
const bg = new RegExp(`\\.theme-${DEFAULT_THEME}\\s*\\{[^}]*--bg:\\s*(#[0-9a-fA-F]+)`).exec(css)[1].toLowerCase();
assert.equal(manifest.background_color, bg);
});

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

88
tests/viewport.test.js Normal file
View file

@ -0,0 +1,88 @@
'use strict';
// public/viewport.js decides whether a soft keyboard is up. The numbers below are real readings
// from an iPhone Air home-screen app (/diag reports), plus the cases that must NOT count.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { keyboardVisible, isTypingElement, shouldPin } = require('../public/viewport.js');
const base = { coarse: true, typing: true, wasVisible: false, apiHeight: 0, vvScale: 1, remembered: 0 };
test('iPhone Air home-screen app: keyboard up is detected although innerHeight shrinks too', () => {
// portrait, keyboard up: visual viewport 409, innerHeight 483 (or 409), large viewport 812
assert.equal(keyboardVisible({ ...base, vvHeight: 409, inner: 483, large: 812 }), true);
assert.equal(keyboardVisible({ ...base, vvHeight: 409, inner: 409, large: 812 }), true);
// the old guess (innerHeight - vv.height < 80) called both of these "keyboard gone"
assert.ok(483 - 409 < 80 && 409 - 409 < 80);
});
test('iPhone Air home-screen app: no keyboard when the viewport is full', () => {
assert.equal(keyboardVisible({ ...base, vvHeight: 751, inner: 751, large: 812 }), false);
// a field still focused with a hardware keyboard / after Done: viewport is full, so no
assert.equal(keyboardVisible({ ...base, typing: true, vvHeight: 751, inner: 751, large: 812 }), false);
});
test('landscape keyboard (viewport 99 of 375) is detected', () => {
assert.equal(keyboardVisible({ ...base, vvHeight: 99, inner: 208, large: 375 }), true);
assert.equal(keyboardVisible({ ...base, vvHeight: 375, inner: 375, large: 375 }), false);
});
test('browser chrome alone is not a keyboard', () => {
// Safari tab: toolbars take ~90px of ~850
assert.equal(keyboardVisible({ ...base, vvHeight: 760, inner: 760, large: 852 }), false);
});
test('pinch zoom is not a keyboard', () => {
assert.equal(keyboardVisible({ ...base, vvHeight: 409, inner: 751, large: 812, vvScale: 1.6 }), false);
});
test('needs a touch device and a typing element (or an already-up keyboard)', () => {
assert.equal(keyboardVisible({ ...base, coarse: false, vvHeight: 409, inner: 751, large: 812 }), false);
assert.equal(keyboardVisible({ ...base, typing: false, vvHeight: 409, inner: 751, large: 812 }), false);
});
test('an up keyboard stays up while the viewport is still shrunk, and ends when it is back', () => {
// focus moved to a toolbar button: nothing typing, viewport still 409
assert.equal(keyboardVisible({ ...base, typing: false, wasVisible: true, vvHeight: 409, inner: 751, large: 812 }), true);
// dismissed: viewport is back
assert.equal(keyboardVisible({ ...base, typing: false, wasVisible: true, vvHeight: 751, inner: 751, large: 812 }), false);
});
test('the VirtualKeyboard API wins when it reports a height', () => {
assert.equal(keyboardVisible({ ...base, apiHeight: 300, vvHeight: 700, inner: 751, large: 812 }), true);
});
test('a remembered unobstructed height counts when the large viewport is the shrunk one', () => {
assert.equal(keyboardVisible({ ...base, vvHeight: 380, inner: 380, large: 380, remembered: 800 }), true);
});
test('typing elements: text fields, textareas, contenteditable and TinyMCE iframes', () => {
assert.equal(isTypingElement({ tagName: 'TEXTAREA' }), true);
assert.equal(isTypingElement({ tagName: 'IFRAME' }), true);
assert.equal(isTypingElement({ tagName: 'INPUT', type: 'text' }), true);
assert.equal(isTypingElement({ tagName: 'INPUT', type: 'search' }), true);
assert.equal(isTypingElement({ tagName: 'DIV', isContentEditable: true }), true);
assert.equal(isTypingElement({ tagName: 'INPUT', type: 'checkbox' }), false);
assert.equal(isTypingElement({ tagName: 'BUTTON' }), false);
assert.equal(isTypingElement(null), false);
});
test('the stylesheet only changes .mobile-app while data-keyboard is set', () => {
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
assert.match(css, /html\[data-keyboard\] \.mobile-app/);
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);
});