diff --git a/README.md b/README.md index 04ff2e4..5c0dd18 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ made every install think a phantom `0.6.0` was available. | [docs/VERIFICATION.md](docs/VERIFICATION.md) | The screenshot loop, and **what is not verified** | | [DECISIONS.md](DECISIONS.md) | Every deviation from the brief, the issue, or openpets — with reasons | | [docs/ASSETS.md](docs/ASSETS.md) | Swapping the art with no code changes | +| [docs/CLAUDE-CODE.md](docs/CLAUDE-CODE.md) | The Claude Code status crown — what it does, what you need, how to wire it up | | [docs/PROMPT.md](docs/PROMPT.md) | The implementation brief this was built from, errors corrected in place | | [SECURITY.md](SECURITY.md) | Reporting a vulnerability | | [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) | openpets (MIT), Noto Color Emoji (OFL 1.1 / Apache-2.0) | diff --git a/apps/desktop/src/claude/claude-state-source.ts b/apps/desktop/src/claude/claude-state-source.ts new file mode 100644 index 0000000..b67d10d --- /dev/null +++ b/apps/desktop/src/claude/claude-state-source.ts @@ -0,0 +1,131 @@ +/** + * Claude Code's state, read off a file that Claude Code's hooks write. + * + * The transport is a file holding one word, because a hook that is `echo running > file` cannot + * fail in an interesting way: no JSON to parse, no port to bind, no dependency on either side. + * + * No Electron import. This module is the one place that knows how the state arrives, so it is + * also the only file that changes when multi-session support lands — at which point it reads a + * directory of per-session files and aggregates them instead. + */ + +import { mkdirSync, readFileSync, statSync, watch, type FSWatcher } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { CLAUDE_STATES, type ClaudeState } from '../pet-frame.js' + +/** + * How old a state file may be before it is disbelieved. + * + * A session killed with Ctrl+C may never fire its `SessionEnd` hook, leaving a file that would + * otherwise pin the cap on for the rest of the day. The mtime is the guard, which is why the + * file holds only a word: the timestamp already exists in the filesystem, and putting a second + * one in the payload would be a second source of truth for the same fact. + */ +export const CLAUDE_STATE_STALE_MS = 15 * 60_000 + +/** How often to re-read regardless of the watch. See `start()`. */ +const DEFAULT_POLL_MS = 5_000 + +export function defaultClaudeStateFile(): string { + return process.env.ARGOS_CLAUDE_STATE_FILE ?? join(homedir(), '.argos', 'claude-state') +} + +export interface ClaudeStateSourceOptions { + /** Defaults to `defaultClaudeStateFile()`. */ + file?: string + /** Injected so tests can drive staleness without waiting a quarter of an hour. */ + now?: () => number + pollMs?: number + onChange?: (state: ClaudeState) => void + log?: (message: string, meta?: unknown) => void +} + +export interface ClaudeStateSource { + start(): void + stop(): void + /** The last state read. Cheap: no disk access. */ + current(): ClaudeState + /** Re-read now and report a change if there is one. Returns the new state. */ + refresh(): ClaudeState +} + +const KNOWN = new Set(CLAUDE_STATES) + +function readState(file: string, nowMs: number): ClaudeState { + let raw: string + let mtimeMs: number + try { + mtimeMs = statSync(file).mtimeMs + raw = readFileSync(file, 'utf8') + } catch { + // Missing, unreadable, a directory, a permission error — all the same answer. + return 'none' + } + if (nowMs - mtimeMs > CLAUDE_STATE_STALE_MS) return 'none' + const word = raw.trim().toLowerCase() + return KNOWN.has(word) ? (word as ClaudeState) : 'none' +} + +export function createClaudeStateSource( + options: ClaudeStateSourceOptions = {}, +): ClaudeStateSource { + const file = options.file ?? defaultClaudeStateFile() + const now = options.now ?? Date.now + const pollMs = options.pollMs ?? DEFAULT_POLL_MS + const onChange = options.onChange ?? ((): void => {}) + const log = options.log ?? ((): void => {}) + + let state: ClaudeState = 'none' + let watcher: FSWatcher | null = null + let timer: NodeJS.Timeout | null = null + + const refresh = (): ClaudeState => { + const next = readState(file, now()) + if (next !== state) { + state = next + onChange(state) + } + return state + } + + return { + current: () => state, + refresh, + + start(): void { + // Make the directory ourselves so the watch has a target from the first launch, rather + // than silently doing nothing until the first hook happens to create it. + try { + mkdirSync(dirname(file), { recursive: true }) + } catch (error) { + log('claude-state: could not create the state directory', error) + } + + refresh() + + // Watch the *directory*, not the file: a writer that replaces the file swaps the inode, + // and a file-level watch is then pointed at something nothing will ever touch again. + try { + watcher = watch(dirname(file), { persistent: false }, () => { + refresh() + }) + } catch (error) { + log('claude-state: directory watch unavailable, polling only', error) + } + + // Backstop. fs.watch misses events on some platforms and filesystems, and a missed event + // here means a cap stuck on the wrong colour — the one failure the whole feature is + // supposed to prevent. + timer = setInterval(refresh, pollMs) + timer.unref?.() + }, + + stop(): void { + watcher?.close() + watcher = null + if (timer) clearInterval(timer) + timer = null + }, + } +} diff --git a/apps/desktop/src/main/app-shell.ts b/apps/desktop/src/main/app-shell.ts index e62db95..4a08b4c 100644 --- a/apps/desktop/src/main/app-shell.ts +++ b/apps/desktop/src/main/app-shell.ts @@ -25,6 +25,7 @@ import { SettingsStore } from './settings-store.js' import { createTray, type TrayController } from './tray.js' import { createPetWindow, type PetWindow } from './pet-window.js' import { createPetController, type PetController } from './pet-controller.js' +import { createClaudeStateSource } from '../claude/claude-state-source.js' import { createMenuController, type MenuController } from './menu.js' import { createActions } from './actions.js' import type { MenuViewModel, UpdateState } from './menu-template.js' @@ -350,10 +351,22 @@ export async function startApp(): Promise { }, }) + // Claude Code's state, for the cap. `tickNow` rather than waiting for the next tick: a colour + // that lags the terminal by a tick is not worth having, and `tickNow` is the existing seam for + // exactly this — it runs the tick body out of phase without resetting the interval. + const claudeState = createClaudeStateSource({ + log, + onChange() { + controller?.tickNow() + }, + }) + claudeState.start() + controller = createPetController({ pet, displays, getMovementEnabled: () => settings.get().movementEnabled, + getClaudeState: () => claudeState.current(), onPositionChanged(displayKey, petCentreX, feetY) { settings.patch({ position: { displayKey, x: petCentreX, feetY } }) }, @@ -915,6 +928,7 @@ export async function startApp(): Promise { backdrop: () => backdrop, spriteRect: () => pet.spriteRect(), bubbleBand: () => pet.bubbleBand(), + crownVisible: () => pet.crownVisible(), setMovement: (enabled) => { // The same three steps `toggleMovement` takes — durable setting, immediate trigger, out-of-phase // tick — so freezing the pet for a screenshot goes through the real path rather than a back door @@ -1016,6 +1030,7 @@ export async function startApp(): Promise { callouts.dispose() toasts.destroyAll() controller?.stop() + claudeState.stop() // Flush before tearing anything down — an unflushed position or reminder deadline is // exactly the state that must survive a quit. await settings.flush() diff --git a/apps/desktop/src/main/harness-control.ts b/apps/desktop/src/main/harness-control.ts index 7df2ac9..e827164 100644 --- a/apps/desktop/src/main/harness-control.ts +++ b/apps/desktop/src/main/harness-control.ts @@ -85,6 +85,8 @@ export interface HarnessTargets { /** The line the speech bubble cannot cross, and which side of the pet it is on. See * PetWindow.bubbleBand. */ bubbleBand?: () => { y: number; side: 'above' | 'below'; visible: boolean } + /** Whether the pet is wearing a status crown. See PetWindow.crownVisible. */ + crownVisible?: () => boolean /** Whether the pet is floor-locked, so the harness knows if feet-on-floor is assertable. */ floorLocked?: () => boolean /** Place the pet at an absolute position, as a drop would. */ @@ -125,6 +127,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void { const isPet = command.window === 'pet' const rect = isPet ? targets.spriteRect?.() : undefined const bubble = isPet ? targets.bubbleBand?.() : undefined + const crownVisible = isPet ? targets.crownVisible?.() : undefined const floorLocked = isPet ? targets.floorLocked?.() : undefined const petScale = isPet ? targets.petScale?.() : undefined emit({ @@ -138,6 +141,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void { ...(bubble === undefined ? {} : { bubbleEdgeY: bubble.y, bubbleSide: bubble.side, bubbleVisible: bubble.visible }), + ...(crownVisible === undefined ? {} : { crownVisible }), ...(floorLocked === undefined ? {} : { floorLocked }), ...(petScale === undefined ? {} : { petScale }), }) @@ -209,6 +213,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void { return } const bubble = targets.bubbleBand?.() + const crownVisible = targets.crownVisible?.() emit({ ev: 'geometry', bounds: win.getContentBounds(), @@ -218,6 +223,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void { ...(bubble === undefined ? {} : { bubbleEdgeY: bubble.y, bubbleSide: bubble.side, bubbleVisible: bubble.visible }), + ...(crownVisible === undefined ? {} : { crownVisible }), }) return } diff --git a/apps/desktop/src/main/harness-handshake.ts b/apps/desktop/src/main/harness-handshake.ts index efb10a5..bbb0586 100644 --- a/apps/desktop/src/main/harness-handshake.ts +++ b/apps/desktop/src/main/harness-handshake.ts @@ -66,6 +66,14 @@ export type HandshakeEvent = bubbleSide?: 'above' | 'below' /** Whether a bubble was actually on screen for this capture. Pet window only. */ bubbleVisible?: boolean + /** + * Whether the pet was wearing a status crown for this capture. Pet window only. + * + * The crown floats above the head, so it paints in the ring A2 requires to be + * transparent. A2 is about the *window* being see-through; app-painted content there is + * not the bug it is looking for. + */ + crownVisible?: boolean /** * Whether the pet is sitting on the floor. False means it has been freely placed, in which * case asserting feet-on-floor would fail on correct behaviour. @@ -91,6 +99,7 @@ export type HandshakeEvent = bubbleEdgeY?: number bubbleSide?: 'above' | 'below' bubbleVisible?: boolean + crownVisible?: boolean } | { ev: 'error'; where: string; message: string } diff --git a/apps/desktop/src/main/pet-controller.ts b/apps/desktop/src/main/pet-controller.ts index 982dfa9..58f8ab1 100644 --- a/apps/desktop/src/main/pet-controller.ts +++ b/apps/desktop/src/main/pet-controller.ts @@ -21,7 +21,7 @@ import { advance, initialState } from '../motion/motion-engine.js' import { DEFAULT_MOTION_CONFIG, type MotionConfig } from '../motion/motion-config.js' import type { MotionState, MotionTrigger } from '../motion/types.js' import { ANIMATIONS, resolveTrigger, type Trigger, type AnimationState } from '../pet-animations.generated.js' -import type { PetFrame, Tone } from '../pet-frame.js' +import type { ClaudeState, PetFrame, Tone } from '../pet-frame.js' import { DRINK_LOOP_GAP_MS } from '../config/constants.js' import { floorForWorkArea, @@ -48,6 +48,13 @@ export interface PetControllerOptions { pet: PetWindow displays: DisplayManager getMovementEnabled: () => boolean + /** + * Claude Code's state, for the status cap. + * + * Injected as a getter, like `getMovementEnabled`, so the controller stays ignorant of where + * the state comes from and the tests need no filesystem. + */ + getClaudeState?: () => ClaudeState /** `feetY` is null when the pet is floor-locked, meaning "re-derive it on launch". */ onPositionChanged: (displayKey: string, petCentreX: number, feetY: number | null) => void /** Fired after a tick when the pose changed, so the hover chip can return after electrocute. */ @@ -114,6 +121,7 @@ export interface PetController { export function createPetController(options: PetControllerOptions): PetController { const config = options.config ?? DEFAULT_MOTION_CONFIG const now = options.now ?? Date.now + const getClaudeState = options.getClaudeState ?? ((): ClaudeState => 'none') const log = options.log ?? (() => {}) const { pet, displays } = options @@ -165,6 +173,7 @@ export function createPetController(options: PetControllerOptions): PetControlle : null, quickActions: callout ? [] : [...quickActions], overlay: animation === config.sleepAnimation ? 'sleep-z' : 'none', + claudeState: getClaudeState(), } } diff --git a/apps/desktop/src/main/pet-window.ts b/apps/desktop/src/main/pet-window.ts index 7d3d569..34e7371 100644 --- a/apps/desktop/src/main/pet-window.ts +++ b/apps/desktop/src/main/pet-window.ts @@ -27,7 +27,7 @@ import { spriteScreenRect, type BubbleSide, } from '../sprite/alpha-mask.js' -import { IPC, petFrameSchema, type PetFrame } from '../pet-frame.js' +import { IPC, frameNeedsCellRegion, petFrameSchema, type PetFrame } from '../pet-frame.js' import { rendererFile, paths } from './paths.js' import { emit } from './harness-handshake.js' import type { DisplaySnapshot, Floor } from './display-manager.js' @@ -72,6 +72,13 @@ export interface PetWindow { * assertion would then measure the bubble and report a transparency failure. */ bubbleBand(): { y: number; side: BubbleSide; visible: boolean } + /** + * Whether a status crown is on the pet's head right now. + * + * The harness needs it because the crown legitimately paints outside the character, in the + * ring A2 otherwise requires to be fully transparent. + */ + crownVisible(): boolean /** * Change the pet's size. * @@ -183,10 +190,16 @@ export async function createPetWindow(options: { let lastAnimation: string | null = null /** Whether the last frame carried a bubble. Drives the shape region and the callout raise. */ let lastBubbleVisible = false - /** Whether the last frame carried a CSS overlay — the sleep Z's. Drives the shape region. */ + /** + * Whether the last frame painted inside the sprite cell but outside the character mask — the + * sleep Z's, or the Claude status cap. Drives the shape region, which on Linux governs what is + * painted at all. + */ let lastOverlayVisible = false /** Whether the hover quick-action menu is up. Drives the shape region on Linux. */ let lastQuickMenuVisible = false + /** The crown on the last frame, for the harness. 'none' means bare-headed. */ + let lastClaudeState: PetFrame['claudeState'] = 'none' /** * The Linux input-and-drawing region for what is currently on screen. @@ -335,7 +348,8 @@ export async function createPetWindow(options: { const previous = { lastAnimation, lastBubbleVisible, lastOverlayVisible, lastQuickMenuVisible } lastAnimation = parsed.data.animation lastBubbleVisible = parsed.data.bubble !== null - lastOverlayVisible = parsed.data.overlay !== 'none' + lastOverlayVisible = frameNeedsCellRegion(parsed.data) + lastClaudeState = parsed.data.claudeState lastQuickMenuVisible = parsed.data.quickActions.length > 0 win.webContents.send(IPC.frame, parsed.data) forwarding.setForceInteractive( @@ -369,6 +383,10 @@ export async function createPetWindow(options: { return spriteScreenRect(ALPHA_MASK, bounds, placement.spriteOrigin, placement.scale) as Rectangle }, + crownVisible(): boolean { + return lastClaudeState !== 'none' + }, + bubbleBand(): { y: number; side: BubbleSide; visible: boolean } { const bounds = win.isDestroyed() ? { x, y } : win.getBounds() const band = bubbleBandRect( diff --git a/apps/desktop/src/pet-frame.ts b/apps/desktop/src/pet-frame.ts index 200ca6a..c9d7ed6 100644 --- a/apps/desktop/src/pet-frame.ts +++ b/apps/desktop/src/pet-frame.ts @@ -18,6 +18,17 @@ import { ANIMATION_STATES } from './pet-animations.generated.js' export const TONES = ['info', 'success', 'warning', 'error'] as const export type Tone = (typeof TONES)[number] +/** + * Claude Code's state, as the pet displays it. + * + * `none` is the absence of a signal — no state file, an unreadable one, or one too old to + * believe — and paints no cap at all. Every failure resolves here, because a status indicator + * that lies is worse than one that is absent: an absent cap is visibly absent, a wrong cap is + * silently wrong. + */ +export const CLAUDE_STATES = ['none', 'waiting', 'running', 'idle'] as const +export type ClaudeState = (typeof CLAUDE_STATES)[number] + /** Bubble text is clamped in main before it ever reaches here. */ export const BUBBLE_TEXT_MAX = 200 @@ -103,6 +114,16 @@ export const petFrameSchema = z.strictObject({ /** Pure-CSS overlays. `sleep-z` is what lets `sleep` ship with no new art. */ overlay: z.enum(['none', 'sleep-z']), + + /** + * Claude Code's state, painted as a coloured cap on the pet's head. + * + * Its own field rather than another value on `overlay`, because the two are independent axes: + * `overlay` is single-valued and already owned by the sleep Z's, and a sleeping pet must still + * be able to wear the cap. Folding them together would make "asleep and waiting" + * unrepresentable. + */ + claudeState: z.enum(CLAUDE_STATES), }) export type PetFrame = z.infer @@ -129,3 +150,20 @@ export const IPC = { bubbleAction: 'keycode-pet:bubble-action', quickAction: 'keycode-pet:quick-action', } as const + +/** + * Does this frame paint anything outside the character's own mask but inside the sprite cell? + * + * `setShape` determines the area where the system permits *drawing* — outside it, no pixels are + * drawn at all. The sleep Z's sit above the hair and the status cap sits on it, both in mask + * cells that are transparent, so both need the region widened to the whole cell or they are + * silently invisible on Linux. See the long note in `sprite/alpha-mask.ts`. + * + * A function here rather than an expression at the call site so it can be tested without a + * window: `pet-window.ts` is Electron all the way down. + */ +export function frameNeedsCellRegion( + frame: Pick, +): boolean { + return frame.overlay !== 'none' || frame.claudeState !== 'none' +} diff --git a/apps/desktop/src/renderer/pet.css b/apps/desktop/src/renderer/pet.css index d27680c..28cf04c 100644 --- a/apps/desktop/src/renderer/pet.css +++ b/apps/desktop/src/renderer/pet.css @@ -462,3 +462,24 @@ html[data-overlay='sleep-z'] #zzz { transform: translate(16px, -26px) rotate(10deg); } } + +/* + Claude Code status crown. + + Placement is split in three, and each part lives where its facts live. Here: the crown is + parked on the sprite cell's corner, because only this file knows the cell exists. In + `pet.generated.css`: its size, its art and the gap above the head, because those come out of + `scripts/lib/crowns.mjs` and the art itself. Also generated: one keyframe stop per animation + frame, holding that frame's real head anchor. + + Anchoring to --body-top instead would pin the crown to a per-state constant, and the pet's + bounce is background-position stepping -- the sprite element never moves, so the crown would + hang in the air while the character bobs underneath it. +*/ +#claude-crown { + position: absolute; + left: var(--sprite-x, 0px); + top: var(--sprite-y, 0px); + display: none; + pointer-events: none; +} diff --git a/apps/desktop/src/renderer/pet.generated.css b/apps/desktop/src/renderer/pet.generated.css index 34744ab..4458fc1 100644 --- a/apps/desktop/src/renderer/pet.generated.css +++ b/apps/desktop/src/renderer/pet.generated.css @@ -299,3 +299,713 @@ .pet-sprite[data-state="waving"][data-nonce="1"] { animation: kp-waving-1 2700ms steps(18, jump-none) 1 forwards; } + +/* The status crown. Size, art and clock are all generated; see buildCrownCss. */ +#claude-crown { + width: calc(28px * var(--pet-scale, 1)); + height: calc(22px * var(--pet-scale, 1)); + background-repeat: no-repeat; + background-size: 100% 100%; + /* Same reason as the sprite: without it the browser smooths the pixel art into mush. */ + image-rendering: pixelated; + /* Composes with the `translate` the keyframes animate. That puts the anchor on the head; + this centres the crown on it and lifts it clear by 4px of daylight. */ + transform: translate(-50%, calc(-100% - 4px * var(--pet-scale, 1))); + animation-timing-function: step-end; +} + +html[data-claude-state="waiting"] #claude-crown { + display: block; + background-image: url('./crown-waiting.png'); +} +html[data-claude-state="running"] #claude-crown { + display: block; + background-image: url('./crown-running.png'); +} +html[data-claude-state="idle"] #claude-crown { + display: block; + background-image: url('./crown-idle.png'); +} + +@keyframes kp-crown-drink-0 { + 0% { translate: calc(104px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 9.0909% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 18.1818% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 27.2727% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 36.3636% { translate: calc(92px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 45.4545% { translate: calc(90px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 54.5455% { translate: calc(90px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 63.6364% { translate: calc(96px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 72.7273% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 81.8182% { translate: calc(104px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 90.9091% { translate: calc(104px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-drink-1 { + 0% { translate: calc(104px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 9.0909% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 18.1818% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 27.2727% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 36.3636% { translate: calc(92px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 45.4545% { translate: calc(90px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 54.5455% { translate: calc(90px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 63.6364% { translate: calc(96px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 72.7273% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 81.8182% { translate: calc(104px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 90.9091% { translate: calc(104px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +html[data-pet-state="drink"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-drink-0 2200ms step-end 2 forwards; +} +html[data-pet-state="drink"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-drink-1 2200ms step-end 2 forwards; +} + +@keyframes kp-crown-electrocute-0 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 4.7619% { translate: calc(98px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 9.5238% { translate: calc(85px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(85px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 19.0476% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 23.8095% { translate: calc(100px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(93px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(93px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 38.0952% { translate: calc(93px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(78px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 47.619% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 52.381% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 61.9048% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(86px * var(--pet-scale, 1)) calc(54px * var(--pet-scale, 1)); } + 76.1905% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 80.9524% { translate: calc(105px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 90.4762% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 95.2381% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-electrocute-1 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 4.7619% { translate: calc(98px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 9.5238% { translate: calc(85px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(85px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 19.0476% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 23.8095% { translate: calc(100px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(93px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(93px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 38.0952% { translate: calc(93px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(78px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 47.619% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 52.381% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 61.9048% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(86px * var(--pet-scale, 1)) calc(54px * var(--pet-scale, 1)); } + 76.1905% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 80.9524% { translate: calc(105px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 90.4762% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 95.2381% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } +} +html[data-pet-state="electrocute"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-electrocute-0 2100ms step-end 1 forwards; +} +html[data-pet-state="electrocute"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-electrocute-1 2100ms step-end 1 forwards; +} + +@keyframes kp-crown-failed-0 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 50% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-failed-1 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 50% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +html[data-pet-state="failed"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-failed-0 1220ms step-end 2 forwards; +} +html[data-pet-state="failed"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-failed-1 1220ms step-end 2 forwards; +} + +@keyframes kp-crown-idle-0 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 50% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-idle-1 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 50% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +html[data-pet-state="idle"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-idle-0 2000ms step-end infinite forwards; +} +html[data-pet-state="idle"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-idle-1 2000ms step-end infinite forwards; +} + +@keyframes kp-crown-idle-left-0 { + 0% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 50% { translate: calc(84px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-idle-left-1 { + 0% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 50% { translate: calc(84px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +html[data-pet-state="idle-left"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-idle-left-0 2000ms step-end infinite forwards; +} +html[data-pet-state="idle-left"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-idle-left-1 2000ms step-end infinite forwards; +} + +@keyframes kp-crown-jumping-0 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(101px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(99px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(102px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(101px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(107px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(105px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(105px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(106px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(105px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-jumping-1 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(101px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(99px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(102px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(101px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(107px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(105px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(105px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(106px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(105px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +html[data-pet-state="jumping"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-jumping-0 4800ms step-end 1 forwards; +} +html[data-pet-state="jumping"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-jumping-1 4800ms step-end 1 forwards; +} + +@keyframes kp-crown-jumping-left-0 { + 0% { translate: calc(85px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(92px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(92px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(93px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(91px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(92px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(87px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(87px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(86px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(85px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(87px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(87px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(85px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(85px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(86px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(87px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-jumping-left-1 { + 0% { translate: calc(85px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(92px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(92px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(93px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(91px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(92px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(87px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(87px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(86px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(85px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(87px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(87px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(85px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(85px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(86px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(87px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(86px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +html[data-pet-state="jumping-left"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-jumping-left-0 4800ms step-end 1 forwards; +} +html[data-pet-state="jumping-left"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-jumping-left-1 4800ms step-end 1 forwards; +} + +@keyframes kp-crown-jumping-right-0 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(101px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(99px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(102px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(101px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(107px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(105px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(105px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(106px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(105px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-jumping-right-1 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(101px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(99px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(102px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(101px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(107px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(105px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(105px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(106px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(105px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +html[data-pet-state="jumping-right"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-jumping-right-0 4800ms step-end 1 forwards; +} +html[data-pet-state="jumping-right"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-jumping-right-1 4800ms step-end 1 forwards; +} + +@keyframes kp-crown-panic-0 { + 0% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 5% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 10% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 15% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 20% { translate: calc(91px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 25% { translate: calc(82px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 30% { translate: calc(82px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 35% { translate: calc(111px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 40% { translate: calc(108px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 45% { translate: calc(87px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(109px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 55% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 60% { translate: calc(91px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 65% { translate: calc(81px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 70% { translate: calc(84px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 75% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 80% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 85% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 90% { translate: calc(97px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 95% { translate: calc(100px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-panic-1 { + 0% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 5% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 10% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 15% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 20% { translate: calc(91px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 25% { translate: calc(82px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 30% { translate: calc(82px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 35% { translate: calc(111px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 40% { translate: calc(108px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 45% { translate: calc(87px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(109px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 55% { translate: calc(112px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 60% { translate: calc(91px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 65% { translate: calc(81px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 70% { translate: calc(84px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 75% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 80% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 85% { translate: calc(97px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 90% { translate: calc(97px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 95% { translate: calc(100px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +html[data-pet-state="panic"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-panic-0 2400ms step-end infinite forwards; +} +html[data-pet-state="panic"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-panic-1 2400ms step-end infinite forwards; +} + +@keyframes kp-crown-review-0 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 4.7619% { translate: calc(109px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 9.5238% { translate: calc(109px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(106px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 19.0476% { translate: calc(103px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 23.8095% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 38.0952% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 47.619% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 52.381% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 61.9048% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 76.1905% { translate: calc(98px * var(--pet-scale, 1)) calc(34px * var(--pet-scale, 1)); } + 80.9524% { translate: calc(103px * var(--pet-scale, 1)) calc(39px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(107px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 90.4762% { translate: calc(107px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 95.2381% { translate: calc(106px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-review-1 { + 0% { translate: calc(106px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 4.7619% { translate: calc(109px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 9.5238% { translate: calc(109px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(106px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 19.0476% { translate: calc(103px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 23.8095% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 38.0952% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 47.619% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 52.381% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 61.9048% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(100px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(101px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 76.1905% { translate: calc(98px * var(--pet-scale, 1)) calc(34px * var(--pet-scale, 1)); } + 80.9524% { translate: calc(103px * var(--pet-scale, 1)) calc(39px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(107px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 90.4762% { translate: calc(107px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 95.2381% { translate: calc(106px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } +} +html[data-pet-state="review"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-review-0 4200ms step-end infinite forwards; +} +html[data-pet-state="review"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-review-1 4200ms step-end infinite forwards; +} + +@keyframes kp-crown-review-left-0 { + 0% { translate: calc(86px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 4.7619% { translate: calc(84px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 9.5238% { translate: calc(83px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(86px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 19.0476% { translate: calc(89px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 23.8095% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 38.0952% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 47.619% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 52.381% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 61.9048% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 76.1905% { translate: calc(95px * var(--pet-scale, 1)) calc(34px * var(--pet-scale, 1)); } + 80.9524% { translate: calc(90px * var(--pet-scale, 1)) calc(39px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(85px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 90.4762% { translate: calc(85px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 95.2381% { translate: calc(86px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-review-left-1 { + 0% { translate: calc(86px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 4.7619% { translate: calc(84px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 9.5238% { translate: calc(83px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(86px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 19.0476% { translate: calc(89px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 23.8095% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 38.0952% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 47.619% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 52.381% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 61.9048% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(92px * var(--pet-scale, 1)) calc(31px * var(--pet-scale, 1)); } + 76.1905% { translate: calc(95px * var(--pet-scale, 1)) calc(34px * var(--pet-scale, 1)); } + 80.9524% { translate: calc(90px * var(--pet-scale, 1)) calc(39px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(85px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 90.4762% { translate: calc(85px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } + 95.2381% { translate: calc(86px * var(--pet-scale, 1)) calc(41px * var(--pet-scale, 1)); } +} +html[data-pet-state="review-left"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-review-left-0 4200ms step-end infinite forwards; +} +html[data-pet-state="review-left"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-review-left-1 4200ms step-end infinite forwards; +} + +@keyframes kp-crown-running-0 { + 0% { translate: calc(109px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(109px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(108px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(108px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-running-1 { + 0% { translate: calc(109px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(109px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(108px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(108px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +html[data-pet-state="running"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-running-0 1400ms step-end infinite forwards; +} +html[data-pet-state="running"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-running-1 1400ms step-end infinite forwards; +} + +@keyframes kp-crown-running-left-0 { + 0% { translate: calc(82px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(82px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(83px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(83px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(83px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(83px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-running-left-1 { + 0% { translate: calc(82px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(82px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(83px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(83px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(83px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(83px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +html[data-pet-state="running-left"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-running-left-0 1400ms step-end infinite forwards; +} +html[data-pet-state="running-left"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-running-left-1 1400ms step-end infinite forwards; +} + +@keyframes kp-crown-running-right-0 { + 0% { translate: calc(109px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(109px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(108px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(108px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-running-right-1 { + 0% { translate: calc(109px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(109px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 50% { translate: calc(108px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(108px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +html[data-pet-state="running-right"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-running-right-0 1400ms step-end infinite forwards; +} +html[data-pet-state="running-right"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-running-right-1 1400ms step-end infinite forwards; +} + +@keyframes kp-crown-sleep-0 { + 0% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 10% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 20% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 30% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 40% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 50% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 60% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 70% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 80% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 90% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-sleep-1 { + 0% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 10% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 20% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 30% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 40% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 50% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 60% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 70% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 80% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 90% { translate: calc(121px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } +} +html[data-pet-state="sleep"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-sleep-0 4000ms step-end infinite forwards; +} +html[data-pet-state="sleep"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-sleep-1 4000ms step-end infinite forwards; +} + +@keyframes kp-crown-sleep-enter-0 { + 0% { translate: calc(108px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 12.5% { translate: calc(96px * var(--pet-scale, 1)) calc(57px * var(--pet-scale, 1)); } + 25% { translate: calc(108px * var(--pet-scale, 1)) calc(87px * var(--pet-scale, 1)); } + 37.5% { translate: calc(123px * var(--pet-scale, 1)) calc(112px * var(--pet-scale, 1)); } + 50% { translate: calc(124px * var(--pet-scale, 1)) calc(123px * var(--pet-scale, 1)); } + 62.5% { translate: calc(126px * var(--pet-scale, 1)) calc(123px * var(--pet-scale, 1)); } + 75% { translate: calc(121px * var(--pet-scale, 1)) calc(127px * var(--pet-scale, 1)); } + 87.5% { translate: calc(120px * var(--pet-scale, 1)) calc(127px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-sleep-enter-1 { + 0% { translate: calc(108px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 12.5% { translate: calc(96px * var(--pet-scale, 1)) calc(57px * var(--pet-scale, 1)); } + 25% { translate: calc(108px * var(--pet-scale, 1)) calc(87px * var(--pet-scale, 1)); } + 37.5% { translate: calc(123px * var(--pet-scale, 1)) calc(112px * var(--pet-scale, 1)); } + 50% { translate: calc(124px * var(--pet-scale, 1)) calc(123px * var(--pet-scale, 1)); } + 62.5% { translate: calc(126px * var(--pet-scale, 1)) calc(123px * var(--pet-scale, 1)); } + 75% { translate: calc(121px * var(--pet-scale, 1)) calc(127px * var(--pet-scale, 1)); } + 87.5% { translate: calc(120px * var(--pet-scale, 1)) calc(127px * var(--pet-scale, 1)); } +} +html[data-pet-state="sleep-enter"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-sleep-enter-0 3200ms step-end 1 forwards; +} +html[data-pet-state="sleep-enter"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-sleep-enter-1 3200ms step-end 1 forwards; +} + +@keyframes kp-crown-sleep-exit-0 { + 0% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(120px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(119px * var(--pet-scale, 1)) calc(100px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-sleep-exit-1 { + 0% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 14.2857% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 28.5714% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 42.8571% { translate: calc(121px * var(--pet-scale, 1)) calc(129px * var(--pet-scale, 1)); } + 57.1429% { translate: calc(120px * var(--pet-scale, 1)) calc(128px * var(--pet-scale, 1)); } + 71.4286% { translate: calc(119px * var(--pet-scale, 1)) calc(100px * var(--pet-scale, 1)); } + 85.7143% { translate: calc(105px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } +} +html[data-pet-state="sleep-exit"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-sleep-exit-0 2800ms step-end 1 forwards; +} +html[data-pet-state="sleep-exit"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-sleep-exit-1 2800ms step-end 1 forwards; +} + +@keyframes kp-crown-stretch-0 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(101px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(99px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(102px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(101px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(107px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(105px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(105px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(106px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(105px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-stretch-1 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 5.2632% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 10.5263% { translate: calc(100px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 15.7895% { translate: calc(101px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 21.0526% { translate: calc(99px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 26.3158% { translate: calc(102px * var(--pet-scale, 1)) calc(44px * var(--pet-scale, 1)); } + 31.5789% { translate: calc(101px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 36.8421% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 42.1053% { translate: calc(105px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 47.3684% { translate: calc(106px * var(--pet-scale, 1)) calc(45px * var(--pet-scale, 1)); } + 52.6316% { translate: calc(107px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 57.8947% { translate: calc(105px * var(--pet-scale, 1)) calc(50px * var(--pet-scale, 1)); } + 63.1579% { translate: calc(105px * var(--pet-scale, 1)) calc(46px * var(--pet-scale, 1)); } + 68.4211% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 73.6842% { translate: calc(107px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 78.9474% { translate: calc(106px * var(--pet-scale, 1)) calc(51px * var(--pet-scale, 1)); } + 84.2105% { translate: calc(105px * var(--pet-scale, 1)) calc(43px * var(--pet-scale, 1)); } + 89.4737% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } + 94.7368% { translate: calc(106px * var(--pet-scale, 1)) calc(42px * var(--pet-scale, 1)); } +} +html[data-pet-state="stretch"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-stretch-0 5600ms step-end 1 forwards; +} +html[data-pet-state="stretch"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-stretch-1 5600ms step-end 1 forwards; +} + +@keyframes kp-crown-waving-0 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 5.5556% { translate: calc(108px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 11.1111% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 22.2222% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 27.7778% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 38.8889% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 44.4444% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 50% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 55.5556% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 61.1111% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 72.2222% { translate: calc(108px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 77.7778% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 88.8889% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 94.4444% { translate: calc(107px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +@keyframes kp-crown-waving-1 { + 0% { translate: calc(107px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 5.5556% { translate: calc(108px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 11.1111% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 16.6667% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 22.2222% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 27.7778% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 33.3333% { translate: calc(110px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 38.8889% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 44.4444% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 50% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 55.5556% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 61.1111% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 66.6667% { translate: calc(109px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 72.2222% { translate: calc(108px * var(--pet-scale, 1)) calc(47px * var(--pet-scale, 1)); } + 77.7778% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 83.3333% { translate: calc(110px * var(--pet-scale, 1)) calc(48px * var(--pet-scale, 1)); } + 88.8889% { translate: calc(109px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } + 94.4444% { translate: calc(107px * var(--pet-scale, 1)) calc(49px * var(--pet-scale, 1)); } +} +html[data-pet-state="waving"][data-pet-nonce="0"] #claude-crown { + animation: kp-crown-waving-0 2700ms step-end 1 forwards; +} +html[data-pet-state="waving"][data-pet-nonce="1"] #claude-crown { + animation: kp-crown-waving-1 2700ms step-end 1 forwards; +} diff --git a/apps/desktop/src/renderer/pet.html b/apps/desktop/src/renderer/pet.html index bf51253..fc69aa5 100644 --- a/apps/desktop/src/renderer/pet.html +++ b/apps/desktop/src/renderer/pet.html @@ -40,6 +40,8 @@ + + diff --git a/apps/desktop/src/renderer/pet.ts b/apps/desktop/src/renderer/pet.ts index e9aa8f2..59c1a4a 100644 --- a/apps/desktop/src/renderer/pet.ts +++ b/apps/desktop/src/renderer/pet.ts @@ -70,6 +70,12 @@ function applyFrame(frame: PetFrame): void { sprite.dataset.facing = frame.facing root.dataset.overlay = frame.overlay + root.dataset.claudeState = frame.claudeState + // Also on the root, because the cap is a sibling of #sprite and CSS cannot reach across to + // read an attribute off it. The nonce comes too: the cap restarts with the sprite or it + // drifts out of phase and lands on the wrong head position for the whole loop. + root.dataset.petState = frame.animation + root.dataset.petNonce = String(frame.animationNonce) bubble.dataset.side = frame.bubbleSide quickMenu.dataset.actions = frame.quickActions.join(' ') quickMenu.hidden = frame.quickActions.length === 0 diff --git a/docs/CLAUDE-CODE.md b/docs/CLAUDE-CODE.md new file mode 100644 index 0000000..4effc19 --- /dev/null +++ b/docs/CLAUDE-CODE.md @@ -0,0 +1,269 @@ +# Claude Code status crown + +The pet wears a crown that tells you what Claude Code is doing, so you can stop switching to the +terminal to find out. + +| Crown | Meaning | +|---|---| +| 🔴 red | Claude Code is **waiting for you** to confirm something | +| 🟡 gold | Claude Code is **working** | +| 🟢 green | the task **finished**; ready for a new one | +| *bare-headed* | no Claude Code session, or the last signal is stale | + +| 🔴 waiting | 🟡 running | 🟢 idle | +|---|---|---| +| ![red crown](demo/crown-waiting.png) | ![gold crown](demo/crown-running.png) | ![green crown](demo/crown-idle.png) | + +Red is the one that matters. The other two are ambient. + +--- + +## What you need + +| | | +|---|---| +| **Argos** | running. Any recent build. | +| **Claude Code** | any version with hooks (`~/.claude/settings.json`) | +| **A shell** | `bash` or `zsh`. The hooks are one-liners using `mkdir`, `echo` and `rm`. | +| **Extra tools** | **none.** No `jq`, no Node script, no daemon, no port. | + +macOS is verified. Linux should work — nothing in the mechanism is macOS-specific — but has not +been tested. Windows is unverified. + +--- + +## How it works + +``` +Claude Code hook ──writes one word──▶ ~/.argos/claude-state + │ + watch + poll + ▼ + Argos (main) + │ one field on the pet frame + ▼ + crown painted on the pet's head +``` + +Claude Code fires a **hook** at each point in its lifecycle. Each hook writes a single word to a +file. Argos watches that file and paints the matching crown. + +That is the whole mechanism. There is no server, no port, no polling of Claude Code, and no +dependency in either direction: + +- **Argos not running?** The hooks write a file nothing reads. Nothing breaks. +- **Hooks not installed?** Argos watches a file nobody writes. The pet stays bare-headed. + +The hook is `echo running > file` on purpose. A hook that cannot fail in an interesting way is +worth more than one carrying richer information — it runs on every prompt and every tool call, so +anything slow or fragile there would be felt constantly. + +### Which hook means what + +| Hook | Fires when | Writes | +|---|---|---| +| `Notification` | Claude Code needs a confirmation | `waiting` | +| `UserPromptSubmit` | you send a prompt | `running` | +| `PreToolUse` | before each tool call | `running` | +| `PostToolUse` | after each tool call | `running` | +| `Stop` | Claude Code finishes its turn | `idle` | +| `SessionEnd` | the session closes | *deletes the file* | + +`PreToolUse` is what returns the crown to gold after you approve a prompt — answering a permission +request does not fire `Notification` again. + +### Staleness + +A session killed with `Ctrl+C` never fires `SessionEnd`, so its file lingers. Argos ignores any +state file whose mtime is over 15 minutes old. That is why the file holds only a word: the +timestamp already exists in the filesystem, and putting a second one in the payload would be two +sources of truth for one fact. + +### How the crown is drawn + +Worth knowing if you touch the art or the placement: + +- The crown is a plain `
` with a background image, a sibling of the sprite — not part of the + spritesheet. Colour changes are a stylesheet rule, not a re-rendered sheet. +- It tracks the head **per animation frame**. The pet's bounce is `background-position` stepping, + so the sprite element itself never moves; anything anchored to a per-state constant would hang + still while the character bobs underneath it. The crown gets its own generated keyframes on the + sprite's clock, one `step-end` stop per frame holding that frame's real head anchor. +- Size, art and the gap above the head all come from `scripts/lib/crowns.mjs` through the + generator into `pet.generated.css`. No crown geometry is hand-written. + +--- + +## Integrating it + +### 1. Add the hooks + +Open `~/.claude/settings.json`. **If it already has a `hooks` key, merge into it** — do not replace +it, or you will drop whatever else you had. + +```json +{ + "hooks": { + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo waiting > ~/.argos/claude-state", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo running > ~/.argos/claude-state", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo running > ~/.argos/claude-state", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo running > ~/.argos/claude-state", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo idle > ~/.argos/claude-state", + "timeout": 5 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { "type": "command", "command": "rm -f ~/.argos/claude-state", "timeout": 5 } + ] + } + ] + } +} +``` + +`PreToolUse` and `PostToolUse` carry no `matcher`, which matches every tool. `"*"` is commonly +written for this, but an omitted matcher is the form the settings schema guarantees. + +### 2. That is it + +Claude Code reloads `settings.json` on its own — no restart. Start a task and the crown appears. + +Check what is installed at any time with `/hooks`. + +--- + +## Verifying it + +**Test the transport first, without Claude Code in the picture.** With Argos running: + +```bash +mkdir -p ~/.argos +echo waiting > ~/.argos/claude-state # red crown +echo running > ~/.argos/claude-state # gold crown +echo idle > ~/.argos/claude-state # green crown +rm ~/.argos/claude-state # bare-headed +``` + +The crown changes within a second. This is the test worth running first: if these work and the +hooks do not, the hooks are the only remaining variable. + +**Then test the hooks.** Give Claude Code a task that needs a permission prompt. Expected: gold +while it works, red at the prompt, gold again once you approve, green when it stops. + +--- + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| No crown ever | Argos is not running, or the hooks are not installed. Run the `echo` test above to tell which. | +| `echo` test works, real sessions do not | The hooks are not firing. Check `/hooks`, and check your `settings.json` is valid JSON — an invalid file silently disables **every** setting in it. | +| Crown stuck on one colour | A session died without `SessionEnd`. It clears itself after 15 minutes, or `rm ~/.argos/claude-state` now. | +| Crown flickers gold ↔ green | Expected. Every tool call writes `running` and every turn end writes `idle`. | +| Crown is wrong with two sessions open | Known. See Limits. | + +Point Argos at a different file with `ARGOS_CLAUDE_STATE_FILE`, which is also how the screenshot +harness drives it. + +--- + +## Limits + +- **One session.** Every session writes the same file, so the last hook to fire wins. With two + sessions open, the one waiting on you goes invisible the moment the other runs a tool. Fixing + this is designed and planned — see + [the multi-session spec](superpowers/specs/2026-09-04-multi-session-crown-design.md) and + [its plan](superpowers/plans/2026-09-04-multi-session-crown.md). +- **A long wait loses its crown.** Staleness is a 15-minute mtime check, and a session parked at a + permission prompt does not touch its file while it waits. Leave a prompt unanswered for 15 + minutes and the red crown disappears — exactly when it is most wanted. The multi-session work + fixes this too, by using the session's pid as a liveness handle instead of a timeout. +- **Green never expires** while a session is alive, so a finished session parks a green crown + indefinitely. Also addressed in that plan. +- **macOS only, so far.** + +--- + +## Working on it + +```bash +pnpm install +pnpm generate # crowns, sprite CSS, alpha mask +pnpm test +pnpm build && pnpm dev +``` + +`pnpm generate:check` runs in CI and fails if any generated file is stale. + +**To change a crown:** replace its PNG in `pet/crowns-source/` and run `pnpm generate`. The +downsample to worn size, the stylesheet rules and the asset copying all follow. + +**To capture screenshots** of the crown at a given state: + +```bash +ARGOS_CLAUDE_STATE_FILE=/tmp/state sh -c 'echo waiting > /tmp/state && \ + node scripts/smoke.mjs --name crown --no-composite --state idle --size large' +``` + +The harness skips its window-transparency assertion (A2) while a crown is worn, because the crown +legitimately paints in the ring that assertion requires to be empty. + +### Where the code lives + +| Path | What | +|---|---| +| `apps/desktop/src/claude/claude-state-source.ts` | Owns the state file. The only module that knows how state arrives. | +| `apps/desktop/src/pet-frame.ts` | `claudeState` on the main→renderer seam, plus `frameNeedsCellRegion` | +| `apps/desktop/src/renderer/pet.css` | Parks the crown on the sprite cell. No geometry. | +| `scripts/lib/crowns.mjs` | Crown size, art list, gap. One definition. | +| `scripts/generate-crowns.mjs` | Downsamples the source art | +| `scripts/generate-sprite-css.mjs` | Emits the per-frame crown keyframes | diff --git a/docs/demo/crown-idle.png b/docs/demo/crown-idle.png new file mode 100644 index 0000000..4a884ca Binary files /dev/null and b/docs/demo/crown-idle.png differ diff --git a/docs/demo/crown-running.png b/docs/demo/crown-running.png new file mode 100644 index 0000000..c1dc8cd Binary files /dev/null and b/docs/demo/crown-running.png differ diff --git a/docs/demo/crown-waiting.png b/docs/demo/crown-waiting.png new file mode 100644 index 0000000..4850651 Binary files /dev/null and b/docs/demo/crown-waiting.png differ diff --git a/docs/superpowers/plans/2026-09-04-claude-state-cap.md b/docs/superpowers/plans/2026-09-04-claude-state-cap.md new file mode 100644 index 0000000..c983de9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-claude-state-cap.md @@ -0,0 +1,1018 @@ +# Claude Code State Cap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put a coloured square on the pet's head that shows what Claude Code is doing — red waiting for confirmation, amber running, green done. + +**Architecture:** Claude Code hooks write one word to `~/.argos/claude-state`. A pure-Node module in main watches that file and exposes the current word. The pet controller reads it into a new `PetFrame` field, the renderer copies that field to a `data-` attribute, and CSS paints a square anchored to the head using the custom properties the sleep Z's already use. + +**Tech Stack:** TypeScript, Electron (main only), zod for the frame schema, vitest for tests, pnpm workspaces. No new dependencies. + +## Global Constraints + +Copied from the repo's own enforced rules — `tests/renderer/discipline.spec.ts` fails the build on each of these: + +- **Runtime dependencies stay exactly `["zod"]`.** `apps/desktop/package.json` `dependencies` is asserted to equal that list. Node builtins only. +- **Every renderer file under `apps/desktop/src/renderer/` stays below 150 non-blank, non-comment code lines.** `pet.ts` is at 137 today. +- **The renderer gets no timers, no `fetch`, no `eval`, no `innerHTML`, and no `import ... from 'electron'`.** Set attributes and `textContent`, nothing else. +- **Hand-written CSS contains no `steps(`, no `192px`, no `208px`.** Animation geometry has one source, `pet/spritesheet.json`. +- **No source file may contain the words `plugin`, `plugins`, `marketplace`, `catalog`, `lan-`, `lease` or `leases` in code** (comments are stripped before the check, but avoid them anyway). +- **Tests are Electron-free.** `vitest.config.ts` runs in the `node` environment. Anything a test touches must not import `electron`. +- Tests live at `tests/**/*.spec.ts`. Run with `pnpm test`. +- Platform target for this spike: **macOS**. The Linux shape-region fix is included because it is one expression, but it is not verified here. + +## File Structure + +**Created:** + +- `apps/desktop/src/claude/claude-state-source.ts` — owns the state file: reads it, watches it, reports changes. Pure Node, no Electron, so it unit-tests against a temp directory. This is the only file that changes when multi-session lands. +- `tests/claude/claude-state-source.spec.ts` — its tests. +- `tests/main/frame-region.spec.ts` — tests the frame schema's new field and the shape-region predicate. +- `docs/CLAUDE-CODE.md` — the hook snippet a user pastes into `~/.claude/settings.json`, and how to test without Claude. + +**Modified:** + +- `apps/desktop/src/pet-frame.ts` — adds `CLAUDE_STATES`, the `claudeState` frame field, and `frameNeedsCellRegion()`. +- `apps/desktop/src/main/pet-controller.ts` — a `getClaudeState` option, read in `buildFrame()`. +- `apps/desktop/src/main/pet-window.ts:186,338` — the shape region follows the cap as well as the sleep overlay. +- `apps/desktop/src/main/app-shell.ts:353,1018` — starts the source, feeds the controller, stops it on dispose. +- `apps/desktop/src/renderer/pet.html` — one `div`. +- `apps/desktop/src/renderer/pet.ts` — one attribute assignment. +- `apps/desktop/src/renderer/pet.css` — the square. +- `tests/renderer/discipline.spec.ts` — asserts the renderer publishes the attribute and the CSS consumes it. + +**Not unit-tested, deliberately:** the `app-shell.ts` wiring. There is no `PetWindow` or `DisplayManager` fake in the repo today and building one for four lines of wiring is not worth it. Typecheck plus the manual test in Task 5 covers it. This is a real gap, stated rather than hidden. + +--- + +### Task 1: The state source + +**Files:** +- Create: `apps/desktop/src/claude/claude-state-source.ts` +- Create: `tests/claude/claude-state-source.spec.ts` +- Modify: `apps/desktop/src/pet-frame.ts` (add `CLAUDE_STATES` only — the frame field comes in Task 2) + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `CLAUDE_STATES: readonly ['none', 'waiting', 'running', 'idle']` and `type ClaudeState`, both exported from `apps/desktop/src/pet-frame.ts`. + - `defaultClaudeStateFile(): string` + - `createClaudeStateSource(options: ClaudeStateSourceOptions): ClaudeStateSource` + - `ClaudeStateSource = { start(): void; stop(): void; current(): ClaudeState; refresh(): ClaudeState }` + - `ClaudeStateSourceOptions = { file?: string; now?: () => number; pollMs?: number; onChange?: (state: ClaudeState) => void; log?: (message: string, meta?: unknown) => void }` + - `CLAUDE_STATE_STALE_MS: number` + +- [ ] **Step 1: Add the state list to the shared frame module** + +The list lives in `pet-frame.ts` rather than in the source module because both main and the renderer bundle need the type, and the renderer must never pull in `node:fs`. One list, two consumers. + +Add near the top of `apps/desktop/src/pet-frame.ts`, just below the existing `TONES` declaration: + +```ts +/** + * Claude Code's state, as the pet displays it. + * + * `none` is the absence of a signal — no state file, an unreadable one, or one too old to + * believe — and paints no cap at all. Every failure resolves here, because a status indicator + * that lies is worse than one that is absent: an absent cap is visibly absent, a wrong cap is + * silently wrong. + */ +export const CLAUDE_STATES = ['none', 'waiting', 'running', 'idle'] as const +export type ClaudeState = (typeof CLAUDE_STATES)[number] +``` + +- [ ] **Step 2: Write the failing tests** + +Create `tests/claude/claude-state-source.spec.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, utimesSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createClaudeStateSource, + CLAUDE_STATE_STALE_MS, +} from '../../apps/desktop/src/claude/claude-state-source.js' + +let dir: string +let file: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'argos-claude-')) + file = join(dir, 'claude-state') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** `refresh()` rather than the poll, so no test depends on a timer firing. */ +function sourceFor(onChange?: (s: string) => void) { + return createClaudeStateSource({ file, onChange }) +} + +describe('claude state source', () => { + it('reports none when the file does not exist', () => { + expect(sourceFor().refresh()).toBe('none') + }) + + it('reports none when the whole directory is missing', () => { + const source = createClaudeStateSource({ file: join(dir, 'nope', 'claude-state') }) + expect(source.refresh()).toBe('none') + }) + + it.each(['waiting', 'running', 'idle'])('reads %s', (word) => { + writeFileSync(file, `${word}\n`) + expect(sourceFor().refresh()).toBe(word) + }) + + it('ignores surrounding whitespace and case', () => { + writeFileSync(file, ' WAITING \n') + expect(sourceFor().refresh()).toBe('waiting') + }) + + it('treats an unknown word as none', () => { + writeFileSync(file, 'thinking') + expect(sourceFor().refresh()).toBe('none') + }) + + it('treats an empty file as none', () => { + writeFileSync(file, '') + expect(sourceFor().refresh()).toBe('none') + }) + + it('treats a stale file as none, however good its contents', () => { + // A session killed with Ctrl+C never fires SessionEnd, so the file outlives the session. + writeFileSync(file, 'waiting') + const old = new Date(Date.now() - CLAUDE_STATE_STALE_MS - 60_000) + utimesSync(file, old, old) + expect(sourceFor().refresh()).toBe('none') + }) + + it('picks up a change on the next refresh', () => { + writeFileSync(file, 'running') + const source = sourceFor() + expect(source.refresh()).toBe('running') + writeFileSync(file, 'waiting') + expect(source.refresh()).toBe('waiting') + }) + + it('reports a deletion as none', () => { + writeFileSync(file, 'running') + const source = sourceFor() + expect(source.refresh()).toBe('running') + rmSync(file) + expect(source.refresh()).toBe('none') + }) + + it('fires onChange once per distinct state, not once per read', () => { + const seen: string[] = [] + const source = sourceFor((s) => seen.push(s)) + writeFileSync(file, 'running') + source.refresh() + source.refresh() + writeFileSync(file, 'running\n') + source.refresh() + writeFileSync(file, 'waiting') + source.refresh() + expect(seen).toEqual(['running', 'waiting']) + }) + + it('exposes the last read through current() without touching the disk', () => { + writeFileSync(file, 'idle') + const source = sourceFor() + source.refresh() + rmSync(file) + expect(source.current()).toBe('idle') + }) + + it('creates the directory on start so the watch has something to watch', () => { + // Without this the watch silently does nothing until the first hook happens to create the + // directory, which looks exactly like the feature not working. + const nested = join(dir, 'made-by-start', 'claude-state') + const source = createClaudeStateSource({ file: nested }) + source.start() + source.stop() + expect(existsSync(join(dir, 'made-by-start'))).toBe(true) + }) + + it('start() then stop() leaves no live handles', () => { + const source = sourceFor() + source.start() + expect(() => source.stop()).not.toThrow() + expect(() => source.stop()).not.toThrow() + }) +}) +``` + +- [ ] **Step 3: Run the tests and watch them fail** + +```bash +pnpm test -- tests/claude/claude-state-source.spec.ts +``` + +Expected: every test fails to even load — `Failed to resolve import ".../claude/claude-state-source.js"`. + +- [ ] **Step 4: Write the implementation** + +Create `apps/desktop/src/claude/claude-state-source.ts`: + +```ts +/** + * Claude Code's state, read off a file that Claude Code's hooks write. + * + * The transport is a file holding one word, because a hook that is `echo running > file` cannot + * fail in an interesting way: no JSON to parse, no port to bind, no dependency on either side. + * + * No Electron import. This module is the one place that knows how the state arrives, so it is + * also the only file that changes when multi-session support lands — at which point it reads a + * directory of per-session files and aggregates them instead. + */ + +import { mkdirSync, readFileSync, statSync, watch, type FSWatcher } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { CLAUDE_STATES, type ClaudeState } from '../pet-frame.js' + +/** + * How old a state file may be before it is disbelieved. + * + * A session killed with Ctrl+C may never fire its `SessionEnd` hook, leaving a file that would + * otherwise pin the cap on for the rest of the day. The mtime is the guard, which is why the + * file holds only a word: the timestamp already exists in the filesystem, and putting a second + * one in the payload would be a second source of truth for the same fact. + */ +export const CLAUDE_STATE_STALE_MS = 15 * 60_000 + +/** How often to re-read regardless of the watch. See `start()`. */ +const DEFAULT_POLL_MS = 5_000 + +export function defaultClaudeStateFile(): string { + return process.env.ARGOS_CLAUDE_STATE_FILE ?? join(homedir(), '.argos', 'claude-state') +} + +export interface ClaudeStateSourceOptions { + /** Defaults to `defaultClaudeStateFile()`. */ + file?: string + /** Injected so tests can drive staleness without waiting a quarter of an hour. */ + now?: () => number + pollMs?: number + onChange?: (state: ClaudeState) => void + log?: (message: string, meta?: unknown) => void +} + +export interface ClaudeStateSource { + start(): void + stop(): void + /** The last state read. Cheap: no disk access. */ + current(): ClaudeState + /** Re-read now and report a change if there is one. Returns the new state. */ + refresh(): ClaudeState +} + +const KNOWN = new Set(CLAUDE_STATES) + +function readState(file: string, nowMs: number): ClaudeState { + let raw: string + let mtimeMs: number + try { + mtimeMs = statSync(file).mtimeMs + raw = readFileSync(file, 'utf8') + } catch { + // Missing, unreadable, a directory, a permission error — all the same answer. + return 'none' + } + if (nowMs - mtimeMs > CLAUDE_STATE_STALE_MS) return 'none' + const word = raw.trim().toLowerCase() + return KNOWN.has(word) ? (word as ClaudeState) : 'none' +} + +export function createClaudeStateSource( + options: ClaudeStateSourceOptions = {}, +): ClaudeStateSource { + const file = options.file ?? defaultClaudeStateFile() + const now = options.now ?? Date.now + const pollMs = options.pollMs ?? DEFAULT_POLL_MS + const onChange = options.onChange ?? ((): void => {}) + const log = options.log ?? ((): void => {}) + + let state: ClaudeState = 'none' + let watcher: FSWatcher | null = null + let timer: NodeJS.Timeout | null = null + + const refresh = (): ClaudeState => { + const next = readState(file, now()) + if (next !== state) { + state = next + onChange(state) + } + return state + } + + return { + current: () => state, + refresh, + + start(): void { + // Make the directory ourselves so the watch has a target from the first launch, rather + // than silently doing nothing until the first hook happens to create it. + try { + mkdirSync(dirname(file), { recursive: true }) + } catch (error) { + log('claude-state: could not create the state directory', error) + } + + refresh() + + // Watch the *directory*, not the file: a writer that replaces the file swaps the inode, + // and a file-level watch is then pointed at something nothing will ever touch again. + try { + watcher = watch(dirname(file), { persistent: false }, () => { + refresh() + }) + } catch (error) { + log('claude-state: directory watch unavailable, polling only', error) + } + + // Backstop. fs.watch misses events on some platforms and filesystems, and a missed event + // here means a cap stuck on the wrong colour — the one failure the whole feature is + // supposed to prevent. + timer = setInterval(refresh, pollMs) + timer.unref?.() + }, + + stop(): void { + watcher?.close() + watcher = null + if (timer) clearInterval(timer) + timer = null + }, + } +} +``` + +- [ ] **Step 5: Run the tests and watch them pass** + +```bash +pnpm test -- tests/claude/claude-state-source.spec.ts +``` + +Expected: PASS, 15 tests. + +- [ ] **Step 6: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add apps/desktop/src/claude/claude-state-source.ts apps/desktop/src/pet-frame.ts tests/claude/claude-state-source.spec.ts +git commit -m "feat: read Claude Code's state from a file + +One word in ~/.argos/claude-state, written by Claude Code hooks. The +hook is 'echo running > file' so it cannot fail in an interesting way. + +The file's mtime is the staleness guard, which is why the payload is a +bare word: a session killed with Ctrl+C never fires SessionEnd, and the +timestamp the guard needs already exists in the filesystem. + +Watches the directory rather than the file, because a writer that +replaces the file swaps the inode and leaves a file-level watch pointed +at nothing." +``` + +--- + +### Task 2: The frame field and the square + +**Files:** +- Modify: `apps/desktop/src/pet-frame.ts` +- Modify: `apps/desktop/src/renderer/pet.html` +- Modify: `apps/desktop/src/renderer/pet.ts` +- Modify: `apps/desktop/src/renderer/pet.css` +- Create: `tests/main/frame-region.spec.ts` +- Modify: `tests/renderer/discipline.spec.ts` + +**Interfaces:** +- Consumes: `CLAUDE_STATES`, `ClaudeState` from Task 1. +- Produces: + - `PetFrame.claudeState: ClaudeState` — a required field, so every existing frame construction site must set it (there is exactly one, in Task 3). + - `frameNeedsCellRegion(frame: Pick): boolean` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/main/frame-region.spec.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { + petFrameSchema, + frameNeedsCellRegion, + type PetFrame, +} from '../../apps/desktop/src/pet-frame.js' + +function frame(overrides: Partial = {}): PetFrame { + return { + animation: 'idle', + animationNonce: 0, + facing: 'right', + sprite: { x: 0, y: 0 }, + bubbleSide: 'above', + scale: 1, + bubble: null, + quickActions: [], + overlay: 'none', + claudeState: 'none', + ...overrides, + } +} + +describe('claudeState on the frame', () => { + it.each(['none', 'waiting', 'running', 'idle'] as const)('round-trips %s', (state) => { + const parsed = petFrameSchema.safeParse(frame({ claudeState: state })) + expect(parsed.success).toBe(true) + expect(parsed.success && parsed.data.claudeState).toBe(state) + }) + + it('rejects a state the renderer has no colour for', () => { + expect(petFrameSchema.safeParse(frame({ claudeState: 'busy' as never })).success).toBe(false) + }) + + it('is required, so no frame can reach the renderer without one', () => { + const { claudeState: _dropped, ...without } = frame() + expect(petFrameSchema.safeParse(without).success).toBe(false) + }) +}) + +describe('frameNeedsCellRegion', () => { + // Electron's setShape decides where the system permits *drawing*, not just where clicks land. + // Anything outside the region is never painted. The cap sits over the hair, which is + // transparent in the alpha mask, so without this it would be invisible on Linux — and the + // screenshot harness would not catch it, because capturePage() ignores the window shape. + it('is false for a plain frame', () => { + expect(frameNeedsCellRegion(frame())).toBe(false) + }) + + it('is true while the sleep Z-s are up', () => { + expect(frameNeedsCellRegion(frame({ overlay: 'sleep-z' }))).toBe(true) + }) + + it.each(['waiting', 'running', 'idle'] as const)('is true while the cap is %s', (state) => { + expect(frameNeedsCellRegion(frame({ claudeState: state }))).toBe(true) + }) + + it('is true when both are up at once', () => { + // A sleeping pet still wears the cap. This is why claudeState is its own field rather than + // another value on `overlay`. + expect(frameNeedsCellRegion(frame({ overlay: 'sleep-z', claudeState: 'waiting' }))).toBe(true) + }) +}) +``` + +Then add to `tests/renderer/discipline.spec.ts`, inside the existing +`describe('hand-written CSS carries no generated geometry', ...)` block, after the +`anchors the bubble to the character` test: + +```ts + it('anchors the status cap to the character through the same custom properties', () => { + // Two halves in two files again: the renderer publishes the state, the CSS paints it. Either + // half being dropped leaves a cap that is silently never shown, which looks exactly like + // "Claude is not running" and so would not be noticed. + const pet = read(join(RENDERER_DIR, 'pet.ts')) + expect(pet).toContain('claudeState') + + const css = read(join(RENDERER_DIR, 'pet.css')) + expect(css).toMatch(/#claude-crown/) + expect(css).toMatch(/\[data-claude-state='waiting'\]/) + expect(css).toMatch(/\[data-claude-state='running'\]/) + expect(css).toMatch(/\[data-claude-state='idle'\]/) + // The cap tracks the head, not the window corner. + expect(css).toMatch(/#claude-crown[\s\S]*?var\(--body-cx/) + }) +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +```bash +pnpm test -- tests/main/frame-region.spec.ts tests/renderer/discipline.spec.ts +``` + +Expected: `frame-region.spec.ts` fails to import `frameNeedsCellRegion`; the discipline test fails on `expect(pet).toContain('claudeState')`. + +- [ ] **Step 3: Add the field and the predicate** + +In `apps/desktop/src/pet-frame.ts`, inside `petFrameSchema`, directly after the `overlay` field: + +```ts + /** + * Claude Code's state, painted as a coloured cap on the pet's head. + * + * Its own field rather than another value on `overlay`, because the two are independent axes: + * `overlay` is single-valued and already owned by the sleep Z's, and a sleeping pet must still + * be able to wear the cap. Folding them together would make "asleep and waiting" + * unrepresentable. + */ + claudeState: z.enum(CLAUDE_STATES), +``` + +Then, at the end of the file, after the `IPC` declaration: + +```ts +/** + * Does this frame paint anything outside the character's own mask but inside the sprite cell? + * + * `setShape` determines the area where the system permits *drawing* — outside it, no pixels are + * drawn at all. The sleep Z's sit above the hair and the status cap sits on it, both in mask + * cells that are transparent, so both need the region widened to the whole cell or they are + * silently invisible on Linux. See the long note in `sprite/alpha-mask.ts`. + * + * A function here rather than an expression at the call site so it can be tested without a + * window: `pet-window.ts` is Electron all the way down. + */ +export function frameNeedsCellRegion( + frame: Pick, +): boolean { + return frame.overlay !== 'none' || frame.claudeState !== 'none' +} +``` + +- [ ] **Step 4: Add the element** + +In `apps/desktop/src/renderer/pet.html`, directly after the `#zzz` div: + +```html + + +``` + +- [ ] **Step 5: Publish the state from the renderer** + +In `apps/desktop/src/renderer/pet.ts`, in `applyFrame`, directly after the existing +`root.dataset.overlay = frame.overlay` line: + +```ts + root.dataset.claudeState = frame.claudeState +``` + +One attribute assignment and no branch, which is the only shape of change this file accepts. + +- [ ] **Step 6: Paint the square** + +Append to `apps/desktop/src/renderer/pet.css`: + +```css +/* + Claude Code status cap. + + A plain square standing in for cap art, positioned exactly where the art will go so the + anchoring proven here is the anchoring the art inherits. `--body-cx` and `--body-top` are + published per animation state and already multiplied by the scale, so this tracks the head + through every pose and every pet size without any arithmetic of its own. +*/ +#claude-crown { + position: absolute; + left: var(--body-cx, 50%); + top: calc(var(--body-top, 0px) - 3px * var(--pet-scale, 1)); + width: calc(26px * var(--pet-scale, 1)); + height: calc(13px * var(--pet-scale, 1)); + transform: translateX(-50%); + display: none; + pointer-events: none; + border-radius: 3px; + /* A dark keyline so the square reads against light wallpaper as well as dark. */ + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.5), + 0 1px 3px rgba(0, 0, 0, 0.35); +} + +html[data-claude-state='waiting'] #claude-crown { + display: block; + background: #e5484d; +} + +html[data-claude-state='running'] #claude-crown { + display: block; + background: #f5a524; +} + +html[data-claude-state='idle'] #claude-crown { + display: block; + background: #30a46c; +} +``` + +- [ ] **Step 7: Run the tests** + +```bash +pnpm test -- tests/main/frame-region.spec.ts tests/renderer/discipline.spec.ts +``` + +Expected: PASS. If `stays small enough to read in one sitting` fails, `pet.ts` has crossed 150 code lines — it was at 137 and this adds one, so a failure means something else grew. + +- [ ] **Step 8: Commit** + +```bash +git add apps/desktop/src/pet-frame.ts apps/desktop/src/renderer/pet.html apps/desktop/src/renderer/pet.ts apps/desktop/src/renderer/pet.css tests/main/frame-region.spec.ts tests/renderer/discipline.spec.ts +git commit -m "feat: carry Claude Code's state across the frame seam + +Adds PetFrame.claudeState and a coloured square anchored to the pet's +head through --body-cx / --body-top, the same properties the sleep Z's +use, so it tracks the head through every pose and every pet size. + +A separate field from overlay because the two are independent axes: a +sleeping pet must still be able to wear the cap. + +frameNeedsCellRegion() exists as a function rather than an expression in +pet-window so it can be tested without Electron. It matters because +setShape governs painting, not just hit-testing, and the cap sits in +transparent mask cells." +``` + +--- + +### Task 3: Feed the state into the frame + +**Files:** +- Modify: `apps/desktop/src/main/pet-controller.ts` +- Modify: `apps/desktop/src/main/pet-window.ts` + +**Interfaces:** +- Consumes: `ClaudeState` and `frameNeedsCellRegion` from Task 2. +- Produces: `PetControllerOptions.getClaudeState?: () => ClaudeState`, defaulting to `() => 'none'`. + +- [ ] **Step 1: Add the controller option** + +In `apps/desktop/src/main/pet-controller.ts`, change the frame-type import to bring in the state type: + +```ts +import type { ClaudeState, PetFrame, Tone } from '../pet-frame.js' +``` + +Add to `PetControllerOptions`, after `getMovementEnabled`: + +```ts + /** + * Claude Code's state, for the status cap. + * + * Injected as a getter, like `getMovementEnabled`, so the controller stays ignorant of where + * the state comes from and the tests need no filesystem. + */ + getClaudeState?: () => ClaudeState +``` + +- [ ] **Step 2: Default it and read it** + +In `createPetController`, beside the other option defaults (next to `const now = options.now ?? Date.now`): + +```ts + const getClaudeState = options.getClaudeState ?? ((): ClaudeState => 'none') +``` + +In `buildFrame()`, after the `overlay:` line: + +```ts + claudeState: getClaudeState(), +``` + +- [ ] **Step 3: Widen the shape region for the cap** + +In `apps/desktop/src/main/pet-window.ts`, add to the existing frame-module import: + +```ts +import { frameNeedsCellRegion } from '../pet-frame.js' +``` + +(If `pet-window.ts` already imports named values from `../pet-frame.js`, add `frameNeedsCellRegion` to that import rather than writing a second one.) + +Replace the comment on the `lastOverlayVisible` declaration near line 186: + +```ts + /** + * Whether the last frame painted inside the sprite cell but outside the character mask — the + * sleep Z's, or the Claude status cap. Drives the shape region, which on Linux governs what is + * painted at all. + */ +``` + +And replace the assignment near line 338: + +```ts + lastOverlayVisible = frameNeedsCellRegion(parsed.data) +``` + +- [ ] **Step 4: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: clean. A `claudeState` missing from an object literal here means a second frame construction site exists that this plan did not account for — set it to `'none'` and note it. + +- [ ] **Step 5: Run the whole suite** + +```bash +pnpm test +``` + +Expected: PASS, including everything that existed before. + +- [ ] **Step 6: Commit** + +```bash +git add apps/desktop/src/main/pet-controller.ts apps/desktop/src/main/pet-window.ts +git commit -m "feat: put the Claude status cap on the frame and in the shape region + +getClaudeState is injected like getMovementEnabled, so the controller +stays ignorant of where the state came from. + +The shape region now follows the cap as well as the sleep overlay. On +Linux that region governs painting, not just hit-testing, and the cap +sits over the hair in mask cells that are transparent - so without this +it would be invisible there, and the screenshot harness would not catch +it because capturePage() never consults the window shape." +``` + +--- + +### Task 4: Wire it into the app + +**Files:** +- Modify: `apps/desktop/src/main/app-shell.ts` + +**Interfaces:** +- Consumes: `createClaudeStateSource` from Task 1, `getClaudeState` from Task 3. +- Produces: nothing further. + +- [ ] **Step 1: Import the source** + +In `apps/desktop/src/main/app-shell.ts`, beside the other main-process imports (near the `createPetController` import at line 27): + +```ts +import { createClaudeStateSource } from '../claude/claude-state-source.js' +``` + +- [ ] **Step 2: Start it before the controller** + +Immediately before the `controller = createPetController({` call (around line 353): + +```ts + // Claude Code's state, for the cap. `tickNow` rather than waiting for the next tick: a colour + // that lags the terminal by a tick is not worth having, and `tickNow` is the existing seam for + // exactly this — it runs the tick body out of phase without resetting the interval. + const claudeState = createClaudeStateSource({ + log, + onChange() { + controller?.tickNow() + }, + }) + claudeState.start() +``` + +- [ ] **Step 3: Hand it to the controller** + +In the `createPetController({ ... })` options object, after `getMovementEnabled`: + +```ts + getClaudeState: () => claudeState.current(), +``` + +- [ ] **Step 4: Stop it on dispose** + +In `dispose()`, beside `controller?.stop()` (around line 1018): + +```ts + claudeState.stop() +``` + +- [ ] **Step 5: Typecheck and run the suite** + +```bash +pnpm typecheck && pnpm test +``` + +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add apps/desktop/src/main/app-shell.ts +git commit -m "feat: start the Claude state source with the app + +A state change calls tickNow() rather than waiting for the next tick, so +the cap changes colour when the terminal does. tickNow is the existing +seam for out-of-phase updates and does not reset the interval." +``` + +--- + +### Task 5: The hooks, and proving it works + +**Files:** +- Create: `docs/CLAUDE-CODE.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: nothing in code. + +- [ ] **Step 1: Write the documentation** + +Create `docs/CLAUDE-CODE.md`: + +````markdown +# Claude Code status cap + +The pet wears a coloured square that follows what Claude Code is doing. + +| Colour | Meaning | +|---|---| +| 🔴 red | Claude Code is waiting for you to confirm something | +| 🟡 amber | Claude Code is working | +| 🟢 green | the task finished; ready for a new one | +| *no cap* | no Claude Code session, or the last signal is over 15 minutes old | + +This is a spike: a square, not cap art, and a single session. Two sessions at once will fight +over the colour. + +## How it works + +Claude Code hooks write one word to `~/.argos/claude-state`. Argos watches that file. That is +the whole mechanism — no port, no daemon, no dependency in either direction. If Argos is not +running, the hooks write a file nothing reads, and nothing breaks. + +## Setup + +Add this to `~/.claude/settings.json`. If the file already has a `hooks` key, merge into it +rather than replacing it. + +```json +{ + "hooks": { + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo waiting > ~/.argos/claude-state" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo running > ~/.argos/claude-state" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo running > ~/.argos/claude-state" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo running > ~/.argos/claude-state" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos && echo idle > ~/.argos/claude-state" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [{ "type": "command", "command": "rm -f ~/.argos/claude-state" }] + } + ] + } +} +``` + +`PreToolUse` is what returns the cap to amber after you approve a prompt — answering a +permission request does not fire `Notification` again. + +## Testing it without Claude + +With Argos running: + +```bash +mkdir -p ~/.argos +echo waiting > ~/.argos/claude-state # red +echo running > ~/.argos/claude-state # amber +echo idle > ~/.argos/claude-state # green +rm ~/.argos/claude-state # gone +``` + +The colour changes within a second. This is the integration test worth running first: if these +work and the hooks do not, the hooks are the only remaining variable. + +Point Argos at a different file with `ARGOS_CLAUDE_STATE_FILE`. + +## Limits + +- **One session.** The last hook to fire wins, whichever session it came from. +- **Stale state.** A session killed with `Ctrl+C` never fires `SessionEnd`. Its file lingers, and + the cap stays until the file is 15 minutes old. +- **macOS only, so far.** The Linux shape-region handling is written but unverified. +```` + +- [ ] **Step 2: Link it from the README** + +In `README.md`, add a row to the feature table or a line in the docs list pointing at +`docs/CLAUDE-CODE.md`: + +```markdown +**It watches Claude Code.** Wire up a few hooks and the pet wears a coloured cap: red when Claude +Code needs a confirmation, amber while it works, green when it is done. See +[docs/CLAUDE-CODE.md](docs/CLAUDE-CODE.md). +``` + +- [ ] **Step 3: Build and run** + +```bash +pnpm build && pnpm dev +``` + +Expected: the pet appears with no cap. + +- [ ] **Step 4: Prove the integration by hand** + +In a second terminal, with the pet on screen: + +```bash +mkdir -p ~/.argos +echo waiting > ~/.argos/claude-state +``` + +Expected: a red square appears on the pet's head within a second. Then: + +```bash +echo running > ~/.argos/claude-state # turns amber +echo idle > ~/.argos/claude-state # turns green +rm ~/.argos/claude-state # disappears +``` + +Check the cap stays on the head while the pet **walks**, **jumps** and **sleeps** (leave it +alone, or turn movement off). A cap that detaches during a pose means `--body-top` is not being +tracked per state. + +Check it at all three sizes — right-click → Size. The square should scale with the pet. + +- [ ] **Step 5: Prove the hooks** + +Add the hooks from Step 1 to `~/.claude/settings.json`, start a Claude Code session, and give it +a task that needs a permission prompt. Expected: amber while it works, red at the prompt, amber +again once approved, green when it stops. + +- [ ] **Step 6: Full verification, then commit** + +```bash +pnpm typecheck && pnpm test && pnpm build +``` + +Expected: all three clean. + +```bash +git add docs/CLAUDE-CODE.md README.md +git commit -m "docs: how to wire Claude Code's state to the pet + +Includes the settings.json hook block and, more usefully, how to drive +the cap with echo so the transport can be proven before the hooks are +the variable under test." +``` + +--- + +## Done when + +- `pnpm typecheck`, `pnpm test` and `pnpm build` are all clean. +- `echo waiting > ~/.argos/claude-state` turns the square red on a running pet, and `rm` removes it. +- The square stays on the head while the pet walks, jumps and sleeps, at all three sizes. +- A real Claude Code session drives the colour through amber → red → amber → green. +- Nothing is pushed. The work sits on the local `claude-integration` branch. diff --git a/docs/superpowers/plans/2026-09-04-multi-session-crown.md b/docs/superpowers/plans/2026-09-04-multi-session-crown.md new file mode 100644 index 0000000..513ca96 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-multi-session-crown.md @@ -0,0 +1,820 @@ +# Multi-Session Crown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the crown report the loudest state across every running Claude Code session instead of whichever one moved last. + +**Architecture:** Each session's hooks write `~/.argos/sessions/$PPID`. The state source reads the directory, drops sessions whose pid is dead and greens that have expired, and reduces the rest by `waiting > running > idle`. Nothing outside `claude-state-source.ts` changes. + +**Tech Stack:** TypeScript, Node builtins only, vitest. No new dependencies. + +## Global Constraints + +- **Runtime dependencies stay exactly `["zod"]`.** Asserted by `tests/renderer/discipline.spec.ts`. +- **No source file may contain the words `plugin`, `plugins`, `marketplace`, `catalog`, `lan-`, `lease` or `leases` in code.** +- **Tests are Electron-free** (`vitest.config.ts` runs the `node` environment) and must not depend on real processes, real sleeping, or the developer's own `~/.argos`. +- **`PetFrame.claudeState` keeps its four values** — `none | waiting | running | idle`. If this plan makes you change the frame schema, the aggregation has leaked out of the state source and something is wrong. +- Platform target: **macOS**. `process.kill(pid, 0)` behaves the same on Linux; Windows is unverified either way. +- Tests live at `tests/**/*.spec.ts`. Run with `pnpm test`. `pnpm` is not installed globally on the dev machine — use `corepack pnpm`, or put a shim on `PATH` with `corepack enable --install-directory pnpm`. + +## File Structure + +**Modified:** + +- `apps/desktop/src/claude/claude-state-source.ts` — the whole change. Gains a directory read, a liveness check, green expiry, the reduction, and pruning. Grows from ~130 to ~200 lines, which is still one responsibility: *how the state arrives*. +- `tests/claude/claude-state-source.spec.ts` — rewritten around a session directory. +- `docs/CLAUDE-CODE.md` — new hook block, new limits. +- `~/.claude/settings.json` — the user's hooks (Task 4, done by hand with a backup). + +**Not modified, deliberately:** `pet-frame.ts`, `pet-controller.ts`, `pet-window.ts`, `app-shell.ts`, the renderer, the crown art and the generators. The aggregation is invisible above `current()`. + +--- + +### Task 1: Read a directory of sessions and reduce it + +**Files:** +- Modify: `apps/desktop/src/claude/claude-state-source.ts` +- Modify: `tests/claude/claude-state-source.spec.ts` + +**Interfaces:** +- Consumes: `CLAUDE_STATES`, `ClaudeState` from `pet-frame.ts` (unchanged). +- Produces: + - `defaultClaudeSessionDir(): string` — replaces `defaultClaudeStateFile()`. + - `ClaudeStateSourceOptions` gains `dir?: string`, `isAlive?: (pid: number) => boolean`, `greenTtlMs?: number`; **loses** `file`. + - `GREEN_TTL_MS`, `SESSION_MAX_AGE_MS` exported for the tests. + - `ClaudeStateSource` keeps `start` / `stop` / `current` / `refresh` exactly as they are. + +- [ ] **Step 1: Replace the test file** + +The old tests are all about one file, so this replaces rather than extends. Write +`tests/claude/claude-state-source.spec.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, utimesSync, existsSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createClaudeStateSource, + GREEN_TTL_MS, + SESSION_MAX_AGE_MS, +} from '../../apps/desktop/src/claude/claude-state-source.js' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'argos-claude-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** Write one session's state. `ageMs` backdates it, for the expiry rules. */ +function session(pid: number, word: string, ageMs = 0): void { + const path = join(dir, String(pid)) + writeFileSync(path, `${word}\n`) + if (ageMs > 0) { + const when = new Date(Date.now() - ageMs) + utimesSync(path, when, when) + } +} + +/** Every pid alive unless the test says otherwise. No real processes are involved. */ +function sourceFor(options: { dead?: number[]; onChange?: (s: string) => void } = {}) { + const dead = new Set(options.dead ?? []) + return createClaudeStateSource({ + dir, + isAlive: (pid) => !dead.has(pid), + onChange: options.onChange, + }) +} + +describe('reducing sessions to one crown', () => { + it('is bare-headed with no sessions at all', () => { + expect(sourceFor().refresh()).toBe('none') + }) + + it('is bare-headed when the directory does not exist', () => { + const source = createClaudeStateSource({ dir: join(dir, 'nope'), isAlive: () => true }) + expect(source.refresh()).toBe('none') + }) + + it.each(['waiting', 'running', 'idle'])('reports a lone %s session', (word) => { + session(1, word) + expect(sourceFor().refresh()).toBe(word) + }) + + it('lets waiting beat running, however many are running', () => { + // The whole point: the one session that needs a human wins, whichever terminal it is in. + session(1, 'running') + session(2, 'running') + session(3, 'waiting') + expect(sourceFor().refresh()).toBe('waiting') + }) + + it('lets running beat idle', () => { + session(1, 'idle') + session(2, 'running') + expect(sourceFor().refresh()).toBe('running') + }) + + it('does not care what order the sessions are read in', () => { + session(9, 'idle') + session(2, 'waiting') + session(40, 'running') + expect(sourceFor().refresh()).toBe('waiting') + }) +}) + +describe('liveness', () => { + it('ignores a session whose process is gone', () => { + session(1, 'waiting') + session(2, 'running') + expect(sourceFor({ dead: [1] }).refresh()).toBe('running') + }) + + it('is bare-headed when every session is dead', () => { + session(1, 'waiting') + expect(sourceFor({ dead: [1] }).refresh()).toBe('none') + }) + + it('deletes the dead session file rather than reading it forever', () => { + session(1, 'waiting') + sourceFor({ dead: [1] }).refresh() + expect(existsSync(join(dir, '1'))).toBe(false) + }) + + it('keeps a live session file', () => { + session(1, 'waiting') + sourceFor().refresh() + expect(existsSync(join(dir, '1'))).toBe(true) + }) + + it('ignores an ancient file even when something now holds that pid', () => { + // A dead session's file plus a recycled pid would otherwise read as alive forever. + session(1, 'waiting', SESSION_MAX_AGE_MS + 60_000) + expect(sourceFor().refresh()).toBe('none') + }) +}) + +describe('green decay', () => { + it('drops green once it has gone stale', () => { + // Green means "just finished, come look". A signal that is always on is not a signal. + session(1, 'idle', GREEN_TTL_MS + 60_000) + expect(sourceFor().refresh()).toBe('none') + }) + + it('keeps green inside the window', () => { + session(1, 'idle', Math.floor(GREEN_TTL_MS / 2)) + expect(sourceFor().refresh()).toBe('idle') + }) + + it('does not decay waiting, however long the prompt goes unanswered', () => { + // The shipped single-session version dropped the red crown after 15 minutes, because a + // session sitting at a permission prompt never touches its file while it waits. That is + // exactly when the crown matters most. + session(1, 'waiting', GREEN_TTL_MS * 10) + expect(sourceFor().refresh()).toBe('waiting') + }) + + it('does not decay running', () => { + session(1, 'running', GREEN_TTL_MS * 10) + expect(sourceFor().refresh()).toBe('running') + }) + + it('still finds a waiting session behind a decayed green', () => { + session(1, 'idle', GREEN_TTL_MS + 60_000) + session(2, 'waiting') + expect(sourceFor().refresh()).toBe('waiting') + }) +}) + +describe('junk in the session directory', () => { + it('ignores a filename that is not a pid', () => { + writeFileSync(join(dir, 'claude-state'), 'waiting') + writeFileSync(join(dir, '.DS_Store'), 'waiting') + expect(sourceFor().refresh()).toBe('none') + }) + + it('ignores a subdirectory', () => { + mkdirSync(join(dir, '123')) + expect(sourceFor().refresh()).toBe('none') + }) + + it('ignores an empty file and an unknown word', () => { + session(1, '') + session(2, 'thinking') + expect(sourceFor().refresh()).toBe('none') + }) + + it('ignores a session claiming none', () => { + session(1, 'none') + session(2, 'running') + expect(sourceFor().refresh()).toBe('running') + }) +}) + +describe('change reporting', () => { + it('fires onChange once per distinct result, not once per read', () => { + const seen: string[] = [] + const source = sourceFor({ onChange: (s) => seen.push(s) }) + session(1, 'running') + source.refresh() + source.refresh() + session(2, 'running') + source.refresh() + session(3, 'waiting') + source.refresh() + expect(seen).toEqual(['running', 'waiting']) + }) + + it('exposes the last result through current() without touching the disk', () => { + session(1, 'running') + const source = sourceFor() + source.refresh() + rmSync(join(dir, '1')) + expect(source.current()).toBe('running') + }) + + it('start() then stop() leaves no live handles', () => { + const source = sourceFor() + source.start() + expect(() => source.stop()).not.toThrow() + expect(() => source.stop()).not.toThrow() + }) + + it('creates the session directory on start so the watch has a target', () => { + const nested = join(dir, 'made-by-start') + const source = createClaudeStateSource({ dir: nested, isAlive: () => true }) + source.start() + source.stop() + expect(existsSync(nested)).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +```bash +corepack pnpm test -- tests/claude/claude-state-source.spec.ts +``` + +Expected: the file fails to load — `GREEN_TTL_MS` and `SESSION_MAX_AGE_MS` are not exported, and +`createClaudeStateSource` has no `dir` option. + +- [ ] **Step 3: Rewrite the state source** + +Replace the whole body of `apps/desktop/src/claude/claude-state-source.ts`: + +```ts +/** + * Claude Code's state, read off the files its hooks write — one per session. + * + * Each session writes `/`, where the pid is the hook's `$PPID`: a hook command's + * parent process is that session's own `claude` CLI process. That keeps the hook a one-liner + * with no `jq` and no JSON parsing — the property that made this transport worth having — and + * hands us a liveness handle for free. + * + * The many sessions are reduced to one crown by "loudest wins", because the only question the + * pet needs to answer at a glance is whether anything needs a human, not which terminal it is in. + * + * No Electron import. This module is the one place that knows how the state arrives, which is + * why going from one session to many touches nothing else. + */ + +import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, watch, type FSWatcher } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { CLAUDE_STATES, type ClaudeState } from '../pet-frame.js' + +/** + * How long a finished session keeps its green crown. + * + * Green means "just finished, come look", and a signal that is always on is not a signal — + * without this, any long-lived session parks a green crown on the pet all day. Red and gold + * describe a live condition and do not decay; green describes a moment, and moments pass. + */ +export const GREEN_TTL_MS = 5 * 60_000 + +/** + * The ceiling on a session file's age, whatever its pid says. + * + * Liveness is `kill(pid, 0)`, not a timeout, so this is not the staleness guard — it is the + * backstop against pid reuse. A dead session's leftover file plus a recycled pid would otherwise + * read as alive forever. Long enough that a real session is never mistaken for a recycled one. + */ +export const SESSION_MAX_AGE_MS = 12 * 60 * 60_000 + +/** How often to re-read regardless of the watch. See `start()`. */ +const DEFAULT_POLL_MS = 5_000 + +/** Loudest first. The reduction is "the first of these any session is in". */ +const BY_LOUDNESS: readonly ClaudeState[] = ['waiting', 'running', 'idle'] + +export function defaultClaudeSessionDir(): string { + return process.env.ARGOS_CLAUDE_SESSION_DIR ?? join(homedir(), '.argos', 'sessions') +} + +/** + * Is this process still running? + * + * Signal 0 runs the existence and permission checks without delivering anything. `ESRCH` means + * the process is gone; `EPERM` means it exists but belongs to someone else, which still counts + * as alive. + */ +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export interface ClaudeStateSourceOptions { + /** Defaults to `defaultClaudeSessionDir()`. */ + dir?: string + /** Injected so tests can drive expiry without waiting. */ + now?: () => number + /** Injected so tests need no real processes. */ + isAlive?: (pid: number) => boolean + pollMs?: number + greenTtlMs?: number + onChange?: (state: ClaudeState) => void + log?: (message: string, meta?: unknown) => void +} + +export interface ClaudeStateSource { + start(): void + stop(): void + /** The last state read. Cheap: no disk access. */ + current(): ClaudeState + /** Re-read now and report a change if there is one. Returns the new state. */ + refresh(): ClaudeState +} + +const KNOWN = new Set(CLAUDE_STATES) + +export function createClaudeStateSource( + options: ClaudeStateSourceOptions = {}, +): ClaudeStateSource { + const dir = options.dir ?? defaultClaudeSessionDir() + const now = options.now ?? Date.now + const isAlive = options.isAlive ?? processIsAlive + const pollMs = options.pollMs ?? DEFAULT_POLL_MS + const greenTtl = options.greenTtlMs ?? GREEN_TTL_MS + const onChange = options.onChange ?? ((): void => {}) + const log = options.log ?? ((): void => {}) + + let state: ClaudeState = 'none' + let watcher: FSWatcher | null = null + let timer: NodeJS.Timeout | null = null + + /** One session's contribution, or `none` if it does not get a vote. */ + const readSession = (name: string, nowMs: number): ClaudeState => { + // A pid, or it is not ours: an editor's dotfile, a leftover from the single-session + // version, a directory. Anything unrecognised simply does not vote. + if (!/^\d+$/.test(name)) return 'none' + const pid = Number(name) + const path = join(dir, name) + + let raw: string + let mtimeMs: number + try { + const stat = statSync(path) + if (!stat.isFile()) return 'none' + mtimeMs = stat.mtimeMs + raw = readFileSync(path, 'utf8') + } catch { + return 'none' + } + + const age = nowMs - mtimeMs + if (age > SESSION_MAX_AGE_MS) return 'none' + + if (!isAlive(pid)) { + // Ctrl+C and closed terminals never fire SessionEnd. Drop the file as we find it, or the + // directory grows by one per session forever. + try { + unlinkSync(path) + } catch { + // Already gone, or not ours to remove. It reads as dead either way. + } + return 'none' + } + + const word = raw.trim().toLowerCase() + if (!KNOWN.has(word)) return 'none' + if (word === 'idle' && age > greenTtl) return 'none' + return word as ClaudeState + } + + const read = (): ClaudeState => { + let names: string[] + try { + names = readdirSync(dir) + } catch { + // No directory yet: Argos started before any session did. + return 'none' + } + + const nowMs = now() + const seen = new Set() + for (const name of names) seen.add(readSession(name, nowMs)) + return BY_LOUDNESS.find((candidate) => seen.has(candidate)) ?? 'none' + } + + const refresh = (): ClaudeState => { + const next = read() + if (next !== state) { + state = next + onChange(state) + } + return state + } + + return { + current: () => state, + refresh, + + start(): void { + // Make the directory ourselves so the watch has a target from the first launch, rather + // than silently doing nothing until the first hook happens to create it. + try { + mkdirSync(dir, { recursive: true }) + } catch (error) { + log('claude-state: could not create the session directory', error) + } + + refresh() + + try { + watcher = watch(dir, { persistent: false }, () => { + refresh() + }) + } catch (error) { + log('claude-state: session watch unavailable, polling only', error) + } + + // Backstop, and the only thing that expires a green crown or notices a process dying: + // neither writes to the directory, so neither produces a watch event. + timer = setInterval(refresh, pollMs) + timer.unref?.() + }, + + stop(): void { + watcher?.close() + watcher = null + if (timer) clearInterval(timer) + timer = null + }, + } +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +```bash +corepack pnpm test -- tests/claude/claude-state-source.spec.ts +``` + +Expected: PASS, 26 tests. + +- [ ] **Step 5: Typecheck** + +```bash +corepack pnpm typecheck +``` + +Expected: a single error in `app-shell.ts` if it still passes `file:`. It does not — it passes +only `log` and `onChange` — so expect this to be clean. If it is not, the call site is using an +option this task removed; fix it there and note it. + +- [ ] **Step 6: Run the whole suite** + +```bash +corepack pnpm test +``` + +Expected: PASS. No test outside `tests/claude/` should even notice this change — if one does, the +aggregation has leaked past `current()`. + +- [ ] **Step 7: Commit** + +```bash +git add apps/desktop/src/claude/claude-state-source.ts tests/claude/claude-state-source.spec.ts +git commit -m "feat: aggregate every Claude Code session into one crown + +One file per session at ~/.argos/sessions/, reduced by loudest +wins: any session waiting turns the crown red whichever terminal it is +in, which is the only question the pet needs to answer at a glance. + +The pid comes from the hook's \$PPID -- a hook command's parent is that +session's own claude process -- so the hook stays a one-liner with no jq +and no JSON parsing, and we get a liveness handle for free. + +That handle fixes a real defect. Staleness was a 15-minute mtime TTL, +and a session sitting at a permission prompt never touches its file +while it waits, so the red crown silently vanished after 15 minutes of +waiting -- exactly when it mattered most. kill(pid, 0) replaces the +guess; the mtime ceiling is now only a backstop against pid reuse. + +Green expires after five minutes because it means 'just finished, come +look'. Red and gold describe a live condition and do not decay." +``` + +--- + +### Task 2: Point the app at the session directory + +**Files:** +- Modify: `apps/desktop/src/main/app-shell.ts` + +**Interfaces:** +- Consumes: `createClaudeStateSource` from Task 1. +- Produces: nothing. + +- [ ] **Step 1: Check whether anything needs changing at all** + +```bash +grep -n "createClaudeStateSource" -A 8 apps/desktop/src/main/app-shell.ts +``` + +The call passes only `log` and `onChange`, both of which survive Task 1, so the default +`dir` now resolves to the session directory with no edit. Verified while writing this plan: +`defaultClaudeStateFile` has no callers outside its own module either, so removing it breaks +nothing. **If that grep confirms it, this task +is already done — record that and move to Task 3 rather than inventing a change.** + +- [ ] **Step 2: Confirm by building** + +```bash +corepack pnpm typecheck && corepack pnpm build +``` + +Expected: both clean. + +--- + +### Task 3: New hooks and documentation + +**Files:** +- Modify: `docs/CLAUDE-CODE.md` + +**Interfaces:** +- Consumes: the session directory layout from Task 1. +- Produces: the hook block Task 4 installs. + +- [ ] **Step 1: Replace the hook block** + +The state path changes and every command gains `$PPID`. `mkdir -p` stays so the hooks work +whether or not Argos has ever run. Replace the JSON block in `docs/CLAUDE-CODE.md` with: + +```json +{ + "hooks": { + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos/sessions && echo waiting > ~/.argos/sessions/$PPID", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos/sessions && echo running > ~/.argos/sessions/$PPID", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos/sessions && echo running > ~/.argos/sessions/$PPID", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos/sessions && echo running > ~/.argos/sessions/$PPID", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "mkdir -p ~/.argos/sessions && echo idle > ~/.argos/sessions/$PPID", + "timeout": 5 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [{ "type": "command", "command": "rm -f ~/.argos/sessions/$PPID", "timeout": 5 }] + } + ] + } +} +``` + +- [ ] **Step 2: Explain the pid, and rewrite the testing and limits sections** + +Replace the "Testing it without Claude" and "Limits" sections with: + +````markdown +`$PPID` is the session: a hook command's parent process is that session's own `claude` CLI +process. That is what keeps these one-liners — no `jq`, no JSON parsing — and it is also how +Argos knows a session has gone away without being told. + +## Testing it without Claude + +With Argos running, a made-up pid stands in for a session — any live one will do, and your own +shell is the easiest: + +```bash +mkdir -p ~/.argos/sessions +echo waiting > ~/.argos/sessions/$$ # red crown +echo running > ~/.argos/sessions/$$ # gold crown +echo idle > ~/.argos/sessions/$$ # green crown, for five minutes +rm ~/.argos/sessions/$$ # bare-headed +``` + +Two at once, to see loudest-wins: + +```bash +echo running > ~/.argos/sessions/$$ +echo waiting > ~/.argos/sessions/1 # pid 1 is always alive +# red: one session waiting outvotes one running +rm ~/.argos/sessions/1 +# back to gold +``` + +Point Argos at a different directory with `ARGOS_CLAUDE_SESSION_DIR`. + +## Limits + +- **The crown says what, not how many or which.** Three sessions waiting look like one. +- **Green lasts five minutes.** It means "just finished, come look", not "this session is idle". +- **A pid could in principle be recycled** onto a leftover file. Files older than 12 hours are + ignored for that reason; inside that window a recycled pid would show a stale crown. +- **macOS only, so far.** `process.kill(pid, 0)` behaves the same on Linux; Windows is + unverified, as is the Linux shape-region handling. +```` + +- [ ] **Step 3: Verify and commit** + +```bash +corepack pnpm test && corepack pnpm typecheck +git add docs/CLAUDE-CODE.md +git commit -m "docs: hooks for one file per session + +Every command gains \$PPID, which is the session's own claude process, so +the hooks stay one-liners with no jq. Adds a two-session recipe for +seeing loudest-wins without running a second Claude." +``` + +--- + +### Task 4: Install the new hooks and prove it end to end + +**Files:** +- Modify: `~/.claude/settings.json` (the user's, not the repo's) + +**Interfaces:** +- Consumes: the hook block from Task 3. +- Produces: nothing in the repo. + +- [ ] **Step 1: Back up first** + +```bash +cp ~/.claude/settings.json ~/.claude/settings.json.bak-$(date +%Y%m%d-%H%M%S) +``` + +- [ ] **Step 2: Replace the Argos hooks, keeping everything else** + +The file already contains unrelated hooks (`SessionStart` and a second `UserPromptSubmit` entry). +**Merge — do not overwrite the `hooks` key.** Replace only the groups whose command mentions +`.argos`: + +```bash +python3 - <<'PY' +import json, pathlib, collections + +p = pathlib.Path.home() / '.claude' / 'settings.json' +s = json.loads(p.read_text(), object_pairs_hook=collections.OrderedDict) +hooks = s.setdefault('hooks', collections.OrderedDict()) + +DIR = '~/.argos/sessions' +def cmd(word): + return f'mkdir -p {DIR} && echo {word} > {DIR}/$PPID' + +WORDS = { + 'Notification': cmd('waiting'), + 'UserPromptSubmit': cmd('running'), + 'PreToolUse': cmd('running'), + 'PostToolUse': cmd('running'), + 'Stop': cmd('idle'), + 'SessionEnd': f'rm -f {DIR}/$PPID', +} + +def is_argos(group): + return any('.argos' in h.get('command', '') for h in group.get('hooks', [])) + +for event, command in WORDS.items(): + groups = [g for g in hooks.get(event, []) if not is_argos(g)] + groups.append(collections.OrderedDict([ + ('hooks', [collections.OrderedDict([ + ('type', 'command'), ('command', command), ('timeout', 5), + ])]), + ])) + hooks[event] = groups + +p.write_text(json.dumps(s, indent=2) + '\n') +print('argos hooks replaced') +PY +``` + +- [ ] **Step 3: Verify the merge did not eat anything** + +```bash +jq -e '[.hooks[][].hooks[] | select(.command | test("caveman"))] | length' ~/.claude/settings.json +jq -r '.hooks | to_entries[] | .key as $e | .value[].hooks[] | select(.command | test("argos")) | "\($e): \(.command)"' ~/.claude/settings.json +``` + +Expected: the caveman count is unchanged from before the edit (2 at the time of writing), and +exactly six `argos` lines, one per event, all naming `$PPID`. + +- [ ] **Step 4: Retire the old single-session file** + +```bash +rm -f ~/.argos/claude-state +``` + +Nothing reads it any more — the state source only considers filenames that are entirely digits, +so it would be ignored regardless, but leaving it invites confusion later. + +- [ ] **Step 5: Restart Argos and watch it work** + +```bash +corepack pnpm build +node -e "console.log(require('electron'))" # the binary path +``` + +Launch it with that binary and `apps/desktop` as the argument. Then, in a second terminal: + +```bash +echo waiting > ~/.argos/sessions/$$ +``` + +Expected: a red crown within a second, on top of whatever this Claude session's own hooks are +already writing — which is the loudest-wins reduction working on two real sessions. + +- [ ] **Step 6: Prove the fix that motivated the liveness change** + +Backdate a waiting session well past the old 15-minute TTL and confirm the crown stays red: + +```bash +echo waiting > ~/.argos/sessions/$$ +touch -A -003000 ~/.argos/sessions/$$ # 30 minutes ago +``` + +Expected: still red. On the shipped single-session version this went bare-headed. + +- [ ] **Step 7: Clean up** + +```bash +rm -f ~/.argos/sessions/$$ +``` + +--- + +## Done when + +- `corepack pnpm test`, `corepack pnpm typecheck` and `corepack pnpm build` are all clean. +- One session waiting and two running shows red. +- Killing a waiting session with `Ctrl+C` clears its crown without a `SessionEnd`, and its file + disappears from `~/.argos/sessions/`. +- A 30-minute-old waiting session still shows red. +- A finished session's green is gone five minutes later, while the session stays alive. +- Nothing outside `claude-state-source.ts` changed in the app. diff --git a/docs/superpowers/specs/2026-09-04-claude-state-cap-design.md b/docs/superpowers/specs/2026-09-04-claude-state-cap-design.md new file mode 100644 index 0000000..e4b5c6c --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-claude-state-cap-design.md @@ -0,0 +1,171 @@ +# Claude Code state cap — design + +**Date:** 2026-09-04 +**Branch:** `claude-integration` +**Status:** approved, not yet implemented + +## Problem + +While Claude Code works, the user has no ambient signal for what it is doing. Checking means +switching to the terminal and reading it. The three states that matter are: + +- **waiting for confirmation** — Claude is blocked on the user. This is the only state that + demands attention. +- **running** — Claude is working. Nothing to do. +- **idle** — the task finished; ready for a new one. + +Argos already lives on screen over whatever the user is working on, so it is a free carrier for +that signal. + +## Solution + +The pet wears a coloured cap. The colour is driven by Claude Code's state. + +This spike ships a **coloured square** on the head rather than cap art, so the transport, the +frame seam and the head anchoring can all be proven before any art is drawn. The square occupies +the exact slot the real cap will occupy, so the geometry validated here is the geometry kept. + +**Scope of this spike:** one Claude Code session, macOS first, local only. + +## Non-goals + +Explicitly out of scope, listed so they are not smuggled in: + +- **Multi-session aggregation.** Deferred to iteration 2. Single session, last writer wins. +- **Real cap art.** Square only. +- **Animation or bubble changes.** The pet's motion and speech are untouched. The cap is + additive. +- **A settings toggle.** No user-facing on/off switch yet. +- **Windows and Linux verification.** The code is written to not break them (see "Shape region" + below), but only macOS is tested. + +## Architecture + +``` +Claude Code hook ──writes word──▶ ~/.argos/claude-state + │ + fs.watch + poll + ▼ + claude-state-source.ts (new, no Electron) + │ current(): ClaudeState + ▼ + pet-controller.ts (buildFrame) + │ PetFrame.claudeState + ▼ + pet.ts (one attribute set) + │ html[data-claude-state] + ▼ + pet.css (#claude-crown square) +``` + +Each arrow is one-directional and each box has one job. The renderer stays dumb, which +`tests/renderer/discipline.spec.ts` enforces by grepping it. + +### Transport: a file, written by hooks + +Claude Code hooks write a single word to a state file. + +| Hook | Writes | Cap colour | +|---|---|---| +| `Notification` | `waiting` | red | +| `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | `running` | amber | +| `Stop` | `idle` | green | +| `SessionEnd` | *deletes the file* | no cap | + +Path: `~/.argos/claude-state`, overridable via `$ARGOS_CLAUDE_STATE_FILE` for tests. + +The hook body is `echo running > ~/.argos/claude-state`. No `jq`, no stdin parsing, no localhost +port, no new dependency on either side. A hook that cannot fail in an interesting way is worth +more in a spike than one that carries more information. + +**Staleness.** A session killed with `Ctrl+C` may never fire `SessionEnd`, leaving a file that +pins the cap on forever. The file's mtime is the guard: older than 15 minutes counts as no state. +This is why the file holds only a word — the timestamp already exists in the filesystem, so +putting one in the payload would be a second source of truth for the same fact. + +### Seam: a new frame field + +`petFrameSchema` in `apps/desktop/src/pet-frame.ts` gains: + +```ts +claudeState: z.enum(['none', 'waiting', 'running', 'idle']) +``` + +This is a **new field rather than a new value on `overlay`**. `overlay` is single-valued and +already owned by the sleep Z's, and a sleeping pet must still be able to wear the cap. Two +independent axes need two fields; folding them into one would make "asleep and waiting" +unrepresentable. + +### Shape region — the platform trap + +`pet-window.ts` sets the window shape region from the alpha mask. Electron's `setShape` +determines where the system permits *drawing*, not merely where clicks land: outside the region +**no pixels are painted at all**. This already bit the speech bubble and the sleep Z's, and is +documented at length in `apps/desktop/src/sprite/alpha-mask.ts`. + +A square drawn over the hair sits in transparent mask cells. Left alone it would be invisible on +Linux, and the screenshot harness would not catch it, because `webContents.capturePage()` renders +the web contents and never consults the window shape. + +Fix: the existing overlay branch already pushes a whole-sprite-cell rect, so the cap reuses it — +the condition becomes `overlay !== 'none' || claudeState !== 'none'`. No new geometry, one +changed expression. + +## Components + +**`apps/desktop/src/claude/claude-state-source.ts`** (new) + +Owns the state file and nothing else. Watches the *directory*, not the file: an atomic write +replaces the inode and a file-level watch goes dead against the old one. A 5-second poll backs +`fs.watch` up, because it is unreliable on some platforms and a missed event here means a stuck +cap. + +Interface: `current(): ClaudeState`, `onChange(cb)`, `stop()`. Pure Node — no Electron import — +so it can be unit-tested against a temp directory. + +This is also the single file that changes when multi-session lands, which is the reason the file +boundary is drawn here. + +**`apps/desktop/src/main/pet-controller.ts`** (changed) + +`buildFrame()` reads the source and sets `claudeState`. The existing `sendFrameIfChanged` +signature check means a colour change pushes exactly one frame and a steady colour pushes none. + +**`apps/desktop/src/renderer/pet.ts`** (changed) + +One line: `root.dataset.claudeState = frame.claudeState`. A pure attribute set, so it passes the +renderer-discipline test. + +**`apps/desktop/src/renderer/pet.css`** (changed) + +`#claude-crown`, built on the `#zzz` pattern: absolutely positioned from `--body-cx` and +`--body-top`, offset by a scaled amount. Those custom properties are already per-animation-state +and scale-aware, so the square tracks the head through every pose and every pet size without any +new arithmetic. + +Colours: red `#e5484d`, amber `#f5a524`, green `#30a46c`. + +## Error handling + +Every failure resolves to **no cap** rather than a wrong cap. A missing file, an unreadable file, +an unparseable word, and a stale mtime are all `none`. A status indicator that lies is worse than +one that is absent, because an absent cap is visibly absent while a wrong cap is silently wrong. + +The state source never throws into main. A watch that cannot be established falls back to the +poll alone. + +## Testing + +- **Unit — state source:** against a temp directory. Reads a word; sees a change; sees a delete; + treats a stale mtime as `none`; treats garbage content as `none`; survives a missing directory. +- **Unit — frame builder:** each `ClaudeState` reaches `PetFrame.claudeState`. +- **Schema:** the frame round-trips through zod with the new field. +- **Manual, and this is the actual integration test:** with Argos running, + `echo waiting > ~/.argos/claude-state` turns the square red, with Claude not involved at all. If + that works and the hooks do not, the hooks are the only remaining variable. + +## Iteration 2 (not now) + +Multi-session: hooks write `/.json` instead, and the source aggregates by +loudest-wins (any waiting → red, else any running → amber, else green). Only +`claude-state-source.ts` changes. Then the real cap art replaces the square. diff --git a/docs/superpowers/specs/2026-09-04-multi-session-crown-design.md b/docs/superpowers/specs/2026-09-04-multi-session-crown-design.md new file mode 100644 index 0000000..8aaf0f5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-multi-session-crown-design.md @@ -0,0 +1,126 @@ +# Multi-session status crown — design + +**Date:** 2026-09-04 +**Branch:** `claude-integration` +**Status:** approved, not yet implemented +**Supersedes the single-session limit in:** `2026-09-04-claude-state-cap-design.md` + +## Problem + +The crown tracks one Claude Code session. Every session writes the same file, so the last hook to +fire wins and the crown reports whichever session moved most recently — not the one that needs +attention. Two sessions running at once make the crown actively misleading: the one waiting on a +permission prompt is invisible the moment the other one runs a tool. + +This is not hypothetical on the target machine — two `claude` processes were running while this +was written. + +## Solution + +One state file per session, aggregated by **loudest wins**. + +| Any session | Crown | +|---|---| +| waiting for confirmation | 🔴 red | +| else running | 🟡 gold | +| else recently finished | 🟢 green | +| else | bare-headed | + +Red answers the only question worth answering at a glance — *does anything need me?* — regardless +of which terminal it came from. + +## Identifying a session + +The hook writes to `~/.argos/sessions/$PPID`. + +A hook command's parent process is the session's own `claude` CLI process. Verified by probe: a +hook reporting `$PPID` gave a pid whose `ps -o comm=` is `claude`. So the session identity is +already in the shell, and the hook stays a one-liner with no `jq`, no JSON parsing, and no +dependency on either side — the property that made the original transport worth having. + +The alternative was parsing `session_id` out of the hook's stdin JSON, which needs either `jq` +(not guaranteed present) or a script installed at a stable path (a second thing to keep in sync). + +## Liveness, and a bug this fixes + +A pid is a liveness handle, which the previous design lacked: + +1. **`SessionEnd`** deletes the file. Covers a clean exit. +2. **`process.kill(pid, 0)`** covers everything else — `Ctrl+C`, a crash, a closed terminal. + Sending signal 0 performs the permission and existence check without delivering a signal, so + `ESRCH` means the session is gone. Exact, not a guess. +3. **A long mtime ceiling** (12h) is the only remaining backstop, against pid reuse: a dead + session's file plus a recycled pid would otherwise read as alive forever. + +**This corrects a real defect in the shipped version.** Today staleness is a 15-minute mtime TTL, +and a session sitting at a permission prompt does not touch its file while it waits. Leave a +prompt unanswered for 15 minutes and the red crown silently disappears — precisely when it is +most wanted. Pid liveness replaces the guess, so waiting and running states persist for as long +as the process does. + +## Green decay + +Green means *just finished, come look*. It expires 5 minutes after the finishing hook wrote it, +and the pet goes bare-headed while the session stays alive for red and gold purposes. + +A signal that is always on is not a signal. Without decay, any long-lived session parks a green +crown on the pet all day and the crown stops carrying information. + +Red and gold do not decay. They describe a live condition; green describes a moment that passes. + +## Architecture + +Only `claude-state-source.ts` changes, which is why the module boundary was drawn there. + +``` +Claude Code hooks ──▶ ~/.argos/sessions/ (one word each) + │ + fs.watch + poll + ▼ + claude-state-source.ts + read each file + drop dead pids (kill(pid, 0)) + drop expired green + reduce: waiting > running > idle > none + │ current(): ClaudeState + ▼ + unchanged from here on +``` + +`PetFrame.claudeState` keeps its four values, so the controller, the frame seam, the renderer, +the crown art and the shape region are all untouched. The aggregation is entirely inside the one +module that already owns "how the state arrives". + +### Pruning + +Files whose pid is dead are unlinked as they are found, so the directory does not grow by one +file per session forever. A failed unlink is ignored — the file will be skipped on every read +regardless, and a source that throws into main over a leftover file would be worse than the +leftover file. + +## Error handling + +Unchanged in principle: every failure resolves to **no crown** rather than a wrong crown. A +missing directory, an unreadable file, a filename that is not a number, an unparseable word and a +dead pid are all simply absent from the reduction. + +Injected seams for testing: `now()` (already present) and `isAlive(pid)`, so the unit tests need +no real processes and no sleeping. + +## Non-goals + +- **Showing how many sessions.** Loudest-wins reports the condition, not the queue depth. A count + needs a second element and a numeral legible at 0.5× pet scale, which is about 5px tall. +- **Showing which session.** No project name, no hover detail. +- **Changing the crown art, the frame seam, or the renderer.** Nothing outside the state source. + +## Testing + +- Reduction: every ordering of waiting/running/idle/none across several sessions. +- Liveness: a dead pid is ignored, and its file is unlinked. +- Pid reuse: a live pid with an ancient mtime is ignored. +- Green decay: green older than the window is dropped; red and gold of the same age are kept. +- The waiting-prompt regression: a `waiting` file far older than the old 15-minute TTL still + shows red while its pid lives. +- Junk: non-numeric filenames, empty files, unknown words, a missing directory. +- Manual: two terminals, one made to wait, confirm red while the other runs. diff --git a/package.json b/package.json index 531cd2e..01e274c 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ }, "scripts": { "postinstall": "install-electron", - "generate": "node scripts/generate-sprite-css.mjs && node scripts/generate-alpha-mask.mjs", - "generate:check": "node scripts/generate-sprite-css.mjs --check && node scripts/generate-alpha-mask.mjs --check", + "generate": "node scripts/generate-crowns.mjs && node scripts/generate-sprite-css.mjs && node scripts/generate-alpha-mask.mjs", + "generate:check": "node scripts/generate-crowns.mjs --check && node scripts/generate-sprite-css.mjs --check && node scripts/generate-alpha-mask.mjs --check", "build": "pnpm generate:check && pnpm -F @keycode/desktop build", "dev": "pnpm -F @keycode/desktop dev", "typecheck": "pnpm -F @keycode/desktop typecheck", diff --git a/pet/crown-idle.png b/pet/crown-idle.png new file mode 100644 index 0000000..31b0932 Binary files /dev/null and b/pet/crown-idle.png differ diff --git a/pet/crown-running.png b/pet/crown-running.png new file mode 100644 index 0000000..9a50f08 Binary files /dev/null and b/pet/crown-running.png differ diff --git a/pet/crown-waiting.png b/pet/crown-waiting.png new file mode 100644 index 0000000..54d361a Binary files /dev/null and b/pet/crown-waiting.png differ diff --git a/pet/crowns-source/idle.png b/pet/crowns-source/idle.png new file mode 100644 index 0000000..b67e9e8 Binary files /dev/null and b/pet/crowns-source/idle.png differ diff --git a/pet/crowns-source/running.png b/pet/crowns-source/running.png new file mode 100644 index 0000000..bd5eddb Binary files /dev/null and b/pet/crowns-source/running.png differ diff --git a/pet/crowns-source/waiting.png b/pet/crowns-source/waiting.png new file mode 100644 index 0000000..0319b81 Binary files /dev/null and b/pet/crowns-source/waiting.png differ diff --git a/scripts/copy-static.mjs b/scripts/copy-static.mjs index 39e6281..d73db24 100644 --- a/scripts/copy-static.mjs +++ b/scripts/copy-static.mjs @@ -7,9 +7,9 @@ * 1. **The preload.** It is `.cjs` on purpose — a sandboxed preload cannot be an ES module — so * tsc (which is compiling ESM TypeScript) does not emit it, and Vite does not own it either. * - * 2. **The spritesheet.** The generated CSS refers to it by literal filename, so it must not be - * fingerprinted by the bundler; and it cannot be copied at runtime because in a packaged app - * `dist/` lives inside a read-only asar archive. + * 2. **The spritesheet and the status crowns.** The generated CSS refers to them by literal + * filename, so they must not be fingerprinted by the bundler; and they cannot be copied at + * runtime because in a packaged app `dist/` lives inside a read-only asar archive. * * Runs after `vite build`, which empties `dist/renderer`. */ @@ -18,6 +18,7 @@ import { copyFile, mkdir } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join, dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { CROWNS } from './lib/crowns.mjs' const HERE = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(HERE, '..') @@ -29,6 +30,7 @@ const COPIES = [ ['apps/desktop/src/preload/pet-preload.cjs', 'preload/pet-preload.cjs'], ['apps/desktop/src/preload/toast-preload.cjs', 'preload/toast-preload.cjs'], ['pet/spritesheet.png', 'renderer/spritesheet.png'], + ...CROWNS.map((crown) => [`pet/${crown.file}`, `renderer/${crown.file}`]), ] async function main() { diff --git a/scripts/generate-crowns.mjs b/scripts/generate-crowns.mjs new file mode 100644 index 0000000..8438458 --- /dev/null +++ b/scripts/generate-crowns.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Downsample the status crown art to the size it is actually worn at. + * + * node scripts/generate-crowns.mjs [--check] + * + * The source art is ~340x264 with soft shading, and the pet is drawn at 192x208 per cell. Letting + * CSS shrink the big PNG instead would fight `image-rendering: pixelated`: the browser would be + * resampling by a non-integer factor every frame, and the result is mush at exactly the sizes the + * pet is usually shown at. Resampling once, here, means the renderer only ever paints 1:1 (at + * full size) or by a clean half/three-quarter step. + * + * The filter is a box average over premultiplied alpha. Averaging straight RGBA instead pulls the + * fully transparent pixels' colour into the edge pixels, which on this art is black — the crown + * would come out with a dark fringe all the way round. + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { decodePng, encodePng } from './lib/png.mjs' +import { ROOT } from './lib/spritesheet.mjs' +import { CROWNS, CROWN_WIDTH, CROWN_HEIGHT } from './lib/crowns.mjs' + +const SOURCE_DIR = join(ROOT, 'pet', 'crowns-source') +const OUT_DIR = join(ROOT, 'pet') + +/** Tight bounding box of everything not fully transparent. */ +function opaqueBounds(png) { + let x0 = png.width + let y0 = png.height + let x1 = -1 + let y1 = -1 + for (let y = 0; y < png.height; y += 1) { + for (let x = 0; x < png.width; x += 1) { + if (png.data[(y * png.width + x) * 4 + 3] < 8) continue + if (x < x0) x0 = x + if (x > x1) x1 = x + if (y < y0) y0 = y + if (y > y1) y1 = y + } + } + if (x1 < 0) throw new Error('crown source is entirely transparent') + return { x0, y0, width: x1 - x0 + 1, height: y1 - y0 + 1 } +} + +/** Box-average `src` (cropped to `box`) down to `outW` x `outH`, premultiplied. */ +function downsample(png, box, outW, outH) { + const out = Buffer.alloc(outW * outH * 4) + for (let oy = 0; oy < outH; oy += 1) { + for (let ox = 0; ox < outW; ox += 1) { + const sx0 = box.x0 + Math.floor((ox * box.width) / outW) + const sx1 = box.x0 + Math.floor(((ox + 1) * box.width) / outW) + const sy0 = box.y0 + Math.floor((oy * box.height) / outH) + const sy1 = box.y0 + Math.floor(((oy + 1) * box.height) / outH) + + let r = 0 + let g = 0 + let b = 0 + let a = 0 + let n = 0 + for (let sy = sy0; sy < Math.max(sy1, sy0 + 1); sy += 1) { + for (let sx = sx0; sx < Math.max(sx1, sx0 + 1); sx += 1) { + const i = (sy * png.width + sx) * 4 + const alpha = png.data[i + 3] / 255 + r += png.data[i] * alpha + g += png.data[i + 1] * alpha + b += png.data[i + 2] * alpha + a += png.data[i + 3] + n += 1 + } + } + + const meanAlpha = a / n + const o = (oy * outW + ox) * 4 + if (meanAlpha < 1) { + out[o] = 0 + out[o + 1] = 0 + out[o + 2] = 0 + out[o + 3] = 0 + continue + } + // Un-premultiply back to straight alpha for storage. + const scale = 255 / meanAlpha / n + out[o] = Math.min(255, Math.round(r * scale)) + out[o + 1] = Math.min(255, Math.round(g * scale)) + out[o + 2] = Math.min(255, Math.round(b * scale)) + out[o + 3] = Math.round(meanAlpha) + } + } + return out +} + +function main() { + const check = process.argv.slice(2).includes('--check') + const stale = [] + + for (const crown of CROWNS) { + const sourcePath = join(SOURCE_DIR, crown.source) + if (!existsSync(sourcePath)) { + console.error(`generate-crowns: missing source pet/crowns-source/${crown.source}`) + process.exit(1) + } + const png = decodePng(readFileSync(sourcePath)) + const box = opaqueBounds(png) + const rgba = downsample(png, box, CROWN_WIDTH, CROWN_HEIGHT) + const encoded = encodePng(CROWN_WIDTH, CROWN_HEIGHT, rgba) + + const outPath = join(OUT_DIR, crown.file) + const current = existsSync(outPath) ? readFileSync(outPath) : null + if (current && current.equals(encoded)) continue + if (check) { + stale.push(crown.file) + continue + } + writeFileSync(outPath, encoded) + } + + if (check && stale.length > 0) { + console.error(`crowns are stale: ${stale.join(', ')} — run \`pnpm generate\``) + process.exit(1) + } + console.log( + `✓ crowns (${CROWNS.length} at ${CROWN_WIDTH}x${CROWN_HEIGHT})${check ? ': up to date' : ''}`, + ) +} + +main() diff --git a/scripts/generate-sprite-css.mjs b/scripts/generate-sprite-css.mjs index bc7567a..e1e58dd 100644 --- a/scripts/generate-sprite-css.mjs +++ b/scripts/generate-sprite-css.mjs @@ -53,7 +53,11 @@ */ import { join } from 'node:path' -import { loadSpritesheet, ROOT } from './lib/spritesheet.mjs' +import { readFileSync } from 'node:fs' +import { loadSpritesheet, ROOT, SPRITESHEET_PNG } from './lib/spritesheet.mjs' +import { headAnchorsByFrame } from './lib/mask.mjs' +import { CROWNS, CROWN_WIDTH, CROWN_HEIGHT, CROWN_GAP } from './lib/crowns.mjs' +import { decodePng } from './lib/png.mjs' import { emitOrCheck, reportResults, cssBanner, tsBanner } from './lib/generated-file.mjs' const CSS_OUT = join(ROOT, 'apps', 'desktop', 'src', 'renderer', 'pet.generated.css') @@ -66,7 +70,58 @@ function keyframesName(state, nonce) { return `kp-${state}-${nonce}` } -function buildCss(sheet, states, holdStrategy) { +/** + * The status cap's keyframes. + * + * The pet's bounce is `background-position` stepping, so the sprite element itself never moves + * and nothing can be made to follow it by parenting. The cap is therefore given its own + * animation, on the same clock, with one stop per frame holding that frame's real head anchor. + * + * `step-end` rather than a two-endpoint ramp: the head tops are not a ramp (jumping runs + * 44,42,42,42,50,...), so interpolating between the first and last would put the cap in the + * wrong place on most frames. One stop per frame, each held until the next, lands it exactly. + * + * Offsets are emitted in unscaled cell space and multiplied by `--pet-scale` here, so the cap + * tracks the head at every pet size without the renderer computing anything. + */ +function buildCrownCss(sheet, states, anchors) { + const lines = [] + + for (const state of states) { + const frames = anchors[state.name] + const iterations = state.iterations === 'infinite' ? 'infinite' : String(state.iterations) + + for (const nonce of NONCES) { + lines.push(`@keyframes ${crownKeyframesName(state.name, nonce)} {`) + frames.forEach((anchor, index) => { + // Stop i opens the window in which the sprite is showing frame i, matching the + // `steps(n, jump-none)` the sprite runs on. + const pct = ((index / frames.length) * 100).toFixed(4).replace(/\.?0+$/, '') + lines.push( + ` ${pct}% { translate: calc(${anchor.cx}px * var(--pet-scale, 1)) calc(${anchor.top}px * var(--pet-scale, 1)); }`, + ) + }) + lines.push('}') + } + + for (const nonce of NONCES) { + lines.push( + `html[data-pet-state="${state.name}"][data-pet-nonce="${nonce}"] #claude-crown {`, + ` animation: ${crownKeyframesName(state.name, nonce)} ${state.durationMs}ms step-end ${iterations} forwards;`, + '}', + ) + } + lines.push('') + } + + return lines +} + +function crownKeyframesName(state, nonce) { + return `kp-crown-${state}-${nonce}` +} + +function buildCss(sheet, states, holdStrategy, anchors) { const lines = [cssBanner()] lines.push( @@ -121,6 +176,33 @@ function buildCss(sheet, states, holdStrategy) { lines.push('') } + lines.push( + '/* The status crown. Size, art and clock are all generated; see buildCrownCss. */', + '#claude-crown {', + ` width: calc(${CROWN_WIDTH}px * var(--pet-scale, 1));`, + ` height: calc(${CROWN_HEIGHT}px * var(--pet-scale, 1));`, + ' background-repeat: no-repeat;', + ' background-size: 100% 100%;', + ' /* Same reason as the sprite: without it the browser smooths the pixel art into mush. */', + ' image-rendering: pixelated;', + ' /* Composes with the `translate` the keyframes animate. That puts the anchor on the head;', + ` this centres the crown on it and lifts it clear by ${CROWN_GAP}px of daylight. */`, + ` transform: translate(-50%, calc(-100% - ${CROWN_GAP}px * var(--pet-scale, 1)));`, + ' animation-timing-function: step-end;', + '}', + '', + ) + + for (const crown of CROWNS) { + lines.push( + `html[data-claude-state="${crown.state}"] #claude-crown {`, + ' display: block;', + ` background-image: url('./${crown.file}');`, + '}', + ) + } + lines.push('', ...buildCrownCss(sheet, states, anchors)) + return `${lines.join('\n').trimEnd()}\n` } @@ -230,9 +312,10 @@ function main() { } const { sheet, states, aliases, reactionMap } = loadSpritesheet() + const anchors = headAnchorsByFrame(decodePng(readFileSync(SPRITESHEET_PNG)), sheet, states) const results = [ - emitOrCheck(CSS_OUT, buildCss(sheet, states, holdStrategy), { check }), + emitOrCheck(CSS_OUT, buildCss(sheet, states, holdStrategy, anchors), { check }), emitOrCheck(TS_OUT, buildTs(sheet, states, aliases, reactionMap), { check }), ] diff --git a/scripts/lib/crowns.mjs b/scripts/lib/crowns.mjs new file mode 100644 index 0000000..980eed7 --- /dev/null +++ b/scripts/lib/crowns.mjs @@ -0,0 +1,33 @@ +/** + * The status crowns: which art belongs to which Claude Code state, and how big it is worn. + * + * Shared by the asset generator (which produces the small PNGs) and the CSS generator (which + * sizes and places them), so the crown's dimensions have exactly one definition. Hand-written + * CSS is asserted to contain no such geometry. + */ + +/** Claude Code state -> source art. Order is the order they are emitted in. */ +export const CROWNS = [ + { state: 'waiting', source: 'waiting.png', file: 'crown-waiting.png' }, + { state: 'running', source: 'running.png', file: 'crown-running.png' }, + { state: 'idle', source: 'idle.png', file: 'crown-idle.png' }, +] + +/** + * Worn size, in unscaled sprite-cell pixels. + * + * The pet's head is ~40px of visible skull in a 192px cell, so a crown much wider than this + * stops reading as worn and starts reading as a banner floating overhead. The height follows + * the source art's 340x264 aspect ratio; changing the width without the height would squash it. + */ +export const CROWN_WIDTH = 28 +export const CROWN_HEIGHT = 22 + +/** + * Gap between the crown's bottom edge and the top of the head, in unscaled cell pixels. + * + * Deliberate daylight rather than a seated hat: it keeps the crown clear of the horn, which + * reaches as high as the hair does, and reads as "status floating above the pet" rather than as + * a costume change. + */ +export const CROWN_GAP = 4 diff --git a/scripts/lib/mask.mjs b/scripts/lib/mask.mjs index 2dc4090..3bdcfc7 100644 --- a/scripts/lib/mask.mjs +++ b/scripts/lib/mask.mjs @@ -10,6 +10,14 @@ /** Alpha above this counts as opaque. Low enough to keep antialiased edges, high enough to drop dust. */ export const ALPHA_THRESHOLD = 8 +/** + * Rows below the top of the head used to find its horizontal centre. + * + * Deep enough to span the skull and shallow enough to exclude the shoulders, which on this + * art are much wider and would drag the centre towards the body. + */ +export const HEAD_BAND = 12 + /** Mask resolution. 4px keeps the mask tiny (312 bytes) while staying finer than any limb. */ export const GRANULARITY = 4 @@ -70,6 +78,71 @@ export function buildUnion(png, sheet, states) { return { union, perFrameOpaque, footInsetByState, headTopByState } } +/** + * Per-frame head anchor for every state: where the top of the head is, and where it is centred. + * + * This is the deliberate opposite of `headTopByState`. That one collapses a state's frames to + * their minimum so the speech bubble does not jitter with the pet's breathing — correct for a + * bubble, wrong for anything worn *on* the head. The pet's bounce is entirely + * `background-position` stepping, so the sprite element never moves and a hat pinned to a + * per-state constant floats while the character bobs underneath it. + * + * `cx` is the centre of the head specifically, not of the whole body: measured across the top + * `HEAD_BAND` rows of the frame, because the body's bounding-box centre barely moves while the + * head swings several pixels in a leaning pose. + * + * @param {{width:number,height:number,data:Uint8Array}} png + * @param {{frameWidth:number,frameHeight:number}} sheet + * @param {Array<{row:number,frames:number,name:string,startColumn?:number}>} states + * @returns {Record>} + */ +export function headAnchorsByFrame(png, sheet, states) { + const { frameWidth: fw, frameHeight: fh } = sheet + const alpha = (x, y) => { + if (x < 0 || y < 0 || x >= png.width || y >= png.height) return 0 + return png.data[(y * png.width + x) * 4 + 3] + } + + const out = {} + for (const state of states) { + const frames = [] + for (let frame = 0; frame < state.frames; frame += 1) { + const originX = ((state.startColumn ?? 0) + frame) * fw + const originY = state.row * fh + + let top = fh + for (let y = 0; y < fh && top === fh; y += 1) { + for (let x = 0; x < fw; x += 1) { + if (alpha(originX + x, originY + y) > ALPHA_THRESHOLD) { + top = y + break + } + } + } + + // A fully transparent cell (art in progress, or a short row) has no head to anchor to. + // Fall back to the cell centre rather than emitting NaN into a stylesheet. + if (top === fh) { + frames.push({ top: 0, cx: Math.round(fw / 2) }) + continue + } + + let minX = fw + let maxX = -1 + for (let y = top; y < Math.min(fh, top + HEAD_BAND); y += 1) { + for (let x = 0; x < fw; x += 1) { + if (alpha(originX + x, originY + y) <= ALPHA_THRESHOLD) continue + if (x < minX) minX = x + if (x > maxX) maxX = x + } + } + frames.push({ top, cx: Math.round((minX + maxX) / 2) }) + } + out[state.name] = frames + } + return out +} + /** Tight bounding box of the set pixels. */ export function boundingBox(union, width, height) { let minX = width diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 0f52927..44122e4 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -889,20 +889,27 @@ async function assertPass(session, opts, name) { } const colours = assertNotBlank(png, region, 'A3 not-blank') const fill = assertSpritePainted(png, region, 'A1 sprite-painted') - const ring = assertWindowTransparentAround( - png, - region, - 'A2 window-transparent', - Math.max(8, Math.round(24 * scale)), - // Gated on main reporting a bubble actually on screen — never on `--callout`, because a - // broadcast or a reminder raises one with no flag involved. Absent, the full ring is checked. - captured.bubbleVisible && captured.bubbleEdgeY !== undefined - ? { - edge: Math.round((captured.bubbleEdgeY - captured.bounds.y) * scale), - side: captured.bubbleSide ?? 'above', - } - : null, - ) + // A2 asks whether the *window* is see-through. A status crown floats in the gap above the + // head, which is inside the ring, so it would fail an assertion it is not the subject of. + // Skipped rather than narrowed: the crown's exact band is per animation frame and lives in + // the generated stylesheet, so main cannot report a bound tight enough to be worth trusting. + // Gated on main reporting a crown actually worn, so ordinary runs still check the full ring. + const ring = captured.crownVisible + ? null + : assertWindowTransparentAround( + png, + region, + 'A2 window-transparent', + Math.max(8, Math.round(24 * scale)), + // Gated on main reporting a bubble actually on screen — never on `--callout`, because a + // broadcast or a reminder raises one with no flag involved. Absent, the full ring is checked. + captured.bubbleVisible && captured.bubbleEdgeY !== undefined + ? { + edge: Math.round((captured.bubbleEdgeY - captured.bounds.y) * scale), + side: captured.bubbleSide ?? 'above', + } + : null, + ) // `floorLocked` is absent on builds before free placement, where floor-locked was the only mode. const floorLocked = captured.floorLocked ?? true assertFeetOnFloor(spriteRect, pet.display, 'A4 feet-on-floor', floorLocked) @@ -915,14 +922,16 @@ async function assertPass(session, opts, name) { ) console.log(` ✓ A1 sprite painted (${(fill * 100).toFixed(1)}% of the bbox has alpha)`) console.log( - ` ✓ A2 window transparent around the sprite (${(ring * 100).toFixed(1)}% of ring` + - `${ - captured.bubbleVisible - ? captured.bubbleSide === 'below' - ? ', down to the feet — a bubble is up below the pet' - : ', from the hair down — a bubble is up' - : '' - })`, + ring === null + ? ' · A2 skipped — a status crown is worn, and it paints in the ring by design' + : ` ✓ A2 window transparent around the sprite (${(ring * 100).toFixed(1)}% of ring` + + `${ + captured.bubbleVisible + ? captured.bubbleSide === 'below' + ? ', down to the feet — a bubble is up below the pet' + : ', from the hair down — a bubble is up' + : '' + })`, ) console.log(` ✓ A3 image is not blank (${colours} distinct colours)`) console.log( diff --git a/tests/claude/claude-state-source.spec.ts b/tests/claude/claude-state-source.spec.ts new file mode 100644 index 0000000..223bf06 --- /dev/null +++ b/tests/claude/claude-state-source.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, utimesSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createClaudeStateSource, + CLAUDE_STATE_STALE_MS, +} from '../../apps/desktop/src/claude/claude-state-source.js' + +let dir: string +let file: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'argos-claude-')) + file = join(dir, 'claude-state') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +/** `refresh()` rather than the poll, so no test depends on a timer firing. */ +function sourceFor(onChange?: (s: string) => void) { + return createClaudeStateSource({ file, onChange }) +} + +describe('claude state source', () => { + it('reports none when the file does not exist', () => { + expect(sourceFor().refresh()).toBe('none') + }) + + it('reports none when the whole directory is missing', () => { + const source = createClaudeStateSource({ file: join(dir, 'nope', 'claude-state') }) + expect(source.refresh()).toBe('none') + }) + + it.each(['waiting', 'running', 'idle'])('reads %s', (word) => { + writeFileSync(file, `${word}\n`) + expect(sourceFor().refresh()).toBe(word) + }) + + it('ignores surrounding whitespace and case', () => { + writeFileSync(file, ' WAITING \n') + expect(sourceFor().refresh()).toBe('waiting') + }) + + it('treats an unknown word as none', () => { + writeFileSync(file, 'thinking') + expect(sourceFor().refresh()).toBe('none') + }) + + it('treats an empty file as none', () => { + writeFileSync(file, '') + expect(sourceFor().refresh()).toBe('none') + }) + + it('treats a stale file as none, however good its contents', () => { + // A session killed with Ctrl+C never fires SessionEnd, so the file outlives the session. + writeFileSync(file, 'waiting') + const old = new Date(Date.now() - CLAUDE_STATE_STALE_MS - 60_000) + utimesSync(file, old, old) + expect(sourceFor().refresh()).toBe('none') + }) + + it('picks up a change on the next refresh', () => { + writeFileSync(file, 'running') + const source = sourceFor() + expect(source.refresh()).toBe('running') + writeFileSync(file, 'waiting') + expect(source.refresh()).toBe('waiting') + }) + + it('reports a deletion as none', () => { + writeFileSync(file, 'running') + const source = sourceFor() + expect(source.refresh()).toBe('running') + rmSync(file) + expect(source.refresh()).toBe('none') + }) + + it('fires onChange once per distinct state, not once per read', () => { + const seen: string[] = [] + const source = sourceFor((s) => seen.push(s)) + writeFileSync(file, 'running') + source.refresh() + source.refresh() + writeFileSync(file, 'running\n') + source.refresh() + writeFileSync(file, 'waiting') + source.refresh() + expect(seen).toEqual(['running', 'waiting']) + }) + + it('exposes the last read through current() without touching the disk', () => { + writeFileSync(file, 'idle') + const source = sourceFor() + source.refresh() + rmSync(file) + expect(source.current()).toBe('idle') + }) + + it('creates the directory on start so the watch has something to watch', () => { + // Without this the watch silently does nothing until the first hook happens to create the + // directory, which looks exactly like the feature not working. + const nested = join(dir, 'made-by-start', 'claude-state') + const source = createClaudeStateSource({ file: nested }) + source.start() + source.stop() + expect(existsSync(join(dir, 'made-by-start'))).toBe(true) + }) + + it('start() then stop() leaves no live handles', () => { + const source = sourceFor() + source.start() + expect(() => source.stop()).not.toThrow() + expect(() => source.stop()).not.toThrow() + }) +}) diff --git a/tests/main/frame-region.spec.ts b/tests/main/frame-region.spec.ts new file mode 100644 index 0000000..b86d050 --- /dev/null +++ b/tests/main/frame-region.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { + petFrameSchema, + frameNeedsCellRegion, + type PetFrame, +} from '../../apps/desktop/src/pet-frame.js' + +function frame(overrides: Partial = {}): PetFrame { + return { + animation: 'idle', + animationNonce: 0, + facing: 'right', + sprite: { x: 0, y: 0 }, + bubbleSide: 'above', + scale: 1, + bubble: null, + quickActions: [], + overlay: 'none', + claudeState: 'none', + ...overrides, + } +} + +describe('claudeState on the frame', () => { + it.each(['none', 'waiting', 'running', 'idle'] as const)('round-trips %s', (state) => { + const parsed = petFrameSchema.safeParse(frame({ claudeState: state })) + expect(parsed.success).toBe(true) + expect(parsed.success && parsed.data.claudeState).toBe(state) + }) + + it('rejects a state the renderer has no colour for', () => { + expect(petFrameSchema.safeParse(frame({ claudeState: 'busy' as never })).success).toBe(false) + }) + + it('is required, so no frame can reach the renderer without one', () => { + const { claudeState: _dropped, ...without } = frame() + expect(petFrameSchema.safeParse(without).success).toBe(false) + }) +}) + +describe('frameNeedsCellRegion', () => { + // Electron's setShape decides where the system permits *drawing*, not just where clicks land. + // Anything outside the region is never painted. The cap sits over the hair, which is + // transparent in the alpha mask, so without this it would be invisible on Linux — and the + // screenshot harness would not catch it, because capturePage() ignores the window shape. + it('is false for a plain frame', () => { + expect(frameNeedsCellRegion(frame())).toBe(false) + }) + + it('is true while the sleep Z-s are up', () => { + expect(frameNeedsCellRegion(frame({ overlay: 'sleep-z' }))).toBe(true) + }) + + it.each(['waiting', 'running', 'idle'] as const)('is true while the cap is %s', (state) => { + expect(frameNeedsCellRegion(frame({ claudeState: state }))).toBe(true) + }) + + it('is true when both are up at once', () => { + // A sleeping pet still wears the cap. This is why claudeState is its own field rather than + // another value on `overlay`. + expect(frameNeedsCellRegion(frame({ overlay: 'sleep-z', claudeState: 'waiting' }))).toBe(true) + }) +}) diff --git a/tests/renderer/discipline.spec.ts b/tests/renderer/discipline.spec.ts index 55cdaeb..d892a76 100644 --- a/tests/renderer/discipline.spec.ts +++ b/tests/renderer/discipline.spec.ts @@ -137,6 +137,47 @@ describe('hand-written CSS carries no generated geometry', () => { expect(css).toMatch(/var\(--body-top/) }) + it('paints the status crown from state the renderer publishes', () => { + // Two halves in two files again: the renderer publishes the state, the CSS paints it. Either + // half being dropped leaves a crown that is silently never shown, which looks exactly like + // "Claude is not running" and so would not be noticed. + const pet = read(join(RENDERER_DIR, 'pet.ts')) + expect(pet).toContain('claudeState') + + const css = read(join(RENDERER_DIR, 'pet.css')) + expect(css).toMatch(/#claude-crown/) + + // The per-state rules live in the generated stylesheet rather than in pet.css, because the + // crown filenames come out of scripts/lib/crowns.mjs and hand-copying them here is exactly + // how the art and the stylesheet drift apart. + const generated = read(join(RENDERER_DIR, 'pet.generated.css')) + for (const state of ['waiting', 'running', 'idle']) { + expect(generated, state).toMatch( + new RegExp(`\\[data-claude-state="${state}"\\] #claude-crown \\{[^}]*crown-${state}\\.png`), + ) + } + }) + + it('leaves the crown\'s per-frame geometry to the generator', () => { + // The crown has to bob with the pet, and the pet's bounce is background-position stepping -- + // the sprite element never moves. So the crown cannot hang off --body-top, which is a + // per-state constant chosen precisely so the speech bubble does NOT bob. It rides generated + // per-frame keyframes instead, and the hand-written rule may only park it on the cell. + const css = read(join(RENDERER_DIR, 'pet.css')) + const rule = css.slice(css.indexOf('#claude-crown {')) + const decl = rule.slice(0, rule.indexOf('}')) + expect(decl, 'crown is pinned to the non-bobbing bubble anchor').not.toMatch(/var\(--body-top/) + expect(decl).toMatch(/var\(--sprite-x/) + expect(decl).toMatch(/var\(--sprite-y/) + + // The renderer must publish the sprite's state and nonce on the root: the cap is a sibling + // of #sprite, so CSS cannot read them off it, and without the nonce the crown does not restart + // with the sprite and drifts a frame out of phase for the whole loop. + const pet = read(join(RENDERER_DIR, 'pet.ts')) + expect(pet).toContain('petState') + expect(pet).toContain('petNonce') + }) + it('scales the sprite by transform, and publishes the scale', () => { // The generated keyframes step `background-position` in absolute pixels off the unscaled sheet. // Resizing the element or its background-size to change the pet's size would invalidate every one diff --git a/tests/sprite/crown-anchor.spec.ts b/tests/sprite/crown-anchor.spec.ts new file mode 100644 index 0000000..9e3c568 --- /dev/null +++ b/tests/sprite/crown-anchor.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +// @ts-expect-error — untyped .mjs helpers shared with the generators +import { loadSpritesheet } from '../../scripts/lib/spritesheet.mjs' +// @ts-expect-error — untyped .mjs helpers shared with the generators +import { headAnchorsByFrame } from '../../scripts/lib/mask.mjs' +// @ts-expect-error — untyped .mjs helpers shared with the generators +import { decodePng } from '../../scripts/lib/png.mjs' + +const REPO = resolve(import.meta.dirname, '..', '..') +const CSS = readFileSync(resolve(REPO, 'apps/desktop/src/renderer/pet.generated.css'), 'utf8') + +/** + * The status cap has to bob with the pet. + * + * The pet's bounce is entirely `background-position` stepping — the sprite element never moves — + * so anything anchored to a per-state constant stays put while the character bobs underneath it. + * `headTopByState` is deliberately such a constant (it is the minimum across the state's frames, + * so the speech bubble does not jitter with the pet's breathing), which makes it exactly the + * wrong anchor for a hat. + */ +describe('per-frame head anchors', () => { + const { sheet, states } = loadSpritesheet() as { + sheet: { frameWidth: number; frameHeight: number } + states: Array<{ name: string; row: number; frames: number; startColumn?: number }> + } + const png = decodePng(readFileSync(resolve(REPO, 'pet/spritesheet.png'))) + const anchors = headAnchorsByFrame(png, sheet, states) as Record< + string, + Array<{ top: number; cx: number }> + > + + it('produces one anchor per frame of every state', () => { + for (const state of states) { + expect(anchors[state.name], state.name).toHaveLength(state.frames) + } + }) + + it('sees the idle bob the bubble is deliberately blind to', () => { + // Measured from the art: idle's two frames sit 7px apart vertically. A cap pinned to the + // per-state minimum floats a gap wider than half its own height on the second frame, which + // is the bug this exists to prevent regressing. + const tops = anchors['idle'].map((a) => a.top) + expect(Math.max(...tops) - Math.min(...tops)).toBeGreaterThanOrEqual(5) + }) + + it('tracks the head sideways too, which a body-bbox centre cannot', () => { + // jumping leans: the head centre swings while the body's bounding box barely moves. + const cxs = anchors['jumping'].map((a) => a.cx) + expect(Math.max(...cxs) - Math.min(...cxs)).toBeGreaterThanOrEqual(5) + }) + + it('keeps every anchor inside the cell', () => { + for (const [name, frames] of Object.entries(anchors)) { + for (const { top, cx } of frames) { + expect(top, `${name} top`).toBeGreaterThanOrEqual(0) + expect(top, `${name} top`).toBeLessThan(sheet.frameHeight) + expect(cx, `${name} cx`).toBeGreaterThanOrEqual(0) + expect(cx, `${name} cx`).toBeLessThan(sheet.frameWidth) + } + } + }) +}) + +describe('generated crown keyframes', () => { + const { states } = loadSpritesheet() as { + states: Array<{ name: string; frames: number; durationMs: number; iterations?: unknown }> + } + + it('emits a crown keyframe rule per state per nonce', () => { + for (const state of states) { + for (const nonce of [0, 1]) { + expect(CSS, `${state.name}/${nonce}`).toContain(`@keyframes kp-crown-${state.name}-${nonce}`) + } + } + }) + + it('gives multi-frame states one stop per frame', () => { + // One stop per frame is what makes the crown land on the real head position rather than on a + // linear interpolation between the first and last — the head tops are not a ramp. + const block = CSS.slice(CSS.indexOf('@keyframes kp-crown-jumping-0')) + const body = block.slice(0, block.indexOf('}\n@') + 1) + const stops = body.match(/^\s+[\d.]+% \{/gm) ?? [] + const jumping = states.find((s) => s.name === 'jumping')! + expect(stops).toHaveLength(jumping.frames) + }) + + it('steps rather than interpolates, and scales with the pet', () => { + expect(CSS).toMatch(/#claude-crown \{[\s\S]*?animation-timing-function:\s*step-end/) + expect(CSS).toMatch(/@keyframes kp-crown-idle-0[\s\S]*?var\(--pet-scale/) + }) + + it('runs the crown on the same clock as the sprite', () => { + // Out of phase by even one frame and the crown lands on the wrong head position all the way + // through the loop. Same duration, same iteration count, restarted by the same nonce flip. + for (const state of states) { + const rule = CSS.slice(CSS.indexOf(`[data-pet-state="${state.name}"][data-pet-nonce="0"] #claude-crown`)) + const decl = rule.slice(0, rule.indexOf('}')) + expect(decl, state.name).toContain(`${state.durationMs}ms`) + } + }) +})