Build agent notes per the implementation-notes SOP. Spec: 2026-07-21-ink-build-spec.md. Scope: v0 only.
Decisions
- API surface (from claude-api skill + live verification): raw
fetchtoPOST https://api.anthropic.com/v1/messageswith headerscontent-type,x-api-key,anthropic-version: 2023-06-01, andanthropic-dangerous-direct-browser-access: true. The CORS header was not in the skill docs, so I verified it empirically with an OPTIONS preflight from originhttp://localhost:8747— the API returnsaccess-control-allow-origin: *and lists the header inaccess-control-allow-headers. Browser-direct is confirmed live. - Model ids (from skill, cached 2026-06-24): default
claude-opus-4-8; picker also offersclaude-sonnet-5,claude-sonnet-4-6,claude-haiku-4-5. Notemperature/top_p/thinkingparams sent (removed/400 on Opus 4.8-family; omittingthinkingis correct on all of them). - Vision message shape: two base64 PNG image blocks (page context first, region crop second) before the text block, per skill vision docs.
- Streaming:
stream: true, SSE parsed by hand (reader + TextDecoder,data:lines). Reply text is accumulated then hand-written — layout (wrap + shrink-to-fit) needs the full text, so streaming buys early-failure detection + a subtle "thinking" pulse in the region, not incremental writing. - File layout: classic scripts (no build step, no modules) so quick-publish ships verbatim:
index.html,ink.css,js/stroke-model.js(DOM-free),js/handwriting.js(DOM-free except opentype font object),js/api.js,js/app.js,vendor/opentype.min.js(1.3.4, jsdelivr),fonts/DancingScript.ttf(Google Fonts variable font, OFL; opentype.js renders the default Regular instance) +fonts/OFL.txt,manifest.webmanifest,sw.js,icons/. - Portability (spec ruling 3): stroke model + gesture detection live in
js/stroke-model.jswith zero DOM references — pure data + math ({id, actor, color, baseWidth, points:[{x,y,p,t}]}, bbox/pathLength/centroid, circle-candidate test, TripleCircleDetector state machine, serialize/deserialize).js/app.jsis the only DOM-coupled layer. - Circle detection units: spec thresholds (60px min diagonal etc.) are applied in world coordinates, so behavior is zoom-invariant. At scale 1.0 world units == CSS px.
- Capture-before-absorb: the page + region PNGs are rendered synchronously at trigger time, before the fade-absorb removes the request strokes — the model must be able to read what the page "drank".
- Absorbed strokes go to
doc.history(capped at 800) and are included in export JSON. Undo restores erased strokes; absorbed strokes are not undoable (they were "sent"). - Idle / send-button trigger modes: the spec's spatial-addressing flow is described for triple-circle only. For idle + button modes the region is the union bbox of strokes drawn since the last send, and the reply is written below that bbox (same width) — writing into the bbox itself would overpaint the user's request, and nothing was circled/absorbed in these modes. Decision noted as a deviation-by-necessity; founder can re-rule.
- Reply glyph rendering: glyph outline contours sampled to polylines, animated contour-by-contour at ~700 world-units/s, then committed as normal agent strokes (persist mode) or faded after the vanish delay. Outline-stroke rendering (not fill) per spec wording "animate glyph outline paths stroke-by-stroke"; Dancing Script outlines read well at 1.4px stroke weight.
- Failure honesty: all API/precondition failures hand-write "the page is silent…" (muted color, auto-vanishes after ~7s) into the region + a toast with the real error. Missing API key toast includes the settings hint and pulses the gear.
- Theme-aware ink defaults: colors are stored as
autountil the user customizes;autoresolves to near-black/fountain-blue on light paper and warm-cream/sky-blue on dark paper (spec default "user black, agent blue" is unreadable on dark paper otherwise). - Service worker: network-first with cache fallback (app shell precached at install). Network-first keeps Playwright cache-busted verification honest and still gives offline drawing.
api.anthropic.comrequests bypass the SW entirely. - Security: API key lives only in
localStorage['ink.apikey'], a separate key from doc/settings, and is excluded from export JSON. Nothing secret ships in the folder. - Synthetic-pen testability:
setPointerCaptureandgetCoalescedEventsare guarded (try/catch + feature check) so dispatched PointerEvents withpointerType:'pen'exercise the real code path.
Tradeoffs
- Stroke-level eraser (removes whole stroke on hit) rather than pixel eraser — matches v0 scope, simpler model, undoable.
- Canvas redraw is full-repaint on dirty flag via rAF, no incremental tiling. Fine for v0 stroke counts; PencilKit port makes this moot.
ctx.filterblur during fade-absorb is feature-detected; falls back to alpha-only fade on browsers without canvas filter support.- Variable-font handling: opentype.js 1.3.4 renders the default instance (Regular 400) of the Dancing Script variable font — no named-instance selection needed for v0.
Open questions (for founder / v1)
- Idle/button trigger region placement (reply below request bbox) — acceptable, or should those modes also require a circled target?
- Should absorbed request strokes be recoverable (an "un-drink" gesture)? Currently history-only.
- 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).
- 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).
- Pen input: synthetic PointerEvents
pointerType:'pen'with varying pressure → stroke captured with per-point pressure (61 pts, p 0.25–0.8), actoruser, color sentinelauto-user.setPointerCapture/getCoalescedEventsguards let synthetic events exercise the real path. - Triple-circle trigger: scribble + 3 overlapping wobbly circles → detector fired; all 4 strokes absorbed to history, 4 fade-absorb animations ran (opacity² + blur), visible strokes → 0.
- Failure honesty (no key): "the page is silent…" hand-written in muted Dancing Script into the circled region (screenshot:
verification/ink-silent-writing.jpeg), toast with settings hint, gear pulse. Silent text auto-vanishes ~6.5s later. - Live fetch path (dummy key): button-mode send fired a real browser-direct request to api.anthropic.com; 401 surfaced honestly as "API error (401) — invalid x-api-key" + silent handwriting. Confirms headers/CORS/vision-payload shape reach the API. Dummy key removed afterward.
- Idle trigger: idle mode @2s auto-fired after the last stroke (1.6s observed incl. debounce); silent path rendered.
- Settings round-trip: key save →
localStorage['ink.apikey']only (not in settings/doc JSON); export blob contains doc+settings and zero key material; import of a mutated export replaced doc + settings (send button appeared fortrigger:'button'); persisted across cache-busted reload. - Interactions: two-finger touch pinch → zoom 1.0→1.8 + pan; single-finger touch draws nothing (palm rejection); mouse draws; undo removes; eraser (stroke-level) erases and undo restores; wheel pan / ctrl-wheel zoom wired.
- Reply rendering: agent reply written in fountain-blue Dancing Script, committed as agent strokes in persist mode (
verification/ink-desktop-reply-light.jpeg). - Themes: light + dark verified at desktop 1440x900 and iPad 1180x820; auto ink colors remap per theme, explicit colors preserved; dark settings panel legible. Screenshots in
01-projects/ink/verification/. - PWA: manifest + icons load clean; service worker registers and controls the page (network-first, so cache-busting verification stayed honest).
- Fix applied during verification: first-run hint now only shows in triple-circle mode (its "circle three times" guidance was wrong for idle/button modes).
- Known environment quirk: Playwright PNG screenshots timed out repeatedly on this canvas-heavy page; JPEG capture works — future critic passes should use JPEG.
Design-critic iteration (same day, ITERATE verdict — 4 findings fixed)
- 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). - 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.)
- Settings panel scrollTop reset to 0 on open (LOW).
- 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)
- Root cause (a) — round letters as circle candidates: a letter 'o'/'a'/'e' (diag ~70 world units) passes every shape check; 2 round letters + 1 real circle around the word = "3 circles" (centroid check passes because the letters sit inside the big circle). Fix: size-similarity gate on the trio — max bbox diag ≤
sizeFactor(2.25) × min diag. Letters vs a word-circle are 3–6x apart; genuine sloppy concentric circles stay within ~2x. Same gate applied to the 2-circle whisper pair. Temporal ordering judged adequate as-is: ordinary letters are hard non-candidates and reset the run, the 6s window prunes stale entries, and the size gate closes the remaining letter hole — no further tightening needed. - Root cause (b) — squares pass everything: closed ✓, aspect ~1.0 ✓, pathLength/bboxPerimeter ~1.0 ✓. Fix: corner-concentration test (
cornerConcentration(points, k)): turning angle at each downsampled vertex (adaptive stepclamp(perimeter/44, 5, 26)≈ 44 samples); fraction of total |turning| carried by the top-k vertices. Circles spread ~2π evenly; polygons concentrate it at corners. k=6 (not the spec-suggested 4) because a hand corner can split across two samples — top-6 still captures 4 split corners. Thresholds tuned with synthetics (scratchpadtune-gesture.js, 26 assertions): rounds score ≤0.53 (worst: flat 3:1 oval 0.529), polygons ≥0.65 (squares crisp/hand/axis-aligned/small, rectangle 2:1, triangle, diamond).CORNER_MAX = 0.58for candidates.CORNER_MAX_NEAR = 0.62for the near-miss whisper — first browser round caught a noisy square whispering "once more around…" at the original 0.75 ceiling (hand squares live at 0.65–0.8, jerky circle attempts ≤ ~0.5; 0.62 separates). Squares now neither trigger nor whisper. - Multi-loop path hardened (a square scribbled 3 laps also accumulates ~3 signed revolutions): corner test with lap-scaled k (
6 × round(turns)), plusloopsAreRegionScale— decompose the stroke at each 2π of cumulative turning; every lap's bbox diag must be ≥40% of the whole stroke's and its centroid within 0.35×diag of the shared center. Cursive words that accumulate revolutions do it with letter-sized loops strung along the word → rejected; deliberate triple-circling (even gently spiraling inward, shrink ≤~12%/lap) passes. Cursive was already mostly blocked by the closed-ish gate (start–end < 0.35×diag); this closes the compact-scribble remainder. - New exports for tests:
cornerConcentration,loopsAreRegionScale; detector takessizeFactoropt.
2. SVG drawing (promoted from v1) — js/agent-reply.js (new file)
- Locked architecture honored: model markup is NEVER injected into the DOM. Chose own parsers over inert DOMParser — keeps the file DOM-free (Node-testable, PencilKit-portable) and the attack surface nil: geometry is extracted by regex element scan + a hand-written SVG path-data parser, sampled into ordinary stroke objects.
- Reply format (system prompt v2,
js/api.js):<ink:text>…</ink:text>+<ink:svg viewBox="…">…</ink:svg>blocks, defined in aFORMAT_SPECconstant always appended to the persona (default or custom — custom personas can't break the format). Spec tells the model: allowed elements only (path/line/polyline/circle/ellipse/rect/text), stroke-only/no fill/no transforms/no groups, viewBox-space coords, 5–60 elements, text labels get hand-written, write-vs-draw-vs-both guidance, "playful requests deserve enthusiastic drawings, never an apology". Per-request user text now includes the target region's W×H + aspect.max_tokens1024 → 2048 (drawings are token-hungrier). - StreamParser: incremental; text parts emit the moment their close tag arrives (render-as-it-arrives at part granularity — layout needs the full text of a part, recorded v0 decision unchanged), svg parts only when complete; bare untagged text falls back to a text part; malformed svg (bad/missing viewBox, never-closed, >80KB runaway) silently dropped — never crashes, never blocks later parts.
- Path-data parser: full grammar M/L/H/V/C/S/Q/T/A/Z + relative forms, implicit lineto after moveto, S/T reflection, W3C endpoint→center arc conversion with radii correction; beziers flattened (16/12 samples), arcs ≤ π/16 per segment; garbage args abort that command gracefully (readNums returns null, keeps what parsed).
- svgToStrokes: fits viewBox into the target rect (aspect-preserving, centered), one stroke per polyline/subpath, densifies long straight runs to ≤6 world units between points (the canvas renderer smooths through midpoints — sparse corners would round off), caps 400 strokes / 24k points, skips NaN geometry.
<text>is rendered through an injectedtextToPolylines(the app wires the loaded Dancing Script font; tests stub it) — so diagram labels come out hand-written, and fills simply don't exist (outlines only = stroke-only enforced structurally). - Playback (
js/app.js): newreplyJobqueue — completed parts play sequentially into the region, stacking downward via a cursorY (text advances by actual ink bottomlayout.textBottom; svg by drawn bounds; overflow continues below the region exactly like v0 text). SharedbeginContourAnimationcore: text contours and svg strokes animate through the identical pipeline (drawings atclamp(totalLen/6, 900, 4200)u/s so a whole drawing lands in ≤~6s), and commit through the samefinishWriting— persist/vanish/export behavior needs no new code. If a stream ends with zero drawable parts (all-malformed), failure-honesty writes "the page is silent…" + toast.callAgentfeeds SSE deltas into the parser;busyclears at stream end (v0 semantics), thinking pulse stops when the first ink lands. Test hook:__ink.playReply(raw, region)drives the whole path with no API.
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)
- All subresource tags
?v=03→?v=05(04 burned mid-verify for the CORNER_MAX_NEAR fix — bump per edit round, per the v0 false-verdict trap note); newjs/agent-reply.js?v=05tag added. sw.js: cacheink-v2→ink-v3,js/agent-reply.jsadded to SHELL.
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):
- (a) square ×3 → 3 strokes on page, no absorb, no whisper, no send ✓
- (b) zigzag + 'o' + 'o' + ONE circle around the word → no trigger, no whisper ✓
- (c) scribble + three sloppy ovals → trigger: all 5 strokes absorbed to history, mock API called once, streamed text+svg reply animated and committed (55 agent strokes;
verification/ink-v01-unicorn-reply.jpeg— handwritten line + rainbow-and-unicorn line art, read and confirmed) ✓ - (d) cursive loopy word (drifting) + compact loopy scribble → no multi-loop fire; single-stroke 3.15-lap oval around a scribble still fires end-to-end (absorb + send) ✓
- (e) diagram fixture via
playReply→ boxes/arrows/hand-written labels animated into the region (ink-v01-diagram-reply.jpeg, read); malformed fixture → no throw, no strokes, honest silent-writing + toast ✓ - (f) humanization side-by-side mechanical vs humanized (
ink-v01-humanize-compare.jpeg, read): visible organic wobble, same legibility — subtle not drunk; in-page determinism assert passed ✓ - (g) both v0.1 loads: 0 console errors/warnings (only a VERBOSE password-field note, pre-existing). Three api.anthropic.com error lines surfaced under
all:truewere traced to the PRIOR v0 session's deliberate 401 test in the persistent browser profile — per-navigation logs clean, 0 real API requests this session ✓ - (h) version bumps as above ✓
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)
- Arc-length resample first (
resamplePoints, 48 pts; multi-loop: 48×laps ≤192): kills Pencil point-density noise before ANY metric. This alone drops noisy-circle cornerK from 0.58–0.62 to ≤0.46. - Kasa least-squares circle fit (
kasaFit— centroid-centered 3×3 solve;circleFitDeviation= mean |radial dev| / fitted R) computed in bbox-normalized space (bbox → 300×300 square) so wide/flat ovals score like circles. - Measured separation (20 seeds/family,
tune-v02.jsin this vault folder — kept out of the public repo, 34/34): hand circles/ovals (incl. flat 3:1, heavy-wobble) fit-dev 0.019–0.038; squares 0.086–0.097; triangles ~0.18. ⚠️ Deviation from the dispatch's expectation: squares do NOT score "~0.20+" on this metric — Kasa fit on a square is deceptively good (~0.09). So the corner test could not be demoted to fully-optional; it carries square rejection in the ambiguous band. - Two-tier acceptance (
classifyStroke): fit ≤ 0.055 → candidate, no second opinion (covers every observed noisy round shape ≤0.038 with margin); fit 0.055–0.18 → candidate only if normalized cornerK < 0.55; polygon verdict only when cornerK ≥ 0.65 (extreme level) — i.e. in the ambiguous band a stroke is only rejected when BOTH tests lean polygon. cornerK is now computed on aspect-NORMALIZED resampled points (raw-space cornerK on flat 3:1 ovals spiked to 0.72 — turning concentrates at the tips; normalized it sits ~0.40, squares unchanged). - Verdict taxonomy replaces the pass/fail candidate test:
candidate/small(diag ≤ 60 = writing-sized) /polygon/near(fit ≤ 0.30 narrow fail) /neutral. Exported:classifyStroke,resamplePoints,kasaFit,circleFitDeviation,decomposeLaps;isCircleCandidate/isNearCandidatekept as compat wrappers.
2. Run semantics — near-misses no longer kill the run
- Only
small(writing-sized) strokes reset.near,polygon, and any largeneutralstroke leave the run untouched — a sloppy in-between attempt can no longer block the following clean circle (the accumulation half of the live regression). - Size-similarity 2.25 → 3.0 (detector default): successive lassos grow — you circle around your previous circle. Letters vs word-circle are still 3–6x apart, so the letters-inside-circle protection holds (verified: o+o+word-circle scenario still blocked by size gate; centroid gate 0.30 unchanged).
- Whisper only on genuine 2-of-3 progress: the 'shape' single-stroke near-miss whisper is REMOVED (it was the visible symptom). Whisper paths now: 2 valid concentric circles gone stale 2.2s ('two'), or a round 2-lap continuous attempt ('loops'). A shape rejected as polygon stays silent.
3. Multi-loop path retuned consistently
- Corner test on aspect-normalized, lap-proportional resampled points; ceilings 0.58 trigger / 0.62 near.
lapsAreRound(new gate, both loop paths): each decomposed lap must pass per-lap circle-fit (≤0.24, loose for seam distortion) AND per-lap normalized cornerK < 0.65. Caught the last leak: a square scribbled in laps circle-fits deceptively well as a whole (concentric square band ~0.09) and one seed whispered — per-lap corners kill it. 3-lap noisy circles trigger 20/20; 3-lap squares fire 0/20.
4. Diagnostics mode (js/app.js, founder-approved concept)
- Enable:
?debug=1URL param OR triple-tap the gear (toggles; closes settings panel). Zero cost off: one boolean check per stroke; overlay DOM built lazily on first enable. - Per pen stroke: verdict, closed?, bbox diag, aspect, fit-dev, cornerK, turns, run n-of-3, decision (TRIGGER / whisper-pending (2 of 3) / whisper (2-lap attempt) / none). Color-coded lines (candidate=agent blue, polygon=danger, small=faint). Detector now stores
lastEvalafter every call — the overlay reads metrics the detection already computed. - Copy button: last 20 strokes' metrics as pretty JSON → clipboard (
navigator.clipboardwith execCommand fallback + confirmation toast) for pasting into iMessage.__ink.diag.lastCopiedexposed for tooling.
5. Cache/version bumps
- All subresource tags
?v=05→?v=06;sw.jscacheink-v3→ink-v4.
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):
- (a) word + 3 noisy circles growing 1.0→1.3→1.7x → stroke 1: candidate fit=0.022 K=0.383, no whisper; stroke 3 TRIGGER, all 4 strokes absorbed to history, mock API called once ✓
- (b) word + 3 noisy wide ovals (2.5:1) → TRIGGER ✓
- (c) noisy square ×3 → verdicts polygon, run 0/3, no trigger, no whisper after 3.2s, strokes intact ✓
- (d) word + ONE circle → run 1/3, no trigger, no whisper after 3.3s; positive control: 2 circles + stall → whisper fires at 2-of-3 ✓; single-stroke 3.05-turn noisy loop → loop-trigger + absorb + API ✓
- (e) diagnostics overlay renders (
verification/ink-v02-diagnostics.jpeg— read and confirmed, color-coded log + hand-noisy square/circle on canvas); copy button → 665-char valid JSON (3 records post-reset), success toast; triple-tap gear ON→OFF verified; overlay absent from DOM without debug ✓ - (f) 0 console errors/warnings across all loads (single session line: favicon 404 from a stale port-8750 tab in the persistent profile — not this app, not this origin) ✓
- Note for future critics:
navigator.clipboard.readText()in Playwright hangs on a permission prompt — assert via__ink.diag.lastCopied+ the success toast instead.
Open questions (v0.2 → founder)
- 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.
- 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)
- 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.
- 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.
- 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).
- 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
beginContourAnimationif 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
- New setting
drawInput:auto(default) /pen/finger. AUTO = a single finger draws ONLY on devices that have never produced a'pen'pointer event. The first pen event ever seen sets a persistent flaglocalStorage['ink.pen-seen']='1'; from then on AUTO is pen-only on that device. Phones get finger drawing with zero configuration; iPads with a Pencil keep palm rejection intact the moment the Pencil first touches (or hovers — the flag is set from penpointermovetoo, so M2-era Pencil hover arms it before first contact). - Flag checkpoints:
notePenSeen()fires on pen pointerdown AND pen pointermove (hover). Cost when already set: one boolean check. The flag is device-level (deliberately NOT inink.settings.v1) — it never travels through export/import, since it describes the hardware, not the page. - One finger draws, two fingers pan/zoom in any finger-drawing mode: first touch starts a stroke (or an erase, if the eraser toggle is armed — the eraser follows whatever draws); a second touch cancels the in-progress finger stroke (nothing is committed) and hands over to the existing
beginGesturepan/zoom path unchanged.beginGesturenow also clearsstate.erasingfor symmetry. - Shared commit path: finger strokes go through the same
commitStrokeas pen strokes — triple-circle detection, whisper, idle trigger, diagnostics overlay, undo, pending-set all work identically on finger ink with no per-input code. - Pressure: constant mid-weight 0.5 for fingers, except where webkit exposes a genuine force reading through
PointerEvent.pressure(>0), which is used as-is — same rule the pen path already used. - Settings UI: new "drawing" section (after "listening") with row "what draws":
the page decides (auto)/pen only/pen & finger, in the v0.1 restyled select (inherits the wobbly-corner + hand-drawn-chevron styling automatically). A.fineprintline explains AUTO's one-way flip and that two fingers always pan. - Mode semantics:
pen= hard pen-only regardless of flag (an iPad user who hates accidental finger ink can lock it);finger= finger always draws regardless of flag (an iPad user without their Pencil on the train can override the armed flag). - Test hooks:
__ink.fingerDraws()and__ink.penSeen()exposed for verification.
Cache/version bumps
- All subresource tags
?v=06→?v=07;sw.jscacheink-v4→ink-v5.
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.
- (a) fresh device (localStorage cleared, no pen flag), AUTO: single-finger squiggle → 1 user stroke, 41 pts ✓
- (b) one synthetic pen stroke →
ink.pen-seen='1',fingerDraws()false; single finger draws nothing; two-finger pan moves viewport (0,0)→(90,60) ✓ - (c) manual
pen & fingeroverride (via the real select) draws by finger despite the armed flag;pen onlyblocks finger even after override round-trip; setting persisted toink.settings.v1✓ - (d) two-finger pan verified in AUTO-pen-locked mode AND finger mode; mid-stroke second finger cancels the finger stroke (
state.drawingnull, nothing committed) and pans ✓ - (e) finger-drawn word + 3 noisy growing circles (r 95→125→165, wobble+jitter) → stroke 3 TRIGGER: all 4 strokes absorbed to history, mock API called exactly once, streamed
<ink:text>reply hand-written and committed as 45 agent strokes (verification/ink-v03-finger-triple-circle.jpeg, read and confirmed) ✓ - (f)
drawInput:'finger'+ pen flag survive a cache-busted reload; select shows "pen & finger"; panel screenshot (ink-v03-settings-panel.jpeg, read) — new section matches the crafted panel style ✓ - (g) 0 console errors/warnings across all loads (single session line: the known stale-tab favicon 404 from port 8750 in the persistent profile — different origin, not this app) ✓
Open questions (v0.3 → founder)
- 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.
- 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
- One mapping for ALL input:
screenToDoc(clientX, clientY, rect?)= freshcanvas.getBoundingClientRect()origin subtraction → ratio guard (canvas CSS box vs backing store, absorbs drift betweenresize()calls) → inverse viewport transform. Used by draw (finger + pen/mouse, incl. coalesced events), erase, wheel-zoom anchor, and pan/zoom gesture math.clientXandgetBoundingClientRect()are defined in the same coordinate space in every browser, so the subtraction is exact whatever iOS does to the viewport. Rect cached per stroke (captured at pointerdown, reused for its moves) — never at init. resize()sizes the backing store from the canvas element's own rect, neverwindow.inner*— self-consistent by construction; reallocation only when dims actually change, so spurious visualViewport events never blank an in-progress page.- Resync listeners:
orientationchange+visualViewportresize/scroll(all →resize()), alongside the existing windowresize. - Safari page-pinch guard:
gesturestart/gesturechangepreventDefault at document level — blocks WebKit native page-zoom starting on overlays (canvas itself was alreadytouch-action: none); the app's own two-finger pointer-event pan/zoom is unaffected. - Gesture math now runs in canvas space via a per-gesture cached rect (start and current mids in one consistent frame), and
updateGesture's anchor inversion is unchanged on top of that. - Two coherence bugs found in audit, both fixed: (1) 3-finger edge — a gesture surviving a finger change kept the stale pair's baseline and the viewport jumped; now re-baselined via
beginGesture()on any touch add/remove while ≥2 remain. (2) Ghost touches — touch pointers were never captured, so a finger lifting over an overlay left a permanentstate.touchesentry that turned every later single-finger touch into a phantom pan; touch pointers now getsetPointerCapturelike pen/mouse. - Test hooks:
__ink.screenToDoc/clientToCanvas/canvasRect/resizeexposed;__ink.screenToWorldkept as a back-compat alias.
Cache/version bumps
- All subresource tags
?v=07→?v=08;sw.jscacheink-v5→ink-v6.
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.
- (a) identity viewport, finger line at known clientXY → 1 stroke, round-trip maxErr 0.0000 ✓
- (b) pan+zoom via the app's own transform (x 240.5, y −87.25, scale 1.65) → draw → maxErr 0 ✓; real two-finger gesture through the pointer path (scale grew, gesture + touches cleaned up) → draw → maxErr 0 ✓; mid-stroke second-finger cancel: nothing committed, pan proceeds, next stroke maxErr 0 ✓
- (c) layout shift (canvas box moved to left 37/top 23) → maxErr 0 (pre-fix code would err by 37px) ✓; backing-store drift (CSS box 600 vs backing 900, no resize call) → ratio guard → maxErr 0 ✓; restore +
resize()resyncs backing exactly ✓ - (d) viewport height 664 → 750 (URL-bar collapse) → backing resynced to rect×dpr, draw maxErr 0 ✓
- (e) after pan+zoom (scale 1.3), 3 noisy growing finger circles (r 130/170/215 client) → TRIGGER: mock API called exactly once, 3 strokes absorbed to history, streamed
<ink:text>hand-written as 28 agent strokes (ink-v031-coordfix-reply.jpeg, read and confirmed) ✓ - (f) pen path: draws, round-trip maxErr 0, arms
ink.pen-seen, AUTO flips to pen-locked ✓ - (g) 0 console errors/warnings for this app (single session line: the known stale-tab favicon 404 from port 8750 in the persistent profile — different origin, pre-existing) ✓
Open questions (v0.3.1 → founder)
- 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.