phone app: installable, opens offline, takes the share sheet onto the scratchpad, back lets go of one thing

A manifest with a 192, a 512 and a maskable icon, and a hand-written service
worker built beside the app by a small vite plugin: it keeps index.html, the
logo, the entry with its imports and the latin fonts, keeps each hashed asset
once fetched, asks the server for pages first and answers from the cache when
it is down, and never touches /api. The CSP names worker-src and manifest-src.

The share target posts to /share, which the worker catches: the text waits in
a cache and becomes a scratchpad note once someone is logged in, with the
drawer down on it. On a touch screen one guard entry in the history makes back
let go of one thing at a time — a prompt, the line, a sheet, the drawer, the
editor, a tile, then a folder up — and leave only at a space's root. The bar
and the band grow by the safe-area insets. The login no longer says the server
is unreachable once it answers again with the session gone.
This commit is contained in:
nvrl committed 2026-10-22 20:10:13 +02:00
1 parent 9363a0024d
commit a27d5de9c7
23 files changed
+377 -26

No files matched your search

+2 -1
View File
@@ -2,12 +2,13 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0c0d0b" />
<title>~/workspace</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"id": "/",
"name": "workspace",
"short_name": "workspace",
"description": "A private workspace: documents, a plan, a scratchpad and a diary.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0c0d0b",
"theme_color": "#0c0d0b",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": { "title": "title", "text": "text", "url": "url" }
}
}
+31 -2
View File
@@ -3,7 +3,7 @@
import { openOverview } from './lib/actions'
import { hooks } from './lib/commands'
import { keysOf } from './lib/keymap'
import { dispatch } from './lib/modes'
import { back, dispatch } from './lib/modes'
import { embedSnippet } from './lib/markdown'
import Agenda from './lib/Agenda.svelte'
import Calendar from './lib/Calendar.svelte'
@@ -30,7 +30,7 @@
import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { workspace, type Sheet } from './lib/store.svelte'
import { dwindle } from './lib/util'
import { dwindle, isTouchDevice } from './lib/util'
/*
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
@@ -181,6 +181,35 @@
return () => clearTimeout(timer)
})
/*
* On a phone, back lets go of one thing at a time rather than leaving the
* app. One entry is kept in the history over the page: back takes it off,
* `back()` lets go of something, and it goes on again. With nothing left to
* let go of it stays off, so the next back leaves. It is put on at a touch,
* since a browser skips an entry made without one.
*/
$effect(() => {
if (!isTouchDevice || shareToken) return
let guarded = false
const guard = () => {
if (guarded) return
history.pushState({ guard: true }, '')
guarded = true
}
const popstate = () => {
guarded = false
if (back()) guard()
// The entry under the guard still has the address the page opened on.
else workspace.tree.syncUrl(workspace.tree.path)
}
window.addEventListener('pointerdown', guard, true)
window.addEventListener('popstate', popstate)
return () => {
window.removeEventListener('pointerdown', guard, true)
window.removeEventListener('popstate', popstate)
}
})
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!workspace.tiles.all.some((tile) => tile.dirty)) return
+11 -6
View File
@@ -41,10 +41,15 @@
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
/* The frame: a bar on top, a rail on the right, a band at the bottom, and a
rounded opening between them that the panes are seen through. */
--bar-h: 40px;
rounded opening between them that the panes are seen through. Installed
on a phone the app reaches under the status bar and the gesture bar
(`viewport-fit=cover`), so the bar and the band grow by what those cover
and keep their contents clear of them; in a browser both insets are 0. */
--inset-top: env(safe-area-inset-top, 0px);
--inset-bottom: env(safe-area-inset-bottom, 0px);
--bar-h: calc(40px + var(--inset-top));
--rail-w: 48px;
--status-h: 28px;
--status-h: calc(28px + var(--inset-bottom));
--frame: 8px;
--frame-radius: 18px;
@@ -69,7 +74,7 @@
@media (max-width: 767px) {
:root {
--rail-w: var(--frame);
--status-h: 40px;
--status-h: calc(40px + var(--inset-bottom));
--frame: 6px;
--frame-radius: 14px;
}
@@ -333,7 +338,7 @@ input::placeholder { color: var(--muted); }
display: flex;
align-items: center;
gap: 12px;
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
padding: var(--inset-top) calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
@@ -402,7 +407,7 @@ input::placeholder { color: var(--muted); }
display: flex;
align-items: center;
gap: 14px;
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 4px);
padding: 0 calc(var(--rail-w) + 4px) var(--inset-bottom) calc(var(--frame) + 4px);
font-size: 11px;
color: var(--subtle);
white-space: nowrap;
+1 -1
View File
@@ -356,7 +356,7 @@
.mobilebar {
display: flex;
gap: 6px;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
padding: 8px 12px;
border-top: 1px solid var(--rim);
flex: none;
}
+1 -1
View File
@@ -68,7 +68,7 @@
* doors.
*/
@media (max-width: 767px) {
.status { padding: 0 6px; gap: 6px; overflow: visible; }
.status { padding: 0 6px var(--inset-bottom); gap: 6px; overflow: visible; }
.hint, .file, .readout, .flag, .pending { display: none; }
.mode:not(.editing) { display: none; }
.tools { display: flex; flex: 1; height: 100%; }
+1 -1
View File
@@ -105,7 +105,7 @@ function tileKey(direction: Direction, shift: boolean) {
}
/** The drawer up, and the keys back where they were: in the editor, when a document was being written. */
async function raise() {
export async function raise() {
await workspace.scratch.close()
if (!workspace.scratch.open && workspace.editing && workspace.pane === 'document') hooks.focusEditor()
}
+31 -1
View File
@@ -16,7 +16,8 @@
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command
* line keeps its own keys while it has them.
*/
import { actions, type Run } from './actions'
import { actions, raise, type Run } from './actions'
import { hooks, runCommand } from './commands'
import { bindings, type Binding, type Context } from './keymap'
import { workspace } from './store.svelte'
@@ -150,3 +151,32 @@ export function claim(event: KeyboardEvent): boolean {
event.stopPropagation()
return true
}
/*
* The phone's back gesture, or its back button: one thing let go of, the way
* Esc and q let go of it on a keyboard — a question, the line, a sheet, the
* drawer, the editor, a tile — and then up a folder. False when there is
* nothing left to leave, and the app itself may go.
*/
export function back(): boolean {
if (hooks.closeCommandLine()) return true
const step = leaving()
if (!step) return false
void step()
return true
}
function leaving(): (() => unknown) | null {
const { prompts, sheets, scratch, drafts, tree } = workspace
if (prompts.prompt) return () => prompts.prompt?.resolve(false)
if (prompts.secret) return () => prompts.secret?.resolve(null)
if (prompts.verify) return () => prompts.verify?.resolve(null)
if (sheets.shown !== 'none') return () => sheets.close()
if (scratch.open) return scratch.editing ? () => drafts.leaveWriting() : () => workspace.guard(raise)
if (drafts.writing || workspace.editing) return () => runCommand('q')
if (workspace.fullscreen) return () => (workspace.fullscreen = false)
if (workspace.focus !== 'list' && workspace.focus !== 'node') return () => runCommand('close')
if (workspace.open && workspace.pane === 'document') return () => runCommand('q')
if (tree.path.length > 1) return () => workspace.guard(() => tree.up())
return null
}
+22
View File
@@ -163,6 +163,28 @@ export class Scratchpad {
this.ws.say('scratched — Q shows the scratchpad')
}
/*
* What the phone's share sheet sent while the app was shut, kept by the
* service worker in the cache `share` (see sw.ts): each made a note, and
* the drawer down on the newest. One is let go of only once it is a note,
* so a share made with no network or no login waits for the next start.
*/
async takeShared() {
if (typeof caches === 'undefined') return
const cache = await caches.open('share')
const kept = [...(await cache.keys())].sort((a, b) => a.url.localeCompare(b.url, undefined, { numeric: true }))
if (!kept.length) return
for (const request of kept) {
const text = await (await cache.match(request))?.text()
if (text?.trim()) await api.createScratch(text)
await cache.delete(request)
}
if (this.open) this.notes = await api.scratch()
else await this.toggle()
this.cursor = 0
this.ws.say(kept.length === 1 ? 'shared — on the scratchpad' : `${kept.length} shared — on the scratchpad`)
}
/**
* `:keep [title]`: the note under the cursor made a document in the folder
* being browsed, and taken off the scratchpad. Without a title, the first
+2 -2
View File
@@ -109,10 +109,10 @@ export class Tree {
* outside that and shows matches from anywhere in the current space.
*/
/** Keep the address bar on the node being looked at, so it can be linked to. */
/** Keep the address bar on the node being looked at, so it can be linked to — the history entry, back's guard among them, kept as it is. */
syncUrl(path: Crumb[]) {
const slugs = path.map((crumb) => crumb.slug).join('/')
if (slugs) history.replaceState(null, '', `/n/${slugs}`)
if (slugs) history.replaceState(history.state, '', `/n/${slugs}`)
}
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
+6
View File
@@ -140,12 +140,16 @@ export class Workspace {
await this.cards.loadAgenda()
await this.vault.load()
await this.cards.openAgendaIfDue()
void this.guard(() => this.scratch.takeShared())
} catch (error) {
this.user = null
// Down is not the same as logged out: say so, and keep asking.
if (unreachable(error)) {
this.notice = 'the server cannot be reached — trying again'
setTimeout(() => void this.boot(), 5000)
} else if (this.notice.startsWith('the server cannot')) {
// Back, but the session did not last the wait.
this.notice = ''
}
} finally {
this.booting = false
@@ -170,6 +174,8 @@ export class Workspace {
await this.cards.loadAgenda()
await this.vault.load()
await this.cards.openAgendaIfDue()
// A share that came in while nobody was logged in has waited for this.
void this.guard(() => this.scratch.takeShared())
}
async logout(everywhere = false) {
+4
View File
@@ -5,3 +5,7 @@ import App from './App.svelte'
import './app.css'
export default mount(App, { target: document.getElementById('app')! })
// The worker that lets the app be installed and opened offline; see sw.ts.
// Not in development, where it would keep Vite's modules.
if (import.meta.env.PROD && 'serviceWorker' in navigator) void navigator.serviceWorker.register('/sw.js')
+117
View File
@@ -0,0 +1,117 @@
/*
* The service worker: what makes the workspace an app on a phone.
*
* - The app shell — index.html, the logo, and what they need to paint — is
* kept, so the app opens with no network and says the server cannot be
* reached, rather than the browser's error page.
* - Every hashed file under /assets is kept the first time it is fetched;
* they never change, so a lazy chunk is fetched once.
* - The share sheet posts to /share. The text is kept here, in the cache
* `share`, and the page takes it onto the scratchpad once someone is logged
* in (`Scratchpad.takeShared`). The post never reaches the server.
*
* Nothing from /api is touched or kept: the caches hold the public shell only,
* so logging out leaves nothing private behind.
*
* The worker is built by `serviceWorker` in vite.config.ts, which writes in
* the two names below. The DOM types have no worker events, so the few used
* here are spelled out; a module, so they stay its own.
*/
export {}
declare const __VERSION__: string
declare const __PRECACHE__: string[]
interface ExtendableEvent extends Event {
waitUntil(promise: Promise<unknown>): void
}
interface FetchEvent extends ExtendableEvent {
readonly request: Request
respondWith(response: Response | Promise<Response>): void
}
interface Scope {
skipWaiting(): Promise<void>
clients: { claim(): Promise<void> }
addEventListener(type: 'install' | 'activate', listener: (event: ExtendableEvent) => void): void
addEventListener(type: 'fetch', listener: (event: FetchEvent) => void): void
}
const worker = self as unknown as Scope
const SHELL = `shell-${__VERSION__}`
worker.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL)
await cache.addAll(['/index.html', '/favicon.svg', ...__PRECACHE__])
await worker.skipWaiting()
})(),
)
})
worker.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
if (name.startsWith('shell-') && name !== SHELL) await caches.delete(name)
}
await worker.clients.claim()
})(),
)
})
worker.addEventListener('fetch', (event) => {
const request = event.request
const url = new URL(request.url)
if (url.origin !== location.origin) return
if (request.method === 'POST' && url.pathname === '/share') return event.respondWith(share(request))
if (request.method !== 'GET') return
if (url.pathname.startsWith('/assets/')) return event.respondWith(asset(request))
if (request.mode === 'navigate') return event.respondWith(fresh(request, '/index.html'))
if (url.pathname === '/favicon.svg') return event.respondWith(fresh(request, '/favicon.svg'))
})
/** A hashed file: from the cache, or fetched once and kept. */
async function asset(request: Request): Promise<Response> {
const cache = await caches.open(SHELL)
const kept = await cache.match(request)
if (kept) return kept
const response = await fetch(request)
if (response.ok) await cache.put(request, response.clone())
return response
}
/*
* A page — /, /n/…, /s/… — is index.html whatever the path, and the logo is
* the logo. The server's is newer, so it is asked first; the kept one answers
* when it cannot be reached, or a proxy in front of it says it is down.
*/
async function fresh(request: Request, kept: string): Promise<Response> {
try {
const response = await fetch(request)
if (response.status < 502) return response
return (await caches.match(kept)) ?? response
} catch (error) {
const copy = await caches.match(kept)
if (copy) return copy
throw error
}
}
/*
* The share sheet: a title, some text, a link — any of them may be missing,
* and Android often puts the link in the text as well. They become one note,
* the title on its first line.
*/
async function share(request: Request): Promise<Response> {
const form = await request.formData()
const field = (name: string) => String(form.get(name) ?? '').trim()
const [title, text, link] = [field('title'), field('text'), field('url')]
const lines = [title && title !== text ? title : '', text, link && !text.includes(link) ? link : ''].filter(Boolean)
if (lines.length) {
const cache = await caches.open('share')
await cache.put(`/share/${Date.now()}`, new Response(lines.join('\n\n')))
}
return Response.redirect('/', 303)
}
+48 -2
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import type { Plugin } from 'vite'
import type { Plugin, Rollup } from 'vite'
/*
* KaTeX lists each font as woff2, woff and ttf. Every browser the app runs in
@@ -15,8 +15,54 @@ const katexWoff2Only: Plugin = {
},
}
/*
* src/sw.ts, built beside the app as /sw.js — unhashed, since its address is
* what the browser remembers. Two names are written into it:
*
* __PRECACHE__ what index.html needs to paint: the entry, what it imports,
* their CSS, and the latin faces of the two fonts
* __VERSION__ a hash of every file name in the build, so a build that
* changes anything changes sw.js too, and the browser takes
* the new worker and drops the old shell
*/
const serviceWorker: Plugin = {
name: 'service-worker',
apply: 'build',
buildStart() {
this.emitFile({ type: 'chunk', id: '/src/sw.ts', fileName: 'sw.js' })
},
generateBundle(_, bundle) {
const chunks = Object.values(bundle).filter((file): file is Rollup.OutputChunk => file.type === 'chunk')
const entry = chunks.find((chunk) => chunk.isEntry && chunk.facadeModuleId?.endsWith('index.html'))
const sw = bundle['sw.js']
if (!entry || sw?.type !== 'chunk') return this.error('service worker: no entry, or no sw.js')
const shell = new Set<string>()
const walk = (name: string) => {
if (shell.has(name)) return
shell.add(name)
const chunk = bundle[name]
if (chunk?.type !== 'chunk') return
chunk.viteMetadata?.importedCss.forEach((css) => shell.add(css))
chunk.imports.forEach(walk)
}
walk(entry.fileName)
for (const name of Object.keys(bundle)) {
if (/\/(jetbrains-mono|noto-sans)-latin(-ext)?-wght-normal-[^/]+\.woff2$/.test(name)) shell.add(name)
}
// FNV-1a: only a change has to show, so nothing stronger is needed.
let hash = 0x811c9dc5
for (const char of Object.keys(bundle).sort().join('\n')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193)
const version = (hash >>> 0).toString(16)
sw.code = sw.code
.replaceAll('__VERSION__', JSON.stringify(version))
.replaceAll('__PRECACHE__', JSON.stringify([...shell].map((name) => `/${name}`)))
},
}
export default defineConfig({
plugins: [svelte(), katexWoff2Only],
plugins: [svelte(), katexWoff2Only, serviceWorker],
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
server: {
port: 4243,