Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this project is

A Conductor starter project: a single-page React 19 + TypeScript + Vite app where clicking (or pressing Space/Enter) dispatches an animated emoji train across the screen. It exists to give new Conductor workspaces something quick to install, run, edit, review, and ship.

Conductor runs each workspace as an isolated git worktree. `conductor.json` defines the two scripts Conductor invokes: `setup` (`npm install`) on workspace creation and `run` (`npm run dev`) when the user clicks Run.

## Commands

- `npm run dev` — Vite dev server (Conductor's Run button calls this)
- `npm run build` — type-check (`tsc -b`) then production build
- `npm run lint` — ESLint over the repo
- `npm run preview` — preview the built output

There is no test runner configured.

## Architecture

The entire app lives in `src/App.tsx`. Key things to know before editing it:

- **State model**: `trains` and `puffs` are arrays of independent animated entities, each with a unique id from a single `nextIdRef` counter. They are removed from state via their CSS `onAnimationEnd` handler — animation duration is the source of truth for lifetime, so changing CSS timings without updating `MIN_DURATION_MS`/`MAX_DURATION_MS` will desync the steam puffs that are scheduled with `setTimeout`.
- **Dispatch throttling**: rapid clicks are gated by `DISPATCH_THROTTLE_MS` against `performance.now()` in `lastDispatchRef`. Lane and direction also alternate via refs so consecutive trains don't overlap.
- **Audio**: a single `<audio>` element is created once in a ref and `cloneNode`'d per dispatch so overlapping plays don't cut each other off. Mute state persists in `localStorage` under `wtc:muted`, and `mutedRef` mirrors it so the keyboard/click handlers stay stable.
- **Styling**: positions use `vw`/`vh` and lanes are computed as `10 + lane * 16` (vh). The hero overlay hides itself via the `dispatched` class once `hasDispatched` flips.

## Conductor-specific conventions

- `.context/` is gitignored and intended for handoff notes between agents in the same workspace.
- `.claude/skills/` and `.agents/skills/` hold per-workspace skills bundles managed via `skills-lock.json` — don't hand-edit those.
- The PR template (`.github/pull_request_template.md`) is intentionally a one-line celebration message for the tutorial flow; keep PRs in this repo lightweight.
80 changes: 80 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,86 @@ main {
z-index: 10;
}

.milestone {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 20;
animation: milestone-fade 2200ms ease forwards;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}

@keyframes milestone-fade {
0% { opacity: 0; }
10% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}

.milestone-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 28px 44px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #eee;
border-radius: 18px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
animation: milestone-pop 700ms cubic-bezier(0.18, 1.2, 0.4, 1) both;
}

@keyframes milestone-pop {
0% { transform: scale(0.6) rotate(-4deg); opacity: 0; }
60% { transform: scale(1.08) rotate(2deg); opacity: 1; }
100% { transform: scale(1) rotate(0); opacity: 1; }
}

.milestone-emoji {
font-size: 56px;
line-height: 1;
}

.milestone-count {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 48px;
font-weight: 600;
color: #222;
letter-spacing: 0.02em;
}

.milestone-label {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
color: #888;
letter-spacing: 0.04em;
}

.confetti {
position: absolute;
top: -8vh;
font-size: 24px;
line-height: 1;
will-change: transform, opacity;
animation-name: confetti-fall;
animation-timing-function: cubic-bezier(0.35, 0.1, 0.6, 1);
animation-fill-mode: forwards;
}

@keyframes confetti-fall {
0% { transform: translate(0, 0) rotate(0); opacity: 0; }
10% { opacity: 1; }
100% { transform: translate(var(--drift, 0), 110vh) rotate(540deg); opacity: 0.9; }
}

@media (prefers-reduced-motion: reduce) {
.confetti { display: none; }
.milestone-card { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
.train-emoji:hover { animation: none; }
.steam { display: none; }
Expand Down
69 changes: 68 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,31 @@ type Puff = {
char: string
}

type Confetti = {
id: number
x: number
delay: number
duration: number
char: string
drift: number
}

type Milestone = {
id: number
count: number
confetti: Confetti[]
}

const TRAIN_EMOJIS = ['🚂', '🚃', '🚅', '🚋', '🚄']
const PUFF_CHARS = ['·', '°', '・', '∘']
const LANE_COUNT = 5
const DISPATCH_THROTTLE_MS = 120
const PUFF_COUNT = 4
const MIN_DURATION_MS = 4500
const MAX_DURATION_MS = 7500
const MILESTONE_EVERY = 10
const CONFETTI_COUNT = 24
const CONFETTI_CHARS = ['🎉', '🎊', '✨', '🌟', '🎈']

function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
Expand All @@ -34,6 +52,7 @@ function pick<T>(arr: readonly T[]): T {
function App() {
const [trains, setTrains] = useState<Train[]>([])
const [puffs, setPuffs] = useState<Puff[]>([])
const [milestone, setMilestone] = useState<Milestone | null>(null)
const [count, setCount] = useState(0)
const [muted, setMuted] = useState<boolean>(() => {
if (typeof localStorage === 'undefined') return false
Expand Down Expand Up @@ -107,7 +126,21 @@ function App() {
}

setTrains((prev) => [...prev, train])
setCount((c) => c + 1)
setCount((c) => {
const next = c + 1
if (next % MILESTONE_EVERY === 0) {
const confetti: Confetti[] = Array.from({ length: CONFETTI_COUNT }, () => ({
id: nextIdRef.current++,
x: Math.random() * 100,
delay: Math.random() * 400,
duration: 1600 + Math.random() * 900,
char: pick(CONFETTI_CHARS),
drift: (Math.random() - 0.5) * 30,
}))
setMilestone({ id: nextIdRef.current++, count: next, confetti })
}
return next
})
setHasDispatched(true)
playChoo()

Expand Down Expand Up @@ -172,6 +205,40 @@ function App() {
</span>
))}

{milestone && (
<div
key={milestone.id}
className="milestone"
role="status"
aria-live="polite"
onAnimationEnd={(e) => {
if (e.target === e.currentTarget) setMilestone(null)
}}
>
<div className="milestone-card">
<div className="milestone-emoji">🎉</div>
<div className="milestone-count">{milestone.count}</div>
<div className="milestone-label">
{milestone.count === MILESTONE_EVERY ? 'first ten!' : 'trains dispatched'}
</div>
</div>
{milestone.confetti.map((c) => (
<span
key={c.id}
className="confetti"
style={{
left: `${c.x}vw`,
animationDelay: `${c.delay}ms`,
animationDuration: `${c.duration}ms`,
['--drift' as string]: `${c.drift}vw`,
}}
>
{c.char}
</span>
))}
</div>
)}

<button
className="mute-toggle"
onClick={(e) => {
Expand Down