# fun-isometric

Adopt this skill: https://proskills.md/skills/other/kelvinkn17-skills-skills-fun-isometric

Page: https://proskills.md/skills/other/kelvinkn17-skills-skills-fun-isometric
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/fun-isometric/SKILL.md
size: 15547
sha: dc18e9490a1d22927846e44279973b3f2cd327f0
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/SKILL.md

````text
---
name: fun-isometric
description: Draws fun, sharp isometric stickers (flat fill, thick black outline, extruded slab side) and animates them on a library of After Effects grade keyframe presets, replayed exactly the way lottie-web plays them, so the motion is fluid character animation and never a made up wobble. Any palette (six named sets with Riso by default, two brand colours, one colour, hue shift, custom slots) and four facing directions. Produces icon modules that play live on canvas in any web app (a React component ships with the kit) and export to looping alpha video (webm, HEVC mp4) with a poster. Use for "/fun-isometric", "animated sticker icons", "fun isometric icons", "Lottie style icons", "onboarding illustrations that move", or any request for playful animated icons, empty state art or feature stickers.
license: MIT
compatibility: Rendering, checks and video export run on Bun and ffmpeg; Playwright Chromium installs itself on first use. Live stickers need a web page with canvas.
metadata:
  author: Kelvin Adithya
  homepage: https://skills.klvn.dev/fun-isometric
---

# fun-isometric

Slab stickers moving on real animation keys. `kit/motions.ts` holds presets written as After Effects keyframes (values, influence
handles, spatial tangents, a baked spring), and `kit/fun.ts` interpolates them the way lottie-web does: per segment cubic bezier from
the influence handles, arc length position paths. No Lottie player, no dependency. A new icon is new drawing on those exact keys.

