Commits
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
i'd skipped this. register 'yours' + 'delete' in SLOTS and reuse the existing
'remix' slot: the settings entry renders slotNode('yours'), and the mine cards'
remix/delete buttons render their slots — so all three are drawable and auto-show
in settings -> app icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
new /mine view, linked from settings, lists your own drawings via listRecords on
your repo — authoritative and instant, no microcosm enumeration. each card opens
its display page, remixes in-app, deletes, and toggles whether it shows in
explore (per-drawing prefs override). the explore toggle uses putRecord, so the
drawing scope gains action=update (hosted metadata updated to match) — a one-time
re-consent enables it; open/remix/delete work without it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
doodl PNGs are transparent (the canvas bakes no background), so on dark surfaces
the dark bled through — bot posts, link-preview og:images, dark-theme thumbnails.
keep the record transparent (icons need it) and flatten onto white at serve time
via the worker /img transform's new bg param: og:image + share hero + explore
thumbnails use ?bg=ffffff, the download link forces a white PNG, and the bot posts
the white-backed PNG. fixes every already-posted drawing too (serve-time, no
re-save).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a lineage/remix link can point at a since-deleted drawing; slingshot getRecord
then 400s RecordNotFound. the main share-page fetch wasn't caught, so the whole
page 502'd with a raw error. catch it and serve a clean 'no longer exists' 404
(no og:image, so a stale link won't unfurl).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
revive dynamic width without the perfect-freehand gap regression. width is
per-point (real pen pressure on a stylus, velocity otherwise: faster = thinner),
rendered STAMP-based — a filled circle at every point plus trapezoids between
them — so a joint is always a full circle and the stroke can never gap, even on
sharp/sparse input. render-time width moving-average + densify removes the
sample-to-sample jaggedness. source -> v2 (per-point width), back-compat with v1.
dynamic is OFF by default (the solid uniform pen stays the default); the toggle +
sensitivity live in the stroke sheet and persist to localStorage. also restores
the finishOAuth resilience guard (a failed OAuth degrades to signed-out, doesn't
blank the UI) so local dev boots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Space wasn't the gap source — that was the perfect-freehand outline fill, now
gone. on the constant-width pen, sparse hold-Space input draws gap-free (verified
in-browser with a sparse zigzag). begin/extend/end mode + cursor hover tracking;
ignored while typing or when the canvas isn't visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
re-adds just the shortcuts from the reverted batch; velocity/perfect-freehand
stays out, to be revived more thoughtfully later.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This reverts commit 68db9e8f2f4f2b41af6cf97cfb5df1d893f959f8.
This reverts commit 14747766eb2834f1ba48569617d7cba2d438f6f3.
sparse pointer input (esp. hold-to-draw, which fires fewer moves) made sharp
corners whose filled outline self-intersected — leaving V-shaped gaps and a
'cheap' pinched look. densify the input (subdivide gaps > 6px) so corners are
rounded out of many small segments, and render the outline with quadratic curves
through its midpoints (the canonical perfect-freehand path) instead of a faceted
straight-line polygon. verified in-browser: the worst-case zigzag now fills solid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stroke rendering moves to perfect-freehand: width varies by real pen pressure
(stylus) or simulated velocity (finger/mouse), with tapered ends and smoothing.
source format -> v2 (per-point pressure + per-stroke render intent), back-compat
with v1. stroke sheet gains a dynamic-width toggle + sensitivity slider; both
persist. hold Space to draw without clicking (ballpoint feel); coalesced events
for high-fidelity input.
also: autosave the in-progress drawing to localStorage every change and restore
on load, so a reload never loses work; keyboard shortcuts (s=stroke, c=color,
cmd/ctrl+s=save); and make finishOAuth resilient so a failed OAuth init degrades
to signed-out instead of blanking the whole UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nothing rendered until the cold corpus fetch AND a 76-author avatar batch both
finished in series. now: skeleton shimmer the instant you open explore; render
cards as soon as the drawings JSON arrives (no avatar wait); resolve avatars
lazily per visible page and patch them onto cards in place; cards fade in. also
widen the api edge cache (fresh 2min, stale 10min) so cold enumeration is rare.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the single sentinel was observed once; re-appending it within the rootMargin
fired no new intersection transition, so paging stopped. (masked while PAGE=48
covered the whole corpus.) re-arm the observer (unobserve -> append -> observe)
each page so it keeps firing while in view, then waits for scroll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
explore loaded 48 full-res 1024px PNGs (~4.6MB) straight from many PDS origins.
add a worker /img/<did>/<cid> route that resolves the PDS and has Cloudflare's
image transformer downscale the blob (remote origin fetched inside the worker,
so no per-PDS allowlist); CIDs are immutable so the result edge-caches forever.
cards now load a ~3KB avif/webp from one cached origin instead of a ~97KB PNG.
also drop the first page 48->24. full-size stays direct for share og:images.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the preview dot was box-relative (frac of a small box) so it under-represented
the real stroke: same ratio, far fewer absolute pixels than the displayed canvas.
size the dot to the stroke's actual on-screen pixels (size x canvas-display-scale)
so it matches what you paint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the preview dot was capped at 26px (Math.min(n,26)) so it stopped growing and
told you nothing past small sizes. make it a framed box standing in for the
canvas, with the dot sized as the real fraction (size/1024) of the canvas the
mark covers — so dragging the slider grows it proportionally — plus a numeric
readout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the 'remixing @x' banner was a full padded block at the top of the draw view,
permanently shrinking the canvas. move it inline next to the 'show in explore'
toggle as a compact glyph+handle+cancel, and shrink the mobile footer. the top
block is gone, so the canvas gets that vertical space back.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
both rendered a filled dot of the current color (the --bc tint change made the
stroke dot match the color swatch exactly). give the stroke trigger a tapered
wedge that reads as 'width' and is theme-neutral; the live size-preview dot stays
in the stroke sheet where growing it is meaningful.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the 1024x1024 backing store was stretched to a non-square box because #pad
pinned a definite width from a guessed chrome height (100dvh - 150px) while
max-height:100% independently clamped the height — on short/landscape viewports
the two disagreed and the bitmap stretched wide. size as a contain square from
the intrinsic 1024x1024 instead; no magic numbers, no distortion on any screen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UFOs /records is a fixed recent sample (~42, no paging) — explore was never the
full archive. enumerate the whole thing instead, in the one place CORS allows:
- worker /api/drawings: lightrail listReposByCollection (cursor-paged) → every
creator → resolveMiniDoc + their explore default + a listRecords walk →
merged, moderation-filtered, sorted newest-first. CORS'd + short-cached. it's
a live aggregator (computed per request, nothing stored). 67 drawings vs 42.
- data/explore.ts: fetch that endpoint (absolute URL so dev hits prod too); the
SPA just filters opt-out + resolves avatars now.
- gallery: infinite-scroll a page (48) at a time over the full list.
CAR/MST walk would be one call per creator vs paginated listRecords — the scale
path; doodl's repos are tiny so listRecords is plenty (noted in the worker).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main.ts: 1289 → 88 lines — now a composition root that reads as a boot tour.
- src/app.ts: the shell — shared state (session/handle/defaults), the canvas +
chrome refs, the router, and a tiny event bus ("view"/"refresh") so features
react without importing each other.
- src/auth.ts: oauth init, signIn, logout.
- src/ui/: slots (the icon registry), sheets (the shared bottom-sheet).
- src/features/: tools, chrome, gallery, settings, preferences, remix,
icon-mode, save. each exposes init(); save reads remix/icon/prefs via their
getters (the integration seam stays explicit, no behavior change).
behaviour preserved; tsc + build green. plan in docs/refactor-main.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
composition root + feature modules over a shared App context, with a tiny event
bus only for the 3 circular hubs (show/refreshUI) and a beforeSave/afterSave
seam so save doesn't import every feature. module map + safe incremental order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
src/ was a flat splat of 17 files. give it the boundary that was already there:
- src/data/ — atproto + microcosm services, no DOM (net, oauth, pds, clients,
source, lineage, prefs, moderation, explore). a closed set: never imports ui/.
- src/ui/ — draw, dom, theme, login.
- root keeps the entry + the two views: boot.ts, main.ts, display.ts, style.css.
lexicons mirror the NSID like the official repos: lexicons/tech/waow/doodl/*.json
(nothing imports them; the collection strings are hardcoded, so zero code impact).
pure reorg — no behavior change, build + check green. main.ts (1289 lines) is
still the elephant; a remix/gallery/settings split is the next, separate pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cut redundant round-trips without risking staleness:
- src/net.ts: cache identity (5m) and drawing records (60s) with single-flight
promise dedup. concurrent identical requests share one fetch — zero-staleness.
explore's per-author resolve, the remix lineage walks, and the share-page
lineage now share this cache, so K remixes of one parent stop re-fetching it.
- explore credit uses resolveOriginal (walk records to the root, resolve
identity once) instead of resolving every ancestor's identity.
deliberately NOT cached — remix counts (Constellation), preferences, the
moderation denylist: all stay fresh, read live as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the remix credit (and long handles generally) wrapped and spilled the card in
loud blue — thoughtless. fix it:
- handles + remix credit are single lines, ellipsis-truncated, never wrapping;
full value on title hover
- the remix credit is smaller + dimmer than the maker's handle (clear hierarchy),
not accent-blue; accent only on hover
- one tight line: a fixed mark (the viewer's drawn remix slot, else a compact ↻)
+ the original handle, which is the part that truncates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a remixed drawing now reads as one at a glance, in the zds register (subtle
color-mix accent-tinted edge, muted micro-credit — no loud borders):
- the card gets a faint accent-tinted border
- a credit line under the handle shows the ORIGINAL artist (root of the chain,
resolved lazily so the grid never waits), linking to their drawing
- the remix mark is the viewer's own `remix` slot drawing — redraw everything;
it's the same mark as the remix button, defaulting to its label otherwise
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bun auto-loads .env, so the moderate script just works — no inline secret. the
hide-doodl skill is now an agent runbook (I run the takedown; the operator
doesn't type commands). also gitignore .env so the secret never lands in git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
abusive/illegal/disgusting content needs a kill switch before the chain UI
amplifies reach. since there's no server, moderation is a public denylist record
(tech.waow.doodl.moderation/self) on the doodl operator account that every
client reads and filters against. it can't touch the author's PDS — it only
tells doodl to stop surfacing the drawing.
- explore + icon picker drop hidden drawings (always, not just the opt-out path)
- share page (worker + SPA) shows a neutral "removed" page with NO og:image, so
a hidden drawing can't unfurl in link previews; remix stacks drop hidden ones
- scripts/moderate.ts: hide/unhide/list as the operator (BOT_APP_PASSWORD)
- .claude/skills/hide-doodl: the admin runbook
- fail-open: unreadable denylist hides nothing rather than breaking explore
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
walk a drawing's `parent` chain up to its root (src/lineage.ts; mirrored in the
worker). this is the backend the chain UI will stand on; for now it powers
better attribution:
- share page: "a remix of @handle's [thumbnail]" (possessive, named) instead of
a bare thumbnail; when the parent is itself a remix, also credit the original
creator ("originally @root's").
- remix banner: "remixing @handle's doodl" + "· originally @root's" when you're
remixing something that's already a remix — it's honestly your remix down a
chain, not of the original.
- the word "remix" now lives in one const per codebase (REMIX) so it can be
reconsidered later without a hunt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
settings is its own page and everything auto-saves, so the top "done" checkmark
implied a commit step that doesn't exist — remove it (and the now-unused `done`
slot). leave settings via the nav or the title, like any page.
preferences polish:
- each change persists on pick and flashes an inline "saved ✓" — the indication
that was missing, without a save button or a floating toast.
- "explore" in the question links to /explore; "@doodl.waow.tech" links to the
bot's profile via your preferred-client setting (no hardcoded app).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- copy: drop "has been walked" → "has completed"; trim the question blurbs; stop
naming bluesky (the bot just "features" your work, like the rest of the app
stays platform-agnostic).
- walkthrough: lead with the doodl mark + centered intro instead of a blank
form, full-width save button, and a "saved ✓" green flash before it closes so
there's a clear signal it worked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
replace presence-based botConsent with a real preference cascade:
effective = per-drawing override ?? author's global default ?? hard fallback.
- tech.waow.doodl.preferences/self holds an open `defaults` map; its existence
means the user has been through the one-time walkthrough.
- drawing records gain an optional `prefs` override map (additive — existing
records stay valid).
- src/prefs.ts: the keys (explore: show|hide, botFeature: on|off), hard
defaults, the cascade, and the walkthrough questions.
- first-run walkthrough sheet (fires when no record exists); same controls edit
the defaults live in settings.
- a per-drawing "show in explore" toggle on the draw view writes an override
only when it differs from your default.
- explore filters opted-out drawings (one UFOs prefs query); the icon picker
still sees everything.
- bot reads effective botFeature (per-drawing override ?? global default ?? off)
instead of botConsent presence.
- botConsent is gone: lexicon, scope, settings toggle, bot check all deleted.
scope swaps repo:botConsent → repo:preferences, so existing sessions must
re-consent before writing; new sign-ins get it automatically. curation only —
drawings stay public regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
finish the escape-by-construction pass Codex flagged. network-ish values
(remix-banner author, signed-in handle, try-on handle, marketplace iconset
author) and every <img src> built from PDS/CID data now go through DOM
construction instead of innerHTML.
- extract el() into src/dom.ts, shared with display.ts (de-dups the helper)
- slotContent()/slotThumb() → slotNode()/slotThumbNode() returning nodes
- convert title, nav, toolbar, header trigger, both banners, settings view,
account, marketplace, the icon/color/stroke sheets, gallery error, showSaved
- remaining innerHTML is static app-constant markup only (no interpolated
network data), which is safe
no behavior change. `bun run check` green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stop hand-building HTML strings with scattered esc() calls (which had drifted
into two copies). instead:
- display.ts builds DOM nodes via a small el() helper — text goes in as text
nodes, URLs as attributes, both inert. no escaping needed, none to forget.
- worker/share.js gets one auto-escaping html`` tagged template (it has no DOM,
so must emit a string); every interpolation is escaped unless it's a nested
html`` fragment. removes the per-value esc() footgun.
no behavior change — same markup, same escaping, just no way to forget it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
these were break-glass scaffolding from the duplicate-posting fix (purge deleted
accidental dupes; kill stopped orphaned DO instances spun up during testing) —
not part of normal operation. removing them is cleaner than gating them behind a
secret nobody needs to manage. supersedes the prior admin-token commit; the bot
worker is back to just kick/status. if cleanup is ever needed again, a local
one-off script with the bot app password is safer than a public endpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`bun run check` = tsc --noEmit + node --check on the worker and bot, so the
JS outside src/ isn't unguarded. also correct a comment: records aren't
content-addressed (only blobs are).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
an editable remix loads the parent's strokes, so tapping save immediately
created a child with parent but no actual change. track the serialized state the
remix started from (persisted across the OAuth round-trip) and require a change
before publishing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the SPA detail renderer interpolated handles, URLs, DIDs and remix metadata from
other people's records straight into innerHTML. add a shared esc() (matching the
worker's) and apply it to every network-sourced value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
?kill (stop the subscriber) and ?purge (delete the bot's posts) were reachable
unauthenticated on the public workers.dev URL. gate both behind ?token=
<ADMIN_TOKEN>, failing closed when no ADMIN_TOKEN is set so they're never open.
set it with `wrangler secret put ADMIN_TOKEN` in bot/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
caching is a parameter, not a separate function: getJson(url, FRESH) for the
dynamic remix count, getJson(url) (default 300s) for immutable data. one code
path, no duplicated fetch/throw/json body.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a short ttl still let a stale backlinks entry linger; the count is cheap, so
fetch it uncached (getJsonFresh) and it reflects new remixes immediately.
immutable record/identity/blob fetches still cache hard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the remix count is dynamic but was cached two ways for 300s (the page HTML and
the per-fetch edge cache on the backlinks subrequest), so new remixes took up to
5 min to show. give the Constellation backlinks call a 15s ttl and the page a
30s max-age + stale-while-revalidate. immutable record/identity/blob fetches
still cache hard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
remixes of a drawing now render as an overlapping, clickable thumbnail stack
(the plyr.fm likers-stack pattern, remix art instead of avatars) — each chip
links to that remix's detail page, so "N remixes" is no longer a dead end.
and "a remix of" is just the inline parent thumbnail now, no trailing handle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- remix count via Constellation backlinks (source tech.waow.doodl.drawing:
parent.uri) — the down-the-tree direction the record can't answer itself.
- replace the bare "remixed from @handle" with "a remix of" + a tiny inline
thumbnail of the parent drawing, the whole thing linking to the parent's
detail page (resolves the parent record's image, not just its author).
- both on the worker share page and the SPA fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
views are now deep-linkable: navigating pushes the path, back/forward + direct
loads resolve the view from it. no migration needed — wisp --spa already serves
index.html for any path, so /explore is shareable as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- show up-the-tree lineage ("↻ remixed from @handle", linked) on the share page
and SPA fallback, read straight off the record's `parent`.
- register `remix` in SLOTS so it's drawable; the share page renders the
author's own remix doodle on the button when they've drawn one.
- parse at:// with AtUri in app code (drop three slice(5) spots); the dep-free
worker uses a small named atUri() helper.
- CLAUDE.md: standing rule — every new affordance is slottable by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a drawing's up-the-tree lineage is on the record (`parent`), so the share page
(worker) and SPA fallback both resolve the parent's author and link to it. no
backlink index needed for this direction — that's what Constellation is for
when we surface children/counts.
also drop the hand-rolled at:// parsing: the app already depends on
@atproto/api, which exports AtUri — use it here and in pds.ts's three existing
slice(5) spots. (the worker stays dependency-free and keeps its own small parse.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the /l/* route is served by the doodl-share CF worker (server-rendered for OG
tags), not the wisp SPA — so the phase-B button in src/display.ts never showed.
add it to worker/share.js as the primary CTA (kept in display.ts too, for the
no-worker SPA fallback / local dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the display page gets a "remix this →" button that opens the canvas pre-loaded
and remembers the parent, so the next save writes a child record with `parent`.
two honest modes, decided by what the parent stored:
- parent has recoverable source → its strokes load as your editable starting
point; your child's source is the full, self-contained stroke stack.
- parent is image-only → its pixels become a locked base layer you draw over;
your child's source carries a `base` strongRef to those pixels (doodl.delta.v1
gains an optional `base` field) so the chain never fabricates strokes that
were never captured. remixing a remix carries the same ancestor base forward.
mechanics:
- source.ts: decodeSourceV1 / parseSourceV1, optional `base`
- draw.ts: loadStrokes(), setBaseImage() (locked, survives undo/clear)
- pds.ts: getRemixSource() resolves editable-vs-locked + the base image;
saveDrawing() takes an optional parent
- main.ts: /?remix=<did>/<rkey> entry, lineage survives the OAuth round-trip
(REMIX_KEY), banner with cancel, parent threaded through save
display-page first by design; explore-feed remix comes next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
every save now uploads a doodl.delta.v1 `source` blob (delta-encoded strokes)
next to the rendered PNG, and the drawing record gains optional `source` and
`parent` fields. the PNG stops being the source of truth for editing — it's the
artifact; the strokes are the truth. groundwork for remix + lineage.
graceful by design: if the source upload or the source-backed record create
fails, the save retries image-only. existing image-only records stay valid
forever — no migration.
REQUIRED for source to actually land: broaden the OAuth blob scope from
image/png to */* (the source blob is application/vnd.waow.doodl.source+json,
which the old MIME-gated scope rejected). this is a re-consent rollout —
existing sessions keep writing image-only until users sign out and back in.
design + empirical sizing analysis in docs/research/recoverable-drawings.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the post text named the author but only carried a link facet, so @handle
rendered as plain text and never notified them. add an
app.bsky.richtext.facet#mention (author DID) alongside the link, both measured
by UTF-8 byte offsets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
i'd skipped this. register 'yours' + 'delete' in SLOTS and reuse the existing
'remix' slot: the settings entry renders slotNode('yours'), and the mine cards'
remix/delete buttons render their slots — so all three are drawable and auto-show
in settings -> app icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
new /mine view, linked from settings, lists your own drawings via listRecords on
your repo — authoritative and instant, no microcosm enumeration. each card opens
its display page, remixes in-app, deletes, and toggles whether it shows in
explore (per-drawing prefs override). the explore toggle uses putRecord, so the
drawing scope gains action=update (hosted metadata updated to match) — a one-time
re-consent enables it; open/remix/delete work without it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
doodl PNGs are transparent (the canvas bakes no background), so on dark surfaces
the dark bled through — bot posts, link-preview og:images, dark-theme thumbnails.
keep the record transparent (icons need it) and flatten onto white at serve time
via the worker /img transform's new bg param: og:image + share hero + explore
thumbnails use ?bg=ffffff, the download link forces a white PNG, and the bot posts
the white-backed PNG. fixes every already-posted drawing too (serve-time, no
re-save).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a lineage/remix link can point at a since-deleted drawing; slingshot getRecord
then 400s RecordNotFound. the main share-page fetch wasn't caught, so the whole
page 502'd with a raw error. catch it and serve a clean 'no longer exists' 404
(no og:image, so a stale link won't unfurl).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
revive dynamic width without the perfect-freehand gap regression. width is
per-point (real pen pressure on a stylus, velocity otherwise: faster = thinner),
rendered STAMP-based — a filled circle at every point plus trapezoids between
them — so a joint is always a full circle and the stroke can never gap, even on
sharp/sparse input. render-time width moving-average + densify removes the
sample-to-sample jaggedness. source -> v2 (per-point width), back-compat with v1.
dynamic is OFF by default (the solid uniform pen stays the default); the toggle +
sensitivity live in the stroke sheet and persist to localStorage. also restores
the finishOAuth resilience guard (a failed OAuth degrades to signed-out, doesn't
blank the UI) so local dev boots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Space wasn't the gap source — that was the perfect-freehand outline fill, now
gone. on the constant-width pen, sparse hold-Space input draws gap-free (verified
in-browser with a sparse zigzag). begin/extend/end mode + cursor hover tracking;
ignored while typing or when the canvas isn't visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sparse pointer input (esp. hold-to-draw, which fires fewer moves) made sharp
corners whose filled outline self-intersected — leaving V-shaped gaps and a
'cheap' pinched look. densify the input (subdivide gaps > 6px) so corners are
rounded out of many small segments, and render the outline with quadratic curves
through its midpoints (the canonical perfect-freehand path) instead of a faceted
straight-line polygon. verified in-browser: the worst-case zigzag now fills solid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stroke rendering moves to perfect-freehand: width varies by real pen pressure
(stylus) or simulated velocity (finger/mouse), with tapered ends and smoothing.
source format -> v2 (per-point pressure + per-stroke render intent), back-compat
with v1. stroke sheet gains a dynamic-width toggle + sensitivity slider; both
persist. hold Space to draw without clicking (ballpoint feel); coalesced events
for high-fidelity input.
also: autosave the in-progress drawing to localStorage every change and restore
on load, so a reload never loses work; keyboard shortcuts (s=stroke, c=color,
cmd/ctrl+s=save); and make finishOAuth resilient so a failed OAuth init degrades
to signed-out instead of blanking the whole UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nothing rendered until the cold corpus fetch AND a 76-author avatar batch both
finished in series. now: skeleton shimmer the instant you open explore; render
cards as soon as the drawings JSON arrives (no avatar wait); resolve avatars
lazily per visible page and patch them onto cards in place; cards fade in. also
widen the api edge cache (fresh 2min, stale 10min) so cold enumeration is rare.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the single sentinel was observed once; re-appending it within the rootMargin
fired no new intersection transition, so paging stopped. (masked while PAGE=48
covered the whole corpus.) re-arm the observer (unobserve -> append -> observe)
each page so it keeps firing while in view, then waits for scroll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
explore loaded 48 full-res 1024px PNGs (~4.6MB) straight from many PDS origins.
add a worker /img/<did>/<cid> route that resolves the PDS and has Cloudflare's
image transformer downscale the blob (remote origin fetched inside the worker,
so no per-PDS allowlist); CIDs are immutable so the result edge-caches forever.
cards now load a ~3KB avif/webp from one cached origin instead of a ~97KB PNG.
also drop the first page 48->24. full-size stays direct for share og:images.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the preview dot was box-relative (frac of a small box) so it under-represented
the real stroke: same ratio, far fewer absolute pixels than the displayed canvas.
size the dot to the stroke's actual on-screen pixels (size x canvas-display-scale)
so it matches what you paint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the preview dot was capped at 26px (Math.min(n,26)) so it stopped growing and
told you nothing past small sizes. make it a framed box standing in for the
canvas, with the dot sized as the real fraction (size/1024) of the canvas the
mark covers — so dragging the slider grows it proportionally — plus a numeric
readout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the 'remixing @x' banner was a full padded block at the top of the draw view,
permanently shrinking the canvas. move it inline next to the 'show in explore'
toggle as a compact glyph+handle+cancel, and shrink the mobile footer. the top
block is gone, so the canvas gets that vertical space back.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
both rendered a filled dot of the current color (the --bc tint change made the
stroke dot match the color swatch exactly). give the stroke trigger a tapered
wedge that reads as 'width' and is theme-neutral; the live size-preview dot stays
in the stroke sheet where growing it is meaningful.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the 1024x1024 backing store was stretched to a non-square box because #pad
pinned a definite width from a guessed chrome height (100dvh - 150px) while
max-height:100% independently clamped the height — on short/landscape viewports
the two disagreed and the bitmap stretched wide. size as a contain square from
the intrinsic 1024x1024 instead; no magic numbers, no distortion on any screen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UFOs /records is a fixed recent sample (~42, no paging) — explore was never the
full archive. enumerate the whole thing instead, in the one place CORS allows:
- worker /api/drawings: lightrail listReposByCollection (cursor-paged) → every
creator → resolveMiniDoc + their explore default + a listRecords walk →
merged, moderation-filtered, sorted newest-first. CORS'd + short-cached. it's
a live aggregator (computed per request, nothing stored). 67 drawings vs 42.
- data/explore.ts: fetch that endpoint (absolute URL so dev hits prod too); the
SPA just filters opt-out + resolves avatars now.
- gallery: infinite-scroll a page (48) at a time over the full list.
CAR/MST walk would be one call per creator vs paginated listRecords — the scale
path; doodl's repos are tiny so listRecords is plenty (noted in the worker).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main.ts: 1289 → 88 lines — now a composition root that reads as a boot tour.
- src/app.ts: the shell — shared state (session/handle/defaults), the canvas +
chrome refs, the router, and a tiny event bus ("view"/"refresh") so features
react without importing each other.
- src/auth.ts: oauth init, signIn, logout.
- src/ui/: slots (the icon registry), sheets (the shared bottom-sheet).
- src/features/: tools, chrome, gallery, settings, preferences, remix,
icon-mode, save. each exposes init(); save reads remix/icon/prefs via their
getters (the integration seam stays explicit, no behavior change).
behaviour preserved; tsc + build green. plan in docs/refactor-main.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
composition root + feature modules over a shared App context, with a tiny event
bus only for the 3 circular hubs (show/refreshUI) and a beforeSave/afterSave
seam so save doesn't import every feature. module map + safe incremental order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
src/ was a flat splat of 17 files. give it the boundary that was already there:
- src/data/ — atproto + microcosm services, no DOM (net, oauth, pds, clients,
source, lineage, prefs, moderation, explore). a closed set: never imports ui/.
- src/ui/ — draw, dom, theme, login.
- root keeps the entry + the two views: boot.ts, main.ts, display.ts, style.css.
lexicons mirror the NSID like the official repos: lexicons/tech/waow/doodl/*.json
(nothing imports them; the collection strings are hardcoded, so zero code impact).
pure reorg — no behavior change, build + check green. main.ts (1289 lines) is
still the elephant; a remix/gallery/settings split is the next, separate pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cut redundant round-trips without risking staleness:
- src/net.ts: cache identity (5m) and drawing records (60s) with single-flight
promise dedup. concurrent identical requests share one fetch — zero-staleness.
explore's per-author resolve, the remix lineage walks, and the share-page
lineage now share this cache, so K remixes of one parent stop re-fetching it.
- explore credit uses resolveOriginal (walk records to the root, resolve
identity once) instead of resolving every ancestor's identity.
deliberately NOT cached — remix counts (Constellation), preferences, the
moderation denylist: all stay fresh, read live as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the remix credit (and long handles generally) wrapped and spilled the card in
loud blue — thoughtless. fix it:
- handles + remix credit are single lines, ellipsis-truncated, never wrapping;
full value on title hover
- the remix credit is smaller + dimmer than the maker's handle (clear hierarchy),
not accent-blue; accent only on hover
- one tight line: a fixed mark (the viewer's drawn remix slot, else a compact ↻)
+ the original handle, which is the part that truncates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a remixed drawing now reads as one at a glance, in the zds register (subtle
color-mix accent-tinted edge, muted micro-credit — no loud borders):
- the card gets a faint accent-tinted border
- a credit line under the handle shows the ORIGINAL artist (root of the chain,
resolved lazily so the grid never waits), linking to their drawing
- the remix mark is the viewer's own `remix` slot drawing — redraw everything;
it's the same mark as the remix button, defaulting to its label otherwise
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
abusive/illegal/disgusting content needs a kill switch before the chain UI
amplifies reach. since there's no server, moderation is a public denylist record
(tech.waow.doodl.moderation/self) on the doodl operator account that every
client reads and filters against. it can't touch the author's PDS — it only
tells doodl to stop surfacing the drawing.
- explore + icon picker drop hidden drawings (always, not just the opt-out path)
- share page (worker + SPA) shows a neutral "removed" page with NO og:image, so
a hidden drawing can't unfurl in link previews; remix stacks drop hidden ones
- scripts/moderate.ts: hide/unhide/list as the operator (BOT_APP_PASSWORD)
- .claude/skills/hide-doodl: the admin runbook
- fail-open: unreadable denylist hides nothing rather than breaking explore
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
walk a drawing's `parent` chain up to its root (src/lineage.ts; mirrored in the
worker). this is the backend the chain UI will stand on; for now it powers
better attribution:
- share page: "a remix of @handle's [thumbnail]" (possessive, named) instead of
a bare thumbnail; when the parent is itself a remix, also credit the original
creator ("originally @root's").
- remix banner: "remixing @handle's doodl" + "· originally @root's" when you're
remixing something that's already a remix — it's honestly your remix down a
chain, not of the original.
- the word "remix" now lives in one const per codebase (REMIX) so it can be
reconsidered later without a hunt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
settings is its own page and everything auto-saves, so the top "done" checkmark
implied a commit step that doesn't exist — remove it (and the now-unused `done`
slot). leave settings via the nav or the title, like any page.
preferences polish:
- each change persists on pick and flashes an inline "saved ✓" — the indication
that was missing, without a save button or a floating toast.
- "explore" in the question links to /explore; "@doodl.waow.tech" links to the
bot's profile via your preferred-client setting (no hardcoded app).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- copy: drop "has been walked" → "has completed"; trim the question blurbs; stop
naming bluesky (the bot just "features" your work, like the rest of the app
stays platform-agnostic).
- walkthrough: lead with the doodl mark + centered intro instead of a blank
form, full-width save button, and a "saved ✓" green flash before it closes so
there's a clear signal it worked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
replace presence-based botConsent with a real preference cascade:
effective = per-drawing override ?? author's global default ?? hard fallback.
- tech.waow.doodl.preferences/self holds an open `defaults` map; its existence
means the user has been through the one-time walkthrough.
- drawing records gain an optional `prefs` override map (additive — existing
records stay valid).
- src/prefs.ts: the keys (explore: show|hide, botFeature: on|off), hard
defaults, the cascade, and the walkthrough questions.
- first-run walkthrough sheet (fires when no record exists); same controls edit
the defaults live in settings.
- a per-drawing "show in explore" toggle on the draw view writes an override
only when it differs from your default.
- explore filters opted-out drawings (one UFOs prefs query); the icon picker
still sees everything.
- bot reads effective botFeature (per-drawing override ?? global default ?? off)
instead of botConsent presence.
- botConsent is gone: lexicon, scope, settings toggle, bot check all deleted.
scope swaps repo:botConsent → repo:preferences, so existing sessions must
re-consent before writing; new sign-ins get it automatically. curation only —
drawings stay public regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
finish the escape-by-construction pass Codex flagged. network-ish values
(remix-banner author, signed-in handle, try-on handle, marketplace iconset
author) and every <img src> built from PDS/CID data now go through DOM
construction instead of innerHTML.
- extract el() into src/dom.ts, shared with display.ts (de-dups the helper)
- slotContent()/slotThumb() → slotNode()/slotThumbNode() returning nodes
- convert title, nav, toolbar, header trigger, both banners, settings view,
account, marketplace, the icon/color/stroke sheets, gallery error, showSaved
- remaining innerHTML is static app-constant markup only (no interpolated
network data), which is safe
no behavior change. `bun run check` green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stop hand-building HTML strings with scattered esc() calls (which had drifted
into two copies). instead:
- display.ts builds DOM nodes via a small el() helper — text goes in as text
nodes, URLs as attributes, both inert. no escaping needed, none to forget.
- worker/share.js gets one auto-escaping html`` tagged template (it has no DOM,
so must emit a string); every interpolation is escaped unless it's a nested
html`` fragment. removes the per-value esc() footgun.
no behavior change — same markup, same escaping, just no way to forget it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
these were break-glass scaffolding from the duplicate-posting fix (purge deleted
accidental dupes; kill stopped orphaned DO instances spun up during testing) —
not part of normal operation. removing them is cleaner than gating them behind a
secret nobody needs to manage. supersedes the prior admin-token commit; the bot
worker is back to just kick/status. if cleanup is ever needed again, a local
one-off script with the bot app password is safer than a public endpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
an editable remix loads the parent's strokes, so tapping save immediately
created a child with parent but no actual change. track the serialized state the
remix started from (persisted across the OAuth round-trip) and require a change
before publishing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
?kill (stop the subscriber) and ?purge (delete the bot's posts) were reachable
unauthenticated on the public workers.dev URL. gate both behind ?token=
<ADMIN_TOKEN>, failing closed when no ADMIN_TOKEN is set so they're never open.
set it with `wrangler secret put ADMIN_TOKEN` in bot/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the remix count is dynamic but was cached two ways for 300s (the page HTML and
the per-fetch edge cache on the backlinks subrequest), so new remixes took up to
5 min to show. give the Constellation backlinks call a 15s ttl and the page a
30s max-age + stale-while-revalidate. immutable record/identity/blob fetches
still cache hard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
remixes of a drawing now render as an overlapping, clickable thumbnail stack
(the plyr.fm likers-stack pattern, remix art instead of avatars) — each chip
links to that remix's detail page, so "N remixes" is no longer a dead end.
and "a remix of" is just the inline parent thumbnail now, no trailing handle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- remix count via Constellation backlinks (source tech.waow.doodl.drawing:
parent.uri) — the down-the-tree direction the record can't answer itself.
- replace the bare "remixed from @handle" with "a remix of" + a tiny inline
thumbnail of the parent drawing, the whole thing linking to the parent's
detail page (resolves the parent record's image, not just its author).
- both on the worker share page and the SPA fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- show up-the-tree lineage ("↻ remixed from @handle", linked) on the share page
and SPA fallback, read straight off the record's `parent`.
- register `remix` in SLOTS so it's drawable; the share page renders the
author's own remix doodle on the button when they've drawn one.
- parse at:// with AtUri in app code (drop three slice(5) spots); the dep-free
worker uses a small named atUri() helper.
- CLAUDE.md: standing rule — every new affordance is slottable by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a drawing's up-the-tree lineage is on the record (`parent`), so the share page
(worker) and SPA fallback both resolve the parent's author and link to it. no
backlink index needed for this direction — that's what Constellation is for
when we surface children/counts.
also drop the hand-rolled at:// parsing: the app already depends on
@atproto/api, which exports AtUri — use it here and in pds.ts's three existing
slice(5) spots. (the worker stays dependency-free and keeps its own small parse.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the /l/* route is served by the doodl-share CF worker (server-rendered for OG
tags), not the wisp SPA — so the phase-B button in src/display.ts never showed.
add it to worker/share.js as the primary CTA (kept in display.ts too, for the
no-worker SPA fallback / local dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
the display page gets a "remix this →" button that opens the canvas pre-loaded
and remembers the parent, so the next save writes a child record with `parent`.
two honest modes, decided by what the parent stored:
- parent has recoverable source → its strokes load as your editable starting
point; your child's source is the full, self-contained stroke stack.
- parent is image-only → its pixels become a locked base layer you draw over;
your child's source carries a `base` strongRef to those pixels (doodl.delta.v1
gains an optional `base` field) so the chain never fabricates strokes that
were never captured. remixing a remix carries the same ancestor base forward.
mechanics:
- source.ts: decodeSourceV1 / parseSourceV1, optional `base`
- draw.ts: loadStrokes(), setBaseImage() (locked, survives undo/clear)
- pds.ts: getRemixSource() resolves editable-vs-locked + the base image;
saveDrawing() takes an optional parent
- main.ts: /?remix=<did>/<rkey> entry, lineage survives the OAuth round-trip
(REMIX_KEY), banner with cancel, parent threaded through save
display-page first by design; explore-feed remix comes next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
every save now uploads a doodl.delta.v1 `source` blob (delta-encoded strokes)
next to the rendered PNG, and the drawing record gains optional `source` and
`parent` fields. the PNG stops being the source of truth for editing — it's the
artifact; the strokes are the truth. groundwork for remix + lineage.
graceful by design: if the source upload or the source-backed record create
fails, the save retries image-only. existing image-only records stay valid
forever — no migration.
REQUIRED for source to actually land: broaden the OAuth blob scope from
image/png to */* (the source blob is application/vnd.waow.doodl.source+json,
which the old MIME-gated scope rejected). this is a re-consent rollout —
existing sessions keep writing image-only until users sign out and back in.
design + empirical sizing analysis in docs/research/recoverable-drawings.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>