Add implementation plan
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
63b72079ea
commit
ab59ad3cd2
486
docs/plans/2026-01-06-implementation.md
Normal file
486
docs/plans/2026-01-06-implementation.md
Normal file
|
|
@ -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 `<Sprite />` 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 = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).toString()
|
||||||
|
expect(html).toContain('width:32px')
|
||||||
|
expect(html).toContain('height:32px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Sprite sets background-image", () => {
|
||||||
|
const html = (<Sprite src="/warrior.png" width={32} height={32} frames={4} frameDuration={100} />).toString()
|
||||||
|
expect(html).toContain("background-image:url('/warrior.png')")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Sprite calculates background-size for horizontal strip", () => {
|
||||||
|
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).toString()
|
||||||
|
expect(html).toContain('background-size:128px 32px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Sprite generates keyframes animation", () => {
|
||||||
|
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).toString()
|
||||||
|
expect(html).toContain('@keyframes sprite-')
|
||||||
|
expect(html).toContain('steps(4)')
|
||||||
|
expect(html).toContain('400ms')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Sprite keyframes animate background-position", () => {
|
||||||
|
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).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 = (<Sprite src="/test.png" width={32} height={32} frames={8} frameDuration={100} columns={4} />).toString()
|
||||||
|
expect(html).toContain('background-size:128px 64px')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Sprite with columns generates grid keyframes", () => {
|
||||||
|
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} columns={2} />).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 = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} class="my-sprite" />).toString()
|
||||||
|
expect(html).toContain('class="my-sprite"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Sprite passes through style", () => {
|
||||||
|
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} style="opacity:0.5" />).toString()
|
||||||
|
expect(html).toContain('opacity:0.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Sprite pauses when playing is false", () => {
|
||||||
|
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} playing={false} />).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 (
|
||||||
|
<>
|
||||||
|
<style>{keyframes}</style>
|
||||||
|
<div class={className} style={divStyle} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tiny Sprites</title>
|
||||||
|
<link rel="stylesheet" href="/public/pico.min.css">
|
||||||
|
<style>
|
||||||
|
.preview-area {
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #3a3a3a 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #3a3a3a 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #3a3a3a 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #3a3a3a 75%);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
}
|
||||||
|
.preview-area > div { image-rendering: pixelated; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<h1>Tiny Sprites</h1>
|
||||||
|
<div id="app"></div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./app.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<number | undefined>()
|
||||||
|
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 = `<Sprite
|
||||||
|
src="YOUR_SPRITE_URL"
|
||||||
|
width={${width}}
|
||||||
|
height={${height}}
|
||||||
|
frames={${frames}}
|
||||||
|
frameDuration={${frameDuration}}${columnsAttr}
|
||||||
|
/>`
|
||||||
|
|
||||||
|
const copyCode = () => navigator.clipboard.writeText(code)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
Spritesheet
|
||||||
|
<input type="file" accept="image/*" onChange={handleFileChange} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Frame Width (px)
|
||||||
|
<input type="number" value={width} min={1} onInput={(e) => setWidth(+(e.target as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Frame Height (px)
|
||||||
|
<input type="number" value={height} min={1} onInput={(e) => setHeight(+(e.target as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Frame Count
|
||||||
|
<input type="number" value={frames} min={1} onInput={(e) => setFrames(+(e.target as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Columns (empty = horizontal strip)
|
||||||
|
<input type="number" value={columns} min={1} onInput={(e) => {
|
||||||
|
const val = (e.target as HTMLInputElement).value
|
||||||
|
setColumns(val ? +val : undefined)
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Frame Duration (ms)
|
||||||
|
<input type="number" value={frameDuration} min={1} onInput={(e) => setFrameDuration(+(e.target as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="preview-area">
|
||||||
|
{spriteUrl ? (
|
||||||
|
<>
|
||||||
|
<style>{keyframes}</style>
|
||||||
|
<div style={previewStyle} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Upload a spritesheet to preview</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Generated Code
|
||||||
|
<textarea readonly rows={8} value={code} />
|
||||||
|
</label>
|
||||||
|
<button onClick={copyCode}>Copy Code</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app')!)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run to verify it works**
|
||||||
|
|
||||||
|
Run: `bun run dev`
|
||||||
|
Expected: Visit http://localhost:3000, upload a spritesheet, see it animate, adjust values, copy code
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dev/app.tsx
|
||||||
|
git commit -m "feat: add dev tool client component with hono jsx"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final Verification
|
||||||
|
|
||||||
|
**Step 1: Run all tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass
|
||||||
|
|
||||||
|
**Step 2: Start dev server and verify**
|
||||||
|
|
||||||
|
Run: `bun run dev`
|
||||||
|
Expected: Server starts, UI loads, upload works, preview animates, code generates
|
||||||
|
|
||||||
|
**Step 3: Create final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: tiny-sprites v1 complete"
|
||||||
|
```
|
||||||
Loading…
Reference in New Issue
Block a user