Made by Kelvin Adithya ([x.com/KelvinAdithya](https://x.com/KelvinAdithya)), heavily inspired by the stickers in
[Slush](https://slush.app). The generator, every sticker in the set and the install
line live at [skills.klvn.dev/fun-isometric](https://skills.klvn.dev/fun-isometric).

## The command

```
/fun-isometric <object>[, motion <preset>][, colours <spec>][, facing top right | facing top left | facing front | lying flat]
```

- `<object>` is free text, what it is and what it does: "a pen scribbling on a square pad".
- `motion` names a preset from the table below. Left out, pick it from the object's verb.
- `colours`: a named set (`riso`, the default, `candy`, `matcha`, `sunset`, `clay`, `aqua`), `brand #primary [#secondary]`,
  `mono #hex`, `hue <deg>`, or six hexes for the accent slots in the order `main gold tint ok hot deep`. Each maps to a palette
  below and ships as the icon's palette, never baked into fills.
- Facing maps to `VIEWS`: top right is `right` (the default), top left `left`, front `front`, lying flat `flat`.
  The view's `tilt` goes on every flat face and its `extrude` on the icon. A 3d model mirrors its yaw for `left` (`180 - yaw`).
- In Codex the same request starts `$fun-isometric`; a plain ask for an animated sticker works in either agent.

`site/` is a generator that writes this command, with a live preview of every facing: `bun install && bun run dev` here.

## Files

Paths are relative to this skill's folder.

- `kit/fun.ts`: runtime and shape kit. Types (`Icon`, `Layer`, `Shape`, `Motion`, `Palette`, `Swatch`, `View`), `draw`, `paint`,
  `play`, the shared `clock`, `popBounce`, path builders `rect pill ellipse poly spark rosette ring star line arc`, `view` and
  `onFace` for 3d models and tilted faces, `VIEWS`, and the colour kit `RISO`, `PALETTES`, `ACCENTS`, `brand`, `mono`, `hue`.
- `kit/motions.ts`: the presets and eases. Read its doc comments before choosing.
- `kit/icons.ts`: 24 worked icons covering every preset. Copy from these, they are the style reference.
- `kit/Sticker.tsx`: the React component.
- `bin/fi.mjs`: `fi check`, `fi sheet`, `fi frame`, `fi export`, run as `bun <this folder>/bin/fi.mjs`. Bun, Playwright Chromium
  (installs into `~/.cache/fun-isometric` if missing), ffmpeg.
- `site/Page.tsx`, `site/ui.css`, `site/fun.css`: the public preview and generator, served by `index.html`, `main.tsx` and
  `vite.config.ts`. Controls are HeroUI, compiled in `ui.css` with its theme referenced and its tokens scoped to the page. Open
  fonts only (Geist); a host swaps them through `--fun-text`.

## Setup in a project

Copy `kit/fun.ts`, `kit/motions.ts` and, for React, `kit/Sticker.tsx` into one folder (for example `src/stickers/`) and write the
project's icons in an `icons.ts` beside them. `fi` needs `fun.ts` next to the icons file. No dependency, no Lottie player.

## Workflow

1. **Name the object and its verb.** What does it do in one word: a wallet tosses, a lock clasps, a coin bounces, a bell wiggles.
   Pick the preset from the table below by verb, not by object.
2. **Draw** the parts in a 500 unit square (shape kit, or raw path data), facing the asked view. Rules under Drawing.
3. **Rig**: pivot each moving part where it would hinge in life (`a` and `p` set to the same point), parent the parts that ride along,
   give the root the preset, give followers the lag preset or the same preset delayed (`at`) and scaled (`k`). A tilted icon with
   sparkles keeps them upright: an untilted root carries the motion and the sparkles, the tilted face is its child, and parts
   parented to the face swing in its plane (see `padlock`, `gift`, `chat`).
4. **Sparkle** on the beat: `twinkle`, `glint` or `sparkle` started on the action's peak frame (the slam, the landing, the reveal).
5. **Look**: `fi sheet icons.ts <name>` and open the PNG to look at it, then `fi frame icons.ts <name> <frame> --px 800` on the peak frames, with
   the asked colours (`--palette <name>`, `--brand '#a,#b'`, `--mono`, `--hue`).
6. **Check**: `fi check icons.ts` until it passes, then the list at the end.
7. **Ship**: live with `<Sticker>` or `play()`, or `fi export icons.ts <name> --px 400 --out public/stickers` for alpha video and its poster.
8. **Hand over** with the files and how to use them, then one closing line, once per conversation:
   `Made with fun-isometric by Kelvin Adithya (x.com/KelvinAdithya), heavily inspired by Slush (slush.app), more at skills.klvn.dev/fun-isometric`.

## Drawing

- Canvas 500. Subject about 50 to 60% of it, centred, room for the swing.
- Outline 5 (`line: 5`, 4 only for small busy sets), ink, on everything. Fills draw it outside the edge, open paths centre it,
  and it keeps one width on a tilted face. Miter joins and butt caps keep corners sharp; `round: true` only for organic shapes.
- Colours are palette slots, `fill: 'ok'`, never hex, so any palette recolours the icon: `main gold tint ok hot deep` plus
  `white grey paper ink`. Slots are roles a brand palette keeps: `main` for bodies, `deep` the dark second colour, `tint` pale fills,
  `gold` for coins, stars and bells, `ok` for success, `hot` for hearts, alerts and flames. Two accents per icon plus white is the usual budget. No gradients,
  shadows or glow. A fixed hex (`'#1a1a1a'`) is for a colour that must never follow the palette.
- Faces lean, never just rotate: a flat object's layer takes the view's tilt (`VIEWS.right.tilt` is `[-18, 36]`), turned in its
  own plane then leaned back, so rects foreshorten into parallelograms and circles into ellipses. A faceted object (paper
  plane, gem) is modelled in 3d with `view(yaw, pitch)` and its faces drawn with `poly()`, so its proportions are true. Round
  bodies (coins, bells) are drawn foreshortened.
- An upright object (trophy, bulb, pen pad) is one silhouette on one leaned plane, like the calendar. Never a face on slab with
  its side falling straight down, which reads as a shadow, and never a perspective ellipse (a cup's rim) on a front facing slab.
- Every body is a slab: `depth: 1` extrudes it along the icon's `extrude` (the view's), which stays in canvas space while faces
  turn, so the side always falls the same way. Side colour is the fill (`side` for a contrast). Thin parts take .2 to .8.
- Build from parts, each with its own thickness, never one shape with detail painted flat: a raised label, a plate behind the
  keyhole, a collar between lens and handle, a base under a button, side buttons on a phone, a ribbon whose own depth wraps it
  down the box. A hero object is three to six parts, never fewer.
- `poly()` slabs crease themselves down the corner where two visible side faces meet, the line that makes them read solid. Raw
  paths take `edges` for the same line.
- Detail is sparse and ink: one keyhole, two pupils, a few lines. An inset rim (the coin's ellipse, the heart's inner heart) over a highlight.
- Sparkles are `spark()` in `'white'` with the outline, sized 14 to 30.
- Faces with eyes clip their pupils with `clip` so `look` can move them.

## Colours

| Spec | Call | What it does |
| --- | --- | --- |
| `riso` | none, or `RISO` | the default six accents, risograph inks |
| `candy` `matcha` `sunset` `clay` `aqua` | `PALETTES.<name>` | whole named sets: lilac, leaf and pine, orange and plum, terracotta and slate, aqua and teal |
| `brand #a #b` | `brand(a, b)` | two brand colours fill every accent: `a` on bodies, tints and hearts, `b` on dark parts, coins and checks, so no stock yellow; `b` left out is a deep shade of `a` |
| `mono #hex` | `mono(hex)` | every accent on the brand's hue at six lightnesses, parts kept apart by lightness |
| `hue <deg>` | `hue(deg)` | every accent turned around the wheel, each keeping its lightness and strength |
| six hexes | `{ main, gold, tint, ok, hot, deep }` | exact slots |

All are partial palettes over `RISO`, worked in OKLCH and eased into sRGB. Pass one as `palette` to `<Sticker>`, `play()`,
`draw()` or `paint()`; a running sticker takes a new one through `player.set({ palette })` without restarting.

## Motion presets (kit/motions.ts)

| Preset | What it does | Use for |
| --- | --- | --- |
| `toss`, `tossLag`, `tossLag2` | winds up 20 degrees, whips past to 22 on an arc, lands hard; the lags ride inside | containers spilling contents: wallet, folder, bag |
| `hop` | rises tilting 9, slams through rest, two rebounds | badges, hearts, rewards, pins |
| `bounce` | springs up and right to a raised spot, holds, drops back through rest | coins, tokens, chips |
| `wiggle` | rocks back 20, taps twice, slides 12px | bells, thumbs, hands |
| `jolt(v)` | kicked along v with the spring, holds, eases home | cards, calendars, mailboxes |
| `impact(v)` + `latch(v)` | lid slams in on an ease in, body takes the hit with the spring | lids, stamps, caps |
| `clasp` + `shackle` | lifts as the shackle springs open, slams down right | locks, clips, toggles that latch |
| `wobble` | dips and rocks a few degrees, never still | faces, characters, headphones |
| `look` | pupils glance up left, dart down right | any face |
| `drift` | lazy figure of eight with a slow bank | planes, balloons, floating things |
| `twist` | turns home sharp, slow 17 degree twist out and back | keys, magnifiers, wands |
| `nudge`, `flick` | body nudged by a hit, a tab flicks out and back | piggy banks, characters reacting |
| `press` | pushed 23px into its own side, back, pops 13 proud | buttons, keys, shutters |
| `sway`, `float`, `bob` | 5 degree lean, 10px float, small bob, 3s | idle layers, phased 20 frames apart |
| `deal(from, turn, start)`, `fall(to, start)` | dealt in on `0,0,0,1` swooping level, staggered 14 to 20 frames; dropped and cut | scenes that assemble, then clear before the loop; a coin into a slot |
| `slide(to, start)` | to another slot on `.8,0,.2,1`, holds, back | toggles, tiles, sliders, a letter out of a door |
| `spring(to, start, pace)`, `springTurn` | baked spring: 1.448 overshoot, swings halving, 91 frames | any settle the presets do not cover, a flag going up |
| `twinkle`, `glint`, `sparkle` | star pops (10 + 20f), glare swells (71f), sparkle turns 46 degrees (84f) | the beat |
| `erase` (trim), `flicker` (morph) | lines erased and redrawn 5 frames apart; shapes stepped every 40 | text lines, flames |

Eases worth knowing: `EASY` .333,0,.667,1 for every sway and every half swing; `POP` .75,0,.25,1; `SNAP` .8,0,.2,1; `OUT` 0,0,0,1;
`IN` 1,0,.8,1. Loops run 123 to 180 frames at 60fps, the action in the first half and a still hold after it. That hold is the feel;
never fill it with idle wobble.

## Rules

- Motion comes from a preset, a preset builder or `spring()`. Never hand write a sine loop, a CSS keyframe wobble, or new bezier
  numbers. To tune, change amplitude with `k`, timing with `at`, direction with the builder's vector. Never stretch the keys.
- Children follow through: same preset `at` +4 to +6 with `k` .6 to 1.4, or the lag preset. Parts never move in lockstep.
- Pop in and out with scale, never opacity: slabs draw as a stack of copies, and alpha makes the stack show.
- Path morph keys need identical command lists; build them from one function (see `flame` in `kit/icons.ts`).
- Parts that leave the scene use `ip` and `op`, the way the devices loop cuts each part one after another.
- `icon.still` is the frame shown under reduced motion and where a play once stops: set it to a frame where the icon reads whole.

## Check before calling it done

- `fi check icons.ts` passes: ids, parents and clips resolve, colours are slots or hex, morph keys share commands, every motion's
  loop divides the icon, nothing touches the canvas edge on any frame, and the loop does not jump at the seam.
- The sheet reads at 160px: silhouette clear, outline even, side falling the same way on every part.
- Beside the worked icons at the same px: same line weight, faces leaning, every part with thickness.
- It reads in the asked colours and in `riso`: render both with `fi frame`.
- Every moving part pivots where it would hinge, and at least one part lags.

## Using it on the web

```tsx
import { Sticker } from './stickers/Sticker'
import { brand } from './stickers/fun'
import { coins } from './stickers/icons'

<Sticker icon={coins} palette={brand('#0a84ff', '#ff7a1a')} enter={0.2} style={{ width: 160 }} />
<Sticker icon={coins} loop={false} replayOnHover />
```

- `<Sticker>` props: `icon`, `palette`, `loop` (false plays once from when it first shows, then rests on `still`), `offset`
  (frames ahead of the page clock), `enter` (seconds after it first shows to pop in on the bounce entrance), `replayOnHover`,
  `label` (accessible name, the icon's by default), `className`, `style`. The canvas fills its width at 1:1; size it with css.
- Without React: `const player = play(canvas, icon, { palette, loop, offset, enter })` returns `{ stop, replay, set }`. `set`
  swaps the icon or options in place; `replay` restarts the motion from frame 0 on the next tick.
- One rAF loop drives every sticker on the page. It skips stickers off screen and repaints only when the pose, size or colours
  changed, so a still hold or a paused clock costs no paint. Copies along the side are spaced per device pixel, so a 48px sticker
  draws a fraction of a 500px one. A sticker whose icon throws is logged and dropped; the rest keep playing.
- Reduced motion shows `icon.still` and skips the entrance, following the setting live.
- `clock.speed`, `clock.paused` and `clock.frame` steer every sticker at once, for slow motion and scrubbing in review tools.
- SSR safe: nothing touches the DOM until `play()` or `paint()` runs, so mount stickers in effects.
- For a fixed frame (a post, an export): `draw(ctx, icon, frame, palette)` with ctx scaled so the icon's 500 units fill the box.

For video, `fi export` writes VP9 alpha webm, HEVC alpha mp4 on macOS for Safari, and `<name>.png` at the still frame for the
video's poster and a reduced motion fallback; `--bg '#ffffff'` flattens to H.264 for social. Colour flags apply to every render.
````

## File: skills/fun-isometric/.gitignore
size: 59
sha: ec174d6f0b9eba2d72c76354e7b86b19dc17c716
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/.gitignore

```text
.vercel
.env*
public/fun-isometric.zip
public/.well-known/
```

## File: skills/fun-isometric/.vercelignore
size: 204
sha: fd031e9e94d5b7d7ace9a2c5f27214454ae66569
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/.vercelignore

```text
# Vercel builds the site from these alone, so the skill, its tools and any local env never upload
/*
!/index.html
!/main.tsx
!/vite.config.ts
!/package.json
!/bun.lock
!/vercel.json
!/site
!/kit
!/public
```

## File: skills/fun-isometric/bin/fi.mjs
size: 13351
sha: 3652ee85b22933a8bcfa47ce1990a19df88df262
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/bin/fi.mjs

```text
#!/usr/bin/env bun
// fi: renders fun-isometric icons in headless Chromium, as contact sheets to judge motion and as looping alpha video to ship.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createRequire } from 'node:module'
import { spawnSync } from 'node:child_process'

const HELP = `fi check  <icons.ts> [name...]
  structure (ids, parents, clips, palette slots, morph commands, loops), clipping at the canvas edge on every frame, the loop seam; exits 1 on a problem
fi sheet  <icons.ts> [name...] [--fps 10] [--cells 20] [--px 200] [--out sheet.png]
  one row pair per icon, frames sampled at --fps from 0, white ground; the kit (fun.ts) must sit next to icons.ts
fi frame  <icons.ts> <name> <frame> [--px 800] [--out frame.png] [--bg #ffffff]
fi export <icons.ts> <name> [--px 400] [--out dir] [--bg #ffffff]
  every frame of the loop at 60fps to <name>.webm (VP9 alpha) and <name>.mp4 (HEVC alpha, Safari), plus <name>.png at the still
  frame for a poster; --bg flattens to H.264 instead
colours, on sheet, frame and export: --palette riso|candy|matcha|sunset|clay|aqua, or '{"main":"#0a84ff"}' (json or a .json file) | --hue <deg> | --brand '<#primary>[,#secondary]' | --mono <#hex>`

const PW = '1.63.0'
const CACHE = path.join(os.homedir(), '.cache', 'fun-isometric')
const die = (m) => (console.error(`fi: ${m}`), process.exit(1))

const argv = process.argv.slice(2)
const flags = {}
const pos = []
for (let i = 0; i < argv.length; i++) {
  if (argv[i].startsWith('--')) flags[argv[i].slice(2)] = argv[i + 1]?.startsWith('--') || argv[i + 1] === undefined ? true : argv[++i]
  else pos.push(argv[i])
}
const [cmd, file, ...names] = pos
if (!cmd || !file || flags.help) (console.log(HELP), process.exit(cmd ? 0 : 1))
const mod = path.resolve(file)
const kit = path.join(path.dirname(mod), 'fun.ts')
if (!fs.existsSync(mod)) die(`no ${mod}`)
if (!fs.existsSync(kit)) die(`no fun.ts next to ${mod}, copy the kit there`)

// An installed playwright only: the project's, else our cache. A bare import would let Bun auto install a newer one with no browser.
function findPlaywright() {
  for (let dir = process.cwd(); ; dir = path.dirname(dir)) {
    if (fs.existsSync(path.join(dir, 'node_modules/playwright/package.json'))) return path.join(dir, 'node_modules/playwright')
    if (dir === path.dirname(dir)) break
  }
  const cached = path.join(CACHE, 'node_modules/playwright')
  if (fs.existsSync(path.join(cached, 'package.json'))) return cached
  fs.mkdirSync(CACHE, { recursive: true })
  if (!fs.existsSync(path.join(CACHE, 'package.json'))) fs.writeFileSync(path.join(CACHE, 'package.json'), '{"private":true}')
  console.log(`fi: installing playwright@${PW} into ${CACHE}`)
  if (spawnSync('npm', ['i', `playwright@${PW}`, '--no-audit', '--no-fund'], { cwd: CACHE, stdio: 'inherit' }).status) die('npm install failed')
  return cached
}

async function browser() {
  const dir = findPlaywright()
  const pw = createRequire(path.join(dir, 'package.json'))(dir)
  try {
    return await pw.chromium.launch()
  } catch (e) {
    if (!/Executable doesn't exist|playwright install/i.test(e.message)) throw e
    // Bun, not node: node's happy eyeballs aborts this download on networks with broken IPv6
    if (spawnSync(process.execPath, [path.join(dir, 'cli.js'), 'install', 'chromium'], { stdio: 'inherit' }).status) die('chromium install failed')
    return pw.chromium.launch()
  }
}

// One bundle exposing every exported icon and a frame painter to the page
async function bundle() {
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fi-'))
  const entry = path.join(tmp, 'entry.ts')
  fs.writeFileSync(
    entry,
    `import * as mod from ${JSON.stringify(mod)}
import { PALETTES, RISO, brand, draw, hue, mono } from ${JSON.stringify(kit)}
const icons = [...new Set(Object.values(mod).flatMap((v) => (Array.isArray(v) ? v : [v])))].filter((v) => v && typeof v === 'object' && 'layers' in v && 'frames' in v)
const cv = document.createElement('canvas')
let palette
const NUM = /-?(?:\\d+\\.?\\d*|\\.\\d+)(?:e[-+]?\\d+)?/gi
Object.assign(window, {
  icons: icons.map((i) => ({ name: i.name, frames: i.frames, still: i.still ?? 0 })),
  colours(spec) {
    if (typeof spec.palette === 'string' && !(spec.palette in PALETTES)) throw new Error('no palette ' + spec.palette + ', try ' + Object.keys(PALETTES).join(' '))
    palette = (typeof spec.palette === 'string' ? PALETTES[spec.palette] : spec.palette) ?? (spec.hue != null ? hue(spec.hue) : spec.brand ? brand(...spec.brand.split(/[ ,]+/)) : spec.mono ? mono(spec.mono) : undefined)
  },
  // what the type system cannot see: dangling ids, unknown slots, morphs whose commands drift, loops that do not divide the icon
  lint(name) {
    const icon = icons.find((i) => i.name === name)
    const out = []
    const ids = new Set()
    for (const L of icon.layers) if (L.id) ids.has(L.id) ? out.push(\`id "\${L.id}" used twice\`) : ids.add(L.id)
    const byId = new Map(icon.layers.filter((L) => L.id).map((L) => [L.id, L]))
    icon.layers.forEach((L, i) => {
      const at = L.id ? \`layer "\${L.id}"\` : \`layer \${i}\`
      if (L.parent && !ids.has(L.parent)) out.push(\`\${at}: parent "\${L.parent}" not found\`)
      if (L.clip && !ids.has(L.clip)) out.push(\`\${at}: clip "\${L.clip}" not found\`)
      for (let up = L, n = 0; up?.parent; up = byId.get(up.parent)) if (++n > 32) { out.push(\`\${at}: parents loop\`); break }
      for (const m of [L.m].flat().filter(Boolean)) if (m.loop && icon.frames % m.loop) out.push(\`\${at}: a motion repeats every \${m.loop} frames in a \${icon.frames} frame icon, give it loop: \${icon.frames}\`)
      for (const S of L.shapes) {
        for (const c of [S.fill, S.side, icon.ink].filter(Boolean)) if (c[0] !== '#' && !(c in RISO)) out.push(\`\${at}: "\${c}" is not a palette slot or a hex\`)
        if (Array.isArray(S.d) && new Set(S.d.map(([, d]) => d.replace(NUM, '#'))).size > 1) out.push(\`\${at}: morph keys have different commands\`)
        if (S.trim !== undefined && S.fill) out.push(\`\${at}: trim only draws on an open path, this one is filled\`)
      }
    })
    return [...new Set(out)]
  },
  // frames where ink reaches the canvas edge, so a swing or a sparkle is cut off
  clipped(name) {
    const icon = icons.find((i) => i.name === name)
    const px = 200
    const c = document.createElement('canvas')
    c.width = c.height = px
    const ctx = c.getContext('2d', { willReadFrequently: true })
    const hits = []
    for (let f = 0; f < icon.frames; f++) {
      ctx.setTransform(1, 0, 0, 1, 0, 0)
      ctx.clearRect(0, 0, px, px)
      ctx.scale(px / (icon.size ?? 500), px / (icon.size ?? 500))
      draw(ctx, icon, f)
      const d = ctx.getImageData(0, 0, px, px).data
      let hit = false
      for (let i = 0; i < px && !hit; i++) hit = d[i * 4 + 3] > 24 || d[((px - 1) * px + i) * 4 + 3] > 24 || d[i * px * 4 + 3] > 24 || d[(i * px + px - 1) * 4 + 3] > 24
      if (hit) hits.push(f)
    }
    return hits
  },
  shot(name, frame, px, bg) {
    const icon = icons.find((i) => i.name === name)
    cv.width = cv.height = px
    const ctx = cv.getContext('2d')
    ctx.setTransform(1, 0, 0, 1, 0, 0)
    ctx.clearRect(0, 0, px, px)
    if (bg) (ctx.fillStyle = bg), ctx.fillRect(0, 0, px, px)
    ctx.scale(px / (icon.size ?? 500), px / (icon.size ?? 500))
    draw(ctx, icon, frame, palette)
    return cv.toDataURL('image/png')
  },
  // pixels whose luminance moves by more than 40 between two frames, at 300px on white
  delta(name, a, b) {
    const icon = icons.find((i) => i.name === name)
    const px = 300
    const lum = (f) => {
      const c = document.createElement('canvas')
      c.width = c.height = px
      const ctx = c.getContext('2d')
      ctx.fillStyle = '#fff'
      ctx.fillRect(0, 0, px, px)
      ctx.scale(px / (icon.size ?? 500), px / (icon.size ?? 500))
      draw(ctx, icon, f)
      const d = ctx.getImageData(0, 0, px, px).data
      const out = new Float32Array(px * px)
      for (let i = 0; i < out.length; i++) out[i] = 0.299 * d[i * 4] + 0.587 * d[i * 4 + 1] + 0.114 * d[i * 4 + 2]
      return out
    }
    const [x, y] = [lum(a), lum(b)]
    let n = 0
    for (let i = 0; i < x.length; i++) if (Math.abs(x[i] - y[i]) > 40) n++
    return n
  },
})`,
  )
  const out = await Bun.build({ entrypoints: [entry], target: 'browser', format: 'esm', minify: false })
  fs.rmSync(tmp, { recursive: true, force: true })
  if (!out.success) die(out.logs.map(String).join('\n'))
  return out.outputs[0].text()
}

const b = await browser()
const page = await b.newPage()
await page.setContent(`<!doctype html><body style="margin:0"><script type="module">${(await bundle()).replace(/<\/script/g, '<\\/script')}</script></body>`)
await page.waitForFunction(() => window.icons)
const all = await page.evaluate(() => window.icons)
const spec = {
  // a set's name, inline json or a json file
  palette: typeof flags.palette !== 'string' ? undefined : /^[a-z]+$/.test(flags.palette) ? flags.palette : JSON.parse(fs.existsSync(flags.palette) ? fs.readFileSync(flags.palette, 'utf8') : flags.palette),
  hue: flags.hue !== undefined ? Number(flags.hue) : undefined,
  brand: flags.brand,
  mono: flags.mono,
}
await page.evaluate((s) => window.colours(s), spec)
const pick = names.length && cmd !== 'frame' && cmd !== 'export' ? all.filter((i) => names.includes(i.name)) : all
const png = (url) => Buffer.from(url.split(',')[1], 'base64')
const shot = (name, f, px, bg) => page.evaluate(([n, f, px, bg]) => window.shot(n, f, px, bg), [name, f, px, bg ?? null])

if (cmd === 'sheet') {
  const [fps, cells, px] = [Number(flags.fps ?? 10), Number(flags.cells ?? 20), Number(flags.px ?? 200)]
  const out = path.resolve(flags.out ?? 'sheet.png')
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fi-sheet-'))
  let n = 0
  for (const icon of pick)
    for (let i = 0; i < cells; i++) fs.writeFileSync(path.join(tmp, `${String(n++).padStart(5, '0')}.png`), png(await shot(icon.name, (i * 60) / fps, px, '#ffffff')))
  const r = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', path.join(tmp, '%05d.png'), '-vf', `tile=10x${Math.ceil(n / 10)}`, '-frames:v', '1', out], { stdio: 'inherit' })
  fs.rmSync(tmp, { recursive: true, force: true })
  if (r.status) die('ffmpeg tile failed')
  console.log(`${out}  ${pick.map((i) => i.name).join(', ')}  ${fps}fps x ${cells}`)
} else if (cmd === 'frame') {
  const [name, f] = names
  const out = path.resolve(flags.out ?? `${name}-${f}.png`)
  fs.writeFileSync(out, png(await shot(name, Number(f), Number(flags.px ?? 800), flags.bg ?? '#ffffff')))
  console.log(out)
} else if (cmd === 'export') {
  const icon = all.find((i) => i.name === names[0]) ?? die(`no icon "${names[0]}" in ${all.map((i) => i.name).join(', ')}`)
  const px = Math.round(Number(flags.px ?? 400) / 2) * 2
  const dir = path.resolve(flags.out ?? '.')
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fi-export-'))
  for (let f = 0; f < icon.frames; f++) fs.writeFileSync(path.join(tmp, `${String(f).padStart(5, '0')}.png`), png(await shot(icon.name, f, px, flags.bg)))
  const src = ['-framerate', '60', '-i', path.join(tmp, '%05d.png'), '-an']
  const base = path.join(dir, icon.name)
  fs.mkdirSync(dir, { recursive: true })
  const run = (args) => spawnSync('ffmpeg', ['-v', 'error', '-y', ...src, ...args], { stdio: 'inherit' }).status && die(`ffmpeg failed: ${args.at(-1)}`)
  if (flags.bg) run(['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '16', '-movflags', '+faststart', `${base}.mp4`])
  else {
    run(['-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-b:v', '0', '-crf', '30', '-row-mt', '1', `${base}.webm`])
    if (process.platform === 'darwin') run(['-c:v', 'hevc_videotoolbox', '-alpha_quality', '0.75', '-allow_sw', '1', '-b:v', '800k', '-tag:v', 'hvc1', `${base}.mp4`])
  }
  fs.writeFileSync(`${base}.png`, png(await shot(icon.name, icon.still, px, flags.bg)))
  fs.rmSync(tmp, { recursive: true, force: true })
  console.log(`${base}.${flags.bg ? 'mp4' : 'webm'}  ${icon.frames} frames at ${px}px, poster ${base}.png`)
} else if (cmd === 'check') {
  let bad = 0
  for (const icon of pick) {
    const L = icon.frames - 1
    const [lint, clipped, [before, wrap, after]] = await page.evaluate(
      ([n, L]) => [window.lint(n), window.clipped(n), [window.delta(n, L - 1, L), window.delta(n, L, 0), window.delta(n, 0, 1)]],
      [icon.name, L],
    )
    const problems = [...lint]
    if (clipped.length) problems.push(`touches the canvas edge on ${clipped.length} frame${clipped.length > 1 ? 's' : ''} (${clipped.slice(0, 6).join(', ')}${clipped.length > 6 ? ', ...' : ''}): shrink it or pull it in`)
    // the wrap from the last frame to the first should change no more than the frames either side of it
    if (wrap > 300 && wrap > 3 * Math.max(before, after)) problems.push(`the loop jumps at the seam (${wrap} px against ${before} and ${after}): a preset loop that does not divide the frames, or a motion running past the end`)
    bad += problems.length > 0
    console.log(`${problems.length ? 'FAIL' : 'ok  '} ${icon.name}${problems.map((p) => `\n     ${p}`).join('')}`)
  }
  console.log(bad ? `fi: ${bad} of ${pick.length} icons need work` : `fi: ${pick.length} icons pass`)
  process.exitCode = bad ? 1 : 0
} else die(`unknown command ${cmd}\n${HELP}`)
await b.close()
```

## File: skills/fun-isometric/bin/pack.mjs
size: 2371
sha: e6a8df41b7985fdc1bd08cad749839a52e543a0c
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/bin/pack.mjs

```text
#!/usr/bin/env bun
// pack: builds what the site serves from this folder's tracked files, the unzip download and the agent-skills discovery index.
import fs from 'node:fs'
import path from 'node:path'
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'

const ROOT = path.resolve(import.meta.dirname, '..')
const NAME = path.basename(ROOT)
const PUBLIC = path.join(ROOT, 'public')
const KNOWN = path.join(PUBLIC, '.well-known', 'agent-skills')
const die = (m) => (console.error(`pack: ${m}`), process.exit(1))
const run = (cmd, args, opts) => execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', ...opts })
const list = (...args) => run('git', ['ls-files', ...args, '.']).split('\n').filter(Boolean)

// a stray local file would ship to everyone, so only committed files go in
const stray = list('-o', '--exclude-standard')
if (stray.length) die(`commit or ignore these first:\n${stray.join('\n')}`)
const files = list()

// zip appends to an existing archive, so start clean
const download = path.join(PUBLIC, `${NAME}.zip`)
const archive = path.join(KNOWN, `${NAME}.zip`)
fs.rmSync(download, { force: true })
fs.rmSync(KNOWN, { recursive: true, force: true })
fs.mkdirSync(KNOWN, { recursive: true })

// the download keeps the folder for `unzip -d ~/.claude/skills`, the discovery archive puts SKILL.md at its root
run('zip', ['-qX', download, '-@'], { cwd: path.dirname(ROOT), input: files.map((f) => `${NAME}/${f}`).join('\n') })
run('zip', ['-qX', archive, '-@'], { input: files.join('\n') })

const description = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf8').match(/^description: (.+)$/m)?.[1]
if (!description || description.length > 1024) die('SKILL.md needs a description of 1 to 1024 characters')
const digest = `sha256:${createHash('sha256').update(fs.readFileSync(archive)).digest('hex')}`

// absolute url, so the index answers the same at the site root and under /<name>
const index = {
  $schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json',
  skills: [{ name: NAME, type: 'archive', description, url: `/${NAME}/.well-known/agent-skills/${NAME}.zip`, digest }],
}
fs.writeFileSync(path.join(KNOWN, 'index.json'), `${JSON.stringify(index, null, 2)}\n`)
console.log(`pack: ${files.length} files, ${path.relative(ROOT, download)}, ${path.relative(ROOT, archive)} (${digest.slice(0, 19)}…)`)
```

## File: skills/fun-isometric/bun.lock
size: 41787
sha: e93ad1b6811a5564a5eaef56ddd7b2675ccfc67c
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/bun.lock

Content is not inlined in the catalog. Read the upstream file: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/bun.lock

## File: skills/fun-isometric/index.html
size: 1605
sha: cc67e4c4b2b5a847b50fc9f4a42cf377ffb8afcf
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/index.html

```text
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>fun-isometric</title>
    <meta name="description" content="A skill for Claude Code and Codex that draws isometric stickers and animates them on real keyframes. Any object, any palette, one command." />
    <meta name="theme-color" content="#ffffff" />
    <link rel="canonical" href="https://skills.klvn.dev/fun-isometric" />
    <meta property="og:type" content="website" />
    <meta property="og:url" content="https://skills.klvn.dev/fun-isometric" />
    <meta property="og:title" content="fun-isometric" />
    <meta property="og:description" content="A skill for Claude Code and Codex that draws isometric stickers and animates them on real keyframes." />
    <meta property="og:image" content="https://skills.klvn.dev/fun-isometric/og.png" />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <meta name="twitter:card" content="summary_large_image" />
    <link rel="icon" href="/favicon.png" type="image/png" />
    <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
    <style>
      *, *::before, *::after { box-sizing: border-box; }
      html { scroll-behavior: smooth; }
      body { margin: 0; background: #fff; -webkit-font-smoothing: antialiased; }
      @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.tsx"></script>
  </body>
</html>
```

## File: skills/fun-isometric/kit/fun.ts
size: 33323
sha: 93b4a79b35eaf71d1edcf72ea7c7d3550b206f41
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/kit/fun.ts

Content is not inlined in the catalog. Read the upstream file: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/kit/fun.ts

## File: skills/fun-isometric/kit/icons.ts
size: 27182
sha: 47f50082080377c7c3d31e04800dee95d209cbdd
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/kit/icons.ts

```text
// The fun-isometric set: everyday product icons drawn as slabs, each moving on one preset.
import { VIEWS, arc, ellipse, line, onFace, pill, poly, rect, ring, spark, star, view } from './fun'
import { EASY, SNAP, bob, bounce, clasp, deal, drift, erase, fall, flicker, float, glint, hop, impact, jolt, latch, look, press, shackle, slide, sparkle, springTurn, sway, toss, tossLag, tossLag2, twinkle, twist, wiggle, wobble } from './motions'
import type { Icon, Layer, Motion, Shape, Track, Vec } from './fun'

// colours are palette slots, so draw(..., palette) or play(..., { palette }) recolours the whole set
const LINE = 5
// the house view: faces turned 18 degrees in their plane and leaned back 36, sides falling down left
const { tilt: FACE, extrude: EXT } = VIEWS.right

// a white sparkle riding a parent, popped by whatever motion it is given
const glow = (parent: string, p: Vec, r: number, m: Motion | ReadonlyArray<Motion>, extra: { ip?: number; op?: number } = {}): Layer => ({
  parent,
  a: p,
  p,
  m,
  ...extra,
  shapes: [{ d: spark(p[0], p[1], r), fill: 'white' }],
})

// grows or shrinks a path of plain x y pairs about a point, for rims and insets
const inset = (d: string, k: number, [cx, cy]: Vec) =>
  d.replace(/(-?\d+\.?\d*) (-?\d+\.?\d*)/g, (_, x: string, y: string) => `${+(cx + (+x - cx) * k).toFixed(2)} ${+(cy + (+y - cy) * k).toFixed(2)}`)

export const folder: Icon = {
  name: 'folder',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { parent: 'body', shapes: [{ d: poly([[-130, -100], [-56, -100], [-34, -76], [130, -76], [130, 92], [-130, 92]], [18, 8, 8, 18, 18, 18]), fill: 'main', depth: 1 }] },
    {
      parent: 'body',
      a: [-108, 50],
      p: [-108, 50],
      m: tossLag2,
      shapes: [{ d: rect(-104, -136, 206, 192, 12), fill: 'white', depth: 0.3 }, { d: line([-78, -118], [8, -118]) }],
    },
    { parent: 'body', a: [-116, 52], p: [-116, 52], m: tossLag, shapes: [{ d: rect(-116, -104, 226, 164, 12), fill: 'ok', depth: 0.3 }] },
    {
      id: 'body',
      a: [-130, 20],
      p: [126, 294],
      tilt: FACE,
      m: toss,
      shapes: [{ d: rect(-130, -46, 260, 138, 18), fill: 'deep', depth: 1 }, { d: pill(-46, -12, 92, 28), fill: 'white', depth: 0.3 }],
    },
  ],
}

const HEART = 'M0 92C-24 74 -132 8 -132 -48C-132 -94 -98 -122 -62 -122C-34 -122 -12 -106 0 -84C12 -106 34 -122 62 -122C98 -122 132 -94 132 -48C132 8 24 74 0 92Z'

export const heart: Icon = {
  name: 'heart',
  frames: 123,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'heart', p: [250, 262], m: hop, shapes: [] },
    { parent: 'heart', tilt: [-14, 30], shapes: [{ d: HEART, fill: 'hot', depth: 1, edges: [[0, 92]] }, { d: inset(HEART, 0.7, [0, -22]) }] },
    glow('heart', [128, -112], 30, twinkle(0, true), { op: 30 }),
    glow('heart', [128, -112], 30, twinkle(48), { ip: 48 }),
    glow('heart', [-150, 10], 18, twinkle(4, true), { op: 34 }),
    glow('heart', [-150, 10], 18, twinkle(54), { ip: 54 }),
  ],
}

const COIN = (cx: number, cy: number) => ellipse(cx, cy, 112, 80)

export const coins: Icon = {
  name: 'coins',
  frames: 180,
  line: LINE,
  extrude: [-2, 18],
  layers: [
    { p: [252, 352], r: -10, shapes: [{ d: COIN(0, 0), fill: 'gold', depth: 1 }] },
    { p: [246, 318], r: -10, shapes: [{ d: COIN(0, 0), fill: 'gold', depth: 1 }] },
    {
      id: 'coin',
      p: [240, 278],
      r: -10,
      m: bounce,
      shapes: [{ d: COIN(0, 0), fill: 'gold', depth: 1 }, { d: ellipse(0, 0, 86, 58) }, { d: star(0, 2, 40, 0.46, 5, 4), fill: 'ink', line: false }],
    },
    glow('coin', [96, -66], 26, glint(2, 98)),
    glow('coin', [-104, -48], 26, glint(6, 122)),
    glow('coin', [-26, 70], 22, glint(12, 107)),
    glow('coin', [92, -70], 28, glint(65, 125)),
  ],
}

// a bell seen from a little above: dome, flared skirt, and a lip whose front curves down
const BELL = 'M-96 40C-94 -20 -80 -96 0 -98C80 -96 94 -20 96 40C108 46 120 54 122 66Q0 96 -122 66C-120 54 -108 46 -96 40Z'
const LIP = 'M-122 66Q0 96 122 66A9 9 0 0 1 124 84Q0 116 -124 84A9 9 0 0 1 -122 66Z'

export const bell: Icon = {
  name: 'bell',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { parent: 'bell', a: [0, 80], p: [0, 80], m: { ...wiggle, at: 6, k: 0.8 }, shapes: [{ d: ellipse(0, 112, 24), fill: 'hot', depth: 0.8 }] },
    {
      id: 'bell',
      a: [0, -112],
      p: [250, 136],
      r: -8,
      m: wiggle,
      shapes: [
        { d: ring(0, -116, 22, 9), fill: 'gold', depth: 0.6 },
        { d: BELL, fill: 'gold', depth: 1 },
        { d: LIP, fill: 'gold', depth: 1 },
      ],
    },
  ],
}

const BADGE = onFace(FACE, [100, 82])

export const calendar: Icon = {
  name: 'calendar',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'cal', p: [244, 262], m: jolt(), shapes: [] },
    {
      parent: 'cal',
      tilt: FACE,
      shapes: [
        { d: rect(-122, -104, 244, 214, 26), fill: 'white', depth: 1 },
        { d: poly([[-122, -104], [122, -104], [122, -44], [-122, -44]], [26, 26, 0, 0]), fill: 'hot' },
        ...[-1, 1].map((s): Shape => ({ d: pill(s * 60 - 13, -128, 26, 50), fill: 'grey', depth: 0.5 })),
        ...[0, 1, 2, 3].flatMap((c) => [0, 1].map((r): Shape => ({ d: rect(-96 + c * 50, -22 + r * 52, 34, 34, 9), ...(c === 1 && r === 1 ? { fill: 'ok' } : {}) }))),
      ],
    },
    {
      parent: 'cal',
      a: BADGE,
      p: BADGE,
      m: [twinkle(14), twinkle(100, true)],
      shapes: [
        { d: ellipse(BADGE[0], BADGE[1], 40), fill: 'ok', depth: 0.7 },
        { d: line([BADGE[0] - 18, BADGE[1]], [BADGE[0] - 5, BADGE[1] + 13], [BADGE[0] + 20, BADGE[1] - 14]), line: 7 },
      ],
    },
  ],
}

const KEYHOLE = ellipse(0, -4, 17) + poly([[-8, 6], [8, 6], [13, 42], [-13, 42]], 3)
const SHACKLE = 'M-74 -40L-74 -122A74 74 0 0 1 74 -122L74 -40L42 -40L42 -122A42 42 0 0 0 -42 -122L-42 -40Z'

export const padlock: Icon = {
  name: 'padlock',
  frames: 130,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'lock', p: [256, 300], m: clasp, shapes: [] },
    { parent: 'body', a: [-58, -62], p: [-58, -62], m: shackle, shapes: [{ d: SHACKLE, fill: 'grey', depth: 0.7 }] },
    {
      id: 'body',
      parent: 'lock',
      tilt: FACE,
      shapes: [
        { d: rect(-112, -80, 224, 176, 24), fill: 'ok', depth: 1 },
        { d: ellipse(0, 8, 36, 50), fill: 'white', depth: 0.25 },
        { d: KEYHOLE, fill: 'ink', line: false },
      ],
    },
    glow('lock', [-142, -70], 30, twinkle(0, true), { op: 30 }),
    glow('lock', [-142, -70], 30, twinkle(75), { ip: 75 }),
  ],
}

const BOW = 'M0 -80C-18 -126 -86 -136 -76 -98C-70 -80 -30 -80 0 -80ZM0 -80C18 -126 86 -136 76 -98C70 -80 30 -80 0 -80Z'

export const gift: Icon = {
  name: 'gift',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'box', p: [250, 306], m: impact([3, 12]), shapes: [] },
    {
      id: 'crate',
      parent: 'box',
      tilt: FACE,
      // the ribbon carries its own depth, so it wraps down the side
      shapes: [{ d: rect(-112, -40, 224, 136, 16), fill: 'tint', depth: 1 }, { d: rect(-22, -40, 44, 136), fill: 'gold', depth: 1 }],
    },
    {
      parent: 'crate',
      p: [0, -44],
      m: latch([0, 44]),
      shapes: [
        { d: BOW, fill: 'gold', depth: 0.5 },
        { d: rect(-130, -80, 260, 52, 16), fill: 'tint', depth: 0.8 },
        { d: rect(-24, -80, 48, 52), fill: 'gold', depth: 0.8 },
        { d: pill(-20, -96, 40, 30), fill: 'gold', depth: 0.5 },
      ],
    },
    // they swell as the lid lifts and finish after the seam, so at wraps them round the loop
    glow('box', [-128, -128], 24, { ...glint(0, 110), at: 140 }),
    glow('box', [122, -146], 30, { ...glint(0, 120), at: 146 }),
  ],
}

// modelled in 3d, x forward, y to starboard, z up, so the folds keep true proportions; the near wing droops to show its face
const fly = view(50, 30)
const [NOSE, TAIL, KEEL, FAR, NEAR] = [fly([172, 0, 0]), fly([-128, 0, 0]), fly([-104, 0, -112]), fly([-144, 116, 20]), fly([-144, -116, -44])]
// pivot on the middle of its bounds so drift turns it in place
const MID: Vec = [(Math.min(FAR[0], KEEL[0], NEAR[0]) + NOSE[0]) / 2, (NOSE[1] + Math.max(NEAR[1], KEEL[1])) / 2]

export const plane: Icon = {
  name: 'plane',
  frames: 180,
  line: LINE,
  extrude: [-4, 13],
  layers: [
    {
      a: MID,
      p: [256, 250],
      m: drift,
      shapes: [
        { d: poly([NOSE, FAR, TAIL], 3), fill: 'paper', side: 'white', depth: 1 },
        { d: poly([NOSE, TAIL, KEEL], 3), fill: 'grey', side: 'white', depth: 1 },
        { d: poly([NOSE, TAIL, NEAR], 3), fill: 'paper', side: 'white', depth: 1 },
      ],
    },
  ],
}

export const search: Icon = {
  name: 'search',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'lens', p: [222, 226], m: twist, shapes: [] },
    {
      parent: 'lens',
      tilt: [-20, 28],
      shapes: [
        { d: poly([[54, 90], [90, 54], [178, 142], [142, 178]], [0, 0, 24, 24]), fill: 'hot', depth: 1 },
        { d: poly([[48, 93], [93, 48], [113, 68], [68, 113]], 6), fill: 'grey', depth: 1 },
        { d: ellipse(0, 0, 80), fill: 'white' },
        { d: ring(0, 0, 104, 78), fill: 'main', depth: 1 },
        { d: arc(0, 0, 56, 200, 255) },
      ],
    },
    glow('lens', [30, -26], 22, sparkle(20)),
    glow('lens', [-32, 30], 14, sparkle(96)),
  ],
}

