draw things doodl.waow.tech
draw atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

attribution + chain backend: credit the original artist by name

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>

+170 -85
+24 -40
src/display.ts
··· 1 - import { AtUri } from "@atproto/api"; 2 1 import { DRAWING_COLLECTION } from "./oauth"; 3 2 import { el } from "./dom"; 3 + import { resolveLineage, REMIX } from "./lineage"; 4 4 5 5 // client-side per-drawing display page. served via wisp SPA fallback at 6 6 // /l/<did>/<rkey>; works logged-out. fetches the record + identity from 7 7 // microcosm (CORS-enabled) and renders a shareable view. no worker needed. 8 8 const SLINGSHOT = "https://slingshot.microcosm.blue"; 9 9 const CONSTELLATION = "https://constellation.microcosm.blue"; 10 - 11 - // resolve a parent drawing → its author handle + its own thumbnail image url. 12 - async function resolveParent(uri: string) { 13 - const p = new AtUri(uri); 14 - const [pdoc, prec] = await Promise.all([ 15 - getJson( 16 - `${SLINGSHOT}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${p.host}`, 17 - ).catch(() => null), 18 - getJson( 19 - `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${p.host}&collection=${p.collection}&rkey=${p.rkey}`, 20 - ).catch(() => null), 21 - ]); 22 - const cid = prec?.value?.image?.ref?.["$link"]; 23 - return { 24 - handle: pdoc?.handle || p.host, 25 - did: p.host, 26 - rkey: p.rkey, 27 - img: 28 - cid && pdoc?.pds 29 - ? `${pdoc.pds}/xrpc/com.atproto.sync.getBlob?did=${p.host}&cid=${cid}` 30 - : null, 31 - }; 32 - } 33 10 34 11 type RemixChip = { did: string; rkey: string; handle: string; img: string | null }; 35 12 ··· 120 97 // up-the-tree lineage is on the record (parent); down-the-tree count comes 121 98 // from Constellation backlinks. resolve both in parallel. 122 99 const selfUri = `at://${did}/${DRAWING_COLLECTION}/${rkey}`; 123 - const [remixes, parent] = await Promise.all([ 100 + const [remixes, lineage] = await Promise.all([ 124 101 resolveRemixes(selfUri), 125 - rec?.value?.parent?.uri 126 - ? resolveParent(rec.value.parent.uri) 127 - : Promise.resolve(null), 102 + resolveLineage(rec), 128 103 ]); 104 + const parent = lineage[0] ?? null; 105 + const original = lineage.length > 1 ? lineage[lineage.length - 1] : null; 129 106 const nodes: Node[] = [ 130 107 el("img", { 131 108 class: "display-img", ··· 139 116 ]), 140 117 ]; 141 118 142 - // up-the-tree: "a remix of [parent thumbnail]", linking to the parent page 119 + // up-the-tree: "a remix of @parent's [thumbnail]", linking to the parent; 120 + // when the parent is itself a remix, also credit the original creator. 143 121 if (parent) { 144 122 nodes.push( 145 123 el("a", { class: "remix-of", href: `/l/${parent.did}/${parent.rkey}` }, [ 146 - el("span", {}, ["a remix of"]), 147 - parent.img 148 - ? el("img", { 149 - class: "remix-of-thumb", 150 - src: parent.img, 151 - alt: "the original doodl", 152 - }) 153 - : `@${parent.handle}`, 124 + el("span", {}, [`a ${REMIX} of @${parent.handle}'s`]), 125 + ...(parent.img 126 + ? [el("img", { class: "remix-of-thumb", src: parent.img, alt: "" })] 127 + : []), 154 128 ]), 155 129 ); 130 + if (original) { 131 + nodes.push( 132 + el("p", { class: "remix-original" }, [ 133 + "originally ", 134 + el("a", { href: `/l/${original.did}/${original.rkey}` }, [ 135 + `@${original.handle}'s`, 136 + ]), 137 + ]), 138 + ); 139 + } 156 140 } 157 141 158 142 // down-the-tree: a clickable stack of the drawings that remix this one ··· 166 150 { 167 151 class: "remix-chip", 168 152 href: `/l/${it.did}/${it.rkey}`, 169 - title: `remix by @${it.handle}`, 153 + title: `${REMIX} by @${it.handle}`, 170 154 }, 171 155 [ 172 156 it.img ··· 178 162 ); 179 163 const remixesEl = el("div", { class: "remixes" }, [ 180 164 el("span", { class: "remix-count" }, [ 181 - `${remixes.total} remix${remixes.total === 1 ? "" : "es"}`, 165 + `${remixes.total} ${REMIX}${remixes.total === 1 ? "" : "es"}`, 182 166 ]), 183 167 stack, 184 168 ]); ··· 194 178 195 179 nodes.push( 196 180 el("div", { class: "display-actions" }, [ 197 - el("a", { class: "remix", href: `/?remix=${did}/${rkey}` }, ["remix this →"]), 181 + el("a", { class: "remix", href: `/?remix=${did}/${rkey}` }, [`${REMIX} this →`]), 198 182 el("a", { href: imageUrl, target: "_blank", rel: "noreferrer" }, ["open image"]), 199 183 el( 200 184 "a",
+57
src/lineage.ts
··· 1 + import { AtUri } from "@atproto/api"; 2 + 3 + // the word for one drawing made from another. kept in one place so it can be 4 + // reconsidered later (riff / trace / fork / …) without hunting through the app. 5 + export const REMIX = "remix"; 6 + 7 + const SLINGSHOT = "https://slingshot.microcosm.blue"; 8 + const DRAWING = "tech.waow.doodl.drawing"; 9 + 10 + export type Ancestor = { 11 + did: string; 12 + rkey: string; 13 + handle: string; 14 + img: string | null; 15 + }; 16 + 17 + async function j(url: string): Promise<any> { 18 + const r = await fetch(url); 19 + if (!r.ok) throw new Error(`${r.status} ${url}`); 20 + return r.json(); 21 + } 22 + 23 + // walk a drawing's `parent` pointers up to the root (the original), starting 24 + // from its already-fetched record. returns [immediate parent, …, original]; 25 + // empty if the drawing is itself an original. each record is fetched once. 26 + export async function resolveLineage( 27 + startRecord: { value?: { parent?: { uri?: string } } } | null, 28 + max = 8, 29 + ): Promise<Ancestor[]> { 30 + const chain: Ancestor[] = []; 31 + let rec = startRecord; 32 + for (let i = 0; i < max && rec; i++) { 33 + const uri = rec?.value?.parent?.uri; 34 + if (!uri) break; 35 + const p = new AtUri(uri); 36 + const [pdoc, prec] = await Promise.all([ 37 + j( 38 + `${SLINGSHOT}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${p.host}`, 39 + ).catch(() => null), 40 + j( 41 + `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${p.host}&collection=${DRAWING}&rkey=${p.rkey}`, 42 + ).catch(() => null), 43 + ]); 44 + const cid = prec?.value?.image?.ref?.["$link"]; 45 + chain.push({ 46 + did: p.host, 47 + rkey: p.rkey, 48 + handle: pdoc?.handle || p.host, 49 + img: 50 + cid && pdoc?.pds 51 + ? `${pdoc.pds}/xrpc/com.atproto.sync.getBlob?did=${p.host}&cid=${cid}` 52 + : null, 53 + }); 54 + rec = prec; 55 + } 56 + return chain; 57 + }
+21 -10
src/main.ts
··· 26 26 type PrefMap, 27 27 type PrefKey, 28 28 } from "./prefs"; 29 + import { REMIX } from "./lineage"; 29 30 import { fetchGallery } from "./explore"; 30 31 import { mountLogin } from "./login"; 31 32 import { getMode, setMode, type ThemeMode } from "./theme"; ··· 278 279 else pad.clear(); 279 280 lastSavedSerial = null; 280 281 remixInitialSerial = pad.serialize(); 282 + // the original creator: the root of the remixed drawing's chain, if it's 283 + // itself a remix (else the immediate author already is the original). 284 + const original = rs.lineage.length 285 + ? rs.lineage[rs.lineage.length - 1].handle 286 + : null; 281 287 localStorage.setItem( 282 288 REMIX_KEY, 283 289 JSON.stringify({ ··· 285 291 baseRef: rs.baseRef, 286 292 baseImageUrl: rs.baseImageUrl, 287 293 initialSerial: remixInitialSerial, 294 + parentHandle: rs.authorHandle, 295 + original, 288 296 }), 289 297 ); 290 298 show("draw"); 291 - showRemixBanner(rs.authorHandle); 299 + showRemixBanner(rs.authorHandle, original); 292 300 } 293 301 294 - function showRemixBanner(who: string | null) { 302 + // "remixing @parent's doodl" — and, when @parent's is itself a remix, credit 303 + // the original creator too, so the chain is acknowledged. 304 + function showRemixBanner(parentHandle: string | null, original: string | null) { 295 305 const b = $("mode-banner"); 296 306 b.hidden = false; 297 307 const cancel = el("button", { class: "pill" }, ["cancel"]); 298 308 cancel.addEventListener("click", cancelRemix); 299 - b.replaceChildren( 300 - ...(who 301 - ? ["remixing ", el("strong", {}, [`@${who}`]), "'s doodl"] 302 - : ["remixing a doodl"]), 303 - " — save to publish yours ", 304 - cancel, 305 - ); 309 + const parts: (Node | string)[] = parentHandle 310 + ? [`${REMIX}ing `, el("strong", {}, [`@${parentHandle}`]), "'s doodl"] 311 + : [`${REMIX}ing a doodl`]; 312 + if (original && original !== parentHandle) { 313 + parts.push(" · originally ", el("strong", {}, [`@${original}`]), "'s"); 314 + } 315 + parts.push(" — save to publish yours ", cancel); 316 + b.replaceChildren(...parts); 306 317 } 307 318 308 319 // drop the lineage but leave the canvas alone (used after a successful save). ··· 1233 1244 remixBaseRef = r.baseRef ?? null; 1234 1245 remixInitialSerial = r.initialSerial ?? null; 1235 1246 if (r.baseImageUrl) pad.setBaseImage(r.baseImageUrl); 1236 - if (pendingParent) showRemixBanner(null); 1247 + if (pendingParent) showRemixBanner(r.parentHandle ?? null, r.original ?? null); 1237 1248 } catch { 1238 1249 localStorage.removeItem(REMIX_KEY); 1239 1250 }
+14 -2
src/pds.ts
··· 7 7 type SourceStroke, 8 8 } from "./source"; 9 9 import { PREFERENCES_COLLECTION, type PrefMap } from "./prefs"; 10 + import { resolveLineage, type Ancestor } from "./lineage"; 10 11 11 12 const SLINGSHOT = "https://slingshot.microcosm.blue"; 12 13 ··· 226 227 export type RemixSource = { 227 228 parent: StrongRef; 228 229 authorHandle: string; 230 + // the remixed drawing's own ancestry [immediate parent, …, original]; empty 231 + // if it's an original. lets the remix banner credit the original creator. 232 + lineage: Ancestor[]; 229 233 strokes: SourceStroke[] | null; 230 234 baseImageUrl: string | null; 231 235 baseRef: StrongRef | null; ··· 246 250 ]); 247 251 const parent: StrongRef = { uri: rec.uri, cid: rec.cid }; 248 252 const authorHandle = doc.handle ?? did; 253 + const lineage = await resolveLineage(rec); 249 254 250 255 const sourceCid = rec?.value?.source?.ref?.["$link"]; 251 256 if (sourceCid) { ··· 260 265 const baseImageUrl = baseRef 261 266 ? await resolveDrawingImageUrl(baseRef.uri) 262 267 : null; 263 - return { parent, authorHandle, strokes, baseImageUrl, baseRef }; 268 + return { parent, authorHandle, lineage, strokes, baseImageUrl, baseRef }; 264 269 } 265 270 } catch { 266 271 /* source unreadable — fall through to locking the rendered image */ ··· 272 277 const baseImageUrl = imgCid 273 278 ? `${doc.pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${imgCid}` 274 279 : null; 275 - return { parent, authorHandle, strokes: null, baseImageUrl, baseRef: parent }; 280 + return { 281 + parent, 282 + authorHandle, 283 + lineage, 284 + strokes: null, 285 + baseImageUrl, 286 + baseRef: parent, 287 + }; 276 288 } 277 289 278 290 // resolve a drawing AT-URI → its image blob URL (for locked base layers).
+2
src/style.css
··· 427 427 .remix-of { display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .9rem; } 428 428 .remix-of:hover { color: var(--accent); } 429 429 .remix-of-thumb { height: 2.2em; width: 2.2em; object-fit: contain; border: 1px solid var(--line); border-radius: 7px; background: #fff; } 430 + .remix-original { margin: -.25rem 0 0; font-size: .82rem; color: var(--muted); } 431 + .remix-original a { color: var(--accent); } 430 432 /* remixes of THIS drawing — overlapping clickable thumbnails (plyr likers-stack) */ 431 433 .remixes { display: inline-flex; align-items: center; gap: .6rem; flex-wrap: wrap; justify-content: center; } 432 434 .remix-count { color: var(--muted); font-size: .9rem; }
+52 -33
worker/share.js
··· 11 11 const TYPEAHEAD = "https://typeahead.waow.tech"; 12 12 const COLLECTION = "tech.waow.doodl.drawing"; 13 13 const APP = "https://doodl.waow.tech"; 14 + const REMIX = "remix"; // the word for one drawing made from another (swappable) 14 15 // doodl.waow.tech is also the handle for the doodl bot account; serve its DID at 15 16 // /.well-known/atproto-did so handle resolution works over HTTP (the wisp SPA 16 17 // would otherwise return index.html here and break verification). ··· 108 109 } 109 110 } 110 111 112 + // walk a drawing's `parent` pointers up to the root, from its already-fetched 113 + // record. returns [immediate parent, …, original]; empty if it's an original. 114 + async function resolveLineage(startRecord, max = 8) { 115 + const chain = []; 116 + let rec = startRecord; 117 + for (let i = 0; i < max && rec; i++) { 118 + const uri = rec?.value?.parent?.uri; 119 + if (!uri) break; 120 + const p = atUri(uri); 121 + const [pdoc, prec] = await Promise.all([ 122 + getJson( 123 + `${SLINGSHOT}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${p.did}`, 124 + ).catch(() => null), 125 + getJson( 126 + `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${p.did}&collection=${p.collection}&rkey=${p.rkey}`, 127 + ).catch(() => null), 128 + ]); 129 + const cid = prec?.value?.image?.ref?.["$link"]; 130 + chain.push({ 131 + did: p.did, 132 + rkey: p.rkey, 133 + handle: pdoc?.handle || p.did, 134 + img: 135 + cid && pdoc?.pds 136 + ? `${pdoc.pds}/xrpc/com.atproto.sync.getBlob?did=${p.did}&cid=${cid}` 137 + : null, 138 + }); 139 + rec = prec; 140 + } 141 + return chain; 142 + } 143 + 111 144 // resolve a user's drawn icon for a slot → image blob URL (or null). lets the 112 145 // share page render the author's own "remix" doodle on the remix button — the 113 146 // page is their doodl, drawn in their iconography. (slots are slottable ··· 135 168 } 136 169 } 137 170 138 - function page({ title, img, pageUrl, handle, did, rkey, created, avatar, parent, remixIcon, remixes }) { 171 + function page({ title, img, pageUrl, handle, did, rkey, created, avatar, parent, original, remixIcon, remixes }) { 139 172 const atUri = `at://${did}/${COLLECTION}/${rkey}`; 140 173 const recordDefault = `https://pds.ls/at/${did}/${COLLECTION}/${rkey}`; 141 174 const profileDefault = `https://bsky.app/profile/${handle}`; ··· 143 176 ? html`<img class="ava" src="${avatar}" alt="">` 144 177 : html`<span class="ava ph">${(handle[0] || "?").toUpperCase()}</span>`; 145 178 const parentFrag = parent 146 - ? html`<a class="remix-of" href="${APP}/l/${parent.did}/${parent.rkey}"><span>a remix of</span>${ 179 + ? html`<a class="remix-of" href="${APP}/l/${parent.did}/${parent.rkey}"><span>a ${REMIX} of @${parent.handle}'s</span>${ 147 180 parent.img 148 - ? html`<img class="remix-of-thumb" src="${parent.img}" alt="the original doodl">` 149 - : `@${parent.handle}` 181 + ? html`<img class="remix-of-thumb" src="${parent.img}" alt="">` 182 + : "" 150 183 }</a>` 151 184 : ""; 185 + const originalFrag = original 186 + ? html`<p class="remix-original">originally <a href="${APP}/l/${original.did}/${original.rkey}">@${original.handle}'s</a></p>` 187 + : ""; 152 188 const remixesFrag = 153 189 remixes.total > 0 154 190 ? html`<div class="remixes"> 155 - <span class="remix-count">${remixes.total} remix${remixes.total === 1 ? "" : "es"}</span> 191 + <span class="remix-count">${remixes.total} ${REMIX}${remixes.total === 1 ? "" : "es"}</span> 156 192 <div class="remix-stack">${remixes.items.map( 157 - (it) => html`<a class="remix-chip" href="${APP}/l/${it.did}/${it.rkey}" title="remix by @${it.handle}">${ 193 + (it) => html`<a class="remix-chip" href="${APP}/l/${it.did}/${it.rkey}" title="${REMIX} by @${it.handle}">${ 158 194 it.img ? html`<img src="${it.img}" alt="">` : html`<span>@${it.handle}</span>` 159 195 }</a>`, 160 196 )}</div>${ ··· 204 240 .remix-of { display:inline-flex; align-items:center; gap:.45rem; margin:-.4rem 0 0; color:var(--muted); font-size:.9rem; } 205 241 .remix-of:hover { color:var(--accent); } 206 242 .remix-of-thumb { height:2.2em; width:2.2em; object-fit:contain; border:1px solid var(--line); border-radius:7px; background:#fff; } 243 + .remix-original { margin:-.25rem 0 0; font-size:.82rem; color:var(--muted); } 244 + .remix-original a { color:var(--accent); } 207 245 /* remixes of THIS drawing — an overlapping, clickable thumbnail stack (the 208 246 plyr.fm likers-stack pattern, with remix art instead of avatars) */ 209 247 .remixes { display:inline-flex; align-items:center; gap:.6rem; flex-wrap:wrap; justify-content:center; } ··· 232 270 <img class="art" src="${img}" alt="${title}"> 233 271 <p class="by">${avatarFrag}<span>a doodl by <a data-profile="${handle}" href="${profileDefault}" target="_blank" rel="noreferrer">@${handle}</a>${created ? ` · ${created}` : ""}</span></p> 234 272 ${parentFrag} 273 + ${originalFrag} 235 274 ${remixesFrag} 236 275 <div class="actions"> 237 - <a class="pill primary remix" href="${APP}/?remix=${did}/${rkey}">${remixIconFrag}remix this →</a> 276 + <a class="pill primary remix" href="${APP}/?remix=${did}/${rkey}">${remixIconFrag}${REMIX} this →</a> 238 277 <a class="pill" href="${img}" download>download image</a> 239 278 <a class="pill" href="${recordDefault}" target="_blank" rel="noreferrer">view record</a> 240 279 <a class="pill" href="${APP}">make your own</a> ··· 292 331 if (!cid) return new Response("no image in record", { status: 404 }); 293 332 const img = `${doc.pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; 294 333 295 - // if this drawing remixes another, resolve the parent's author + its own 296 - // image so we can show "a remix of [thumbnail] by @them", linking to the 297 - // parent's page. (the parent ref is on the record — no index needed for 298 - // the up-the-tree direction.) 299 - let parent = null; 300 - if (rec?.value?.parent?.uri) { 301 - const pp = atUri(rec.value.parent.uri); 302 - const [pdoc, prec] = await Promise.all([ 303 - getJson( 304 - `${SLINGSHOT}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${pp.did}`, 305 - ).catch(() => null), 306 - getJson( 307 - `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${pp.did}&collection=${pp.collection}&rkey=${pp.rkey}`, 308 - ).catch(() => null), 309 - ]); 310 - const pcid = prec?.value?.image?.ref?.["$link"]; 311 - parent = { 312 - handle: pdoc?.handle || pp.did, 313 - did: pp.did, 314 - rkey: pp.rkey, 315 - img: 316 - pcid && pdoc?.pds 317 - ? `${pdoc.pds}/xrpc/com.atproto.sync.getBlob?did=${pp.did}&cid=${pcid}` 318 - : null, 319 - }; 320 - } 334 + // walk the chain up: the immediate parent (attribution + thumbnail) and, 335 + // when the parent is itself a remix, the original creator at the root. 336 + const lineage = await resolveLineage(rec); 337 + const parent = lineage[0] ?? null; 338 + const original = lineage.length > 1 ? lineage[lineage.length - 1] : null; 321 339 const created = rec?.value?.createdAt 322 340 ? new Date(rec.value.createdAt).toLocaleDateString("en-US", { 323 341 year: "numeric", ··· 337 355 created, 338 356 avatar, 339 357 parent, 358 + original, 340 359 remixIcon, 341 360 remixes, 342 361 }),