tiny-sprite/docs/plans/2026-01-06-implementation.md
Corey Johnson ab59ad3cd2 Add implementation plan
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:19:16 -08:00

13 KiB

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

{
  "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

{
  "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

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

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

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

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

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

#!/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

<!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

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

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

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

git add -A
git commit -m "chore: tiny-sprites v1 complete"