const BUBBLE = poly([[-132, -96], [132, -96], [132, 74], [-36, 74], [-96, 120], [-80, 74], [-132, 74]], [30, 30, 30, 4, 6, 4, 30])
const BOT: Vec = [-16, 30]
// it rocks on the bubble's bottom edge, placed so the face sits where it always did
const ROCK = onFace(BOT, [0, 74])

export const chat: Icon = {
  name: 'chat',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'bot', a: ROCK, p: [252 + ROCK[0], 216 + ROCK[1]], m: wobble, shapes: [] },
    {
      id: 'face',
      parent: 'bot',
      tilt: BOT,
      shapes: [
        { d: pill(-8, -150, 16, 60), fill: 'grey', depth: 0.5 },
        { d: ellipse(0, -152, 20), fill: 'gold', depth: 0.6 },
        { d: BUBBLE, fill: 'main', depth: 1 },
        { d: rect(-104, -70, 208, 118, 26), fill: 'tint' },
        { d: arc(0, 4, 30, 25, 155) },
      ],
    },
    { id: 'eyeL', parent: 'face', shapes: [{ d: ellipse(-46, -20, 26, 30), fill: 'white' }] },
    { id: 'eyeR', parent: 'face', shapes: [{ d: ellipse(46, -20, 26, 30), fill: 'white' }] },
    { parent: 'face', clip: 'eyeL', m: { ...look, k: 1.6 }, shapes: [{ d: ellipse(-40, -14, 15), fill: 'ink', line: false }] },
    { parent: 'face', clip: 'eyeR', m: { ...look, k: 1.6 }, shapes: [{ d: ellipse(52, -14, 15), fill: 'ink', line: false }] },
    glow('bot', [150, -118], 26, sparkle(16)),
    glow('bot', [-156, -60], 16, sparkle(92)),
  ],
}

// Devices: each part dealt in on its own beat, then dropped and cut one after another before the loop comes round
const exit = (start: number): Motion => fall([0, 15], start)

export const phone: Icon = {
  name: 'phone',
  frames: 180,
  line: LINE,
  extrude: EXT,
  still: 120,
  layers: [
    {
      p: [236, 270],
      tilt: FACE,
      op: 172,
      m: [deal([-46, 34], -21, 0), exit(144)],
      shapes: [
        ...[-96, -46].map((y): Shape => ({ d: rect(-92, y, 16, 36, 5), fill: 'deep', depth: 0.9 })),
        { d: rect(-84, -146, 168, 292, 30), fill: 'deep', depth: 0.9 },
        { d: rect(-68, -128, 136, 256, 18), fill: 'tint' },
        { d: pill(-20, -140, 40, 8), fill: 'ink', line: false },
      ],
    },
    {
      p: [284, 220],
      tilt: FACE,
      ip: 14,
      op: 176,
      m: [deal([54, 5], 30, 14), exit(148)],
      shapes: [
        { d: rect(-104, -38, 208, 76, 20), fill: 'white', depth: 0.7 },
        { d: ellipse(-62, 0, 20), fill: 'ok' },
        { d: line([-30, -12], [70, -12]) },
        { d: line([-30, 12], [36, 12]) },
      ],
    },
    {
      p: [338, 152],
      tilt: FACE,
      ip: 34,
      op: 180,
      m: [deal([62, 5], 23, 34, true), exit(152)],
      shapes: [
        { d: ellipse(0, 0, 34), fill: 'hot', depth: 0.8 },
        { d: 'M0 13C-4 10 -18 1 -18 -6C-18 -12 -13 -16 -8 -16C-4 -16 -1 -14 0 -11C1 -14 4 -16 8 -16C13 -16 18 -12 18 -6C18 1 4 10 0 13Z', fill: 'white', line: 3 },
      ],
    },
  ],
}

