From d2c6e617bb836741752ac83dd9675d33045a310c Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:28:01 +0000 Subject: [PATCH 1/6] Test palm rejection for add-page button taps Move the draw-or-navigate choice into touchIntent() so the rule the canvas uses can be tested, and cover finger taps on the plus button: accepted after writing and while the pen hovers, rejected while the pen is down, for palm-sized contacts and right after pen activity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- src/lib/components/CanvasView.svelte | 4 ++-- src/lib/ink/palm.test.ts | 35 +++++++++++++++++++++++++++- src/lib/ink/palm.ts | 9 +++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index bab3a8a..c322b27 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -16,7 +16,7 @@ import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout'; import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree'; import { A4, PAGE_PRESETS, type Stroke } from '$lib/model/types'; - import { PalmRejector } from '$lib/ink/palm'; + import { PalmRejector, touchIntent } from '$lib/ink/palm'; import { StrokeRecorder, hitStroke } from '$lib/ink/stroke'; import { pageCssVars } from '$lib/editor/pageStyle'; import { downloadCanvas } from './download'; @@ -301,7 +301,7 @@ // pen-down, palm-sized contacts and right after pen use, but not for the 5 s pen session. const plus = target.closest('.ghost'); if (e.pointerType === 'touch') { - const decision = palm.evaluate(e, fingerInks && !plus ? 'draw' : 'navigate'); + const decision = palm.evaluate(e, touchIntent(fingerInks, !!plus)); if (!decision.accept) return; // palm: ignore entirely touches.set(e.pointerId, p); if (touches.size >= 2) { diff --git a/src/lib/ink/palm.test.ts b/src/lib/ink/palm.test.ts index 473358c..add4644 100644 --- a/src/lib/ink/palm.test.ts +++ b/src/lib/ink/palm.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PalmRejector } from './palm'; +import { PalmRejector, touchIntent } from './palm'; const ev = (type: string, pointerType: string, extra: Partial = {}) => ({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent; @@ -53,4 +53,37 @@ describe('palm rejection', () => { it('allows finger ink again once the pen session ends', () => { expect(afterPen(5100).evaluate(touch(), 'draw').accept).toBe(true); }); + + describe('add-page button taps', () => { + // With a pen in use fingers don't ink, but finger ink may still be on (before any pen). + const tap = (palm: PalmRejector, extra: Partial = {}, fingerInks = false) => + palm.evaluate(touch(extra), touchIntent(fingerInks, true)); + + it('are handled as navigation even when fingers ink', () => { + expect(touchIntent(true, true)).toBe('navigate'); + expect(touchIntent(false, true)).toBe('navigate'); + expect(touchIntent(true, false)).toBe('draw'); + expect(touchIntent(false, false)).toBe('navigate'); + }); + + it('are accepted a couple of seconds after writing', () => { + expect(tap(afterPen(2000)).accept).toBe(true); + expect(tap(afterPen(2000), {}, true).accept).toBe(true); + }); + + it('are accepted while the pen hovers', () => { + expect(tap(afterPen(2000, { leave: false })).accept).toBe(true); + }); + + it('are rejected while the pen is down', () => { + const palm = new PalmRejector(); + palm.track(ev('pointerdown', 'pen')); + expect(tap(palm).accept).toBe(false); + }); + + it('are rejected for a palm or right after the pen', () => { + expect(tap(afterPen(2000), { width: 60, height: 40 }).accept).toBe(false); + expect(tap(afterPen(50)).accept).toBe(false); + }); + }); }); diff --git a/src/lib/ink/palm.ts b/src/lib/ink/palm.ts index 7030b2b..2c9a233 100644 --- a/src/lib/ink/palm.ts +++ b/src/lib/ink/palm.ts @@ -27,6 +27,15 @@ const PALM_CONTACT_PX = 35; /** What an accepted touch would do: ink (draw/erase) or pan/zoom. */ export type TouchIntent = 'draw' | 'navigate'; +/** + * Which policy a touch falls under. It only draws when finger ink is on and it + * didn't land on a button; taps on buttons (e.g. add page) navigate, so they keep + * working between pen strokes. + */ +export function touchIntent(fingerInks: boolean, onButton: boolean): TouchIntent { + return fingerInks && !onButton ? 'draw' : 'navigate'; +} + export interface Decision { accept: boolean; reason: string; From abb11ad407da9634c917488aab889752cbd008be Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:28:10 +0000 Subject: [PATCH 2/6] Drop an eraser drag when the pointer is cancelled A pointercancel (system gesture, notification, palm) used to apply the strokes erased so far. Only a real pointerup commits the erase now, the same as ink strokes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- src/lib/components/CanvasView.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index c322b27..4db89b1 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -442,7 +442,8 @@ live = null; if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke); } else if (g.kind === 'erase') { - for (const [pageId, ids] of g.hits) doc.setStrokes(pageId, (s) => s.filter((x) => !ids.has(x.id))); + // Like ink, an interrupted (pointercancel) erase is dropped. + if (e.type === 'pointerup') for (const [pageId, ids] of g.hits) doc.setStrokes(pageId, (s) => s.filter((x) => !ids.has(x.id))); erasing = new Set(); } } From 5a501a5c871be424e51a4d00d1acd1a647e4a4d0 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:29:16 +0000 Subject: [PATCH 3/6] Offer a new page after a finger fling past the page edge A quick one-finger flick toward a free side of the active page, once that edge is on screen, highlights and pulses the plus button on that side for three seconds. It never adds a page by itself, so ordinary panning can't create pages; a tap on the highlighted plus does. Fling detection lives in ink/fling.ts with tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- src/lib/components/CanvasView.svelte | 67 ++++++++++++++++++++++++++-- src/lib/ink/fling.test.ts | 43 ++++++++++++++++++ src/lib/ink/fling.ts | 41 +++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/lib/ink/fling.test.ts create mode 100644 src/lib/ink/fling.ts diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index 4db89b1..08e97d4 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -17,6 +17,7 @@ import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree'; import { A4, PAGE_PRESETS, type Stroke } from '$lib/model/types'; import { PalmRejector, touchIntent } from '$lib/ink/palm'; + import { flingDir, pushSample, type Sample } from '$lib/ink/fling'; import { StrokeRecorder, hitStroke } from '$lib/ink/stroke'; import { pageCssVars } from '$lib/editor/pageStyle'; import { downloadCanvas } from './download'; @@ -55,6 +56,7 @@ return () => { ro.disconnect(); stage.removeEventListener('wheel', onWheel); + clearTimeout(offerTimer); }; }); @@ -99,6 +101,30 @@ const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []); + // A finger fling past the active page's free edge highlights the plus on that side. + let offer = $state(null); + let offerTimer: ReturnType | undefined; + + function setOffer(slot: Slot | null) { + clearTimeout(offerTimer); + offer = slot; + if (slot) offerTimer = setTimeout(() => (offer = null), 3000); + } + + function offerAfterFling(samples: Sample[]) { + const dir = flingDir(samples); + const id = doc.activeId; + const r = id && doc.rects.get(id); + const slot = dir && ghostSlots.find((s) => s.anchor === id && s.dir === dir); + if (!r || !slot) return; + // Only once that edge has come into view, i.e. the fling went past the end. + const a = viewport.toScreen(r.x, r.y); + const b = viewport.toScreen(r.x + r.width, r.y + r.height); + const edge = { left: a.x, right: b.x, up: a.y, down: b.y }[slot.dir]; + const size = slot.dir === 'left' || slot.dir === 'right' ? viewport.width : viewport.height; + if (edge >= 0 && edge <= size) setOffer(slot); + } + // Show every possible spot when an imported PDF is waiting to be placed. $effect(() => { if (!tools.placing) return; @@ -242,7 +268,7 @@ type Gesture = | { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder } | { kind: 'erase'; pointerId: number; pointerType: string; hits: Map> } - | { kind: 'pan'; pointerId: number; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean } + | { kind: 'pan'; pointerId: number; pointerType: string; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean; trail: Sample[] } | { kind: 'add'; pointerId: number; pointerType: string; startX: number; startY: number; slot: Slot } | { kind: 'pinch' }; @@ -291,6 +317,7 @@ function onPointerDown(e: PointerEvent) { palm.track(e); if (menu.open) menu.close(); + if (offer) setOffer(null); const target = e.target as Element; if (target.closest('.no-stage')) return; const p = local(e); @@ -334,7 +361,18 @@ (e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.slot')); if (wantsPan) { if (onPage && !inEditor) doc.activeId = onPage; - gesture = { kind: 'pan', pointerId: e.pointerId, lastX: p.x, lastY: p.y, startX: p.x, startY: p.y, moved: false, tap: inEditor || !!target.closest('.slot') }; + gesture = { + kind: 'pan', + pointerId: e.pointerId, + pointerType: e.pointerType, + lastX: p.x, + lastY: p.y, + startX: p.x, + startY: p.y, + moved: false, + tap: inEditor || !!target.closest('.slot'), + trail: [{ ...p, t: e.timeStamp }] + }; if (e.pointerType !== 'touch' || !gesture.tap) { if (e.pointerType !== 'touch') e.preventDefault(); capture(e.pointerId); @@ -393,6 +431,7 @@ viewport.panBy(dx, dy); gesture.lastX = p.x; gesture.lastY = p.y; + pushSample(gesture.trail, { ...p, t: e.timeStamp }); return; } @@ -436,7 +475,12 @@ if (!gesture || gesture.pointerId !== e.pointerId) return; const g = gesture; gesture = null; - if (g.kind === 'add') { + if (g.kind === 'pan') { + if (e.type === 'pointerup' && g.moved && g.pointerType === 'touch') { + pushSample(g.trail, { ...local(e), t: e.timeStamp }); + offerAfterFling(g.trail); + } + } else if (g.kind === 'add') { if (e.type === 'pointerup' && (g.pointerType !== 'touch' || palm.evaluate(e, 'navigate').accept)) addPage(g.slot.anchor, g.slot.dir); } else if (g.kind === 'draw') { live = null; @@ -606,6 +650,7 @@ + {/each} + +
+ + + + mm +
+ {#if !valid} +

Sizes must be between {toMm(MIN_PT)} and {toMm(MAX_PT)} mm.

+ {/if} +
+ + +
+ + + + From 3d29255d0c6f16e2ffd219dca9d2061fbd167758 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:31:12 +0000 Subject: [PATCH 5/6] Add background grid settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance now has a toggle for the background grid and a choice of subdivisions (none, 2×2 up to 16×16) that split each page-sized cell into fainter lines. Subdivisions are left out when zoomed so far out that they would be under 8 px apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- src/app.css | 2 ++ src/lib/components/CanvasView.svelte | 28 ++++++++++++++++++------- src/lib/components/SettingsModal.svelte | 14 +++++++++++++ src/lib/state/settings.svelte.ts | 9 ++++++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/app.css b/src/app.css index 76440c5..912d602 100644 --- a/src/app.css +++ b/src/app.css @@ -61,6 +61,7 @@ --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08); --page-line: rgba(0, 0, 0, 0.18); --grid-line: rgba(0, 0, 0, 0.05); + --grid-line-minor: rgba(0, 0, 0, 0.028); --ghost: rgba(0, 0, 0, 0.045); --ghost-border: rgba(0, 0, 0, 0.12); --radius: 6px; @@ -88,6 +89,7 @@ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.35); --page-line: rgba(255, 255, 255, 0.16); --grid-line: rgba(255, 255, 255, 0.04); + --grid-line-minor: rgba(255, 255, 255, 0.022); --ghost: rgba(255, 255, 255, 0.04); --ghost-border: rgba(255, 255, 255, 0.14); color-scheme: dark; diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index 5b2b27f..4746268 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -30,11 +30,28 @@ let stage: HTMLDivElement; const cssVars = pageCssVars(); - // Background grid: one cell per active page, aligned to its edges (A4 at the origin if none). - const grid = $derived.by(() => { + // Background grid: one cell per active page, aligned to its edges (A4 at the origin if none), + // optionally split into fainter subdivisions. + const gridStyle = $derived.by(() => { + const g = settings.data.grid; + if (!g.show) return ''; const r = (doc.activeId && doc.rects.get(doc.activeId)) || { x: 0, y: 0, ...A4 }; const s = viewport.scale; - return { w: r.width * s, h: r.height * s, x: viewport.x + r.x * s, y: viewport.y + r.y * s }; + const w = r.width * s; + const h = r.height * s; + const lines = (color: string) => [ + `linear-gradient(to right, ${color} 1px, transparent 1px)`, + `linear-gradient(to bottom, ${color} 1px, transparent 1px)` + ]; + const images = lines('var(--grid-line)'); + const sizes = [`${w}px ${h}px`, `${w}px ${h}px`]; + // Leave out subdivisions once they'd be too dense to read. + const n = g.divisions; + if (n > 1 && Math.min(w, h) / n >= 8) { + images.push(...lines('var(--grid-line-minor)')); + sizes.push(`${w / n}px ${h / n}px`, `${w / n}px ${h / n}px`); + } + return `background-image: ${images.join(', ')}; background-size: ${sizes.join(', ')}; background-position: ${viewport.x + r.x * s}px ${viewport.y + r.y * s}px`; }); // ---- autosave -------------------------------------------------------- @@ -605,7 +622,7 @@ class:panning class:erasing={tools.tool === 'eraser'} bind:this={stage} - style="background-size: {grid.w}px {grid.h}px; background-position: {grid.x}px {grid.y}px" + style={gridStyle} onpointerdown={onPointerDown} onpointermove={onPointerMove} onpointerup={onPointerUp} @@ -760,9 +777,6 @@ user-select: none; -webkit-user-select: none; background-color: var(--bg-canvas); - background-image: - linear-gradient(to right, var(--grid-line) 1px, transparent 1px), - linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px); } .stage :global(.cm-content) { user-select: text; diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index 6b92676..9c8f886 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -83,6 +83,20 @@ +
+
Background grid
Faint lines behind the pages, one cell per active page.
+ +
+
+
Grid subdivisions
Split each page-sized cell into a finer grid for sketching and lining things up.
+ +
{:else if tab === 'input'}

