A symmetry drawing toy. You scribble, the scribble blooms into a mandala. Drawing is free and needs no account. Sign in and you can save pieces to a hosted gallery, get a permalink, and remix other people's work.
Live: https://kaleidoscope.ponderance.dev, plus a native iOS app and a home screen widget that shows a random piece from the gallery.
A drawing is a list of vector strokes, not pixels. Points are normalized to the shorter half-axis with the origin at the center, so a drawing isn't tied to the screen it was made on.
That one choice pays for undo/redo, export at any resolution, real SVG, animated replay, remix, and saves measured in kilobytes. The PNG you download is one render of the drawing, not the drawing itself. History keeps 250 snapshots and a piece is capped at 256KB of vector JSON, which is more scribbling than it sounds like.
Symmetry is rosette group math (src/client/engine/symmetry.ts). C_n gives n
rotations, D_n gives n rotations plus a reflected copy, so 2n images. It's well
defined for odd and even n alike, which the old toy's i % 2 version was not.
Segments run 3 to 24 and default to 12.
The web engine (src/client/engine) is plain TypeScript with no framework
anywhere near it: symmetry math, the stroke model, serialization, the live
canvas renderer, and every exporter. The live renderer and the SVG/PNG/replay
exporters all read the same symmetry code, so they can't drift apart.
iOS doesn't call that engine. It reimplements it in Swift (ios/KaleidoEngine,
pure Foundation, runs headless under swift test). Two implementations of the
same math is an excellent way to end up with two different answers, so the Swift
serializer is held to producing byte-identical output to the web one.
ios/tools/gen-engine-fixtures.ts runs the actual web serializer to emit
golden.json, and SerializeGoldenTests checks Swift against it. Regenerate
with npx vite-node ios/tools/gen-engine-fixtures.ts. Don't hand-edit the
fixtures, or the test is checking Swift against your typing.
Web: Preact and @preact/signals, state in src/client/state.ts, client-side
routing, keyboard shortcuts throughout. It's a PWA, so drawing works offline.
iOS: SwiftUI, iOS 17+, universal, three tabs (Draw, Gallery, You). The canvas
renders through Core Graphics. The project is generated by XcodeGen and the
.xcodeproj is gitignored, so edit ios/project.yml and regenerate instead of
clicking around in Xcode. Build numbers come from git rev-list --count HEAD at
build time rather than from someone remembering to bump them.
Widget: RandomWidget shows a random public mandala in small, medium, or
large, with an interactive reshuffle. Tapping it opens
kaleidoscopewidget://p/<id> in the app. It pulls a batch from
/api/gallery/random and pre-bakes a rotating timeline, which is why that
endpoint is no-store.
One Cloudflare Worker does all of it. Workers Static Assets serves the SPA from
the edge and a Hono API lives in the same Worker. run_worker_first routes
/api/*, /og/*, /p/*, and /sitemap.xml to the Worker. Everything else comes
off the edge cache.
Google OAuth 2.0 (authorization code + PKCE/S256, id_token verified against Google's JWKS) and Sign in with Apple (identity token verified against Apple's JWKS). Both JWKS are cached in KV.
Sessions are opaque and server-side in KV. The cookie carries a random 32-byte id
and never a JWT, so revoking one is instant. Web clients get a __Host- cookie
plus CSRF double-submit and an Origin allowlist. Native clients send that same
session id as a Bearer token and skip the CSRF check deliberately: CSRF exists
because browsers attach cookies to requests you didn't make, and there's no
ambient credential to forge here.
Two flags, and they mean different things. PLUS_SURFACE_ENABLED decides
whether the Plus UI exists at all — paywall, Restore, the upsell. PLUS_ENABLED
decides whether the CAPS are enforced. They were one flag, which made the app
unreviewable: with it off, App Review finds no purchase to test and rejects the
binary, not just the IAP. The review deploy runs surface on, caps off.
The caps are 3 layers free / 8 with Plus, and 10 public gallery posts free.
Public posts count only from CAP_EPOCH, which ships as a far-future
placeholder so a forgotten epoch counts nothing rather than retroactively
capping every existing user; a test refuses a tree where PLUS_ENABLED is true
and the epoch is still the placeholder.
One entitlement in D1 is honoured by both platforms — StoreKit on iOS
(POST /api/billing/apple, plus Apple's server notifications) and Stripe
Checkout with Managed Payments on the web (POST /api/billing/stripe, HMAC over
the timestamp and raw body). With Managed Payments the merchant of record is
Stripe's affiliate Link, LLC ("Sold through Link, LLC"), which handles sales
tax and VAT. Refunds tombstone rather than delete, because both
providers can deliver a stale credential: Apple's device still holds a JWS
signed before the refund, and Stripe retries checkout.session.completed for
up to three days. A Sandbox entitlement counts only while PLUS_ALLOW_SANDBOX
says it may, and a Stripe test-mode one only while PLUS_ALLOW_TEST does, so
neither outlives its flag.
D1 holds users and artwork metadata. R2 holds vec/{id}.json.gz as the source of
truth, plus webp renders (image, thumb, og) and cached avatars. The bucket is never
public. The Worker proxies it and checks visibility first. KV holds sessions,
transient OAuth state, rate-limit counters, and the JWKS caches.
One wrinkle worth knowing before you touch migrations/: 0002 drops the child
table before the parent, because DROP TABLE users fires an implicit cascading
delete that PRAGMA defer_foreign_keys won't save you from. Deferral delays the
foreign-key checks, not the cascade actions.
Every save fires a Workers AI vision call in the background to write alt text.
POST /api/artworks/suggest-names runs a caption model and then an instruct model
to propose titles, and returns an empty list rather than an error if either one
misbehaves. There's an admin route to backfill alt text over old pieces in bounded
batches.
Turnstile on save (cookie sessions only) and KV rate limits: 60 saves/hour,
300/day, 120 likes/hour, 30 name suggestions/hour. There's no monthly cap. The
flagged and role columns exist for curation if it ever needs it.
| Route | Notes |
|---|---|
GET /api/health |
|
GET,DELETE /api/me |
Delete wipes blobs, avatar, and the user row (artworks cascade) |
GET /api/auth/login, /callback |
PKCE. ?client=ios returns the token via a kaleidoscope:// fragment |
POST /api/auth/apple, /logout |
|
POST /api/auth/test-login |
404s unless ALLOW_TEST_LOGIN=true |
POST /api/artworks |
Save: Turnstile, rate limit, validate, R2, D1, background alt text |
POST /api/artworks/suggest-names |
Two AI calls |
GET /api/artworks/:id + /image, /thumb, /vector |
Visibility-checked R2 proxies |
PATCH,DELETE /api/artworks/:id |
Owner only, though admins can also delete |
POST /api/artworks/:id/like |
|
GET /api/gallery |
Public wall, keyset paginated, 24/page |
GET /api/gallery/random + /image |
Feeds the iOS widget |
GET /api/users/me/artworks, /api/users/:id/avatar |
|
POST /api/admin/flag, /api/admin/backfill-alt |
|
GET /og/:id, /p/:id, /sitemap.xml |
/p/:id injects OG tags into the shell with HTMLRewriter |
Anything else under /api/* returns a JSON 404 instead of the SPA shell.
npm install
npm run dev # vite + worker in workerd (http://localhost:5173)
npm run typecheck # tsc -b (project refs: client / worker / node)
npm run lint
npm test # vitest: unit + worker tests
npm run test:e2e # playwright (starts the dev server itself)
npm run build
npm run db:migrate # d1 migrations, --remote
npm run db:migrate:localDeploying. The supported path is a push to prod, which runs the whole gate
in CI and then migrates and deploys. npm run deploy exists for the rare
hand-deploy and now runs that same gate itself — typecheck, lint, tests, build,
migrations, then wrangler deploy.
It used to be vite build && wrangler deploy: no gates, no migrations, and it
shipped the working tree including uncommitted changes. Deploying 1.2's worker
that way against an unmigrated D1 answers no such column to every save,
/api/me and billing call. deploy:unsafe is still there for when you
genuinely mean it — the name is the point.
Local secrets live in .dev.vars (gitignored). For local OAuth it points
PUBLIC_BASE_URL and GOOGLE_REDIRECT_URI at localhost:5173 and sets
ALLOW_TEST_LOGIN=true, which enables POST /api/auth/test-login, a session
bypass for dev and tests. Never set that in production.
The dev server binds host: true and allows .ts.net, so you can open it on your
phone over Tailscale.
https://, not http://. The
drawing's content hash goes through crypto.subtle, which the browser exposes
only in a secure context — localhost counts, http://<host>.ts.net does
not, and window.crypto.subtle is then simply undefined. Nothing errors
visibly: contentHash throws, the save dialog catches it and skips the duplicate
pre-flight (so every Save* state that depends on the hash is unreachable), and
the artwork page can no longer tell a remix from its parent. Service workers have
the same rule, so the whole PWA — offline shell and the "Update available"
prompt — is inert there too. Testing any of that over http on a tailnet tests
the degraded path, silently.
Give Vite a certificate for the tailnet hostname and turn on server.https in
vite.config.ts:
tailscale cert <host>.ts.net # writes <host>.ts.net.crt / .key// vite.config.ts — server: { ... }
https: { key: readFileSync("<host>.ts.net.key"), cert: readFileSync("<host>.ts.net.crt") },localhost needs none of this and is the right target for everything that is not
specifically about a phone or an iPad.
Vitest covers the symmetry group math (forEachImage for C_n and D_n), stroke
normalize/serialize round-trips, SVG export, validation caps, and the Worker
routes. Playwright drives the real flows: draw and download with no login, then
mock sign-in, save, permalink with OG tags, gallery, remix, delete. Swift side,
swift test runs the engine tests including the golden-file check against the web
serializer.
There are three workflows, and between them they run everything:
ci.yml— on pushes todev: typecheck, lint, unit + worker tests, build, local D1 migrations, and the full Playwright suite.deploy.yml— on pushes toprod: the same gates, then the remote D1 migration and the deploy.ios.yml— on pushes todevthat touchios/**orsrc/shared/**:swift test,build-for-testing, and the logic-test bundle. The UI bundle is deliberately excluded (~10 minutes, and macOS bills at 10x on a private repo); its suites are device-class split, so no single destination is green anyway.
Two things about the e2e gate that are easy to lose:
- It needs live Cloudflare credentials. The
aibinding reaches real infrastructure even undervite dev, so the dev server will not start without them — which means a pull request from a fork can never pass this gate. - A new migration must be applied locally first (
npm run db:migrate:local), or the dev worker answersno such columnand it reads like a code bug.
App Store screenshots are captured by MarketingShotsUITests and exported from
the .xcresult; ios/app-store-screenshots/ is gitignored.
The SPA is static and served from Cloudflare's edge cache, so concurrency there is effectively unbounded. Drawing is entirely client-side and costs nothing while someone scribbles. The Worker runs on sign-in, saves, gallery reads, and the image proxies, and those are edge-cached after the first hit.
A save is a Turnstile verify, a couple of KV and D1 writes, a few small R2 puts, and one Workers AI vision call for alt text. The AI neurons are the part most likely to cost money at volume, not the storage. Storage stays small because the source of truth is vector JSON rather than big PNGs.
Turnstile runs on a real widget for kaleidoscope.ponderance.dev.
TURNSTILE_SITE_KEY is a var in wrangler.jsonc and TURNSTILE_SECRET_KEY is a
Wrangler secret.
Grant yourself admin after signing in once:
wrangler d1 execute kaleidoscope --remote \
--command "UPDATE users SET role='admin' WHERE email='you@example.com'"Web Analytics is cookieless and turns on through the Cloudflare dashboard. The CSP already allows the beacon origins.
Set the App Store Support URL to https://ponderance.dev/support/kaleidoscope/
— with the trailing slash, since the bare path 307s. It currently points at the
ponderance.dev hub, which makes a user hunting for help find every app but this
one.
Fix it while the version is still editable. Apple refuses the change on a
READY_FOR_SALE version: PATCH /v1/appStoreVersionLocalizations/{id} answers
409 STATE_ERROR — "Attribute 'supportUrl' cannot be edited at this time"
(verified 2026-07-31). So this is release-time work, not a thing that can be
cleaned up afterwards.
Keep package.json version equal to the iOS MARKETING_VERSION, as the plain
string Apple shows — 1.1, not 1.1.0. Orrery compares the two sides as raw
strings, and App Store Connect reports the version exactly as entered.
The original jQuery toy, "Drawing Thingy", kept untouched as a reference. Two
canvases, globals, var, and jQuery 3.7.1 loaded from a CDN for exactly one
keypress handler. It had no clear button, because the way you cleared it was to
reload the page.