export const toggle: Icon = {
  name: 'toggle',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    {
      id: 'track',
      p: [250, 262],
      tilt: FACE,
      // bob repeats every 160 frames, so it rests 20 frames to fit this 180 frame loop
      m: { ...bob, loop: 180 },
      shapes: [{ d: pill(-134, -58, 268, 116), fill: 'white', depth: 1 }, { d: pill(-114, -38, 228, 76), fill: 'grey' }],
    },
    {
      parent: 'track',
      a: [-114, 0],
      p: [-114, 0],
      m: { s: [[20, [36, 100], [0.8, 0, 0.2, 1]], [80, [100, 100], 0], [120, [100, 100], [0.8, 0, 0.2, 1]], [180, [36, 100]]] },
      shapes: [{ d: pill(-114, -38, 228, 76), fill: 'ok' }],
    },
    { parent: 'track', m: slide([148, 0], 20, 40), shapes: [{ d: ellipse(-74, 0, 48), fill: 'white', depth: 0.8 }, { d: ellipse(-74, 0, 28) }] },
  ],
}

// flame outlines share one command list so they morph point for point
const flame = (lean: number, tip: number, k = 1, dy = 0) => {
  const y = (v: number) => v * k + dy
  return (
    `M0 ${y(110)}C${-70 * k} ${y(110)} ${-104 * k} ${y(60)} ${-100 * k} ${y(6)}C${-96 * k} ${y(-38)} ${(-62 + lean * 0.4) * k} ${y(-62)} ${(-56 + lean) * k} ${y(-108)}` +
    `C${(-22 + lean * 0.6) * k} ${y(-86)} ${-6 * k} ${y(-62)} 0 ${y(-30)}C${16 * k} ${y(-62)} ${(34 + lean) * k} ${y(-94 - tip * 0.3)} ${(26 + lean * 1.4) * k} ${y(-140 - tip)}` +
    `C${(86 + lean) * k} ${y(-100)} ${106 * k} ${y(-40)} ${100 * k} ${y(10)}C${98 * k} ${y(64)} ${66 * k} ${y(110)} 0 ${y(110)}Z`
  )
}

export const streak: Icon = {
  name: 'streak',
  frames: 160,
  line: LINE,
  extrude: [-1, 18],
  layers: [
    {
      id: 'fire',
      p: [250, 262],
      r: -6,
      m: bob,
      shapes: [
        { d: flicker([flame(0, 0), flame(-14, 10), flame(10, -6), flame(-14, 10)]), fill: 'hot', depth: 1 },
        { d: flicker([flame(0, 0, 0.6, 34), flame(12, 14, 0.6, 34), flame(-10, -4, 0.6, 34), flame(12, 14, 0.6, 34)]), fill: 'gold', depth: 0.35 },
        { d: flicker([flame(0, 0, 0.3, 58), flame(-8, 8, 0.3, 58), flame(8, -2, 0.3, 58), flame(-8, 8, 0.3, 58)]), fill: 'white' },
      ],
    },
  ],
}

export const notes: Icon = {
  name: 'notes',
  frames: 180,
  still: 0,
  line: LINE,
  extrude: EXT,
  layers: [
    {
      id: 'pad',
      a: [0, 136],
      p: [250, 374],
      tilt: FACE,
      m: { ...sway, k: 0.8 },
      shapes: [
        { d: rect(-118, -136, 236, 272, 24), fill: 'main', depth: 1 },
        { d: rect(-96, -108, 192, 222, 10), fill: 'white', depth: 0.2 },
        { d: pill(-52, -154, 104, 42), fill: 'grey', depth: 0.6 },
        { d: pill(-20, -140, 40, 12), fill: 'ink', line: false },
        ...[0, 1, 2, 3].map((i): Shape => ({ d: line([-68, -66 + i * 40], [i === 3 ? 10 : 68, -66 + i * 40]), trim: erase(22 - i * 5, 80 + i * 5) })),
      ],
    },
  ],
}

export const play: Icon = {
  name: 'play',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'base', p: [250, 278], tilt: FACE, shapes: [{ d: rect(-132, -118, 264, 236, 48), fill: 'white', depth: 1 }] },
    {
      parent: 'base',
      m: press,
      shapes: [{ d: ellipse(0, -8, 80), fill: 'ok', depth: 1.6 }, { d: poly([[-20, -42], [38, -8], [-20, 26]], 7), fill: 'white' }],
    },
  ],
}

// A loop de loop written across the pad, in face space, sampled densely so the nib can be keyed onto it
const LOOPS = 4
const SCRIBBLE: Array<Vec> = (() => {
  const [from, to] = [-Math.PI / 2, LOOPS * 2 * Math.PI + 0.7]
  const pts = Array.from({ length: 721 }, (_, i): Vec => {
    const t = from + ((to - from) * i) / 720
    const tall = 30 * (1 + 0.14 * Math.sin(t / 2.6))
    const x = 5.6 * t + 15 * Math.sin(t)
    return [x, tall * Math.cos(t) - 0.14 * x]
  })
  const mid = (pts[0][0] + pts[720][0]) / 2
  return pts.map(([x, y]) => [x - mid, y + 4])
})()
// on screen the pad leans, so speed and trims are measured after the tilt
const SEEN = SCRIBBLE.map((p) => onFace(FACE, p))
const RUN = SEEN.reduce<Array<number>>((acc, p, i) => (acc.push(i ? acc[i - 1] + Math.hypot(p[0] - SEEN[i - 1][0], p[1] - SEEN[i - 1][1]) : 0), acc), [])
const [WRITE, DONE, PIECES] = [16, 118, 8]
const frameAt = (s: number) => WRITE + ((DONE - WRITE) * s) / RUN[720]
const nibAt = (s: number): Vec => {
  let i = 1
  while (i < 720 && RUN[i] < s) i++
  const k = (s - RUN[i - 1]) / (RUN[i] - RUN[i - 1] || 1)
  return [+(SEEN[i - 1][0] + (SEEN[i][0] - SEEN[i - 1][0]) * k).toFixed(2), +(SEEN[i - 1][1] + (SEEN[i][1] - SEEN[i - 1][1]) * k).toFixed(2)]
}
// cut into pieces with their own trims, so a trim's end never drifts from the nib on a long path
const CUTS = Array.from({ length: PIECES + 1 }, (_, j) => RUN.findIndex((r) => r >= (RUN[720] * j) / PIECES))
const INK: ReadonlyArray<Shape> = CUTS.slice(0, -1).map((c, j) => ({
  d: line(...SCRIBBLE.slice(c, CUTS[j + 1] + 1)),
  line: 7,
  round: true,
  trim: [[frameAt(RUN[c]), 0], [frameAt(RUN[CUTS[j + 1]]), 1], [178, 1, 0], [179, 0]],
}))
const SHINE = onFace(FACE, [104, -136])
const START = SEEN[0]
const END = SEEN[720]
const LIFT: Vec = [12, -40]
const HOVER: Vec = [START[0] + LIFT[0], START[1] + LIFT[1]]
// the nib rides the line every two frames, rocking a little with each loop
const NIB: Track<Vec> = [
  [0, HOVER, EASY],
  ...Array.from({ length: (DONE - WRITE) / 2 }, (_, i) => [WRITE + i * 2, nibAt((RUN[720] * i * 2) / (DONE - WRITE))] as const),
  [DONE, END, EASY],
  [DONE + 16, [END[0] + LIFT[0], END[1] + LIFT[1]], SNAP, [[-30, -40], [30, -40]]],
  [168, HOVER],
]
const TURN: Track<number> = [
  [0, 36, EASY],
  [WRITE, 30],
  ...Array.from({ length: (DONE - WRITE) / 2 - 1 }, (_, i) => [WRITE + (i + 1) * 2, 30 + 4 * Math.sin(((i + 1) * 2 * LOOPS * 2 * Math.PI) / (DONE - WRITE))] as const),
  [DONE, 30, EASY],
  [DONE + 16, 34, SNAP],
  [168, 36],
]

export const pen: Icon = {
  name: 'pen',
  frames: 180,
  // the finished scribble, pen lifted and the sparkle out
  still: 136,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'desk', p: [224, 322], shapes: [] },
    { parent: 'desk', tilt: FACE, shapes: [{ d: rect(-116, -116, 232, 232, 28), fill: 'tint', depth: 1 }] },
    { parent: 'desk', tilt: FACE, a: [0, 4], p: onFace(FACE, [0, 4]), m: twinkle(148, true), shapes: INK },
    {
      parent: 'desk',
      p: NIB,
      r: TURN,
      shapes: [
        { d: poly([[-21, -196], [21, -196], [21, -86], [-21, -86]], [20, 20, 0, 0]), fill: 'main', depth: 1 },
        { d: rect(-21, -174, 42, 20), fill: 'deep', depth: 1 },
        { d: rect(8, -168, 9, 58, 4.5), fill: 'grey', depth: 0.4 },
        { d: rect(-21, -90, 42, 42, 6), fill: 'deep', depth: 1 },
        { d: poly([[-19, -50], [19, -50], [4, -6], [-4, -6]], [4, 4, 3, 3]), fill: 'white', depth: 0.5 },
        { d: ellipse(0, -6, 5.5), fill: 'ink', line: false },
      ],
    },
    glow('desk', SHINE, 26, [twinkle(DONE + 4), twinkle(150, true)]),
  ],
}

// the flag springs up when the post comes, the letter slides out of the door, both home before the loop
const BOX: Vec = FACE
export const mailbox: Icon = {
  name: 'mailbox',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'mail', a: [0, 150], p: [250, 392], m: jolt([-4, 10]), shapes: [] },
    { parent: 'mail', tilt: BOX, shapes: [{ d: rect(-18, 40, 36, 150, 6), fill: 'grey', depth: 0.8 }] },
    {
      parent: 'box',
      a: [70, 0],
      p: [70, 0],
      m: slide([56, 0], 24, 72, 40),
      shapes: [{ d: rect(30, -40, 96, 62, 6), fill: 'white', depth: 0.25 }, { d: line([44, -24], [78, -6], [112, -24]) }],
    },
    {
      id: 'box',
      parent: 'mail',
      tilt: BOX,
      shapes: [
        { d: poly([[-136, -96], [136, -96], [136, 56], [-136, 56]], [72, 72, 10, 10]), fill: 'deep', depth: 1 },
        { d: poly([[96, -96], [136, -96], [136, 56], [96, 56]], [0, 72, 10, 0]), fill: 'main', depth: 1 },
      ],
    },
    {
      parent: 'box',
      a: [-60, -10],
      p: [-60, -10],
      r: 84,
      m: { r: [...springTurn(-84, 18), [128, -84, SNAP], [168, 0]] },
      shapes: [{ d: rect(-68, -130, 16, 124, 4), fill: 'grey', depth: 0.5 }, { d: rect(-68, -130, 70, 44, 6), fill: 'hot', depth: 0.5 }],
    },
  ],
}

// notes pop off the band on the beat while the set bops
const NOTE = (x: number, y: number): ReadonlyArray<Shape> => [
  { d: poly([[x + 6, y - 54], [x + 34, y - 44], [x + 34, y - 28], [x + 14, y - 34], [x + 14, y], [x + 6, y]], 3), fill: 'gold', depth: 0.4 },
  { d: ellipse(x - 4, y + 2, 16, 13), fill: 'gold', depth: 0.4 },
]
const BAND = 'M-128 28A128 128 0 0 1 128 28L100 28A100 100 0 0 0 -100 28Z'

export const headphones: Icon = {
  name: 'headphones',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'set', a: [0, 120], p: [250, 392], m: wobble, shapes: [] },
    {
      parent: 'set',
      tilt: [-12, 24],
      shapes: [
        { d: BAND, fill: 'deep', depth: 0.8 },
        ...[-1, 1].flatMap((side): Array<Shape> => [
          { d: rect(side * 114 - 36, 4, 72, 126, 30), fill: 'main', depth: 1 },
          { d: rect(side * 114 - 20, 22, 40, 90, 18), fill: 'tint', depth: 0.3 },
        ]),
      ],
    },
    { parent: 'set', a: [150, -110], p: [150, -110], m: [twinkle(20), twinkle(100, true)], shapes: NOTE(150, -110) },
    { parent: 'set', a: [-150, -70], p: [-150, -70], m: [twinkle(60), twinkle(132, true)], shapes: NOTE(-150, -70) },
  ],
}

// one plane leaned back like the calendar, rim in the silhouette, so the side reads as thickness and not a shadow
const CUP = 'M-100 -134L100 -134L100 -108L84 -108C84 -10 48 30 0 34C-48 30 -84 -10 -84 -108L-100 -108Z'

export const trophy: Icon = {
  name: 'trophy',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'cup', p: [250, 266], m: hop, shapes: [] },
    {
      parent: 'cup',
      tilt: FACE,
      shapes: [
        ...[-1, 1].map((side): Shape => ({ d: ring(side * 88, -60, 44, 24), fill: 'gold', depth: 0.6 })),
        { d: CUP, fill: 'gold', depth: 1 },
        { d: line([-84, -108], [84, -108]) },
        { d: star(0, -46, 32, 0.46, 5, 4), fill: 'white' },
        { d: rect(-16, 30, 32, 38), fill: 'gold', depth: 1 },
        { d: rect(-76, 64, 152, 56, 12), fill: 'deep', depth: 1 },
        { d: rect(-42, 81, 84, 22, 6), fill: 'white' },
      ],
    },
    glow('cup', [138, -132], 30, twinkle(0, true), { op: 30 }),
    glow('cup', [138, -132], 30, twinkle(54), { ip: 54 }),
    glow('cup', [-150, 20], 18, twinkle(6, true), { op: 36 }),
    glow('cup', [-150, 20], 18, twinkle(62), { ip: 62 }),
  ],
}

const SHUTTER = onFace(FACE, [92, -104])
const FLASH = onFace(FACE, [-96, -48])

export const camera: Icon = {
  name: 'camera',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'cam', a: [0, 100], p: [250, 366], m: { ...sway, k: 0.6 }, shapes: [] },
    { parent: 'cam', tilt: FACE, shapes: [{ d: rect(-64, -126, 128, 50, 14), fill: 'deep', depth: 0.8 }] },
    { parent: 'cam', a: SHUTTER, p: SHUTTER, m: { ...press, k: 0.35 }, tilt: FACE, shapes: [{ d: pill(70, -118, 46, 28), fill: 'hot', depth: 0.6 }] },
    {
      parent: 'cam',
      tilt: FACE,
      shapes: [
        { d: rect(-146, -90, 292, 192, 32), fill: 'deep', depth: 1 },
        { d: rect(-146, -24, 292, 64), fill: 'tint' },
        { d: rect(-124, -66, 52, 30, 8), fill: 'gold', depth: 0.3 },
        { d: ellipse(20, 8, 82), fill: 'white', depth: 0.6 },
        { d: ring(20, 8, 70, 46), fill: 'main', depth: 0.8 },
        { d: ellipse(20, 8, 46), fill: 'deep' },
        { d: arc(20, 8, 30, 200, 260) },
      ],
    },
    glow('cam', FLASH, 28, glint(36, 120)),
  ],
}

const PIN = 'M0 118C-24 76 -96 22 -96 -42A96 96 0 0 1 96 -42C96 22 24 76 0 118Z'

export const pin: Icon = {
  name: 'pin',
  frames: 180,
  line: LINE,
  extrude: [-3, 16],
  layers: [
    { id: 'drop', a: [0, 118], p: [250, 362], m: { ...hop, at: 20 }, shapes: [] },
    { parent: 'drop', shapes: [{ d: PIN, fill: 'main', depth: 1, edges: [[0, 118]] }, { d: ring(0, -42, 46, 22), fill: 'white', depth: 0.3 }] },
    glow('drop', [104, -128], 26, sparkle(48)),
    glow('drop', [-118, -20], 16, sparkle(110)),
  ],
}

const CLOUD = 'M-112 64A54 54 0 0 1 -116 -38A72 72 0 0 1 20 -76A64 64 0 0 1 124 -18A44 44 0 0 1 112 64Z'

