Reference this alongside CLAUDE.md. CLAUDE.md covers architecture; this file covers task patterns, where things live, and how to make changes safely.
Tokokino is a client-heavy Next.js 15.5 app deployed to Cloudflare Workers through OpenNext Cloudflare. Almost all editor logic runs in the browser via a Zustand store. The server exists only for auth (/api/auth), share/draft/preset metadata in Cloudflare D1, image and draft storage in Cloudflare R2, an image CORS proxy (/api/export/image), Unsplash integration, and Cloudflare Browser Rendering screenshots. The canvas, styling, export, and annotation tools are pure client-side React with no server round-trips.
| Task | File(s) |
|---|---|
| Add/change a canvas property (shadow, border, etc.) | lib/editor/state-types.ts (type) + lib/editor/store.tsx (action) + relevant inspector component |
| Add a new store action | lib/editor/store.tsx — add to EditorActions type and implement in createEditorStore |
| Change export behavior | lib/editor/export.ts |
| Add a new background type | lib/editor/state-types.ts (BgType) + lib/editor/css-utils.ts (backgroundCss) + components/inspector/background-section.tsx |
| Add a new shadow type | state-types.ts (ShadowType) + css-utils.ts (shadowCss) + inspector/shadow-section.tsx |
| Add a new device frame | lib/mockups/index.ts + add/update assets in the R2-backed device mockup paths |
| Add a layout preset (multi-screenshot) | lib/editor/present-presets.ts → LAYOUT_PRESETS array |
| Add a single-screenshot tilt preset | lib/editor/present-presets.ts → PRESENT_PRESETS array |
| Add a new overlay texture | Drop PNG in the overlays directory, run pnpm build:thumbs |
| Add a Google Font | lib/editor/fonts.ts → add to the fonts array |
| Add an API route | /app/api/<name>/route.ts |
| Change auth providers | lib/auth.ts |
| Change share storage behavior | lib/share-storage.ts for R2 objects + lib/share-db.ts for D1 metadata |
| Change D1 schema | lib/db/schema.ts + migration in migrations/ + relevant *-db.ts module |
| Change environment variables | lib/env.ts; change Cloudflare bindings in wrangler.jsonc and then run pnpm cf-typegen |
| Change top-bar UI | components/editor/top-bar.tsx |
| Change right inspector panel | components/editor/inspector/ |
| Change canvas rendering | components/editor/canvas/ |
| Change annotation tools | components/editor/annotation-toolbar.tsx + annotation-shape/ |
- Framework/runtime versions are pinned in
package.json: Next.js15.5.18, React19.2.x, TypeScript5.9.x,@opennextjs/cloudflare1.19.x, and Wrangler4.93.x. pnpm devrunsnext dev --turbopack;next.config.mjsinitializes OpenNext Cloudflare dev bindings.pnpm buildrunsopennextjs-cloudflare build; OpenNext then callspnpm run build:nextfromopen-next.config.ts.pnpm previewandpnpm deployboth build through OpenNext first. Do not swap these to plainnext buildunless you are only debugging framework output.- Cloudflare Worker config lives in
wrangler.jsonc:.open-next/worker.js,.open-next/assets,nodejs_compat, observability, and theTOKOKINO_DBD1 binding. - Prefer
pnpm typecheckfor correctness checks unless the user explicitly allows build/test/browser work.
// In a component — always use a selector, never subscribe to the whole store
const shadow = useEditorStore(s =>
s.present.canvases.find(c => c.id === s.present.activeCanvasId)?.shadow
)
// Multiple values with shallow equality
import { useShallow } from "zustand/react/shallow"
const { padding, borderRadius } = useEditorStore(
useShallow(s => {
const c = s.present.canvases.find(c => c.id === s.present.activeCanvasId)
return { padding: c?.padding, borderRadius: c?.borderRadius }
})
)const setShadow = useEditorStore(s => s.setShadow)
setShadow({ type: "drop", intensity: 60, color: "#000000", lightSource: "top" })- Add to
EditorActionstype instore.tsx - Implement in the
create(...)block — always callset()with temporal middleware:myAction: (value) => { set((state) => { const canvas = getActiveCanvas(state) if (!canvas) return state return produce(state, draft => { draft.present.canvases.find(c => c.id === draft.present.activeCanvasId).myProp = value }) }) }
- History grouping: rapid sequential calls within 600 ms are merged into one undo step automatically by the temporal middleware.
undo()/redo()— keyboard:Cmd+Z/Cmd+Shift+Z- Max 100 history states
reset()clears everything to defaults- Writes to IndexedDB with 250 ms debounce — no explicit save needed
The canvas DOM node must have data-canvas-id="{canvasId}". Export finds it with:
document.querySelector(`[data-canvas-id="${canvasId}"]`)Mark UI chrome that shouldn't appear in exports:
<div data-export-hidden="true">...</div>Mark selection borders so they are stripped:
<div data-selection-border="true">...</div>The 3D tilt effect is pure CSS transform: rotateX() rotateY() rotateZ() with perspective. The Tilt type is { rx, ry, rz } in degrees (range −180 to 180).
Screenshot slots are positioned with left: {xPct}% / top: {yPct}% inside the canvas container.
The active preset is stored in activeLayoutPresetId. When a user picks a preset:
setActiveLayoutPresetId(id)is called- When slots are added/updated,
setScreenshotSlotImagecallsresolveLayoutPresetGeometry(preset, frame)to get the geometry resolveLayoutPresetGeometryreturns different geometry for portrait-device frames vs. browser/desktop framesrelativeSlotPositions: truemeans slotxPct/yPctare offsets from the natural row-layout position (computed bycomputeRowLayoutinscreenshot-layout.ts)
To add a new layout preset, append an object to LAYOUT_PRESETS in present-presets.ts. Required fields:
{
id: string // unique kebab-case id
name: string // display name
canvasTilt: Tilt
canvasScale: number
slots: SlotLayoutConfig[] // one entry per extra slot (max 2 for a 3-screenshot layout)
portraitDevice?: LayoutPresetGeometry // optional portrait phone override
}captureCanvasAsPngBlob(canvasId, targetWidth)— renders canvas element to PNG viahtml-to-image- For external images in the canvas,
rewriteExportAssetsrewrites src attributes to/api/export/image?url=...to avoid CORS - An override
<style>is injected to hide UI chrome and remove focus rings - Assets are preloaded before capture
- For sharing:
captureCanvasForSharewraps step 1, then if the PNG exceeds 4 MB it re-encodes as JPEG
Share upload:
POST /api/sharewith body = the blob- Accepts
image/pngorimage/jpeg - Server deduplicates by SHA-256 hash per user (same image → reuses existing share URL)
- Stores in R2 at
shares/{uuid}.pngwith the real Content-Type header
Import from zod/v4 (not zod):
import { z } from "zod/v4"For editor numeric inputs use the pre-built helpers:
import { clampNumber, parseEditorNumber } from "@/lib/editor/value-schemas"
const safe = clampNumber(rawValue, 0, 100) // returns null if NaN/Infinity
const n = parseEditorNumber(inputStr, 0, 240) // parses string → number, clampsAll slider/input ranges are defined in editorValueSchemas. Match new inputs to existing ranges or add a new entry to the schema map.
Checklist:
- Type — add to
CanvasStateinstate-types.ts - Default — add to
DEFAULT_CANVAS_STATEinstore.tsx - Action — add setter to
EditorActionstype and implement instore.tsx - CSS — if the property affects rendering, add CSS generation in
css-utils.ts - UI — add control in the relevant inspector section under
components/editor/inspector/ - Validation — add Zod range to
value-schemas.tsif it's a numeric input
Create /app/api/<name>/route.ts. For protected routes:
import { auth } from "@/lib/auth"
export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: request.headers })
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// ...
}For large body uploads, set:
export const runtime = "nodejs"Validated in lib/env.ts. Server-only vars throw at import time if missing. Client vars use NEXT_PUBLIC_ prefix.
Required for share feature:
R2_BUCKET
R2_S3_ENDPOINT
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
Required for auth:
BETTER_AUTH_SECRET
BETTER_AUTH_URL
GOOGLE_CLIENT_ID (optional, for Google OAuth)
GOOGLE_CLIENT_SECRET (optional)
Required for Cloudflare Browser Rendering screenshots:
CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_BROWSER_API_TOKEN
Access D1 through lib/d1.ts, which reads the TOKOKINO_DB binding from OpenNext Cloudflare context and returns a Drizzle client. Schema lives in lib/db/schema.ts; migrations live in migrations/.
Tables:
shares— share metadata, content hashes, R2 object keys, and view countersshare_views— per-IP-hash view tracking for public sharesdrafts— saved draft metadata; full draft JSON and thumbnails live in R2custom_presets— user-created layout/style preset metadata
better-auth stores its auth tables in D1 through lib/auth.ts.
Located in components/ui/. Use them directly:
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Slider } from "@/components/ui/slider"Do not modify shadcn base components — wrap them instead.
Two icon libraries are used:
@remixicon/react— primary (most editor icons)lucide-react— secondary
Pick from Remixicon first. Always use the className="size-4" pattern (not width/height props).
import { toast } from "sonner"
toast.success("Exported!")
toast.error("Export failed. Please try again.")
toast("Feature in development")Use motion from motion/react (not framer-motion):
import { motion, AnimatePresence } from "motion/react"The store uses motion for preview slide/fade/zoom/flip transitions between canvases.
- Zod import: always
from "zod/v4", notfrom "zod"— this project uses the v4 subpath export. - Canvas not found in export: make sure the canvas root element has
data-canvas-id={id}attribute. - History pollution: setting multiple store values in sequence creates multiple undo steps. Use combined actions like
setTiltAndScaleorsetScreenshotPlacementwhen the UI groups changes together. - Bulk edit mode: when
bulkEditModeis true, the layout changes — don't assume a single active canvas. Guard withs.present.bulkEditModechecks where needed. - Max canvases:
MAX_CANVASES = 20. Check before callingaddCanvas(). - Max screenshot slots: 3 per canvas. The store enforces this internally.
- Share content-type: server accepts both
image/pngandimage/jpeg. Always forward the content-type fromcaptureCanvasForShare. - External image CORS: any background or asset image from an external domain must be proxied through
/api/export/imagefor export to work. TherewriteExportAssetsfunction handles this automatically during capture. - Tailwind v4: uses the CSS-first config approach, not
tailwind.config.ts. Theme tokens are inglobals.css.