01-projects/ink

Implementation notes — ink v0 (2026-07-21)

2026-07-21·reference

Build agent notes per the implementation-notes SOP. Spec: 2026-07-21-ink-build-spec.md. Scope: v0 only.

Decisions

Tradeoffs

Open questions (for founder / v1)

  1. Idle/button trigger region placement (reply below request bbox) — acceptable, or should those modes also require a circled target?
  2. Should absorbed request strokes be recoverable (an "un-drink" gesture)? Currently history-only.
  3. Page-PNG downscale is spec'd at ≤1568px long edge; Opus 4.8 supports 2576px high-res vision — bump in v1 if handwriting-reading accuracy needs it (costs ~3x image tokens).
  4. Persona is per-device (localStorage); shared personas need the v1 Worker.

Verification log (Playwright, localhost:8747, cache-busted ?v= on every load)

All loads: 0 console errors, 0 warnings (the single logged error across the whole session was the browser's own network line for the deliberate bad-key 401 test — expected negative-test output, not an app defect).

Design-critic iteration (same day, ITERATE verdict — 4 findings fixed)

  1. Gesture acceptance widened (HIGH): circle-candidate aspect relaxed 0.5–2.0 → 0.3–3.3 (wide oval around a line of handwriting now triggers). New single-stroke multi-loop path: loopTurns() (signed turning angle, downsampled ≥5u to kill noise; handwriting zigzags cancel to ~0) — one continuous stroke with ≥2.6 revolutions, closed-ish, triggers exactly like three circles. Near-miss whisper: 2 concentric circles left stale 2.2s, a 2-loop attempt, or a sloppy near-candidate shape → "once more around…" hand-written faintly under the attempt (5s cooldown, 3.8s hold, never fires over active writing; near-candidates no longer reset the run).
  2. Key checked BEFORE absorbing (MED): no key → circles + contents stay intact; "the page is silent…" writes below the circled region + toast/gear-pulse. Absorb now only happens when a key exists. (Invalid-key case still absorbs then surfaces the API error — that is the spec'd post-send failure-honesty path.)
  3. Settings panel scrollTop reset to 0 on open (LOW).
  4. Selects + checkbox restyled (LOW): appearance:none, hand-drawn chevron/check as theme-var data-URI SVGs (light + dark variants), wobbly-corner paper checkbox. Model label shortened ("Claude Opus 4.8") — the added chevron padding clipped "(default)".

Cache hardening found during re-verify: a subresource edit was served fresh but executed stale from browser cache despite ?v= on index.html — exactly the environment's false-verdict trap. All css/js tags now carry explicit ?v=03 params (bump on future edits) and the SW cache name bumped to ink-v2. SW caches.match(..., {ignoreSearch:true}) keeps offline fallback working across version bumps.

Re-verification (port 8748, cache-busted, JPEG screenshots): all six coordinator assertions pass — (a) wide-oval ×3 around a handwriting line triggers; (b) single-stroke triple loop triggers; (c) 2-circle stale pair whispers (mode whisper observed; visual verification/ink-whisper-4.jpeg); (d) no-key send leaves all strokes intact (ink-nokey-intact.jpeg); (e) settings reopen at scrollTop 0, title visible; (f) 0 console errors on clean loads (the only console lines all session were the two network-log lines from the deliberate invalid-key 401 test). Regression: dummy-key triple-loop → absorb + fades + history + honest 401 toast. Whisper hold raised 2.8s → 3.8s after screenshots showed the window was tight even for tooling.


v0.1 (2026-07-21, same day) — gesture precision + SVG drawing + humanization

Three items from the founder's live test of deployed v0 (reply loop worked; gesture over-fired badly — single circles and even squares sent).

1. Gesture over-fire — two root causes fixed (js/stroke-model.js)

2. SVG drawing (promoted from v1) — js/agent-reply.js (new file)

3. Handwriting humanization (js/handwriting.js)

Per-glyph (via font.forEachGlyph, kerning preserved): baseline offset ±1.5px, rotation ±2° about the glyph's visual center, size drift ±3%; per-line slant ±~0.63° applied as a shear along the line. All from a mulberry32 PRNG seeded deterministically — default hashString(text) (FNV-1a), reply parts seeded hash(part.text) + partIndex·φ — so the same reply always lays out identically: no shimmer on re-layout, and persisted strokes are baked geometry anyway. RNG consumed in fixed order (every glyph, spaces included) to keep sequences stable. humanize:false opt gives mechanical output. Measured effect (Node, real font): mean point displacement 0.85px, max 2.6px — subtle, not drunk. Layout now also returns textBottom/textRight (actual ink extents) for part stacking.

Cache/version bumps (h)

Verification log (v0.1 — Node + Playwright, port 8749, cache-busted, JPEG)

Node (scratchpad ink-v01/): tune-gesture.js 26/26 (corner table, candidate accept/reject, all detector scenarios, loop decomposition, near-miss sanity) · test-agent-reply.js 23/23 (parser whole/chunked/malformed/bare-text, path grammar incl. arcs + relative + garbage, unicorn/diagram/hostile fixtures) · test-humanize.js 10/10 (determinism, seeds, effect size, real font).

Browser (synthetic pen PointerEvents through the real handler path; Anthropic API mocked at window.fetch with a chunked SSE structured reply — 0 live API calls, confirmed via performance resource entries):


v0.2 (2026-07-21, same day) — real-Pencil gesture regression fix + diagnostics

Founder live iPad test: triple-circle stopped triggering ENTIRELY — every real Pencil circle whispered "once more around…" on the FIRST stroke, three circles never accumulated. Root cause confirmed by the fix: real Pencil strokes are dense and jittery; the v0.1 corner-concentration metric (tuned on clean synthetics) graded honest circles into the 0.58–0.62 near-miss band, so stroke 1 hit the 'shape' near-miss whisper and candidates never formed. The v0.1 clean-shape synthetics were the methodological hole — v0.2 tunes and verifies exclusively on noisy strokes (±2px jitter, 3–8% wobble, uneven point density, under/overshoot).

1. Primary roundness test replaced (js/stroke-model.js)

2. Run semantics — near-misses no longer kill the run

3. Multi-loop path retuned consistently

4. Diagnostics mode (js/app.js, founder-approved concept)

5. Cache/version bumps

Verification log (v0.2 — Node + Playwright, port 8749, listener killed first, cache-busted, JPEG)

Node: tune-v02.js 34/34 — metric tables (6 round families all-candidate incl. r=60 small, heavy-wobble r=180, flat 3:1 oval; 6 polygon families never-candidate; zigzag words neutral; sub-60 letter-o small) + 10 detector scenarios (growing trio, neutral-between-circles survival, polygon-between-circles survival, writing reset, stale window, size-gate letter protection, multi-loop trigger/near/square ×20 seeds).

Browser (synthetic noisy pen PointerEvents through the real handler path; Anthropic API mocked at window.fetch, dummy key):

Open questions (v0.2 → founder)

  1. The founder's actual failing strokes are still unseen — the diagnostics overlay exists precisely so he can triple-tap the gear, circle three times, hit copy, and paste the JSON into iMessage. If his real strokes still miss, the pasted fit-dev/cornerK values will say exactly which ceiling to move.
  2. Large round letters (bbox diag 60–75, e.g. a big 'o') classify as candidates; run protection is the size+centroid gates. If a real transcript shows letter-trio false positives, MIN_DIAG can rise to ~72 — no such false positive was producible synthetically.

Open questions (v0.1 → founder / v1.1)

  1. Model-side untested: prompt v2 + real Opus 4.8 drawing quality is unverified until a live send (BYOK). The mocked path proves the rails; drawing taste needs the founder's "unicorn under a rainbow" acceptance run on device.
  2. Flat-oval margin: the flattest hand ovals score ~0.53 vs the 0.58 ceiling. If a very shaky flat oval around a text line ever fails to candidate, the near-miss whisper still guides; ceiling can move to 0.60 with squares still rejected at 0.65+ — wait for a real-world miss before touching.
  3. SVG color/width are fixed to agent ink at 2.2 base width; per-element stroke-width could be honored later (parser already captures attrs).
  4. Reply parts play sequentially; a very long text + big drawing takes ~10-15s of animation. Acceptable theater for now; a speed ramp on long replies is a one-liner in beginContourAnimation if the founder finds it slow.

v0.3 (2026-07-21, same day) — finger-drawing input mode (phones can draw)

Founder discovery: his iPhone can't draw at all — the v0 pointer filter draws only for pointerType 'pen' (+ mouse) and touch only pans, which is perfect iPad palm rejection but leaves pen-less phones with a read-only page. Branch v0.3-finger-input.

Decisions

Cache/version bumps

Verification log (v0.3 — Playwright, port 8749, listener killed first, cache-busted, JPEG)

Synthetic PointerEvents (pointerType:'touch' / 'pen') through the real handler path; Anthropic API mocked at window.fetch with a chunked SSE structured reply; dummy key removed after the run.

Open questions (v0.3 → founder)

  1. The first-run hint still says "write with your pen…" — on a phone in finger mode the word "pen" is slightly off-voice. One-line copy swap if it bothers him; left untouched to keep the diff focused.
  2. In pen-locked AUTO on iPad, a single finger still does nothing (v0 behavior — pan needs two fingers). If the founder wants single-finger pan there, it's a small addition to the touch branch, but it changes iPad muscle memory so it waits for his call.

v0.3.1 (2026-07-21, same day) — coordinate-offset bugfix (ink lands under the finger again)

Founder live report from his iPhone (finger drawing, v0.3): "somehow it started drawing way off to the side instead of under my finger." Branch v0.3.1-coord-fix.

Root cause

All input math assumed the canvas client origin is (0,0) and that the CSS box exactly matches the backing store resize() last saw — and resize() sized the backing store from window.innerWidth/innerHeight, which on iOS track the visual viewport (URL bar collapse/expand, keyboard, accidental page pinch-zoom — iOS ignores user-scalable=no). Any of those shifts detaches the assumed frame from the real one and every stroke lands offset; iPads never showed it because Safari-on-iPad chrome is stable and the founder used a Pencil there. "Started" = the moment the first such shift happened; it then persists.

Decisions

Cache/version bumps

Verification log (v0.3.1 — Playwright, port 8749, listener killed first, cache-busted, JPEG)

Synthetic PointerEvents through the real handler path on a fresh device (localStorage cleared); round-trip assertion = committed stroke's doc coords mapped back through viewport+rect must land within 2px of the input client coords; Anthropic API mocked at window.fetch with a chunked SSE structured reply; dummy key removed after the run.

Open questions (v0.3.1 → founder)

  1. Fix is defensive-in-depth (fresh rect + rect-based sizing + viewport listeners + pinch guard), so it covers the whole failure class; the founder's exact on-device trigger (URL bar vs keyboard vs accidental page pinch) wasn't reproducible from the report alone. If it recurs after this ships, the diagnostics overlay is the next stop — worth adding rect/viewport telemetry to it then.