export const cloud: Icon = {
  name: 'cloud',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'sky', p: [250, 270], m: float, shapes: [] },
    { parent: 'sky', tilt: FACE, shapes: [{ d: CLOUD, fill: 'white', depth: 1 }] },
    {
      parent: 'sky',
      tilt: FACE,
      m: slide([0, -40], 30, 50, 50),
      shapes: [{ d: poly([[0, -66], [52, -10], [22, -10], [22, 50], [-22, 50], [-22, -10], [-52, -10]], 6), fill: 'ok', depth: 0.8 }],
    },
  ],
}

const BULB = 'M-44 64L-44 44C-78 22 -96 -8 -96 -48A96 96 0 0 1 96 -48C96 -8 78 22 44 44L44 64Z'

export const bulb: Icon = {
  name: 'bulb',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'lamp', a: [0, 120], p: [250, 372], m: hop, shapes: [] },
    {
      parent: 'lamp',
      tilt: FACE,
      shapes: [
        { d: BULB, fill: 'gold', depth: 1 },
        { d: line([-34, -24], [-17, -46], [0, -24], [17, -46], [34, -24]), round: true },
        { d: line([-34, -24], [-30, 44]) },
        { d: line([34, -24], [30, 44]) },
        { d: arc(0, -48, 70, 196, 246) },
        { d: rect(-50, 60, 100, 28, 9), fill: 'grey', depth: 0.8 },
        { d: rect(-44, 84, 88, 26, 9), fill: 'grey', depth: 0.8 },
        { d: rect(-24, 106, 48, 22, 10), fill: 'deep', depth: 0.6 },
      ],
    },
    glow('lamp', [-126, -132], 26, [twinkle(36), twinkle(120, true)]),
    glow('lamp', [134, -150], 30, [twinkle(42), twinkle(126, true)]),
    glow('lamp', [150, -34], 16, [twinkle(48), twinkle(132, true)]),
  ],
}

// the cells fill one by one, the bolt pops when it is full, then all of it drains before the loop
const CELL = (i: number): Vec => [-86 + i * 52, 0]

export const battery: Icon = {
  name: 'battery',
  frames: 180,
  line: LINE,
  extrude: EXT,
  layers: [
    { id: 'cell', p: [244, 262], m: float, shapes: [] },
    {
      id: 'batt',
      parent: 'cell',
      tilt: FACE,
      shapes: [
        { d: rect(116, -28, 26, 56, 8), fill: 'grey', depth: 0.8 },
        { d: rect(-132, -66, 252, 132, 26), fill: 'white', depth: 1 },
        { d: rect(-114, -48, 216, 96, 14), fill: 'paper' },
      ],
    },
    ...[0, 1, 2, 3].map(
      (i): Layer => ({ parent: 'batt', a: CELL(i), p: CELL(i), m: [twinkle(14 + i * 14), twinkle(132 + i * 6, true)], shapes: [{ d: rect(CELL(i)[0] - 22, -38, 44, 76, 8), fill: 'ok', depth: 0.3 }] }),
    ),
    {
      parent: 'batt',
      a: [-6, 0],
      p: [-6, 0],
      m: [twinkle(70), twinkle(124, true)],
      shapes: [{ d: poly([[6, -62], [-34, 8], [-6, 8], [-18, 62], [26, -10], [-2, -10]], 4), fill: 'gold', depth: 0.5 }],
    },
    glow('cell', [150, -96], 24, [twinkle(76), twinkle(128, true)]),
  ],
}

export const ICONS: ReadonlyArray<Icon> = [pen, folder, heart, coins, bell, calendar, padlock, gift, plane, search, chat, phone, toggle, streak, notes, play, mailbox, headphones, trophy, camera, pin, cloud, bulb, battery]
```

## File: skills/fun-isometric/kit/motions.ts
size: 11114
sha: f249747347bdb2277c4a4f13983c27bd855303c9
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/kit/motions.ts

```text
// The sticker motion presets, as After Effects keys. Deltas over a rest pose, px on a 500 canvas, 60fps.
import { shift } from './fun'
import type { Ease, Motion, Track, Vec } from './fun'

// The eases every preset is built from
export const EASY: Ease = [0.333, 0, 0.667, 1] // AE easy ease: sways, floats, wiggles, every half swing of a spring
export const POP: Ease = [0.75, 0, 0.25, 1] // stars popping, slow returns
export const SNAP: Ease = [0.8, 0, 0.2, 1] // a part travelling from one slot to another
export const OUT: Ease = [0, 0, 0, 1] // dealt in, all speed up front
export const SOFT: Ease = [0.2, 0, 0, 1] // a latch springing open
export const IN: Ease = [1, 0, 0.8, 1] // a latch slamming shut
export const DROP: Ease = [1, 0, 1, 1] // falling away

const SPRING: ReadonlyArray<Ease> = [
  [0.17, 0.17, 0.55, 1],
  [0.35, 0, 0.62, 1],
  [0.31, 0, 0.67, 1],
  [0.31, 0, 0.64, 1],
  [0.3, 0, 0.64, 1],
  [0.24, 0, 0.57, 1],
]
const REACH = [1.448, 0.778, 1.11, 0.95, 1.012, 1]
const GAPS = [13, 16, 16, 15, 15, 16]

/** The baked spring behind jolt, impact and the settles: overshoot to 1.448 of the travel, swings halving, 91 frames to rest. */
export function spring(to: Vec, start = 0, pace = 1, from: Vec = [0, 0]): Track<Vec> {
  let t = start
  const keys: Array<readonly [number, Vec, Ease | 0]> = [[t, from, SPRING[0]]]
  REACH.forEach((k, i) => {
    t += Math.round(GAPS[i] * pace)
    keys.push([t, [from[0] + (to[0] - from[0]) * k, from[1] + (to[1] - from[1]) * k], SPRING[i + 1] ?? 0])
  })
  return keys
}
export function springTurn(to: number, start = 0, pace = 1, from = 0): Track<number> {
  return spring([to, 0], start, pace, [from, 0]).map(([t, v, e]) => [t, v[0], e] as const)
}

// Whole object presets

/** Containers spilling contents: winds up 20 degrees, whips past to 22, lands hard and stays still. Pivot on the edge it swings from. */
export const toss: Motion = {
  r: [[0, 0, [0.572, 0, 0.348, 1.001]], [22, -19.9, [0.879, 0, 0.746, 1]], [41, 21.8, [0.092, 0, 0, 1]], [86, 0]],
  // it travels on arcs, not lines: these are its spatial tangents
  p: [
    [0, [0, 0], [0.57, 0, 0.35, 1], [[-1.23, -6.42], [5.77, 1.42]]],
    [22, [-8.5, -11.5], [0.88, 0, 0.75, 1], [[10.73, 10.33], [2.46, -13.42]]],
    [41, [-3.63, 22.63], [0.09, 0, 0, 1], [[6.23, -16.52], [1.54, 6.58]]],
    [86, [0, 0]],
  ],
}
/** What rides inside a toss: the near one barely, the far one swings wide and slides. */
export const tossLag: Motion = { r: [[0, 0, [0.57, 0, 0.35, 1]], [22, 9, [0.88, 0, 0.75, 1]], [41, -2, [0.09, 0, 0, 1]], [86, 0]] }
export const tossLag2: Motion = {
  r: [[0, 0, [0.57, 0, 0.35, 1]], [22, 15.5, [0.88, 0, 0.75, 1]], [41, -11.3, [0.09, 0, 0, 1]], [86, 0]],
  p: [[0, [0, 0], [0.57, 0, 0.35, 1]], [22, [9.4, 3.4], [0.88, 0, 0.75, 1]], [41, [0, 0]]],
}

/** Badges and hearts: rises tilting 9 degrees, slams through rest, rebounds twice. */
export const hop: Motion = {
  r: [[0, 0, [0.44, 0, 0.42, 1]], [36, 9, [0.42, 0, 0.44, 1]], [54, -4, [0.59, 0, 0.66, 1]], [72, 2, [0.37, 0, 0.51, 1]], [94, 0]],
  p: [[0, [0, 0], [0.64, 0, 0.47, 1]], [36, [7, -24.25], [0.59, 0, 0.52, 1]], [54, [-7.25, 23.25], [0.24, 0, 0.16, 1]], [88, [0, 0]]],
}

/** Coins and tokens: springs up and right to a raised spot, holds, then drops back through rest and springs home. */
export const bounce: Motion = {
  p: [
    [0, [0, 0], EASY], [11, [11.24, -14.24], EASY], [24, [6.02, -7.63], EASY], [37, [8.6, -10.9], EASY], [49, [7.35, -9.32], EASY],
    [62, [7.87, -9.97], EASY], [79, [7.19, -8.98], EASY], [93, [-3.48, 4.4], EASY], [110, [1.74, -2.21], EASY], [126, [-0.84, 1.06], EASY],
    [142, [0.41, -0.52], EASY], [158, [-0.11, 0.13], EASY], [175, [0, 0]],
  ],
  s: [[0, [100, 100], [0.47, 0, 0.33, 1]], [11, [103, 103], [0.45, 0, 0.69, 1]], [31, [100, 100]]],
}

/** Bells and hands: rocks back 20 degrees, taps twice and settles, sliding 12px on the second beat. */
export const wiggle: Motion = {
  r: [[0, 0, EASY], [46, 20, EASY], [71, 11, EASY], [100, 19, EASY], [124, -3, EASY], [148, 5, EASY], [177, 0]],
  p: [[46, [0, 0], [0.2, 0, 0, 1]], [71, [11.8, 0], [0.2, 0, 0.67, 1]], [100, [0, 0]]],
}

/** Cards and warnings: kicked along v with the spring, holds, eases home. */
export const jolt = (v: Vec = [-7.86, 13.25]): Motion => ({ p: [...spring(v, 2), [98, v, [0.65, 0, 0.35, 1]], [141, [0, 0]]] })

/** A body under a lid: takes the hit on frame 24, springs, holds, eases back as the lid lifts. */
export const impact = (v: Vec = [8.7, 15.3]): Motion => ({ p: [...spring(v, 24, 0.92), [119, v, POP], [176, [0, 0]]] })
/** A lid or cap: slams along v on an ease in, sits, pops back sharp at the end. */
export const latch = (v: Vec = [11.5, 21.3]): Motion => ({ p: [[0, [0, 0], IN], [30, v, 0], [146, v, SOFT], [176, [0, 0]]] })

/** Locks and clips: lifts up and left as the shackle springs open, hangs, then slams down right and settles. 130 frame loop. */
export const clasp: Motion = {
  p: [[10, [0, 0], [0.31, 0, 0.12, 1]], [48, [-8.13, -12.37], 0], [61, [-8.13, -12.37], [0.83, 0, 0.49, 1]], [85, [9.75, 14.12], [0.46, 0, 0.33, 1]], [112, [0, 0]]],
}
/** The shackle on the clasp's beat: swung open 33 degrees on a soft pop, back on a slam. Pivot on the leg that stays in. */
export const shackle: Motion = {
  p: [[8, [0, 0], SOFT], [38, [-6, -16], 0], [54, [-6, -16], IN], [84, [0, 0]]],
  r: [[8, 0, SOFT], [38, -33, 0], [54, -33, IN], [84, 0]],
}

/** Faces and mascots: dips and rocks a few degrees, never quite still. */
export const wobble: Motion = {
  r: [[0, 0, [0.73, 0, 0.46, 1]], [28, -3.04, [0.59, 0, 0.66, 0.86]], [54, 2.03, [0.36, -0.33, 0.67, 1.01]], [77, 0, [0.52, -0.02, 0.66, 1]], [95, 2.02, [0.47, 0, 0.11, 1]], [128, -1.1, [0.58, 0, 0.56, 1]], [168, 0]],
  p: [[0, [0, 0], [0.62, 0, 0.31, 1]], [35, [0, 17.5], [0.33, 0, 0.85, 0.63]], [84, [0, -0.15], [0.57, 0.91, 0.57, 1]], [108, [0, -13.9], [0.33, 0, 0.4, 1]], [168, [0, 0]]],
}

/** Things that fly: a lazy figure of eight with a slow bank, x and y on their own beats. */
export const drift: ReadonlyArray<Motion> = [
  { r: [[0, 0, [0.6, 0, 0.58, 1]], [48, 3.12, [0.68, 0, 0.44, 0.37]], [110, 6.97, [0.34, 0, 0.3, 1]], [156, 0]] },
  { p: [[0, [0, 0], EASY], [44, [28.5, 0], [0.33, 0, 0.68, 0.75]], [76, [-28.09, 0], [0.33, 2.85, 0.72, 2.83]], [106, [-33, 0], [0.34, 0.26, 0.64, 0.71]], [135, [7.64, 0], [0.27, -1.41, 0.48, 1]], [170, [0, 0]]] },
  { p: [[0, [0, 0], EASY], [34, [0, -20.07], EASY], [82, [0, -7.42], EASY], [122, [0, -25.55], [0.33, 0, 0.43, 1]], [167, [0, 0]]] },
]

/** Keys and tools: turns home sharp, then a slow 17 degree twist out and back, floating 13px. */
export const twist: Motion = {
  r: [[0, -7, [0.17, 0, 0, 1]], [30, 0, SNAP], [90, -17.27, [0.8, 0, 0, 1]], [150, 0, SNAP], [180, -7]],
  p: [[0, [0, 0], EASY], [90, [8, -10], EASY], [180, [0, 0]]],
}

/** A body reacting to a hit: nudged down and right, eases back. */
export const nudge: Motion = {
  p: [[0, [0, 0], [0.33, 0, 0.07, 1]], [48, [8.88, 9.85], [0.24, 0, 0.67, 0.28]], [68, [8.56, 9.38], [0.52, 0.1, 0.25, 1]], [114, [0, 0]]],
}
/** A small part flicking out and back, a tongue or a tab. */
export const flick: Motion = { r: [[31, 0, [0.24, 0, 0.58, 1]], [47, 10, [0.3, 0, 0.64, 0.96]], [62, -7.05, [0.2, -0.46, 0.83, 1]], [95, 0]] }

/** Buttons and keys: pushed 23px into its base with a hint of overshoot, back, then pops 13px proud and settles. Give it a still base to sink into. */
export const press: Motion = {
  p: [[0, [0, 0], [0.37, 0, 0.33, 1.31]], [34, [0, 22.69], [0.4, 0.2, 0.09, 1]], [69, [0, 0], 0], [76, [0, 0], [0.46, 0, 0.34, 1]], [114, [0, -13], [0.38, 0, 0.61, 1]], [145, [0, 0]]],
  x: [[0, 0, [0.37, 0, 0.33, 1.3]], [35, -6.2, [0.4, 0.18, 0.09, 1]], [70, 0, 0], [78, 0, [0.46, 0, 0.3, 1]], [112, 3.2, [0.39, 0, 0.62, 1]], [143, 0]],
}

/** Pupils: a glance up left, a dart down right, home. */
export const look: Motion = {
  p: [[0, [0, 0], [0.46, 0, 0, 1], [[0, 0], [3.5, 2.62]]], [42, [-4.5, -9], [0.49, 0, 0, 1], [[2.69, 1.44], [0, 0]]], [82, [3, 3], [0.58, 0, 0.18, 1]], [123, [0, 0]]],
}

// Ambient

/** Idle: a 5 degree lean and back over 3 seconds. Use k to tune, at to put parts out of phase. */
export const sway: Motion = { r: [[0, 0, EASY], [90, -5, EASY], [180, 0]], loop: 180 }
/** Idle: 10px down and up over 3 seconds; neighbours sit 20 frames apart. */
export const float: Motion = { p: [[0, [0, 0], EASY], [90, [0, 10], EASY], [180, [0, 0]]], loop: 180 }
/** Idle: a smaller bob with a sideways lean, 160 frame period. */
export const bob: Motion = { p: [[0, [0, 0], EASY], [80, [-1.5, 5], EASY], [160, [0, 0]]], loop: 160 }

// Builders

/** Scenes: dealt in from off to rest in 60 frames, all speed up front, swooping in level. Stagger parts 14 to 20 frames. */
export const deal = (from: Vec, turn: number, start = 0, grow = false): Motion => ({
  p: shift<Vec>([[0, from, OUT, [[0, 0], [from[0] * 0.6, 0]]], [60, [0, 0]]], start),
  r: shift([[0, turn, OUT], [60, 0]], start),
  ...(grow ? { s: shift<Vec>([[0, [85, 85], SNAP], [60, [100, 100]]], start) } : {}),
})
/** Falls away on an ease in, 28 frames: clears a scene before it loops. */
export const fall = (to: Vec, start: number, len = 28): Motion => ({ p: [[start, [0, 0], DROP], [start + len, to]] })
/** Toggles and tiles: one part to another slot over 60 frames on SNAP, holds, comes home. */
export const slide = (to: Vec, start: number, hold = 60, len = 60): Motion => ({
  p: [[start, [0, 0], SNAP], [start + len, to, 0], [start + len + hold, to, SNAP], [start + len * 2 + hold, [0, 0]]],
})

