Skip to content

About

a lil drawing thing

Resources

Stars

0 stars

Watchers

1 watching

Forks

Latest commit

 

History

227 Commits

Folders and files

Repository files navigation

Kaleidoscope

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.

The drawing

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.

Two clients, one engine, byte for byte

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.

Backend

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.

Auth

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.

Plus, caps and billing (1.2)

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.

Storage

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.

AI

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.

Abuse defense

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.

Routes

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.

Develop

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:local

Deploying. 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.

⚠️ Over Tailscale you must reach it as 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.

Tests

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 to dev: typecheck, lint, unit + worker tests, build, local D1 migrations, and the full Playwright suite.
  • deploy.yml — on pushes to prod: the same gates, then the remote D1 migration and the deploy.
  • ios.yml — on pushes to dev that touch ios/** or src/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 ai binding reaches real infrastructure even under vite 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 answers no such column and it reads like a code bug.

App Store screenshots are captured by MarketingShotsUITests and exported from the .xcresult; ios/app-store-screenshots/ is gitignored.

Capacity

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.

Operations

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.

Releasing the iOS app

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.

./old

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.

About

a lil drawing thing

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages