diff --git a/docs/plans/2026-01-06-implementation.md b/docs/plans/2026-01-06-implementation.md new file mode 100644 index 0000000..c3059c6 --- /dev/null +++ b/docs/plans/2026-01-06-implementation.md @@ -0,0 +1,486 @@ +# Tiny Sprites Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a CSS-based sprite library with Hono JSX component and dev tuning server. + +**Architecture:** A `` component renders a div with inline styles and a scoped `@keyframes` animation. The dev server provides a UI using Hono's client-side JSX to load spritesheets, adjust parameters, and copy generated code. + +**Tech Stack:** Bun, Hono, Hono JSX (server + client), Pico CSS + +--- + +### Task 1: Project Setup + +**Files:** +- Create: `package.json` +- Create: `tsconfig.json` + +**Step 1: Create package.json** + +```json +{ + "name": "tiny-sprites", + "type": "module", + "exports": { + ".": "./src/sprite.tsx" + }, + "bin": { + "tiny-sprites": "src/dev/server.tsx" + }, + "scripts": { + "dev": "bun --hot src/dev/server.tsx", + "test": "bun test" + }, + "dependencies": { + "hono": "^4" + }, + "devDependencies": { + "bun-types": "latest" + } +} +``` + +**Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "strict": true, + "skipLibCheck": true, + "types": ["bun-types"] + }, + "include": ["src/**/*"] +} +``` + +**Step 3: Install dependencies** + +Run: `bun install` +Expected: Resolves hono and bun-types + +**Step 4: Commit** + +```bash +git add package.json tsconfig.json bun.lockb +git commit -m "chore: project setup with bun and hono" +``` + +--- + +### Task 2: Download Pico CSS + +**Files:** +- Create: `public/pico.min.css` + +**Step 1: Download pico.min.css** + +Run: `mkdir -p public && curl -o public/pico.min.css https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css` +Expected: File downloaded to public/pico.min.css + +**Step 2: Commit** + +```bash +git add public/pico.min.css +git commit -m "chore: add pico css" +``` + +--- + +### Task 3: Sprite Component + +**Files:** +- Create: `src/sprite.tsx` +- Create: `src/sprite.test.tsx` + +**Step 1: Write the failing test** + +```tsx +import { test, expect } from "bun:test" +import { Sprite } from "./sprite" + +test("Sprite renders div with correct dimensions", () => { + const html = ().toString() + expect(html).toContain('width:32px') + expect(html).toContain('height:32px') +}) + +test("Sprite sets background-image", () => { + const html = ().toString() + expect(html).toContain("background-image:url('/warrior.png')") +}) + +test("Sprite calculates background-size for horizontal strip", () => { + const html = ().toString() + expect(html).toContain('background-size:128px 32px') +}) + +test("Sprite generates keyframes animation", () => { + const html = ().toString() + expect(html).toContain('@keyframes sprite-') + expect(html).toContain('steps(4)') + expect(html).toContain('400ms') +}) + +test("Sprite keyframes animate background-position", () => { + const html = ().toString() + expect(html).toContain('from{background-position:0 0}') + expect(html).toContain('to{background-position:-128px 0}') +}) + +test("Sprite with columns calculates grid background-size", () => { + const html = ().toString() + expect(html).toContain('background-size:128px 64px') +}) + +test("Sprite with columns generates grid keyframes", () => { + const html = ().toString() + expect(html).toContain('0%{background-position:0px 0px}') + expect(html).toContain('25%{background-position:-32px 0px}') + expect(html).toContain('50%{background-position:0px -32px}') + expect(html).toContain('75%{background-position:-32px -32px}') +}) + +test("Sprite passes through class", () => { + const html = ().toString() + expect(html).toContain('class="my-sprite"') +}) + +test("Sprite passes through style", () => { + const html = ().toString() + expect(html).toContain('opacity:0.5') +}) + +test("Sprite pauses when playing is false", () => { + const html = ().toString() + expect(html).toContain('animation-play-state:paused') +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/sprite.test.tsx` +Expected: FAIL with "Cannot find module" + +**Step 3: Write implementation** + +```tsx +type SpriteProps = { + src: string + width: number + height: number + frames: number + frameDuration: number + columns?: number + class?: string + style?: string + playing?: boolean +} + +export const Sprite = ({ + src, + width, + height, + frames, + frameDuration, + columns, + class: className, + style, + playing = true, +}: SpriteProps) => { + const isGrid = columns !== undefined + const rows = isGrid ? Math.ceil(frames / columns!) : 1 + const cols = isGrid ? columns! : frames + + const sheetWidth = cols * width + const sheetHeight = rows * height + const totalDuration = frames * frameDuration + + const keyframeId = `sprite-${Bun.hash(`${src}-${width}-${height}-${frames}-${frameDuration}-${columns}`).toString(36)}` + + const keyframes = isGrid + ? generateGridKeyframes(keyframeId, width, height, frames, columns!) + : generateStripKeyframes(keyframeId, sheetWidth) + + const divStyle = [ + `width:${width}px`, + `height:${height}px`, + `background-image:url('${src}')`, + `background-size:${sheetWidth}px ${sheetHeight}px`, + `animation:${keyframeId} ${totalDuration}ms steps(${frames}) infinite`, + playing ? '' : 'animation-play-state:paused', + style, + ].filter(Boolean).join(';') + + return ( + <> + +
+ + ) +} + +const generateStripKeyframes = (id: string, sheetWidth: number): string => + `@keyframes ${id}{from{background-position:0 0}to{background-position:-${sheetWidth}px 0}}` + +const generateGridKeyframes = (id: string, width: number, height: number, frames: number, columns: number): string => { + const steps: string[] = [] + for (let i = 0; i < frames; i++) { + const col = i % columns + const row = Math.floor(i / columns) + const x = -col * width + const y = -row * height + const percent = (i / frames) * 100 + steps.push(`${percent}%{background-position:${x}px ${y}px}`) + } + return `@keyframes ${id}{${steps.join('')}}` +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test src/sprite.test.tsx` +Expected: 10 passing tests + +**Step 5: Commit** + +```bash +git add src/sprite.tsx src/sprite.test.tsx +git commit -m "feat: add Sprite component" +``` + +--- + +### Task 4: Dev Server + +**Files:** +- Create: `src/dev/server.tsx` +- Create: `src/dev/index.html` + +**Step 1: Create dev server** + +```tsx +#!/usr/bin/env bun +import { Hono } from "hono" +import { serveStatic } from "hono/bun" +import index from "./index.html" + +const app = new Hono() + +app.use("/public/*", serveStatic({ root: "./" })) +app.get("/", (c) => c.html(index)) + +export default { + port: 3000, + fetch: app.fetch, +} +``` + +**Step 2: Create index.html** + +```html + + + + + + Tiny Sprites + + + + +
+

Tiny Sprites

+
+
+ + + +``` + +**Step 3: Commit** + +```bash +git add src/dev/server.tsx src/dev/index.html +git commit -m "feat: add dev server" +``` + +--- + +### Task 5: Dev Tool Client Component + +**Files:** +- Create: `src/dev/app.tsx` + +**Step 1: Create client component using Hono JSX** + +```tsx +import { useState } from "hono/jsx" +import { render } from "hono/jsx/dom" + +const App = () => { + const [spriteUrl, setSpriteUrl] = useState('') + const [width, setWidth] = useState(32) + const [height, setHeight] = useState(32) + const [frames, setFrames] = useState(4) + const [columns, setColumns] = useState() + const [frameDuration, setFrameDuration] = useState(100) + + const handleFileChange = (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) return + setSpriteUrl(URL.createObjectURL(file)) + } + + const isGrid = columns !== undefined + const cols = isGrid ? columns : frames + const rows = isGrid ? Math.ceil(frames / columns!) : 1 + const sheetWidth = cols * width + const sheetHeight = rows * height + const totalDuration = frames * frameDuration + + const keyframeId = 'sprite-preview' + let keyframes: string + + if (isGrid) { + const steps: string[] = [] + for (let i = 0; i < frames; i++) { + const col = i % columns! + const row = Math.floor(i / columns!) + const x = -col * width + const y = -row * height + const percent = (i / frames) * 100 + steps.push(`${percent}%{background-position:${x}px ${y}px}`) + } + keyframes = `@keyframes ${keyframeId}{${steps.join('')}}` + } else { + keyframes = `@keyframes ${keyframeId}{from{background-position:0 0}to{background-position:-${sheetWidth}px 0}}` + } + + const previewStyle = { + width: `${width}px`, + height: `${height}px`, + backgroundImage: `url('${spriteUrl}')`, + backgroundSize: `${sheetWidth}px ${sheetHeight}px`, + animation: `${keyframeId} ${totalDuration}ms steps(${frames}) infinite`, + } + + const columnsAttr = columns ? `\n columns={${columns}}` : '' + const code = `` + + const copyCode = () => navigator.clipboard.writeText(code) + + return ( +
+
+ + + + + + +
+
+
+ {spriteUrl ? ( + <> + +
+ + ) : ( + Upload a spritesheet to preview + )} +
+