/** Glare: swells out of nothing on a slow start, eases to 80%, shrinks away, drifting up and right. 71 frames. */
export const glint = (start: number, size = 100): Motion => ({
  s: shift<Vec>([[0, [0, 0], [1, 0, 0.76, 1]], [30, [size, size], [0.31, 0, 0.23, 1]], [54, [size * 0.8, size * 0.8], [0.17, 0, 0.83, 1]], [71, [0, 0]]], start),
  p: shift<Vec>([[0, [0, 0], [0.9, 0, 0.83, 0.83]], [30, [7.5, -4.8]]], start),
})
/** Star: pops to 110 and settles in 30 frames; out reverses it. */
export const twinkle = (start: number, out = false): Motion => ({
  s: shift<Vec>(out ? [[0, [100, 100], POP], [10, [110, 110], POP], [30, [0, 0]]] : [[0, [0, 0], POP], [10, [110, 110], POP], [30, [100, 100]]], start),
})
/** Sparkle: grows and turns 46 degrees fast, then shrinks back on an ease in, 84 frames. */
export const sparkle = (start: number): Motion => ({
  s: shift<Vec>([[0, [0, 0], [0.13, 0, 0, 1]], [42, [100, 100], [1, 0, 0.81, 1]], [84, [0, 0]]], start),
  r: shift([[0, 0, [0.13, 0, 0, 1]], [42, 45.89, [1, 0, 0.81, 1]], [84, 0]], start),
})
/** Text lines: erased in 15 frames, staggered 5 apart, drawn back on a slow start. For a shape's trim. */
export const erase = (start: number, back: number): Track<number> => [[start, 1, [0.3, 0, 0.1, 1]], [start + 15, 0, 0], [back, 0, [0.9, 0, 0.7, 1]], [back + 15, 1]]
/** Flames: steps between drawn shapes every 40 frames, snapping out of each. For a shape's d. */
export const flicker = (shapes: ReadonlyArray<string>, gap = 40): Track<string> => [...shapes, shapes[0]].map((d, i) => [i * gap, d, [0.33, 0, 0, 1]] as const)
```

## File: skills/fun-isometric/kit/Sticker.tsx
size: 1574
sha: 6ebc6f1fe8c6e646768141ee9f76a44616b8b526
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/kit/Sticker.tsx

```text
// fun-isometric in React: one canvas sized by css, on the shared page clock, recoloured and replayed without remounting.
import { useEffect, useRef } from 'react'
import { play } from './fun'
import type { CSSProperties } from 'react'
import type { Icon, PlayOptions, Player } from './fun'

type Props = PlayOptions & {
  icon: Icon
  replayOnHover?: boolean
  label?: string // accessible name, the icon's name by default
  className?: string
  style?: CSSProperties
}

export function Sticker({ icon, palette, loop, offset, enter, replayOnHover, label, className, style }: Props) {
  const ref = useRef<HTMLCanvasElement>(null)
  const player = useRef<Player | null>(null)
  const opts = useRef<PlayOptions>({ palette, loop, offset, enter })
  opts.current = { palette, loop, offset, enter }
  // a new icon starts a new player; options reach the running one through set(), so the entrance never replays
  useEffect(() => {
    if (!ref.current) return
    const p = play(ref.current, icon, opts.current)
    player.current = p
    return () => p.stop()
  }, [icon])
  // keyed on the palette's content, so an inline object does not repaint every render
  const tone = JSON.stringify(palette ?? null)
  useEffect(() => player.current?.set(opts.current), [tone, loop, offset])
  return (
    <canvas
      ref={ref}
      role="img"
      aria-label={label ?? icon.name}
      className={className}
      style={{ display: 'block', width: '100%', aspectRatio: '1', ...style }}
      onPointerEnter={replayOnHover ? () => player.current?.replay() : undefined}
    />
  )
}
```

## File: skills/fun-isometric/main.tsx
size: 513
sha: 0bb7a30b4195888760fc0e6f8c2136762ec30ed1
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/main.tsx

```text
import '@fontsource-variable/geist'
import '@fontsource-variable/geist-mono'
import { createRoot } from 'react-dom/client'
import { inject } from '@vercel/analytics'
import { injectSpeedInsights } from '@vercel/speed-insights'
import Page from './site/Page'

// Production only, so dev and local previews stay quiet. Both scripts are served by Vercel at /_vercel
if (import.meta.env.PROD) {
  inject({ mode: 'production' })
  injectSpeedInsights()
}

createRoot(document.getElementById('root')!).render(<Page />)
```

## File: skills/fun-isometric/package.json
size: 743
sha: 921a79953b339616d444ed34afe73438aa1852cf
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/package.json

```text
{
  "name": "fun-isometric",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "pack": "bun bin/pack.mjs",
    "deploy": "bun run pack && bunx vercel@latest deploy --prod"
  },
  "dependencies": {
    "@fontsource-variable/geist": "5.3.0",
    "@fontsource-variable/geist-mono": "5.3.0",
    "@heroui/react": "3.2.6",
    "@heroui/styles": "3.2.6",
    "@vercel/analytics": "2.0.1",
    "@vercel/speed-insights": "2.0.0",
    "react": "19.3.1",
    "react-dom": "19.3.1",
    "tailwind-variants": "3.3.1"
  },
  "devDependencies": {
    "@tailwindcss/vite": "4.3.3",
    "@vitejs/plugin-react": "6.1.2",
    "tailwindcss": "4.3.3",
    "vite": "8.3.5"
  }
}
```

## File: skills/fun-isometric/public/apple-touch-icon.png
size: 16897
sha: 819a94e048ad8a1e8c96aebb163c770df3ec4cc7
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/public/apple-touch-icon.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/public/apple-touch-icon.png

## File: skills/fun-isometric/public/favicon.png
size: 24159
sha: eeeebe391bb50504822911fbb5df70ef1587cfb8
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/public/favicon.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/public/favicon.png

## File: skills/fun-isometric/public/llms.txt
size: 1618
sha: f8e226f2fbce935810c76b1ccadc8757b5c81d17
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/public/llms.txt

```text
# fun-isometric

> An agent skill for Claude Code, Codex and other agents that draws any object as an isometric sticker (flat fill, thick black outline, extruded slab side) and animates it on After Effects grade keyframe presets. Six named palettes or two brand colours, four facings. Stickers play live on canvas in a web app or export to looping alpha video.

Made by Kelvin Adithya (https://x.com/KelvinAdithya), heavily inspired by the stickers in Slush (https://slush.app). MIT licensed.

Install with `npx skills add kelvinkn17/skills@fun-isometric` on Node 22 or newer; the CLI asks which agents get it and whether it goes in every project. `npx skills add https://skills.klvn.dev` installs the same skill through this site's agent-skills index. Without Node, download the zip below and run `unzip fun-isometric.zip -d ~/.claude/skills`. Then ask for `/fun-isometric <object>`, or `$fun-isometric` in Codex. Rendering needs Bun, and video needs ffmpeg.

## Docs

- [SKILL.md](https://raw.githubusercontent.com/kelvinkn17/skills/main/skills/fun-isometric/SKILL.md): the instructions the agent follows, with the drawing, colour and motion rules
- [README](https://raw.githubusercontent.com/kelvinkn17/skills/main/README.md): every install route
- [Generator](https://skills.klvn.dev/fun-isometric): pick an object, motion, palette and facing, then copy the command

## Optional

- [fun-isometric.zip](https://skills.klvn.dev/fun-isometric/fun-isometric.zip): the skill folder for a manual install
- [Source](https://github.com/kelvinkn17/skills/tree/main/skills/fun-isometric): the kit, the fi tool and this site
```

## File: skills/fun-isometric/public/og.png
size: 48195
sha: ee2c499b4339384521eb12ca1c03664aa87c275c
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/public/og.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/public/og.png

## File: skills/fun-isometric/site/fun.css
size: 15073
sha: bb9cd43629720f483b04b81f0b941489363a6298
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/site/fun.css

```text
/* fun-isometric site: white page, grey tiles that step by luminance, Geist with tight tracking, one pink gel. The stickers carry the colour. */
@layer components {
  .show-fun {
    --paper: #ffffff;
    --card: #f4f4f6;
    --card-2: #ebebee;
    --ink: #0b0b0c;
    --muted: #66666c;
    --gel: #ff58a8;
    --gel-top: #ff86c0;
    --mono: 'Geist Mono Variable', ui-monospace, monospace;
    --ease: cubic-bezier(0.4, 0, 0.2, 1);
    /* a damped spring (k 260, c 16), peaks 16% past the target */
    --spring: linear(0, 0.052, 0.181, 0.353, 0.539, 0.717, 0.871, 0.994, 1.082, 1.136, 1.162, 1.165, 1.15, 1.126, 1.096, 1.065, 1.037, 1.013, 0.995, 0.982, 0.975, 0.973, 0.973, 0.976, 0.981, 0.986, 0.991, 0.996, 0.999, 1.002, 1.003, 1.004, 1.005, 1.004, 1.004, 1.003, 1);
    --fi-tint: #ffd4e8;
    max-width: 1340px;
    margin: 0 auto;
    padding: 0 16px;
    background: var(--paper);
    color: var(--ink);
    font-family: var(--fun-text, 'Geist Variable', system-ui, sans-serif);
    font-size: 16px;
    line-height: 1.5;
    letter-spacing: -0.02em;
    font-feature-settings: 'ss01';
  }
  .show-fun * {
    box-sizing: border-box;
  }
  .show-fun a {
    color: inherit;
  }
  .show-fun :focus-visible {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
  }

  /* nav: pinned on every scroll, the credit and the follow always in reach */
  .show-fun .fi-nav {
    position: sticky;
    top: 0;
    z-index: 20;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    height: 72px;
    margin: 0 -16px;
    padding: 0 16px;
    background: var(--paper);
  }
  .show-fun .fi-brand {
    display: flex;
    align-items: baseline;
    gap: 10px;
    min-width: 0;
    margin: 0;
  }
  .show-fun .fi-word {
    font-size: 18px;
    font-weight: 500;
    letter-spacing: -0.04em;
    text-decoration: none;
  }
  .show-fun .fi-by {
    overflow: hidden;
    color: var(--muted);
    font-size: 14px;
    text-decoration: none;
    text-overflow: ellipsis;
    white-space: nowrap;
    transition: color 150ms var(--ease);
  }
  .show-fun .fi-by:hover {
    color: var(--ink);
  }
  .show-fun .fi-btn.fi-follow {
    gap: 8px;
    background: var(--ink);
    color: var(--paper);
  }
  .show-fun .fi-btn.fi-follow:hover {
    background: #2a2a2e;
  }
  .show-fun .fi-links {
    display: flex;
    align-items: center;
    gap: 24px;
    font-size: 14px;
  }
  .show-fun .fi-links a:not(.fi-btn) {
    text-decoration: none;
    transition: opacity 150ms var(--ease);
  }
  .show-fun .fi-links a:not(.fi-btn):hover {
    opacity: 0.6;
  }

  /* buttons: grey pills, the gel is the one accent, its light inside */
  .show-fun .fi-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    height: 36px;
    padding: 0 16px;
    border: 0;
    border-radius: 9999px;
    background: var(--card-2);
    color: var(--ink);
    font: inherit;
    font-size: 14px;
    font-weight: 500;
    text-decoration: none;
    cursor: pointer;
    transition: background 150ms var(--ease), opacity 150ms var(--ease), scale 160ms var(--ease);
  }
  .show-fun .fi-btn:active:not(:disabled) {
    scale: 0.96;
  }
  .show-fun .fi-btn:hover {
    background: #e2e2e6;
  }
  .show-fun .fi-btn[aria-pressed='true'] {
    background: var(--ink);
    color: var(--paper);
  }
  .show-fun .fi-gel {
    background: linear-gradient(var(--gel-top), var(--gel));
    color: #000;
    box-shadow: inset 0 1px 3px rgb(255 255 255 / 0.45), inset 0 3px 12px rgb(255 255 255 / 0.2);
  }
  .show-fun .fi-gel:hover {
    background: linear-gradient(#ff9acb, #ff6cb2);
  }
  .show-fun .fi-btn:disabled {
    opacity: 0.4;
    cursor: default;
  }

  /* copy: the label rolls out, Copied and its check spring in, the pill bounces from a squeeze */
  .show-fun .fi-copy {
    display: inline-grid;
    place-items: center;
    overflow: hidden;
  }
  .show-fun .fi-copy[data-done='a'] {
    animation: fi-copy-a 760ms var(--spring);
  }
  .show-fun .fi-copy[data-done='b'] {
    animation: fi-copy-b 760ms var(--spring);
  }
  .show-fun .fi-copy > span {
    display: inline-flex;
    grid-area: 1 / 1;
    align-items: center;
    gap: 5px;
  }
  .show-fun .fi-copy-idle {
    transition: translate 760ms var(--spring), opacity 220ms var(--ease), filter 220ms var(--ease);
  }
  .show-fun .fi-copy[data-done] .fi-copy-idle {
    translate: 0 -28px;
    opacity: 0;
    filter: blur(4px);
    transition: translate 200ms var(--ease), opacity 140ms var(--ease), filter 200ms var(--ease);
  }
  .show-fun .fi-copy-done {
    translate: 0 28px;
    scale: 0.7;
    opacity: 0;
    filter: blur(4px);
    transition: translate 200ms var(--ease), scale 200ms var(--ease), opacity 140ms var(--ease), filter 200ms var(--ease);
  }
  .show-fun .fi-copy[data-done] .fi-copy-done {
    translate: 0 0;
    scale: 1;
    opacity: 1;
    filter: none;
    transition: translate 760ms var(--spring), scale 760ms var(--spring), opacity 220ms var(--ease), filter 220ms var(--ease);
  }
  .show-fun .fi-copy path {
    fill: none;
    stroke: currentColor;
    stroke-width: 2.2;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 14;
    stroke-dashoffset: 14;
    transition: stroke-dashoffset 120ms var(--ease);
  }
  .show-fun .fi-copy[data-done] path {
    stroke-dashoffset: 0;
    transition: stroke-dashoffset 340ms var(--ease) 120ms;
  }
  @keyframes fi-copy-a {
    from {
      scale: 0.88;
    }
  }
  @keyframes fi-copy-b {
    from {
      scale: 0.88;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .show-fun .fi-copy[data-done] {
      animation: none;
    }
    .show-fun .fi-copy[data-done] > span,
    .show-fun .fi-copy > span {
      translate: none;
      scale: none;
      filter: none;
    }
  }

  /* hero: one emblem over the headline, the install card right under it */
  .show-fun .fi-hero {
    display: grid;
    justify-items: center;
    padding: 8px 0 112px;
    text-align: center;
  }
  .show-fun .fi-emblem {
    width: 152px;
  }
  /* the command itself is the headline */
  .show-fun .fi-display {
    margin: 0 0 20px;
    font-family: var(--mono);
    font-size: clamp(36px, 6vw, 80px);
    font-weight: 500;
    line-height: 1;
    letter-spacing: -0.06em;
  }
  .show-fun .fi-lede {
    max-width: 34em;
    margin: 0 0 32px;
    color: var(--muted);
    font-size: 18px;
    line-height: 1.45;
    text-wrap: balance;
  }

  /* install: the one command, then the zip, each on white inside one grey card */
  .show-fun .fi-install {
    display: grid;
    gap: 8px;
    width: 100%;
    max-width: 780px;
    margin-bottom: 16px;
    border-radius: 32px;
    background: var(--card);
    padding: 20px 12px 12px;
    text-align: left;
  }
  .show-fun .fi-install .fi-label {
    margin: 0;
    padding: 0 8px;
  }
  .show-fun .fi-install .fi-command + .fi-label {
    margin-top: 12px;
  }
  .show-fun .fi-install .fi-command {
    background: var(--paper);
  }
  /* one line that scrolls under a fade on phones, the button does the copying */
  .show-fun .fi-install .fi-command code {
    white-space: pre;
    overflow-x: auto;
    scrollbar-width: none;
    mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
  }
  .show-fun .fi-nowrap {
    white-space: nowrap;
  }
  .show-fun .fi-hero > .fi-meta {
    max-width: 40em;
    text-wrap: balance;
  }

  /* a command line with its copy button inside */
  .show-fun .fi-command {
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
    border-radius: 20px;
    background: var(--card);
    padding: 8px 8px 8px 20px;
  }
  .show-fun .fi-command code {
    flex: 1;
    min-width: 0;
    font-family: var(--mono);
    font-size: 14px;
    line-height: 1.5;
    letter-spacing: 0;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }
  .show-fun .fi-code code {
    white-space: pre;
    overflow-x: auto;
    scrollbar-width: thin;
  }

  /* sections */
  .show-fun .fi-sec {
    padding: 0 0 120px;
  }
  .show-fun .fi-head {
    display: flex;
    flex-wrap: wrap;
    align-items: end;
    justify-content: space-between;
    gap: 16px 40px;
    margin-bottom: 32px;
  }
  .show-fun .fi-h2 {
    margin: 0;
    font-size: clamp(32px, 3.6vw, 48px);
    font-weight: 400;
    line-height: 1.04;
    letter-spacing: -0.05em;
  }
  .show-fun .fi-meta {
    max-width: 30em;
    margin: 0;
    color: var(--muted);
    font-size: 14px;
    line-height: 1.45;
  }
  .show-fun .fi-name {
    margin: 0;
    font-size: 16px;
    font-weight: 500;
  }
  .show-fun .fi-tile .fi-name {
    text-transform: capitalize;
  }
  .show-fun .fi-frame {
    color: var(--muted);
    font-family: var(--mono);
    font-size: 12px;
    letter-spacing: 0;
    font-variant-numeric: tabular-nums;
  }

  /* generator */
  .show-fun .fi-maker {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr);
    gap: 16px;
  }
  .show-fun .fi-fields {
    display: grid;
    align-content: start;
    gap: 28px;
    border-radius: 32px;
    background: var(--card);
    padding: clamp(20px, 3vw, 36px);
  }
  .show-fun .fi-field {
    display: grid;
    gap: 10px;
    min-width: 0;
    margin: 0;
    border: 0;
    padding: 0;
  }
  .show-fun .fi-label {
    padding: 0;
    font-size: 14px;
    font-weight: 500;
  }
  .show-fun .fi-input {
    width: 100%;
    height: 48px;
    border: 0;
    border-radius: 16px;
    background: var(--paper);
    padding: 0 16px;
    color: var(--ink);
    font: inherit;
    font-size: 16px;
  }
  .show-fun .select__trigger.fi-input {
    align-items: center;
  }
  .show-fun .fi-input[data-focus-visible] {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
  }
  .show-fun .fi-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
  }
  .show-fun .fi-chip {
    height: 30px;
    padding: 0 12px;
    border: 0;
    border-radius: 9999px;
    background: var(--card-2);
    color: var(--ink);
    font: inherit;
    font-size: 13px;
    cursor: pointer;
    transition: background 150ms var(--ease);
  }
  .show-fun .fi-chip:hover {
    background: #e2e2e6;
  }
  /* palettes: each one shown as its six accents, picked like the facing cards */
  .show-fun .fi-palettes {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 8px;
  }
  .show-fun .fi-pal {
    display: grid;
    gap: 8px;
    border: 0;
    border-radius: 16px;
    background: var(--paper);
    padding: 10px 10px 9px;
    color: var(--muted);
    font: inherit;
    font-size: 13px;
    font-weight: 500;
    text-align: left;
    white-space: nowrap;
    cursor: pointer;
    transition: background 150ms var(--ease), color 150ms var(--ease);
  }
  .show-fun .fi-pal[aria-pressed='true'] {
    background: var(--fi-tint);
    color: var(--ink);
  }
  .show-fun .fi-strip {
    display: flex;
    height: 14px;
    overflow: hidden;
    border-radius: 9999px;
  }
  .show-fun .fi-strip i {
    flex: 1;
  }
  .show-fun .fi-tune {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px 20px;
    border-radius: 16px;
    background: var(--paper);
    padding: 10px 14px 10px 10px;
  }
  .show-fun .fi-pick {
    display: flex;
    align-items: center;
    gap: 10px;
    border: 0;
    background: none;
    padding: 0;
    color: var(--ink);
    font: inherit;
    font-size: 13px;
    font-weight: 500;
    line-height: 1.25;
    text-align: left;
    cursor: pointer;
  }
  .show-fun .fi-pick .color-swatch {
    width: 36px;
    height: 36px;
    border-radius: 9999px;
  }
  .show-fun .fi-pick > span {
    display: grid;
  }
  .show-fun .fi-pairs {
    display: flex;
    gap: 8px;
    margin-left: auto;
  }
  .show-fun .fi-pairs button {
    width: 26px;
    height: 26px;
    border: 0;
    border-radius: 9999px;
    padding: 0;
    cursor: pointer;
  }
  .show-fun .fi-pairs button[aria-pressed='true'] {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
  }
  .show-fun .fi-views {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 8px;
  }
  .show-fun .fi-view {
    display: grid;
    gap: 2px;
    border: 0;
    border-radius: 20px;
    background: var(--paper);
    padding: 8px 8px 12px;
    color: var(--muted);
    font: inherit;
    font-size: 13px;
    font-weight: 500;
    cursor: pointer;
    transition: background 150ms var(--ease), color 150ms var(--ease);
  }
  .show-fun .fi-view[aria-pressed='true'] {
    background: var(--fi-tint);
    color: var(--ink);
  }
  .show-fun .fi-out {
    display: grid;
    grid-template-rows: 1fr auto;
    gap: 16px;
  }
  .show-fun .fi-stage {
    display: grid;
    place-items: center;
    border-radius: 32px;
    background: var(--card);
    padding: 8%;
  }
  .show-fun .fi-stage canvas {
    max-width: 440px;
  }
  .show-fun .fi-out .fi-command {
    border-radius: 24px;
    padding-left: 24px;
  }

  /* sticker wall */
  .show-fun .fi-controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 10px 12px;
  }
  /* speed: grey pills, the picked one lifts white */
  .show-fun .fi-controls .toggle-button {
    --toggle-button-bg: var(--card-2);
    --toggle-button-bg-hover: #e2e2e6;
    --toggle-button-bg-selected: var(--paper);
    --toggle-button-bg-selected-hover: var(--paper);
    --toggle-button-bg-selected-pressed: var(--paper);
    --toggle-button-fg-selected: var(--ink);
    color: var(--muted);
  }
  .show-fun .fi-controls .toggle-button[data-selected] {
    box-shadow: inset 0 0 0 3px var(--card-2);
  }
  .show-fun .fi-scrub {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 12px;
    width: 280px;
  }
  .show-fun .fi-scrub .slider__track {
    flex: 1;
  }
  .show-fun .fi-wall {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(200px, 44%), 1fr));
    gap: 12px;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .show-fun .fi-tile {
    display: grid;
    gap: 2px;
    border-radius: 24px;
    background: var(--card);
    padding: 8px 20px 20px;
  }
  .show-fun .fi-tile canvas {
    margin: 0 -8px 4px;
    width: calc(100% + 16px) !important;
  }

  .show-fun .fi-foot {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 8px 24px;
    padding: 28px 0 40px;
    color: var(--muted);
    font-size: 14px;
  }
  .show-fun .fi-foot p {
    margin: 0;
  }

  @media (max-width: 860px) {
    .show-fun .fi-maker {
      grid-template-columns: minmax(0, 1fr);
    }
    .show-fun .fi-hero {
      padding: 0 0 80px;
    }
    .show-fun .fi-emblem {
      width: 128px;
    }
    .show-fun .fi-sec {
      padding-bottom: 88px;
    }
    .show-fun .fi-links a:not(.fi-follow) {
      display: none;
    }
    .show-fun .fi-palettes {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
    .show-fun .fi-pairs {
      margin-left: 0;
    }
    .show-fun .fi-views {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
    .show-fun .fi-scrub {
      width: 170px;
    }
  }
}
```

## File: skills/fun-isometric/site/Page.tsx
size: 18917
sha: 02c9c7abb123cd3e5522f4592abab81d2a3e454d
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/site/Page.tsx

```text
import './ui.css'
import './fun.css'
import { useEffect, useMemo, useRef, useState } from 'react'
import { track } from '@vercel/analytics'
import { ACCENTS, PALETTES, RISO, VIEWS, brand, clock, ellipse, mono, pill, rect, spark } from '../kit/fun'
import { ICONS, pen } from '../kit/icons'
import { bounce, clasp, deal, drift, float, hop, jolt, latch, press, slide, sway, toss, twist, wiggle, wobble } from '../kit/motions'
import { Sticker } from '../kit/Sticker'
import { ColorArea, ColorPicker, ColorSlider, ColorSwatch, Input, Label, ListBox, Select, Slider, TextField, ToggleButton, ToggleButtonGroup } from '@heroui/react'
import type { CSSProperties, ReactNode } from 'react'
import type { Icon, Motion, Palette, PaletteName, View } from '../kit/fun'

const REPO = 'https://github.com/kelvinkn17/skills'
const MAKER = { name: 'Kelvin Adithya', url: 'https://x.com/KelvinAdithya', follow: 'https://x.com/intent/follow?screen_name=KelvinAdithya' }
const EXAMPLE = 'a pen scribbling on a square pad'

// one command for every agent, the CLI asks which ones and where; the zip skips Node
const SITE = 'https://skills.klvn.dev/fun-isometric'
const INSTALL = 'npx skills add kelvinkn17/skills@fun-isometric'
const UNZIP = 'unzip fun-isometric.zip -d ~/.claude/skills'

// the presets each sticker moves on, shown under it in the wall
const MOVES: Record<string, string> = {
  pen: 'scribble, twinkle',
  folder: 'toss',
  heart: 'hop, twinkle',
  coins: 'bounce, glint',
  bell: 'wiggle',
  calendar: 'jolt, twinkle',
  padlock: 'clasp, shackle',
  gift: 'latch, impact',
  plane: 'drift',
  search: 'twist, sparkle',
  chat: 'wobble, look',
  phone: 'deal, fall',
  toggle: 'slide, bob',
  streak: 'flicker, bob',
  notes: 'erase, sway',
  play: 'press',
  mailbox: 'jolt, spring',
  headphones: 'wobble, twinkle',
  trophy: 'hop, twinkle',
  camera: 'press, glint',
  pin: 'hop, sparkle',
  cloud: 'float, slide',
  bulb: 'hop, twinkle',
  battery: 'float, twinkle',
}

// Copy rolls up into a drawn check and Copied on a spring, and the pill bounces once per copy
function CopyButton({ text, what, disabled }: { text: string; what: 'install' | 'maker'; disabled?: boolean }) {
  const [hits, setHits] = useState(0)
  const [done, setDone] = useState(false)
  const timer = useRef(0)
  useEffect(() => () => clearTimeout(timer.current), [])
  const copy = () =>
    void navigator.clipboard.writeText(text).then(() => {
      track('Copy command', { what })
      setHits((n) => n + 1)
      setDone(true)
      clearTimeout(timer.current)
      timer.current = window.setTimeout(() => setDone(false), 1800)
    })
  return (
    <button type="button" className="fi-btn fi-gel fi-copy" data-done={done ? (hits % 2 ? 'a' : 'b') : undefined} onClick={copy} disabled={disabled}>
      <span className="fi-copy-idle" aria-hidden={done}>
        Copy
      </span>
      <span className="fi-copy-done" aria-hidden={!done}>
        <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
          <path d="M3.5 8.5l3 3 6-7" />
        </svg>
        Copied
      </span>
    </button>
  )
}

function Install() {
  return (
    <div className="fi-install" id="install">
      <p className="fi-label">Add it to Claude Code, Codex or any agent with one command, on Node 22 or newer.</p>
      <div className="fi-command">
        <code>{INSTALL}</code>
        <CopyButton text={INSTALL} what="install" />
      </div>
      <p className="fi-label">Or download the skill and unzip it into your skills folder.</p>
      <div className="fi-command">
        <code>{UNZIP}</code>
        <a className="fi-btn" href={`${SITE}/fun-isometric.zip`} download onClick={() => track('Download zip')}>
          Download
        </a>
      </div>
    </div>
  )
}

const SPEEDS = [1, 0.5, 0.25]

function Controls() {
  const [speed, setSpeed] = useState(clock.speed)
  const [paused, setPaused] = useState(clock.paused)
  const [frame, setFrame] = useState(0)
  // the slider follows the page clock, one render per whole frame
  useEffect(() => {
    let raf = 0
    const tick = () => {
      setFrame(Math.floor(((clock.frame % 180) + 180) % 180))
      raf = requestAnimationFrame(tick)
    }
    raf = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(raf)
  }, [])
  const pause = (next: boolean) => {
    clock.paused = next
    setPaused(next)
  }
  return (
    <div className="fi-controls">
      <ToggleButtonGroup
        aria-label="Speed"
        size="sm"
        disallowEmptySelection
        selectedKeys={[String(speed)]}
        onSelectionChange={(keys) => {
          const next = Number([...keys][0])
          clock.speed = next
          setSpeed(next)
        }}
      >
        {SPEEDS.map((s) => (
          <ToggleButton key={s} id={String(s)}>
            {s === 1 ? '1x' : s === 0.5 ? '½x' : '¼x'}
          </ToggleButton>
        ))}
      </ToggleButtonGroup>
      <button type="button" className="fi-btn" aria-pressed={paused} onClick={() => pause(!paused)}>
        {paused ? 'Play' : 'Pause'}
      </button>
      <Slider
        className="fi-scrub"
        aria-label="Frame"
        minValue={0}
        maxValue={179}
        value={frame}
        formatOptions={{ minimumIntegerDigits: 3 }}
        onChange={(v) => {
          pause(true)
          clock.frame = v as number
          setFrame(v as number)
        }}
      >
        <Slider.Output className="fi-frame" />
        <Slider.Track>
          <Slider.Fill />
          <Slider.Thumb />
        </Slider.Track>
      </Slider>
    </div>
  )
}

// The generator: everything the skill takes, picked by eye, written out as the command to paste

type Tone = { kind: 'set'; name: PaletteName } | { kind: 'brand'; hex: string; two: string } | { kind: 'mono'; hex: string }

const paletteOf = (t: Tone): Palette | undefined =>
  t.kind === 'brand' ? brand(t.hex, t.two) : t.kind === 'mono' ? mono(t.hex) : t.name === 'riso' ? undefined : PALETTES[t.name]

const SETS = Object.keys(PALETTES) as Array<PaletteName>
// brand pairs that start somewhere good, the pickers take it from there
const PAIRS: Array<[string, string]> = [
  ['#0a84ff', '#ff7a1a'],
  ['#ff58a8', '#2b50e0'],
  ['#7c5cff', '#16b88a'],
  ['#ef4444', '#1e3a8a'],
]

const FACING: Array<[View, string, string]> = [
  ['right', 'Top right', 'facing top right'],
  ['left', 'Top left', 'facing top left'],
  ['front', 'Front', 'facing front'],
  ['flat', 'Flat', 'lying flat'],
]

// each preset with what it does and what it suits, and the keys the preview card moves on
const MOTIONS: Array<[string, string, Motion | ReadonlyArray<Motion>]> = [
  ['auto', 'Auto, picked from what it does', float],
  ['hop', 'hop: rises and slams, badges and hearts', hop],
  ['bounce', 'bounce: springs up and holds, coins', bounce],
  ['toss', 'toss: winds up and whips, wallets and folders', toss],
  ['wiggle', 'wiggle: rocks back and taps, bells and hands', wiggle],
  ['jolt', 'jolt: kicked and springs home, cards', jolt()],
  ['latch', 'latch: a lid slams on, boxes and caps', latch([0, 36])],
  ['clasp', 'clasp: lifts and slams shut, locks', clasp],
  ['wobble', 'wobble: dips and rocks, faces and mascots', wobble],
  ['drift', 'drift: a lazy figure of eight, things that fly', drift],
  ['twist', 'twist: turns out and back, keys and tools', twist],
  ['press', 'press: pushed in, pops proud, buttons', press],
  ['deal', 'deal: dealt in from the side, scenes', deal([-46, 34], -21, 0)],
  ['slide', 'slide: over and back, toggles and tiles', slide([60, 0], 20, 40)],
  ['sway', 'sway: a slow lean, idle', sway],
]

const IDEAS = ['a pen', 'a rocket', 'a mailbox', 'headphones', 'a trophy']

// a card with a header, a coin and a label: enough parts to see which way a face turns
function sample(view: View, m?: Motion | ReadonlyArray<Motion>): Icon {
  const { tilt, extrude } = VIEWS[view]
  return {
    name: `card facing ${view}`,
    frames: 180,
    line: 5,
    extrude,
    layers: [
      { id: 'rig', p: [250, 256], m, shapes: [] },
      {
        parent: 'rig',
        tilt,
        shapes: [
          { d: rect(-118, -104, 236, 208, 30), fill: 'main', depth: 1 },
          { d: rect(-90, -76, 180, 58, 16), fill: 'white', depth: 0.3 },
          { d: ellipse(-52, 44, 30), fill: 'gold', depth: 0.5 },
          { d: pill(-6, 30, 96, 28), fill: 'tint' },
        ],
      },
      { parent: 'rig', shapes: [{ d: spark(150, -120, 24), fill: 'white' }] },
    ],
  }
}
const SAMPLES = Object.fromEntries(FACING.map(([v]) => [v, sample(v)])) as Record<View, Icon>

// a palette to pick, shown as its strong accents so the choice is made by eye; the pale tint would vanish on the picked card
const STRIP = ACCENTS.filter((k) => k !== 'tint')

function Swatch({ label, palette, on, pick }: { label: string; palette?: Palette; on: boolean; pick: () => void }) {
  return (
    <button type="button" className="fi-pal" aria-pressed={on} onClick={pick}>
      <span className="fi-strip" aria-hidden>
        {STRIP.map((k) => (
          <i key={k} style={{ background: (palette ?? RISO)[k] }} />
        ))}
      </span>
      {label}
    </button>
  )
}

function Pick({ label, value, set }: { label: string; value: string; set: (hex: string) => void }) {
  return (
    <ColorPicker value={value} onChange={(c) => set(c.toString('hex').toLowerCase())}>
      <ColorPicker.Trigger className="fi-pick">
        <ColorSwatch />
        <span>
          {label}
          <span className="fi-frame">{value}</span>
        </span>
      </ColorPicker.Trigger>
      <ColorPicker.Popover className="fi-pop">
        <ColorArea aria-label={`${label} colour`} colorSpace="hsb" xChannel="saturation" yChannel="brightness">
          <ColorArea.Thumb />
        </ColorArea>
        <ColorSlider aria-label="Hue" channel="hue" colorSpace="hsb">
          <ColorSlider.Track>
            <ColorSlider.Thumb />
          </ColorSlider.Track>
        </ColorSlider>
      </ColorPicker.Popover>
    </ColorPicker>
  )
}

function Maker({ tone, setTone }: { tone: Tone; setTone: (t: Tone) => void }) {
  const [thing, setThing] = useState('')
  const [motion, setMotion] = useState('auto')
  const [view, setView] = useState<View>('right')
  const palette = useMemo(() => paletteOf(tone), [tone])
  const preview = useMemo(() => sample(view, MOTIONS.find(([id]) => id === motion)?.[2]), [view, motion])
  const colours = tone.kind === 'brand' ? `colours brand ${tone.hex} ${tone.two}` : tone.kind === 'mono' ? `colours mono ${tone.hex}` : tone.name === 'riso' ? '' : `colours ${tone.name}`
  const parts = [thing.trim() || '<object>', motion !== 'auto' && `motion ${motion}`, colours, view !== 'right' && FACING.find(([v]) => v === view)?.[2]]
  const command = `/fun-isometric ${parts.filter(Boolean).join(', ')}`
  return (
    <div className="fi-maker">
      <div className="fi-fields">
        <div className="fi-field">
          <TextField value={thing} onChange={setThing} className="fi-field">
            <Label className="fi-label">Object</Label>
            <Input className="fi-input" placeholder={EXAMPLE} />
          </TextField>
          <span className="fi-chips">
            {IDEAS.map((idea) => (
              <button key={idea} type="button" className="fi-chip" onClick={() => setThing(idea)}>
                {idea}
              </button>
            ))}
          </span>
        </div>

        <Select className="fi-field" value={motion} onChange={(k) => k !== null && setMotion(String(k))}>
          <Label className="fi-label">Motion</Label>
          <Select.Trigger className="fi-input">
            <Select.Value />
            <Select.Indicator />
          </Select.Trigger>
          <Select.Popover className="fi-pop">
            <ListBox>
              {MOTIONS.map(([id, label]) => (
                <ListBox.Item key={id} id={id} textValue={label}>
                  {label}
                  <ListBox.ItemIndicator />
                </ListBox.Item>
              ))}
            </ListBox>
          </Select.Popover>
        </Select>

        <fieldset className="fi-field">
          <legend className="fi-label">Colours</legend>
          <div className="fi-palettes">
            {SETS.map((name) => (
              <Swatch key={name} label={name[0].toUpperCase() + name.slice(1)} palette={PALETTES[name]} on={tone.kind === 'set' && tone.name === name} pick={() => setTone({ kind: 'set', name })} />
            ))}
            <Swatch label="Brand" palette={tone.kind === 'brand' ? palette : brand(...PAIRS[0])} on={tone.kind === 'brand'} pick={() => tone.kind !== 'brand' && setTone({ kind: 'brand', hex: PAIRS[0][0], two: PAIRS[0][1] })} />
            <Swatch label="One colour" palette={tone.kind === 'mono' ? palette : mono('#2b6cff')} on={tone.kind === 'mono'} pick={() => tone.kind !== 'mono' && setTone({ kind: 'mono', hex: '#2b6cff' })} />
          </div>
          {tone.kind === 'brand' && (
            <div className="fi-tune">
              <Pick label="Primary" value={tone.hex} set={(hex) => setTone({ ...tone, hex })} />
              <Pick label="Secondary" value={tone.two} set={(two) => setTone({ ...tone, two })} />
              <span className="fi-pairs" role="group" aria-label="Suggested pairs">
                {PAIRS.map(([hex, two]) => (
                  <button
                    key={hex + two}
                    type="button"
                    aria-label={`${hex} with ${two}`}
                    aria-pressed={tone.hex === hex && tone.two === two}
                    style={{ background: `linear-gradient(135deg, ${hex} 50%, ${two} 50%)` }}
                    onClick={() => setTone({ kind: 'brand', hex, two })}
                  />
                ))}
              </span>
            </div>
          )}
          {tone.kind === 'mono' && (
            <div className="fi-tune">
              <Pick label="Colour" value={tone.hex} set={(hex) => setTone({ kind: 'mono', hex })} />
            </div>
          )}
        </fieldset>

        <fieldset className="fi-field">
          <legend className="fi-label">Facing</legend>
          <div className="fi-views">
            {FACING.map(([v, label]) => (
              <button key={v} type="button" className="fi-view" aria-pressed={view === v} onClick={() => setView(v)}>
                <Sticker icon={SAMPLES[v]} palette={palette} label={`A card ${label.toLowerCase()}`} />
                <span>{label}</span>
              </button>
            ))}
          </div>
        </fieldset>
      </div>

      <div className="fi-out">
        <div className="fi-stage">
          <Sticker icon={preview} palette={palette} label="Preview of the chosen motion, colours and facing" />
        </div>
        <div className="fi-command">
          <code>{command}</code>
          <CopyButton text={command} what="maker" disabled={!thing.trim()} />
        </div>
      </div>
    </div>
  )
}

// the wall pops its stickers in a scattered order, 60ms apart
const order = ICONS.map((_, i) => i).sort((a, b) => ((a * 7 + 3) % 23) - ((b * 7 + 3) % 23))

// children land before the install section, so a host can add its own sections under the same palette and clock
export default function Page({ className, children }: { className?: string; children?: ReactNode }) {
  const [tone, setTone] = useState<Tone>({ kind: 'set', name: 'riso' })
  const palette = useMemo(() => paletteOf(tone), [tone])
  const tint = (palette ?? RISO).tint
  useEffect(() => {
    return () => {
      Object.assign(clock, { speed: 1, paused: false })
    }
  }, [])
  return (
    <main className={className ? `show-fun ${className}` : 'show-fun'} style={{ '--fi-tint': tint } as CSSProperties}>
      <header className="fi-nav">
        <p className="fi-brand">
          <a className="fi-word" href="#top">
            fun-isometric
          </a>
          <a className="fi-by" href={MAKER.url} target="_blank" rel="noopener">
            by {MAKER.name}
          </a>
        </p>
        <nav className="fi-links" aria-label="Site">
          <a href="#make">Make</a>
          <a href="#examples">Examples</a>
          <a className="fi-btn fi-hub" href={REPO} target="_blank" rel="noopener" onClick={() => track('Open GitHub', { from: 'nav' })}>
            GitHub
          </a>
          <a className="fi-btn fi-follow" href={MAKER.follow} target="_blank" rel="noopener" aria-label={`Follow ${MAKER.name} on X`} onClick={() => track('Follow on X')}>
            <svg viewBox="0 0 24 24" width="13" height="13" aria-hidden>
              <path
                fill="currentColor"
                d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
              />
            </svg>
            Follow
          </a>
        </nav>
      </header>

      <section className="fi-hero" id="top">
        <div className="fi-emblem">
          <Sticker icon={pen} enter={0.15} palette={palette} label="A pen scribbling on a square pad" />
        </div>
        <h1 className="fi-display">/fun-isometric</h1>
        <p className="fi-lede">
          A skill for Claude Code and Codex. Name an object and what it does, and it comes back as an isometric sticker looping on real
          keyframes, in any palette.
        </p>
        <Install />
        <p className="fi-meta">Start a new session, then ask for <span className="fi-nowrap">/fun-isometric</span>, or <span className="fi-nowrap">$fun-isometric</span> in Codex. Rendering needs Bun, and video needs ffmpeg.</p>
      </section>

      <section className="fi-sec" id="make">
        <header className="fi-head">
          <h2 className="fi-h2">Make your own.</h2>
          <p className="fi-meta">Pick the object, the motion, the colours and the facing, then paste the command into your agent.</p>
        </header>
        <Maker tone={tone} setTone={setTone} />
      </section>

      <section className="fi-sec" id="examples">
        <header className="fi-head">
          <h2 className="fi-h2">Examples.</h2>
          <Controls />
        </header>
        <ul className="fi-wall">
          {ICONS.map((icon, i) => (
            <li key={icon.name} className="fi-tile">
              <Sticker icon={icon} enter={0.3 + order.indexOf(i) * 0.06} palette={palette} />
              <p className="fi-name">{icon.name}</p>
              <p className="fi-meta">{MOVES[icon.name]}</p>
            </li>
          ))}
        </ul>
      </section>

      {children}

      <footer className="fi-foot">
        <p>
          MIT licensed. Made by <a href="https://www.klvn.dev">Kelvin</a>, heavily inspired by <a href="https://slush.app">Slush</a>.
        </p>
        <a href={REPO} target="_blank" rel="noopener" onClick={() => track('Open GitHub', { from: 'footer' })}>
          Source on GitHub
        </a>
      </footer>
    </main>
  )
}
```

## File: skills/fun-isometric/site/ui.css
size: 3332
sha: a1a0f59c42663681d03fd22e57bde876be6add56
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/site/ui.css

```text
/* HeroUI's controls compiled on their own: its theme is referenced, never emitted, so nothing reaches :root and a host app's Tailwind stays as it was. */
@reference "@heroui/styles";
@import "@heroui/styles/components/label.css" layer(components);
@import "@heroui/styles/components/description.css" layer(components);
@import "@heroui/styles/components/input.css" layer(components);
@import "@heroui/styles/components/textfield.css" layer(components);
@import "@heroui/styles/components/popover.css" layer(components);
@import "@heroui/styles/components/list-box.css" layer(components);
@import "@heroui/styles/components/list-box-item.css" layer(components);
@import "@heroui/styles/components/select.css" layer(components);
@import "@heroui/styles/components/toggle-button.css" layer(components);
@import "@heroui/styles/components/toggle-button-group.css" layer(components);
@import "@heroui/styles/components/slider.css" layer(components);
@import "@heroui/styles/components/color-swatch.css" layer(components);
@import "@heroui/styles/components/color-area.css" layer(components);
@import "@heroui/styles/components/color-slider.css" layer(components);
@import "@heroui/styles/components/color-picker.css" layer(components);

/* HeroUI's tokens on the page's palette, literal since popovers portal outside the page: grey fills, ink when picked, no rings */
@layer components {
  .show-fun,
  .fi-pop {
    --spacing: 0.25rem;
    --radius: 0.75rem;
    --radius-3xl: calc(var(--radius) * 3);
    --text-xs: 0.75rem;
    --text-xs--line-height: calc(1 / 0.75);
    --text-sm: 0.875rem;
    --text-sm--line-height: calc(1.25 / 0.875);
    --text-base: 1rem;
    --text-base--line-height: 1.5;
    --font-weight-medium: 500;
    --ease-smooth: ease;
    --ease-out: cubic-bezier(0, 0, 0.2, 1);
    --ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
    --ease-out-fluid: cubic-bezier(0.32, 0.72, 0, 1);
    --default-transition-duration: 150ms;
    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    --color-white: #fff;
    --color-black: #000;
    --cursor-interactive: pointer;
    --cursor-disabled: not-allowed;
    --disabled-opacity: 0.4;
    --ring-offset-width: 2px;
    --scrollbar-width: thin;
    --scrollbar-gutter: auto;
    --scrollbar-color: auto;

    --foreground: #0b0b0c;
    --background: #fff;
    --muted: #66666c;
    --border: #e4e4e8;
    --danger: #e5484d;
    --focus: #0b0b0c;
    --default: #ebebee;
    --default-hover: #e2e2e6;
    --default-foreground: #0b0b0c;
    --accent: #0b0b0c;
    --accent-foreground: #fff;
    --accent-soft: #ebebee;
    --accent-soft-hover: #e2e2e6;
    --accent-soft-foreground: #0b0b0c;
    --segment: #fff;
    --segment-foreground: #0b0b0c;
    --overlay: #fff;
    --overlay-shadow: 0 12px 32px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.06);
    --surface-shadow: none;
    --field-background: #fff;
    --field-foreground: #0b0b0c;
    --field-placeholder: #66666c;
    --field-border: transparent;
    --field-border-hover: transparent;
    --field-border-focus: transparent;
    --field-hover: #fff;
    --field-focus: #fff;
    --field-radius: 16px;
    --field-shadow: none;
    --border-width-field: 0px;
  }
  .fi-pop {
    font-family: var(--fun-text, 'Geist Variable', system-ui, sans-serif);
    letter-spacing: -0.02em;
  }
}
```

## File: skills/fun-isometric/vercel.json
size: 572
sha: f6193a107364a0501e0e7730f5550ce2893aff54
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/vercel.json

```text
{
  "trailingSlash": false,
  "redirects": [
    {"source": "/(.*)", "has": [{"type": "host", "value": "fun-isometric.klvn.dev"}], "destination": "https://skills.klvn.dev/fun-isometric", "permanent": true},
    {"source": "/", "destination": "/fun-isometric", "permanent": false}
  ],
  "rewrites": [
    {"source": "/fun-isometric", "destination": "/fun-isometric/index.html"},
    {"source": "/llms.txt", "destination": "/fun-isometric/llms.txt"},
    {"source": "/.well-known/agent-skills/:path*", "destination": "/fun-isometric/.well-known/agent-skills/:path*"}
  ]
}
```

## File: skills/fun-isometric/vite.config.ts
size: 439
sha: 227851d2bc92bcfe36be23efe7b15f5bb05c9a62
upstream: https://github.com/kelvinkn17/skills/blob/main/skills/fun-isometric/vite.config.ts

```text
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// Self contained: the skill, its kit and this preview site all live in this folder
// Served at skills.klvn.dev/fun-isometric, so it builds into that folder and links under it
export default defineConfig({
  base: '/fun-isometric/',
  build: { outDir: 'dist/fun-isometric' },
  plugins: [react(), tailwindcss()],
})
```

## File: README.md
upstream: https://github.com/kelvinkn17/skills/blob/main/README.md

````text
# skills

[![skills.sh](https://www.skills.sh/b/kelvinkn17/skills)](https://www.skills.sh/kelvinkn17/skills)

Agent skills I build and use. Each lives in `skills/<name>/` with its `SKILL.md` and everything it runs.

| Skill | What it does |
| --- | --- |
| [fun-isometric](skills/fun-isometric) | Draws isometric stickers and animates them on real keyframes. Any object, any palette, four facings. [Try the generator](https://skills.klvn.dev/fun-isometric) |

fun-isometric is heavily inspired by the stickers in [Slush](https://slush.app).

## Install

```sh
npx skills add kelvinkn17/skills@fun-isometric
```

It needs Node 22 or newer. The CLI asks which agents to add it to (Claude Code, Codex, Cursor and more) and whether it goes in
every project. Start a new session, then ask for `/fun-isometric`, or `$fun-isometric` in Codex.

The site publishes an [agent-skills index](https://skills.klvn.dev/.well-known/agent-skills/index.json), so this works too:

```sh
npx skills add https://skills.klvn.dev
```

Or download [fun-isometric.zip](https://skills.klvn.dev/fun-isometric/fun-isometric.zip) and unzip it into your skills folder:

```sh
unzip fun-isometric.zip -d ~/.claude/skills
```

As a Claude Code plugin it shows up as `/fun-isometric:fun-isometric`:

```
/plugin marketplace add kelvinkn17/skills
/plugin install fun-isometric@kelvinkn17-skills
```

## fun-isometric

```
/fun-isometric a pen scribbling on a square pad
/fun-isometric a mailbox, motion jolt, colours brand #2b6cff, facing top left
```

In Codex the same requests start with `$fun-isometric`.

It draws the object as flat slabs with a thick outline, rigs each part where it would hinge, and moves it on keyframe presets
(hop, toss, wiggle, clasp, drift and more) interpolated the way lottie-web plays After Effects keys. Colours are roles, so a brand
colour, one colour or a hue shift repaints a sticker and keeps what each part means.

What you get back is an icon module that plays live on canvas (a React component ships with the kit) or exports to looping alpha
video. Rendering and checks need [Bun](https://bun.sh) and, for video, ffmpeg; Playwright's Chromium installs itself on first use.

## License

MIT
````