Pen & touch

diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index 4fa5724..92cee0b 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -6,6 +6,12 @@ import { DEFAULT_PALM, type PalmOptions } from '$lib/ink/palm'; export type ThemePref = 'system' | 'light' | 'dark'; export type PageStylePref = 'paper' | 'match'; +export interface GridSettings { + show: boolean; + /** Minor lines per page cell side (1 = page-sized cells only). */ + divisions: number; +} + export interface GitHubSettings { token: string; owner: string; @@ -18,6 +24,7 @@ export interface GitHubSettings { interface SettingsData { theme: ThemePref; pageStyle: PageStylePref; + grid: GridSettings; fingerDraw: boolean; palm: PalmOptions; github: GitHubSettings; @@ -30,6 +37,7 @@ interface SettingsData { const DEFAULTS: SettingsData = { theme: 'system', pageStyle: 'match', + grid: { show: true, divisions: 1 }, fingerDraw: true, palm: DEFAULT_PALM, github: { token: '', owner: '', repo: '', branch: 'main', dir: '' }, @@ -48,6 +56,7 @@ function load(): SettingsData { return { ...DEFAULTS, ...saved, + grid: { ...DEFAULTS.grid, ...saved.grid }, palm: { ...DEFAULTS.palm, ...saved.palm }, github: { ...DEFAULTS.github, ...saved.github } }; From 2df91b561de29a46d588d4e020144b4f5f09cd35 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:31:54 +0000 Subject: [PATCH 6/6] Bring spec and README up to date The spec still listed undo, the eraser, colours and smoothing as missing. Describe the ink engine and palm rejection as built (draw vs navigate intents, settings toggles, cancelled pointers), the plus buttons, fling offer, grid and page size dialog, note that sync is GitHub-only, and replace the stale open item with the real ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- README.md | 3 ++- spec.md | 32 ++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 824eec3..a3149e3 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ The image builds the PWA and serves it with nginx. Put an HTTPS reverse proxy in ## Using it - **Tools** (bottom bar): Text `T`, Pen `P`, Highlighter `H`, Eraser `E`, Import PDF, Undo/Redo, zoom. The pen's eraser end erases too. -- **Pages**: the grey "+" tiles next to the active page add a page there; `Alt+Arrow` moves to (or creates) the neighbour. Right-click a page or use its `⋯` button to insert, resize or delete it. +- **Pages**: the "+" circles beside the active page add a page there (a quick finger fling past a free edge highlights that side's "+"); `Alt+Arrow` moves to (or creates) the neighbour. Right-click a page or use its `⋯` button to insert, resize or delete it. +- **Grid**: the faint background grid has one cell per active page; Settings → Appearance can hide it or subdivide it. - **Importing a PDF** into a non-empty canvas shows every free spot; click one to place the chain. Into a blank canvas it simply replaces the empty page. The Files panel can also import a PDF as a new canvas. - **View**: wheel/trackpad pans, `Ctrl`+wheel or pinch zooms, `Ctrl+0` fits the page, `Ctrl+9` shows the whole tree, space-drag or middle-drag pans. - **Sync**: Settings → Sync. Use a fine-grained token with *Contents: read & write* on the repo. Edits autosave locally; pushes happen every N minutes and on `Ctrl+S` / the sync button. Conflicts: newer wins. diff --git a/spec.md b/spec.md index 5125de1..aaedd63 100644 --- a/spec.md +++ b/spec.md @@ -9,10 +9,11 @@ One canvas = one tree = one `.pdf` file. - A branch can only ever continue in the direction it started (strictly linear — no sub-branches off a branch). - A trunk page can have at most one left branch and one right branch. - **PDF import**: creates a chain of pages in whatever direction you pick when placing it (up/down = extends the trunk, left/right = becomes a branch off the trunk page you clicked). Only one trunk exists per canvas — additional imported PDFs must attach as a branch off some trunk page, or extend an existing branch further outward in its established direction. -- Hidden/unopened neighbor pages render as a gray tile with a "+" in a circle; clicking (or a shortcut) instantiates them as real pages. +- Each free side of the active page shows a small "+" circle just off its edge; tapping it (or `Alt+Arrow`) adds a blank page there. A quick finger fling past a free edge highlights that side's "+" for a few seconds, but never adds a page by itself. +- The canvas background is a faint grid with one cell per active page, aligned to its edges; Settings can hide it or subdivide it. - Deleting a page removes it (undo via Ctrl+Z); no permanent trash. - Default zoom: one page fills the screen. Zoom out reveals the tree. -- Pages default to A4, or auto-match the aspect ratio of an imported PDF page; resizable manually otherwise. +- Pages default to A4, or auto-match the aspect ratio of an imported PDF page; resizable manually otherwise (page menu: presets, or a custom size in mm). ## 2. Flatten Algorithm (tree → linear page order, e.g. for PDF page order / print) @@ -45,7 +46,7 @@ Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/le ## 5. Sync & Storage -- Repo-backed (GitHub/GitLab), one file per canvas. +- Repo-backed (GitHub for now; GitLab not implemented), one file per canvas. - PWA: offline-first, caches locally, explicit save/sync pushes to repo. - Multi-device: git pull/push is the sync mechanism (not real-time collab). @@ -54,7 +55,7 @@ Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/le - **Frontend**: SvelteKit, deployed on your VPS, installable as PWA. - **Ink input**: Pointer Events + pressure (your existing artifact — pending). - **PDF read/write**: pdf.js + pdf-lib. -- **Storage**: Git repo via GitHub/GitLab API (or local backend proxy holding the token). +- **Storage**: IndexedDB locally; Git repo via the GitHub REST API (token in the browser, no proxy). ## 7. Confirmed Product Features @@ -79,12 +80,23 @@ Source: user-provided `palm-rejection-test.html` test harness. The acceptance po - A `touch` pointer is rejected if any of: a pen stroke is active or was active within the last ~5s, its contact ellipse (`width`/`height`) exceeds ~35px (palm heel vs. fingertip), it's within 150ms of the last pen activity, or a pen is currently hovering (armed lockout before it even touches down). - A touch stroke already in progress gets cut off mid-stroke if pen activity appears. -**Gaps before this is the real ink engine** (currently a raster test, not vector): -- `draw()` writes straight to canvas via `ctx.lineTo`/`stroke` with pressure-scaled line width, and throws points away. Needs to become `recordPoint()`: push `{x, y, pressure, tiltX, tiltY, t}` into a `Stroke.points[]` array per stroke; rendering = replaying that array. This is what makes strokes resizable/erasable/undoable and exportable to both the PDF Ink-annotation layer and the JSON attachment layer. -- `tiltX`/`tiltY` exist on the pen's `PointerEvent` but aren't read anywhere yet — needed for tilt support. -- No smoothing yet (raw straight segments) — fine as a v1, can layer in curve smoothing later without changing point storage. -- No undo/redo, multi-color, eraser yet — needs a `Stroke[]` array per page's ink layer with add/remove, which undo/redo just pops/pushes. +**How the app applies it** (`src/lib/ink/palm.ts`): +- Each touch is evaluated with an intent. `draw` (finger ink) gets the full policy above. `navigate` (pan, pinch, and taps on canvas buttons such as the add-page "+") skips the 5 s pen session and the hover lockout, so fingers can move around and tap between pen strokes; pen-down, wide contacts and the 150 ms window still reject it. +- Once a pen has been used, fingers only navigate. "Draw with finger" in Settings turns finger ink off entirely. +- Each rule (pen session, wide contact, timing, hover) can be switched off in Settings → Pen & touch. +- An interrupted pointer (`pointercancel`) drops the stroke or erase in progress instead of committing it. + +**Ink engine (implemented)**: +- `StrokeRecorder.recordPoint()` stores `x, y, pressure, tiltX, tiltY, t` per sample in a flat `Stroke.points` array (stride 6); rendering replays it. Tilt is recorded but not yet used for rendering. +- Outlines are smoothed with `perfect-freehand`; mouse and finger strokes simulate pressure. +- Pen, highlighter (translucent, no thinning) and stroke eraser, with configurable colour palettes; the pen's eraser end erases too. +- Undo/redo covers ink, erasing and page-tree changes. +- Strokes are written to the PDF both as Ink annotations and as JSON attachments. ## 10. Open Items -- [ ] Nothing blocking — ready to move into implementation planning (component breakdown, page-tree data structures in Svelte, PDF encode/decode module) +- [ ] Conflict copies: sync currently lets the newer version win silently; keep the losing version as a separate file. +- [ ] Tell the user when a new version of the app has been deployed (the service worker updates, but the open tab keeps old code until reload). +- [ ] CI that runs `check`, `test` and `build` on every pull request. +- [ ] Use recorded tilt for rendering. +- [ ] GitLab sync.