Dresses Claude Code for the month: Clawd in a seasonal hat, the logo block and prompt box decorated, critters that follow your cursor, and themed spinner…

A Claude Code mod that dresses the terminal for the month. Nothing is added as a banner: it decorates what is already there.

Nothing flashes: every light change is a slow fade. Bash mode keeps the engine's own border color. Terminal only.
![]() | ![]() | ![]() |
![]() | ![]() | ![]() |
![]() | ![]() | ![]() |
![]() | ![]() | ![]() |
Each was taken mid-turn on "Count pi to 1000 digits", with scripts/shoot.sh.
/seasons turns the decorations off or on (remembered across sessions); /seasons off, /seasons on say which./seasons mar (or march, or 3) shows another month for this session; /seasons auto goes back to the calendar.SEASONS_MONTH=3 claude starts a session on that month.At the prompt of a terminal session:
/plugin install seasons --marketplace snedea/seasons-mod
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install seasons --marketplace snedea/seasons-mod
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with snedea/seasons-mod, then Add plugin.
Claude Code has no render site for the logo block itself, but the notice line under it is one. The mod draws from the tmux session: seasons notice (up over the logo, with absolutely placed cells), which the engine shows whenever CLAUDE_CODE_TMUX_SESSION is set. Add it to ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_TMUX_SESSION": "seasons" } }
A real claude --tmux session sets its own name there, and the mod leaves that notice alone. If the engine has more notices than it shows ("1 more notice hidden", as under --debug), the logo decorations wait for a start without them; the prompt box is decorated regardless.
The prompt decorations assume the fullscreen layout ("tui": "fullscreen") and a status line of at most one row.
Each month is one file in hooks/months/: what Clawd wears and holds, the sky, the flyers, the ground row, the corner decoration, the critter, and colors. Its words are in hooks/words.ts. hooks/scene.ts moves and places everything. After changing words, run node scripts/sayings.ts; after changing looks, scripts/shoot.sh <month number> retakes that screenshot.
Scene style follows the pixel-art header engine in floweyes.
hooks/register.tsx 350 lines1// Seasons: the month, drawn into the chrome Claude Code already draws, not in a
2// band of its own.
3//
4// The logo block has no render site, but the dim notice line right under it does
5// (InfoNotice). Setting CLAUDE_CODE_TMUX_SESSION=seasons in settings.json env makes
6// the engine raise its "tmux session: seasons" notice on every start; this mod
7// draws a one-row yard there and, from it, paints absolutely placed Rasters up
8// over the logo: Clawd in the month's hat, the title in the month's colors, the
9// sky, a corner decoration. The prompt's hint line (PromptHint) is drawn by the
10// engine as ever, with Rasters placed over the box's borders: a decoration at
11// the box's right end (away from the draft and the newest text), a critter that
12// walks to the caret while you type and patrols while Claude works, something
13// drifting on the bottom border. Before the first prompt something comes down
14// into the empty transcript to say hello. What each month looks like is in
15// months/; scene.ts moves it all.
16//
17// /seasons turns it off and on, and previews any month for the session.
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register } from 'claude-code'
20
21import type { Canvas, Run } from './canvas'
22import { MONTHS } from './months'
23import { HEADER_ROWS, Scene, WEB_COLUMNS, promptShape } from './scene'
24import type { PromptLayout } from '../types'
25
26const FPS = 10
27const GUTTER = 2 // both sites start two columns in: the notice's mark, the hint's indent
28const SENTINEL = 'tmux session: seasons'
29const CLAWD_COLUMNS = 11 // Clawd and the gap before the logo's text
30const MODEL_LINE = 34 // room left for the logo's model and plan line, which no API names
31
32const enabled = atom({ plugin: 'seasons', key: 'enabled' } as const, true)
33// a month shown in place of the calendar's, for this session only
34const preview = atom({ plugin: 'seasons', key: 'preview' } as const, -1)
35const NAMES = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']
36const promptLayout = atom({ plugin: 'seasons', key: 'prompt' } as const, {
37 rows: 1,
38 bash: false,
39 status: 1,
40 wide: false,
41} as PromptLayout)
42
43type Block = { key: string; x: number; y: number; w: number; h: number }
44type Site = { requestId: string; blocks: Block[]; canvas: () => Canvas }
45
46const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
47
48// Repaints a site's Rasters whose cells changed since last sent; false once the
49// site is gone (hidden, resized, redrawn without them).
50async function blit($: EngineInterface, site: Site, sent: Map<string, string>): Promise<boolean> {
51 const cv = site.canvas()
52 for (const b of site.blocks) {
53 const cells = cv.pack(b.x, b.y, b.w, b.h)
54 const id = `${site.requestId}/${b.key}`
55 if (sent.get(id) === cells) continue
56 const r = await $.ui.blit({ requestId: site.requestId, key: b.key, cells })
57 if ('deny' in r && r.deny) return false
58 sent.set(id, cells)
59 }
60 return true
61}
62
63// The draft decides where the box's top border is, and where the caret is.
64async function track($: EngineInterface, scene: Scene, columns: number, typed: boolean): Promise<void> {
65 const { text, cursor } = await $.prompt.read()
66 const { rows, caret, wide } = promptShape(text, cursor, columns)
67 if (typed) scene.typed(caret)
68 const bash = text.startsWith('!')
69 const now = await read($, promptLayout)
70 if (now.rows !== rows || now.bash !== bash || now.wide !== wide) {
71 await update($, promptLayout, p => ({ ...p, rows, bash, wide }))
72 }
73}
74
75// The month to show: the preview if one is set, else the calendar's.
76async function monthNow($: EngineInterface): Promise<number> {
77 const p = await read($, preview)
78 return p >= 0 ? p : new Date(await $.clock.now()).getMonth()
79}
80
81// "mar", "March", "3": the month index, or -1.
82function parseMonth(arg: string): number {
83 const a = arg.trim().toLowerCase()
84 const n = Number(a)
85 if (Number.isInteger(n) && n >= 1 && n <= 12) return n - 1
86 return a.length >= 3 ? NAMES.findIndex(m => m.startsWith(a)) : -1
87}
88
89type Shown = { month: number }
90
91// Follows the calendar or the preview; a new month swaps the theme and redraws.
92async function follow($: EngineInterface, scene: Scene, shown: Shown, sent: Map<string, string>): Promise<void> {
93 const m = await monthNow($)
94 if (m === shown.month) return
95 shown.month = m
96 scene.theme = MONTHS[m]
97 sent.clear()
98 $.ui.invalidate('ui.render')
99}
100
101export const register: Register = on => {
102 const scene = new Scene(MONTHS[new Date().getMonth()])
103 let header: Site | null = null
104 let greeting: Site | null = null
105 let prompt: Site | null = null
106 let promptColumns = 0
107 const shown: Shown = { month: -1 }
108 let isOn = true
109 let isBusy = false
110 let version = ''
111 let cwd = ''
112 const sent = new Map<string, string>()
113
114 const live = () => isOn && shown.month >= 0
115
116
117 on('session.start', async ($, e, next) => {
118 // SEASONS_MONTH=mar (or 3) starts the session on that month, as /seasons mar would
119 const forced = parseMonth((await $.env.get('SEASONS_MONTH')) ?? '')
120 if (forced >= 0) await update($, preview, () => forced)
121 await follow($, scene, shown, sent)
122 const saved = await $.store.get('enabled')
123 if (saved === false) await update($, enabled, () => false)
124 isOn = await read($, enabled)
125 version = (await $.session.version()).version
126 cwd = e.cwd
127 const settings = (await $.settings.read()) as Record<string, unknown>
128 const status = settings.statusLine ? 1 : 0
129 await update($, promptLayout, p => ({ ...p, status }))
130 await $.command.register({
131 name: 'seasons',
132 description: 'Turn the decorations off or on, or preview a month: /seasons mar, /seasons auto',
133 })
134
135 let wasGreeting = true
136 $.clock.every(Math.round(1000 / FPS), () => {
137 scene.step(1 / FPS, promptColumns)
138 if (wasGreeting && scene.greet === null) {
139 wasGreeting = false
140 greeting = null
141 $.ui.invalidate('ui.render') // the thread is home: drop its Raster
142 }
143 if (isBusy || !live()) return
144 isBusy = true
145 void (async () => {
146 for (const which of ['header', 'greeting', 'prompt'] as const) {
147 const site = which === 'header' ? header : which === 'greeting' ? greeting : prompt
148 if (!site) continue
149 const ok = await blit($, site, sent).catch(() => false)
150 if (ok) continue
151 // not mounted any more (hidden, a resize): wait for the next draw
152 if (which === 'header') header = null
153 else if (which === 'greeting') greeting = null
154 else prompt = null
155 }
156 })().finally(() => { isBusy = false })
157 })
158 // the month can turn over in a long session
159 $.clock.every(60_000, () => void follow($, scene, shown, sent))
160
161 return next(e)
162 })
163
164 on('command.run', { command: 'seasons' }, async ($, e) => {
165 const arg = e.args.trim().toLowerCase()
166 if (arg === '' || arg === 'on' || arg === 'off') {
167 const value = arg === '' ? !(await read($, enabled)) : arg === 'on'
168 await update($, enabled, () => value)
169 await $.store.set('enabled', value)
170 isOn = value
171 sent.clear()
172 $.ui.invalidate('ui.render')
173 return { text: value ? 'Decorations are up.' : 'Decorations are packed away. /seasons brings them back.' }
174 }
175 if (arg === 'auto') {
176 await update($, preview, () => -1)
177 await follow($, scene, shown, sent)
178 return { text: `Back to the calendar: ${NAMES[shown.month]}.` }
179 }
180 const m = parseMonth(arg)
181 if (m < 0) return { text: 'Try /seasons mar, /seasons 12, /seasons auto, or /seasons off.' }
182 await update($, preview, () => m)
183 await follow($, scene, shown, sent)
184 return { text: `Showing ${NAMES[m]} for this session. /seasons auto goes back to the calendar.` }
185 })
186
187 on('turn.start', ($, e, next) => {
188 scene.mood = 'working'
189 return next(e)
190 })
191
192 on('turn.complete', ($, e, next) => {
193 if (scene.mood === 'working') scene.cheer()
194 scene.mood = 'idle'
195 return next(e)
196 })
197
198 on('tool.call', ($, e, next) => {
199 scene.toolCall()
200 return next(e)
201 })
202
203 on('prompt.submit', ($, e, next) => {
204 scene.dismissGreeting()
205 return next(e)
206 })
207
208 on('prompt.edit', async ($, e, next) => {
209 const r = await next(e)
210 if (live() && promptColumns > 0) await track($, scene, promptColumns, true)
211 return r
212 })
213
214 on('prompt.change', async ($, e, next) => {
215 const r = await next(e)
216 if (live() && promptColumns > 0) await track($, scene, promptColumns, false)
217 return r
218 })
219
220 // a message in the transcript (a resumed session, say): no greeting
221 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
222 scene.dismissGreeting()
223 return next(e)
224 })
225
226 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
227 if (e.surface !== 'terminal' || !e.props.text.startsWith(SENTINEL)) return next(e)
228 const { Box, Raster } = $.ui.resolve(e)
229 await follow($, scene, shown, sent)
230 if (!(await read($, enabled))) {
231 header = null
232 greeting = null
233 return <Box />
234 }
235 const W = Math.max(20, Math.min(512, e.viewport?.columns ?? 100))
236 const info = { columns: W, version, cwd }
237 // around the logo's text: the rows above and below it, Clawd, the title
238 // line, and right of the model and path lines
239 const right = CLAWD_COLUMNS + Math.max(MODEL_LINE, cwd.length) + 2
240 const blocks: Block[] = [
241 { key: 'top', x: 0, y: 0, w: W, h: 2 },
242 { key: 'clawd', x: 0, y: 2, w: CLAWD_COLUMNS, h: 2 },
243 ...(right < W - 6 ? [{ key: 'right', x: right, y: 2, w: W - right, h: 2 }] : []),
244 { key: 'yard', x: 0, y: 4, w: W, h: 2 },
245 ]
246 header = { requestId: e.requestId, blocks, canvas: () => scene.header(info) }
247 const cv = scene.header(info)
248 const place = (b: Block, top: number, left: number) => (
249 <Box key={`at-${b.key}`} position="absolute" top={top} left={left}>
250 <Raster key={b.key} columns={b.w} rows={b.h} cells={cv.pack(b.x, b.y, b.w, b.h)} />
251 </Box>
252 )
253 for (const b of blocks) sent.set(`${e.requestId}/${b.key}`, '')
254
255 let thread = null
256 if (scene.greet !== null) {
257 const rows = Math.max(3, Math.min(12, Math.floor((e.viewport?.rows ?? 40) * 0.3)))
258 const g: Block = { key: 'greet', x: 0, y: 0, w: 1, h: rows }
259 greeting = { requestId: e.requestId, blocks: [g], canvas: () => scene.greeting(rows) }
260 sent.set(`${e.requestId}/greet`, '')
261 thread = (
262 <Box key="at-greet" position="absolute" top={1} left={W - 9 - GUTTER}>
263 <Raster key="greet" columns={1} rows={rows} cells={scene.greeting(rows).pack(0, 0, 1, rows)} />
264 </Box>
265 )
266 }
267 const notice = HEADER_ROWS - 1 // the notice row is the header's last
268 return (
269 <Box height={1}>
270 {blocks.map(b => place(b, b.y - notice, b.x - GUTTER))}
271 {thread}
272 </Box>
273 )
274 })
275
276 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
277 await follow($, scene, shown, sent)
278 isOn = await read($, enabled)
279 if (e.surface !== 'terminal' || !live()) {
280 prompt = null
281 return next(e)
282 }
283 const { Box, Text, Raster } = $.ui.resolve(e)
284 const engine = await next(e)
285 const W = Math.max(20, Math.min(512, e.viewport?.columns ?? 100))
286 promptColumns = W
287 const { rows, bash, status, wide } = await read($, promptLayout)
288 // canvas rows from the row over the box to the hint line; as offsets from
289 // the hint line's box the hint row is -1, the status line above it, then
290 // the bottom border, the draft, the top border
291 const hintRow = 3 + rows + status
292 const at = (y: number) => y - hintRow - 1
293 const topRow = 1
294 const bottomRow = 2 + rows
295 const paint = !bash
296 const blocks: Block[] = paint
297 ? [
298 { key: 'top', x: 0, y: topRow, w: W, h: 1 },
299 { key: 'bottom', x: 0, y: bottomRow, w: W, h: 1 },
300 ]
301 : []
302 const draw = () => scene.prompt(W, paint, rows, status)
303 prompt = paint ? { requestId: e.requestId, blocks, canvas: draw } : null
304 for (const b of blocks) sent.set(`${e.requestId}/${b.key}`, '')
305 const cv = draw()
306
307 const runs = (list: Run[], y: number) =>
308 list.map(r => (
309 <Box key={`r${y}-${r.x}`} position="absolute" top={at(y)} left={r.x - GUTTER}>
310 <Text color={hex(r.color)}>{r.text}</Text>
311 </Box>
312 ))
313 // the web's rows other than the borders, in its own columns: the draft's
314 // only while the draft stays clear of it; never the row over the box,
315 // where the engine's effort label sits at the right
316 const zone = W - WEB_COLUMNS - 2
317 const strands = []
318 for (let y = topRow + 1; y <= hintRow; y++) {
319 if (y === bottomRow || (y < bottomRow && wide)) continue
320 strands.push(...runs(cv.runs(y, zone, W), y))
321 }
322 return (
323 <Box>
324 {engine}
325 {paint
326 ? [
327 <Box key="at-top" position="absolute" top={at(topRow)} left={-GUTTER}>
328 <Raster key="top" columns={W} rows={1} cells={cv.pack(0, topRow, W, 1)} />
329 </Box>,
330 <Box key="at-bottom" position="absolute" top={at(bottomRow)} left={-GUTTER}>
331 <Raster key="bottom" columns={W} rows={1} cells={cv.pack(0, bottomRow, W, 1)} />
332 </Box>,
333 ]
334 : [...runs(cv.runs(topRow), topRow), ...runs(cv.runs(bottomRow), bottomRow)]}
335 {strands}
336 </Box>
337 )
338 })
339
340 on('ui.render', { component: 'Spinner' }, ($, e, next) =>
341 live() && e.props.message === null
342 ? next({ ...e, props: { ...e.props, word: scene.verb(e.props.word) } })
343 : next(e),
344 )
345
346 on('ui.render', { component: 'TurnDuration' }, ($, e, next) =>
347 live() ? next({ ...e, props: { ...e.props, word: scene.past(e.props.word) } }) : next(e),
348 )
349}
350hooks/canvas.ts 355 lines1// A grid of terminal cells that mixes three kinds of drawing: glyphs (text),
2// half-block pixels (two stacked pixels a cell, U+2580/U+2584/U+2588) and braille
3// dots (2x4 dots a cell, U+2800..). The last kind drawn into a cell wins; pixels
4// merge with pixels and dots with dots. Cells nothing drew stay blank, so a slice
5// can be packed as a Raster (blank = a space on the terminal's own background) or
6// read back as sparse runs that leave whatever is underneath alone.
7
8export const DEFAULT = 0x01000000 // the terminal's own color, as a Raster takes it
9
10const BLANK = 0
11const GLYPH = 1
12const PIXEL = 2
13const DOTS = 3
14
15const DOT_BITS = [
16 [0x01, 0x08],
17 [0x02, 0x10],
18 [0x04, 0x20],
19 [0x40, 0x80],
20]
21
22export function mix(a: number, b: number, t: number): number {
23 const k = t < 0 ? 0 : t > 1 ? 1 : t
24 const r = ((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * k
25 const g = ((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * k
26 const bl = (a & 255) + ((b & 255) - (a & 255)) * k
27 return (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(bl)
28}
29
30const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
31
32export function base64(bytes: Uint8Array): string {
33 const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
34 if (typeof native === 'function') return native.call(bytes)
35 let out = ''
36 let i = 0
37 for (; i + 2 < bytes.length; i += 3) {
38 const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
39 out += B64[n >> 18] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63]
40 }
41 const left = bytes.length - i
42 if (left === 1) {
43 const n = bytes[i] << 16
44 out += B64[n >> 18] + B64[(n >> 12) & 63] + '=='
45 } else if (left === 2) {
46 const n = (bytes[i] << 16) | (bytes[i + 1] << 8)
47 out += B64[n >> 18] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + '='
48 }
49 return out
50}
51
52export type Run = { x: number; y: number; text: string; color: number }
53
54export class Canvas {
55 readonly kind: Uint8Array
56 readonly code: Uint32Array
57 readonly fg: Uint32Array
58 readonly bg: Uint32Array
59 private readonly top: Int32Array
60 private readonly bottom: Int32Array
61
62 constructor(readonly W: number, readonly H: number) {
63 const n = W * H
64 this.kind = new Uint8Array(n)
65 this.code = new Uint32Array(n)
66 this.fg = new Uint32Array(n)
67 this.bg = new Uint32Array(n)
68 this.top = new Int32Array(n).fill(-1)
69 this.bottom = new Int32Array(n).fill(-1)
70 }
71
72 private at(x: number, y: number): number {
73 return x < 0 || y < 0 || x >= this.W || y >= this.H ? -1 : y * this.W + x
74 }
75
76 /** Text, one code point a cell; a space in it paints a blank cell. */
77 text(x: number, y: number, s: string, color: number): void {
78 let i = 0
79 for (const ch of s) {
80 const c = this.at(x + i++, y)
81 if (c < 0) continue
82 this.kind[c] = GLYPH
83 this.code[c] = ch.codePointAt(0) ?? 0x20
84 this.fg[c] = color
85 this.bg[c] = DEFAULT
86 }
87 }
88
89 /** One half-block pixel; `py` counts two pixels a row. */
90 px(x: number, py: number, color: number): void {
91 const c = this.at(x, Math.floor(py / 2))
92 if (c < 0 || py < 0) return
93 if (this.kind[c] !== PIXEL) {
94 this.kind[c] = PIXEL
95 this.top[c] = -1
96 this.bottom[c] = -1
97 }
98 if (py % 2 === 0) this.top[c] = color
99 else this.bottom[c] = color
100 const t = this.top[c]
101 const b = this.bottom[c]
102 if (t >= 0 && b >= 0) {
103 this.code[c] = t === b ? 0x2588 : 0x2580
104 this.fg[c] = t
105 this.bg[c] = t === b ? DEFAULT : b
106 } else {
107 this.code[c] = t >= 0 ? 0x2580 : 0x2584
108 this.fg[c] = t >= 0 ? t : b
109 this.bg[c] = DEFAULT
110 }
111 }
112
113 /** A sprite: one string a pixel row, '.' transparent, other letters by `colors`. */
114 sprite(x: number, py: number, rows: readonly string[], colors: Record<string, number>): void {
115 rows.forEach((row, j) => {
116 for (let i = 0; i < row.length; i++) {
117 const color = colors[row[i]]
118 if (color !== undefined) this.px(x + i, py + j, color)
119 }
120 })
121 }
122
123 /** One braille dot; `dx` counts two a column, `dy` four a row. */
124 dot(dx: number, dy: number, color: number): void {
125 const x = Math.floor(dx / 2)
126 const y = Math.floor(dy / 4)
127 const c = this.at(x, y)
128 if (c < 0 || dx < 0 || dy < 0) return
129 if (this.kind[c] !== DOTS) {
130 this.kind[c] = DOTS
131 this.code[c] = 0x2800
132 this.bg[c] = DEFAULT
133 }
134 this.code[c] |= DOT_BITS[dy % 4][dx % 2]
135 this.fg[c] = color
136 }
137
138 line(x0: number, y0: number, x1: number, y1: number, color: number): void {
139 const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
140 for (let i = 0; i <= steps; i++) {
141 const t = i / steps
142 this.dot(Math.round(x0 + (x1 - x0) * t), Math.round(y0 + (y1 - y0) * t), color)
143 }
144 }
145
146 /** Packs a block of cells for a Raster; blank cells are spaces. */
147 pack(x0: number, y0: number, w: number, h: number): string {
148 const words = new Uint32Array(w * h * 3)
149 for (let y = 0; y < h; y++) {
150 for (let x = 0; x < w; x++) {
151 const o = (y * w + x) * 3
152 const c = this.at(x0 + x, y0 + y)
153 if (c < 0 || this.kind[c] === BLANK) {
154 words[o] = 0x20
155 words[o + 1] = DEFAULT
156 words[o + 2] = DEFAULT
157 } else {
158 words[o] = this.code[c]
159 words[o + 1] = this.fg[c]
160 words[o + 2] = this.bg[c]
161 }
162 }
163 }
164 return base64(new Uint8Array(words.buffer))
165 }
166
167 /**
168 * The drawn cells of one row as runs of one color, for drawing over rows the
169 * engine owns without blanking what lies between. Pixels lose their second
170 * color here, so keep them out of rows read this way.
171 */
172 runs(y: number, x0 = 0, x1 = this.W): Run[] {
173 const out: Run[] = []
174 let run: Run | null = null
175 for (let x = Math.max(0, x0); x < Math.min(this.W, x1); x++) {
176 const c = this.at(x, y)
177 if (this.kind[c] === BLANK) {
178 run = null
179 continue
180 }
181 const ch = String.fromCodePoint(this.code[c])
182 if (run && run.color === this.fg[c] && run.x + [...run.text].length === x) run.text += ch
183 else out.push((run = { x, y, text: ch, color: this.fg[c] }))
184 }
185 return out
186 }
187}
188
189/**
190 * A corner cobweb in braille: spokes from the anchor fanning across a quarter
191 * turn, and rings of slack thread strung between them. `sx` and `sy` (each
192 * 1 or -1) say which way the web opens from its corner.
193 */
194export function cobweb(
195 cv: Canvas,
196 ax: number,
197 ay: number,
198 rx: number,
199 ry: number,
200 sx: 1 | -1,
201 sy: 1 | -1,
202 color: number,
203 sway = 0,
204 spokes = 4,
205 rings: readonly number[] = [0.45, 1],
206): void {
207 const ends: [number, number][][] = []
208 for (let i = 0; i < spokes; i++) {
209 const a = (i / (spokes - 1)) * (Math.PI / 2)
210 const ex = ax + sx * Math.cos(a) * rx
211 const ey = ay + sy * Math.sin(a) * ry
212 cv.line(ax, ay, ex, ey, color)
213 ends.push(rings.map(k => [ax + sx * Math.cos(a) * rx * k, ay + sy * Math.sin(a) * ry * k] as [number, number]))
214 }
215 // the rings sag a little toward the corner, and sway a hair in the draft
216 for (let ring = 0; ring < rings.length; ring++) {
217 for (let i = 0; i + 1 < spokes; i++) {
218 const [x0, y0] = ends[i][ring]
219 const [x1, y1] = ends[i + 1][ring]
220 const mx = (x0 + x1) / 2 - sx * (0.6 + ring * 0.5) + sway
221 const my = (y0 + y1) / 2 - sy * (0.6 + ring * 0.5)
222 cv.line(x0, y0, mx, my, color)
223 cv.line(mx, my, x1, y1, color)
224 }
225 }
226}
227
228
229/**
230 * Frost creeping out of a corner: spokes like a cobweb's, each feathered with
231 * short side branches the way ice grows on a window.
232 */
233export function frost(cv: Canvas, ax: number, ay: number, rx: number, ry: number, sx: 1 | -1, sy: 1 | -1, color: number): void {
234 const SPOKES = 4
235 for (let i = 0; i < SPOKES; i++) {
236 const a = ((i + 0.5) / SPOKES) * (Math.PI / 2)
237 const dx = sx * Math.cos(a)
238 const dy = sy * Math.sin(a)
239 cv.line(ax, ay, ax + dx * rx, ay + dy * ry, color)
240 for (const k of [0.35, 0.6, 0.85]) {
241 const px = ax + dx * rx * k
242 const py = ay + dy * ry * k
243 const len = 0.22 * (1.1 - k)
244 for (const turn of [0.9, -0.9]) {
245 const b = Math.atan2(dy * ry, dx * rx) + turn
246 cv.line(px, py, px + Math.cos(b) * rx * len, py + Math.sin(b) * ry * len, color)
247 }
248 }
249 }
250}
251
252export type Bloom = { glyph: string; color: number }
253
254/**
255 * A vine climbing out of a corner: one tendril along the top edge and one down
256 * the side, curling as they go, with leaves and flowers (glyphs) along them.
257 */
258export function vine(
259 cv: Canvas,
260 ax: number,
261 ay: number,
262 rx: number,
263 ry: number,
264 sx: 1 | -1,
265 sy: 1 | -1,
266 color: number,
267 blooms: readonly Bloom[],
268): void {
269 const along = (s: number): [number, number] => [ax + sx * rx * s, ay + sy * ry * (0.12 + 0.18 * Math.sin(s * Math.PI * 2.2)) * s * 2]
270 const down = (s: number): [number, number] => [ax + sx * rx * 0.22 * (0.5 + 0.5 * Math.sin(s * Math.PI * 2.6)) * s, ay + sy * ry * s]
271 let n = 0
272 for (const curve of [along, down]) {
273 let [x0, y0] = curve(0)
274 for (let i = 1; i <= 24; i++) {
275 const [x1, y1] = curve(i / 24)
276 cv.line(x0, y0, x1, y1, color)
277 ;[x0, y0] = [x1, y1]
278 }
279 for (const s of [0.3, 0.55, 0.8, 0.98]) {
280 const [x, y] = curve(s)
281 const b = blooms[n++ % blooms.length]
282 cv.text(Math.floor(x / 2), Math.floor(y / 4), b.glyph, b.color)
283 }
284 }
285}
286
287/**
288 * A garland strung from a corner: swags of wire sagging between hooks along
289 * the top edge and down the side, a glyph hung at each swag's low point and
290 * between (fairy lights, flags, hearts). `colorAt` colors the i-th one.
291 */
292export function garland(
293 cv: Canvas,
294 ax: number,
295 ay: number,
296 rx: number,
297 ry: number,
298 sx: 1 | -1,
299 sy: 1 | -1,
300 color: number,
301 glyph: string,
302 colorAt: (i: number) => number,
303): void {
304 let n = 0
305 const hang = (x: number, y: number) => cv.text(Math.floor(x / 2), Math.floor(y / 4), glyph, colorAt(n++))
306 // along the top: two swags
307 for (let k = 0; k < 2; k++) {
308 const x0 = ax + sx * rx * (k / 2)
309 const x1 = ax + sx * rx * ((k + 1) / 2)
310 const sag = Math.min(ry * 0.45, 7)
311 let px = x0
312 let py = ay
313 for (let i = 1; i <= 16; i++) {
314 const s = i / 16
315 const x = x0 + (x1 - x0) * s
316 const y = ay + sy * sag * Math.sin(s * Math.PI)
317 cv.line(px, py, x, y, color)
318 px = x
319 py = y
320 if (i === 5 || i === 11) hang(x, y + sy * 2)
321 }
322 }
323 // down the side: one longer swag curving in
324 let px = ax
325 let py = ay
326 for (let i = 1; i <= 16; i++) {
327 const s = i / 16
328 const x = ax + sx * Math.min(rx * 0.4, 10) * Math.sin(s * Math.PI)
329 const y = ay + sy * ry * s
330 cv.line(px, py, x, y, color)
331 px = x
332 py = y
333 if (i === 6 || i === 12) hang(x + sx * 2, y)
334 }
335}
336
337/** A constellation in a corner: a few stars and the faint lines that join them. */
338export function constellation(cv: Canvas, ax: number, ay: number, rx: number, ry: number, sx: 1 | -1, sy: 1 | -1, color: number, star: number): void {
339 // the Big Dipper, roughly, flipped to open from the corner
340 const pts: [number, number][] = [[0.06, 0.1], [0.28, 0.22], [0.46, 0.3], [0.62, 0.48], [0.6, 0.85], [0.9, 0.92], [0.94, 0.55]]
341 const at = ([u, v]: [number, number]): [number, number] => [ax + sx * rx * u, ay + sy * ry * v]
342 for (let i = 0; i + 1 < pts.length; i++) {
343 const [x0, y0] = at(pts[i])
344 const [x1, y1] = at(pts[i + 1])
345 cv.line(x0, y0, x1, y1, color)
346 }
347 const [qx, qy] = at(pts[3])
348 const [ex, ey] = at(pts[6])
349 cv.line(qx, qy, ex, ey, color)
350 for (const p of pts) {
351 const [x, y] = at(p)
352 cv.text(Math.floor(x / 2), Math.floor(y / 4), '✦', star)
353 }
354}
355hooks/months/index.ts 19 lines1// The twelve months, January first.
2import type { Theme } from '../theme'
3import { january } from './jan'
4import { february } from './feb'
5import { march } from './mar'
6import { april } from './apr'
7import { may } from './may'
8import { june } from './jun'
9import { july } from './jul'
10import { august } from './aug'
11import { september } from './sep'
12import { october } from './oct'
13import { november } from './nov'
14import { december } from './dec'
15
16export const MONTHS: readonly Theme[] = [
17 january, february, march, april, may, june, july, august, september, october, november, december,
18]
19hooks/scene.ts 501 lines1// The engine every month runs on: it places and moves things, and asks the
2// month's Theme what they look like. Drawn into the chrome Claude Code already
3// has: the logo block (Clawd in the month's hat, the title, the sky, a corner
4// decoration), one ground row under it, and the prompt box (a corner decoration
5// at its right end, a critter on the top border, something drifting along the
6// bottom one). Nothing flashes: every light change is a slow fade.
7
8import { Canvas, mix } from './canvas'
9import { paint, type Sprite, type Theme } from './theme'
10import type { Mood } from '../types'
11
12const CLAWD_BODY = ['.XXXXXXX.', '.XXXXXXX.', 'XXXXXXXXX', '.SSSSSSS.']
13const CLAWD_LEGS = '.X.X.X.X.'
14const CLAWD = { clawd: 0xd97757, shade: 0xb45e44, eye: 0x1a0f1e }
15const NIGHT = 0x141018 // what fading lights fade toward
16
17export function seeded(seed: number): number {
18 let t = (seed + 0x6d2b79f5) | 0
19 t = Math.imul(t ^ (t >>> 15), t | 1)
20 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
21 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
22}
23
24function hash(s: string): number {
25 let h = 2166136261
26 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
27 return h >>> 0
28}
29
30/** The header's six rows: the blank row over the logo, its three, the blank under, the notice. */
31export const HEADER_ROWS = 6
32
33/** Columns at the right of the prompt box the corner decoration takes. */
34export const WEB_COLUMNS = 22
35
36export type HeaderInfo = { columns: number; version: string; cwd: string }
37
38type Flight = { x: number; py: number; age: number; vx: number; seed: number }
39
40function sprite(cv: Canvas, x: number, py: number, s: Sprite, t: number): void {
41 const colors: Record<string, number> = {}
42 for (const [k, v] of Object.entries(s.colors)) colors[k] = paint(v, t)
43 cv.sprite(x, py, s.rows, colors)
44}
45
46/** Where the prompt's critter rests: on the top border, over the corner decoration. */
47function critterHome(W: number): number {
48 return W - WEB_COLUMNS / 2
49}
50
51/**
52 * Everything that moves. One clock drives both drawings, so a tool call can
53 * send a flyer across the header and leave a knot on the prompt at once.
54 */
55export class Scene {
56 t = 0
57 mood: Mood = 'idle'
58 /** How far the greeting has come down, 0..1; null once it is home. */
59 greet: number | null = 0
60 private greetDir = 1
61 private hopAt = -99
62 private flights: Flight[] = []
63 private seq = 0
64
65 // prompt
66 private critterX = -1
67 private stride = 0
68 private typedAt = -99
69 private caret = 4
70 private wanderTo: number | null = null
71 private nextWander = 25
72 private patrolDir = 1
73 private knots: number[] = []
74 private drifterX = -1
75 private drifterSeen = 0
76
77 constructor(public theme: Theme) {}
78
79 step(dt: number, promptColumns: number): void {
80 this.t += dt
81 for (const f of this.flights) {
82 f.age += dt
83 f.x += f.vx * dt
84 }
85 this.flights = this.flights.filter(f => f.age < 12)
86
87 if (this.greet !== null) {
88 this.greet = this.greet + this.greetDir * dt * (this.greetDir > 0 ? 0.3 : 1.2)
89 if (this.greet >= 1) this.greet = 1
90 if (this.greet <= 0 && this.greetDir < 0) this.greet = null
91 }
92
93 const W = promptColumns
94 if (W > 0) {
95 if (this.critterX < 0) this.critterX = critterHome(W)
96 this.walk(dt, W)
97 }
98 }
99
100 private walk(dt: number, W: number): void {
101 const lo = 3
102 const hi = W - 6
103 let target: number
104 const chasing = this.t - this.typedAt < 4
105 if (chasing) {
106 target = this.caret
107 } else if (this.mood === 'working') {
108 target = this.patrolDir > 0 ? hi : lo + 5
109 if (Math.abs(this.critterX - target) < 0.5) this.patrolDir = -this.patrolDir
110 } else {
111 if (this.wanderTo === null && this.t > this.nextWander) {
112 this.wanderTo = lo + seeded(Math.floor(this.t)) * (hi - lo - WEB_COLUMNS)
113 }
114 target = this.wanderTo ?? critterHome(W)
115 if (this.wanderTo !== null && Math.abs(this.critterX - this.wanderTo) < 0.5) {
116 this.wanderTo = null
117 this.nextWander = this.t + 20 + seeded(Math.floor(this.t) + 3) * 20
118 }
119 }
120 target = Math.max(lo, Math.min(hi, target))
121 // quick to the caret, an easy patrol, a slow wander; scaled by the critter
122 const speed = (chasing ? 45 : this.mood === 'working' ? 9 : 6) * this.theme.critter.speed
123 const before = this.critterX
124 const d = target - this.critterX
125 this.critterX += Math.sign(d) * Math.min(Math.abs(d), speed * dt)
126 this.stride += Math.abs(this.critterX - before)
127 // at rest the critter tidies up what it left
128 if (this.mood === 'idle') {
129 const x = Math.round(this.critterX)
130 this.knots = this.knots.filter(k => k !== x)
131 }
132
133 if (this.mood === 'working') {
134 this.drifterSeen = Math.min(1, this.drifterSeen + dt / 1.5)
135 if (this.drifterX < 0) this.drifterX = W - 10
136 this.drifterX -= dt * 2.2
137 if (this.drifterX < 8) this.drifterX = W - 10
138 } else {
139 this.drifterSeen = Math.max(0, this.drifterSeen - dt / 2.5)
140 if (this.drifterSeen === 0) this.drifterX = -1
141 }
142 }
143
144 /** A turn finished: Clawd hops. */
145 cheer(): void {
146 this.hopAt = this.t
147 }
148
149 /** A tool call: a flyer leaves Clawd's hat and the critter leaves a knot. */
150 toolCall(): void {
151 const flyer = this.theme.flyer
152 const flock = flyer.kind === 'sprite' ? flyer.flock ?? 1 : 1
153 for (let k = 0; k < flock; k++) {
154 if (this.flights.length >= 8) this.flights.shift()
155 const s = ++this.seq
156 const vx = flyer.kind === 'firework' ? 0 : flyer.kind === 'shooting' ? 28 : 9 + seeded(s * 37) * 7
157 const x = flyer.kind === 'firework' ? 24 + seeded(s * 13) * 40 : flyer.kind === 'shooting' ? 30 + seeded(s * 17) * 30 : 6 - k * 6
158 this.flights.push({ x, py: 0, age: -k * 0.12, vx: flock > 1 ? 11 : vx, seed: s })
159 }
160 const x = Math.round(this.critterX)
161 if (!this.knots.includes(x) && this.knots.length < 24) this.knots.push(x)
162 }
163
164 /** The person typed; the critter comes over to see. `column` is the caret's. */
165 typed(column: number): void {
166 this.typedAt = this.t
167 this.caret = column
168 }
169
170 /** The first prompt went in: the greeting goes home. */
171 dismissGreeting(): void {
172 if (this.greet !== null) this.greetDir = -1
173 }
174
175 // ------------------------------------------------------------------ header
176
177 /** The header's cells, HEADER_ROWS tall and `columns` wide. */
178 header({ columns: W, version, cwd }: HeaderInfo): Canvas {
179 const th = this.theme
180 const cv = new Canvas(W, HEADER_ROWS)
181 const t = this.t
182
183 this.ambient(cv, W)
184 this.yard(cv, W, cwd)
185
186 // Clawd in the month's hat, where the logo's Clawd sits
187 const since = t - this.hopAt
188 const hop = since < 0.9 ? Math.round(Math.abs(Math.sin((since / 0.9) * Math.PI * 2)) * 1.4) : 0
189 const y0 = 0 - hop
190 sprite(cv, 0, y0 + 5 - th.hat.rows.length, th.hat, t)
191 cv.sprite(0, y0 + 5, CLAWD_BODY, { X: CLAWD.clawd, S: CLAWD.shade })
192 const step = this.mood === 'working' && Math.floor(t * 3) % 2 === 1
193 cv.sprite(0, y0 + 9, [step ? '..X.X.X.X' : CLAWD_LEGS], { X: CLAWD.clawd })
194 const blink = t % 6.3 < 0.16
195 const look = this.mood === 'working' ? Math.round(Math.sin(t * 1.3)) : t % 14 < 9 ? 1 : 0
196 if (!blink) {
197 cv.px(2 + look, y0 + 6, CLAWD.eye)
198 cv.px(6 + look, y0 + 6, CLAWD.eye)
199 }
200 if (th.face) sprite(cv, 0, y0 + 6, th.face, t)
201 if (this.mood === 'working') {
202 cv.px(9, y0 + 7, CLAWD.clawd)
203 sprite(cv, 10, y0 + 6, th.prop, t)
204 }
205
206 // the title line, in the month's colors, and a tagline now and then
207 const title = 'Claude Code'
208 const ver = ` v${version}`
209 cv.text(11, 1, title, th.colors.title)
210 cv.text(11 + title.length, 1, ver, th.colors.version)
211 const tags = th.words.taglines
212 const tag = tags[Math.floor(t / 40) % tags.length]
213 const tagX = 11 + title.length + ver.length + 3
214 const room = skyX(W) - 5 - tagX
215 if (room >= tag.length + 4) cv.text(tagX, 1, `~ ${tag} ~`, th.colors.tagline)
216
217 // the corner decoration, then the sky in front of it, and what hangs from it
218 th.corner({ cv, ax: W * 2 - 1, ay: 0, rx: 30, ry: 19, t })
219 if (W >= 70) th.sky(cv, skyX(W) + 4, t)
220
221 this.flyers(cv)
222
223 if (th.dangle) {
224 const sx = W - 9
225 const rest = 11 + Math.sin(t * 1.1) * 1.2
226 const low = this.mood === 'working' ? 17 + Math.sin(t * 0.8) * 1.5 : rest
227 const greeting = this.greet !== null && th.greet.thread !== null
228 const sy = greeting ? HEADER_ROWS * 4 : Math.round(low)
229 for (let dy = 1; dy < sy; dy++) cv.dot(sx * 2, dy, th.colors.cornerDim)
230 if (!greeting) cv.text(sx, Math.min(HEADER_ROWS - 1, Math.floor(sy / 4)), th.dangle.glyph, th.dangle.color)
231 }
232 return cv
233 }
234
235 private ambient(cv: Canvas, W: number): void {
236 const a = this.theme.ambient
237 if (!a) return
238 const t = this.t
239 const H = HEADER_ROWS * 2
240 for (let i = 0; i < a.count; i++) {
241 const r1 = seeded(i * 7 + 1)
242 const r2 = seeded(i * 13 + 5)
243 const color = a.colors[i % a.colors.length]
244 switch (a.kind) {
245 case 'snow':
246 case 'petals':
247 case 'leaves': {
248 const fall = a.kind === 'snow' ? 1.6 : a.kind === 'petals' ? 1.1 : 0.8
249 const sway = a.kind === 'snow' ? 1.5 : 3
250 const py = (r2 * H + t * fall * (0.7 + r1 * 0.6)) % H
251 const x = Math.round(r1 * W + Math.sin(t * 0.6 + i) * sway)
252 // snow is fine dots; petals and leaves a pixel each
253 if (a.kind === 'snow') cv.dot(((x % W) + W) % W * 2 + (i % 2), Math.floor(py * 2), color)
254 else cv.px(((x % W) + W) % W, Math.floor(py), color)
255 break
256 }
257 case 'rain': {
258 const dy = (r2 * H * 2 + t * 22 * (0.8 + r1 * 0.4)) % (H * 2)
259 const dx = Math.round(r1 * W * 2)
260 cv.dot(dx, Math.floor(dy), color)
261 cv.dot(dx, Math.floor(dy) + 1, color)
262 break
263 }
264 case 'hearts': {
265 const y = HEADER_ROWS - 1 - ((r2 * HEADER_ROWS + t * 0.35 * (0.7 + r1 * 0.6)) % HEADER_ROWS)
266 const x = Math.round(r1 * (W - 30) + 20 + Math.sin(t * 0.7 + i) * 1.5)
267 cv.text(x, Math.floor(y), '♥', color)
268 break
269 }
270 case 'fireflies': {
271 const x = Math.round(r1 * W + Math.sin(t * 0.3 + i * 2) * 4)
272 const py = Math.round(4 + r2 * 7 + Math.sin(t * 0.5 + i) * 1.5)
273 const glow = 0.5 + 0.5 * Math.sin(t * 0.9 + i * 1.7)
274 cv.px(((x % W) + W) % W, py, mix(NIGHT, color, 0.25 + 0.75 * glow))
275 break
276 }
277 case 'stars': {
278 const x = Math.round(r1 * W)
279 const py = Math.floor(r2 * 4)
280 const glow = 0.5 + 0.5 * Math.sin(t * 0.5 + i * 2.3)
281 cv.px(x, py, mix(NIGHT, color, 0.3 + 0.7 * glow))
282 break
283 }
284 }
285 }
286 }
287
288 private flyers(cv: Canvas): void {
289 const f = this.theme.flyer
290 for (const b of this.flights) {
291 if (b.age < 0) continue
292 if (f.kind === 'sprite') {
293 const frame = f.frames[Math.floor(b.age * 7) % f.frames.length]
294 cv.sprite(Math.round(b.x), 0, frame, { X: f.color })
295 } else if (f.kind === 'shooting') {
296 // a streak down and across, its tail fading
297 const x = b.x + b.age * 28
298 const py = b.age * 5
299 for (let k = 0; k < 6; k++) {
300 const c = mix(f.color, NIGHT, k / 6)
301 cv.px(Math.round(x - k * 2), Math.round(py - k * 0.4), c)
302 }
303 } else {
304 // a rocket climbs from the ground, then opens and fades slowly
305 const color = f.colors[b.seed % f.colors.length]
306 const rise = 0.8
307 if (b.age < rise) {
308 cv.px(Math.round(b.x), Math.round(11 - (b.age / rise) * 8), mix(color, 0xffffff, 0.5))
309 } else {
310 const k = Math.min(1, (b.age - rise) / 2.4)
311 const r = 1 + k * 3.2
312 for (let i = 0; i < 12; i++) {
313 const a = (i / 12) * Math.PI * 2
314 cv.px(Math.round(b.x + Math.cos(a) * r * 1.6), Math.round(3 + Math.sin(a) * r), mix(color, NIGHT, k))
315 }
316 }
317 }
318 }
319 }
320
321 private yard(cv: Canvas, W: number, cwd: string): void {
322 const th = this.theme
323 const t = this.t
324 const g = HEADER_ROWS * 2 - 1 // the bottom pixel row
325 for (let x = 0; x < W; x++) if (seeded(x * 13) > 0.82) cv.px(x, g, th.colors.ground)
326 th.yard.feet.forEach((s, i) => sprite(cv, 11 + i * 4, g - s.rows.length + 1, s, t + i * 2.1))
327 // props scattered right of the path line
328 const from = Math.min(W - 10, 11 + cwd.length + 4)
329 let x = from
330 let i = 0
331 while (x < W - 26) {
332 const s = th.yard.props[Math.floor(seeded(i * 7 + 1) * th.yard.props.length)]
333 sprite(cv, x, g - s.rows.length + 1, s, t + i)
334 x += 7 + Math.floor(seeded(i * 11 + 5) * 14)
335 i++
336 }
337 // the one that lives here wanders up and down the yard
338 const span = Math.max(1, W - 32 - 20)
339 const gx = 20 + Math.round(span / 2 + (Math.sin(t * 0.11) * span) / 2)
340 const frames = th.yard.wanderer
341 const s = frames[Math.floor(t * 2.5) % frames.length]
342 sprite(cv, gx, g - s.rows.length + 1, s, t)
343 }
344
345 /** The greeting under the header, `rows` tall: its thread (if any) and, at its end, itself. */
346 greeting(rows: number): Canvas {
347 const cv = new Canvas(1, rows)
348 const g = this.theme.greet
349 if (this.greet === null) return cv
350 const eased = 1 - Math.pow(1 - Math.max(0, this.greet), 3)
351 const len = Math.round(eased * (rows - 1))
352 if (g.thread) for (let y = 0; y < len; y++) cv.text(0, y, g.thread, this.theme.colors.cornerDim)
353 if (this.greet > 0) cv.text(0, len, g.glyph, g.color)
354 return cv
355 }
356
357 // ------------------------------------------------------------------ prompt
358
359 /**
360 * The prompt box's decorations, one canvas row per screen row from the row
361 * over the box down to the hint line: 0 over the box, 1 its top border, then
362 * `rows` of draft, its bottom border, `status` rows of status line, the hint.
363 * The decoration hangs at the right, away from the draft and the newest text.
364 */
365 prompt(W: number, paintBorder: boolean, rows: number, status: number): Canvas {
366 const th = this.theme
367 const cv = new Canvas(W, 4 + rows + status)
368 const t = this.t
369 const top = 1
370 const bottom = 2 + rows
371 if (paintBorder) {
372 for (let x = 0; x < W; x++) {
373 const k = x / Math.max(1, W - 1)
374 cv.text(x, top, '─', mix(th.colors.borderA, th.colors.borderB, k))
375 cv.text(x, bottom, '─', mix(th.colors.borderB, th.colors.borderA, k))
376 }
377 }
378 const reach = (rows + status + 2) * 4 - 1
379 th.corner({ cv, ax: W * 2 - 1, ay: top * 4 + 1, rx: WEB_COLUMNS * 2 - 4, ry: Math.min(reach, 24), t })
380
381 for (const k of this.knots) cv.text(k, top, th.knot, th.colors.knot)
382
383 const sx = Math.round(this.critterX < 0 ? critterHome(W) : this.critterX)
384 const moving = Math.abs(this.stride % 2) < 1
385 const [walk, still] = th.critter.glyphs
386 cv.text(sx, top, moving ? walk : still, th.colors.critter)
387
388 if (this.drifterSeen > 0 && this.drifterX >= 0) {
389 const color = mix(th.colors.borderB, th.colors.drifter, this.drifterSeen)
390 cv.text(Math.round(this.drifterX), bottom, th.drifter, color)
391 }
392 return cv
393 }
394
395 /** The spinner's word for this turn, picked by the engine's own so it holds still. */
396 verb(word: string): string {
397 const v = this.theme.words.verbs
398 return v[hash(word) % v.length]
399 }
400
401 past(word: string): string {
402 const p = this.theme.words.past
403 return p[hash(word) % p.length]
404 }
405}
406
407/** Where the left edge of the sky object falls; the tagline stops short of it. */
408function skyX(W: number): number {
409 return W - 22
410}
411
412/** The rows the prompt box's text takes at `columns`, and the caret's column. */
413export function promptShape(
414 text: string,
415 cursor: number,
416 columns: number,
417): { rows: number; caret: number; wide: boolean } {
418 const width = Math.max(10, columns - 4)
419 let rows = 0
420 let widest = 0
421 for (const line of text.split('\n')) {
422 const n = [...line].length
423 rows += Math.max(1, Math.ceil(n / width))
424 widest = Math.max(widest, Math.min(n, width))
425 }
426 const before = text.slice(0, cursor)
427 const lineStart = before.lastIndexOf('\n') + 1
428 const col = [...before.slice(lineStart)].length % width
429 return { rows: Math.max(1, rows), caret: 2 + col, wide: 2 + widest >= columns - WEB_COLUMNS - 1 }
430}
431
432// ---------------------------------------------------------------- skies
433
434/** A round moon with its shaded side, and a cloud drifting past. */
435export function moon(cv: Canvas, cx: number, t: number, c: { moon: number; shade: number; crater: number; cloud: number }): void {
436 const cy = 4.5
437 const r = 3.6
438 for (let py = 0; py < 10; py++) {
439 for (let x = cx - 5; x <= cx + 5; x++) {
440 const dx = x - cx
441 const dy = py - cy
442 if (dx * dx + dy * dy > r * r) continue
443 cv.px(x, py, dx < -1.8 ? c.shade : (x * 7 + py * 3) % 11 === 0 ? c.crater : c.moon)
444 }
445 }
446 const cloudX = cx - 14 + ((t * 0.6) % 30)
447 for (let i = 0; i < 8; i++) {
448 const x = Math.round(cloudX) + i
449 if (Math.abs(x - cx) <= 5) cv.px(x, i < 2 || i > 5 ? 6 : 5, c.cloud)
450 }
451}
452
453/** A sun with short rays turning slowly round it. */
454export function sun(cv: Canvas, cx: number, t: number, c: { sun: number; core: number; ray: number }): void {
455 const cy = 4.5
456 for (let py = 0; py < 10; py++) {
457 for (let x = cx - 4; x <= cx + 4; x++) {
458 const d = Math.hypot(x - cx, py - cy)
459 if (d <= 3) cv.px(x, py, d < 1.6 ? c.core : c.sun)
460 }
461 }
462 for (let i = 0; i < 8; i++) {
463 const a = (i / 8) * Math.PI * 2 + t * 0.15
464 cv.px(Math.round(cx + Math.cos(a) * 5.2), Math.round(cy + Math.sin(a) * 4.6), c.ray)
465 }
466}
467
468/** A rainbow arcing over the sky spot, with a pot of gold where it lands. */
469export function rainbow(cv: Canvas, cx: number, t: number, bands: readonly number[], gold: number): void {
470 bands.forEach((color, i) => {
471 const r = 7 - i
472 for (let a = 0; a <= Math.PI; a += 0.04) {
473 cv.px(Math.round(cx + Math.cos(a) * r * 1.4), Math.round(10 - Math.sin(a) * r * 1.2), color)
474 }
475 })
476 const shine = mix(gold, 0xfff6c0, 0.5 + 0.5 * Math.sin(t * 1.2))
477 cv.sprite(cx + 8, 8, ['.Y.', 'KKK', 'KKK'], { Y: shine, K: 0x2a2a2a })
478}
479
480/** A big pixel heart that swells and settles, slowly. */
481export function heart(cv: Canvas, cx: number, t: number, c: { heart: number; shine: number }): void {
482 const rows = ['.HH...HH.', 'HHHH.HHHH', 'HSHHHHHHH', 'HHHHHHHHH', '.HHHHHHH.', '..HHHHH..', '...HHH...', '....H....']
483 const beat = 0.5 + 0.5 * Math.sin(t * 1.1)
484 cv.sprite(cx - 4, 1, rows, { H: mix(c.heart, 0xff9cc0, beat * 0.35), S: c.shine })
485}
486
487/** A rain cloud, a little sun peeking from behind it. */
488export function cloudy(cv: Canvas, cx: number, t: number, c: { cloud: number; under: number; sun: number }): void {
489 for (let py = 1; py < 6; py++) {
490 for (let x = cx - 1; x <= cx + 5; x++) if (Math.hypot(x - cx - 3, (py - 3) * 1.4) < 3.2) cv.px(x, py, c.sun)
491 }
492 const puffs: [number, number, number][] = [[cx - 3, 5, 2.4], [cx, 4, 3], [cx + 3, 5.5, 2.2], [cx - 5, 6.5, 1.6]]
493 for (let py = 1; py < 10; py++) {
494 for (let x = cx - 8; x <= cx + 6; x++) {
495 const inside = puffs.some(([px, pyc, r]) => Math.hypot(x - px, py - pyc) < r)
496 if (inside) cv.px(x, py, py >= 7 ? c.under : c.cloud)
497 }
498 }
499 void t
500}
501hooks/theme.ts 91 lines1// What a month is made of. The engine (scene.ts) does all the moving and the
2// placing; a month (months/*.ts) only says what things look like.
3
4import type { Canvas } from './canvas'
5
6/** A color, or one that changes slowly with time in seconds (a candle, a bulb). */
7export type Paint = number | ((t: number) => number)
8
9/** Pixel art: one string a pixel row, '.' transparent, other letters by `colors`. */
10export type Sprite = { rows: readonly string[]; colors: Record<string, Paint> }
11
12/** Where a corner decoration hangs from and which way it opens, in braille dots. */
13export type Corner = { cv: Canvas; ax: number; ay: number; rx: number; ry: number; t: number }
14
15/** What the header's tool-call flyers are. */
16export type Flyer =
17 | { kind: 'sprite'; frames: readonly (readonly string[])[]; color: number; flock?: number }
18 | { kind: 'firework'; colors: readonly number[] }
19 | { kind: 'shooting'; color: number }
20
21/** What drifts through the header the whole time. */
22export type Ambient =
23 | { kind: 'snow' | 'petals' | 'leaves' | 'rain'; colors: readonly number[]; count: number }
24 | { kind: 'fireflies' | 'stars'; colors: readonly number[]; count: number }
25 | { kind: 'hearts'; colors: readonly number[]; count: number }
26
27export type Words = {
28 /** The spinner's present participles: "Conjuring". */
29 verbs: readonly string[]
30 /** The closing line's past tense: "Brewed" for 12s. */
31 past: readonly string[]
32 /** Shown after the title, one at a time. */
33 taglines: readonly string[]
34}
35
36export type Theme = {
37 /** 0 for January. */
38 month: number
39 /** Three-letter key for /seasons: `jan`. */
40 key: string
41 words: Words
42 colors: {
43 title: number
44 version: number
45 tagline: number
46 /** The prompt box's border runs from `borderA` at its left to `borderB`. */
47 borderA: number
48 borderB: number
49 /** The thin lines of the corner decorations. */
50 corner: number
51 cornerDim: number
52 critter: number
53 drifter: number
54 knot: number
55 ground: number
56 }
57 /** Clawd's hat: up to five pixel rows over the head, nine wide. */
58 hat: Sprite
59 /** Drawn over Clawd's eyes (sunglasses); the eyes still blink beneath. */
60 face?: Sprite
61 /** Held out while Claude works: three pixel rows, at Clawd's right hand. */
62 prop: Sprite
63 /** The thing in the sky at the header's right, drawn around column `cx`. */
64 sky: (cv: Canvas, cx: number, t: number) => void
65 ambient?: Ambient
66 flyer: Flyer
67 /** The ground row: two things at Clawd's feet, props scattered right, one wanderer. */
68 yard: {
69 feet: readonly Sprite[]
70 props: readonly Sprite[]
71 /** One or two frames, four pixel rows tall. */
72 wanderer: readonly Sprite[]
73 }
74 /** Draws the decoration hung in a top right corner (header and prompt box). */
75 corner: (c: Corner) => void
76 /** What hangs on a thread from the header's corner, if anything. */
77 dangle?: { glyph: string; color: number }
78 /** What lives on the prompt box's top border: [moving, still] glyphs. */
79 critter: { glyphs: readonly [string, string]; speed: number }
80 /** Left on the top border at each tool call, tidied up after. */
81 knot: string
82 /** Drifts along the bottom border while Claude works. */
83 drifter: string
84 /** Before the first prompt: comes down into the empty screen, on `thread` or falling free. */
85 greet: { glyph: string; color: number; thread: string | null }
86}
87
88export function paint(p: Paint, t: number): number {
89 return typeof p === 'function' ? p(t) : p
90}
91hooks/months/jan.ts 47 lines1// January: a snowy night. Clawd in a knit beanie with a mug of cocoa, snow
2// falling, snowmen, a penguin, frost in the corners, a snowflake on the border.
3import { frost, mix } from '../canvas'
4import { moon } from '../scene'
5import type { Theme } from '../theme'
6import { WORDS } from '../words'
7
8export const january: Theme = {
9 month: 0,
10 key: 'jan',
11 words: WORDS[0],
12 colors: {
13 title: 0x9fd4ff, version: 0x7d8fb0, tagline: 0xc6dcf2,
14 borderA: 0x5a7fb8, borderB: 0xcfe6ff,
15 corner: 0xa9c8e8, cornerDim: 0x6d86a6,
16 critter: 0xf2f8ff, drifter: 0xe6f2ff, knot: 0xa9c8e8, ground: 0xdfe9f5,
17 },
18 hat: { rows: ['....W....', '...RRR...', '..RRRRR..', '.WRWRWRW.', 'RRRRRRRRR'], colors: { W: 0xf4f4f4, R: 0xc0392b } },
19 prop: {
20 rows: ['S', 'M', 'M'],
21 colors: { S: t => mix(0x141018, 0xb8b8c8, 0.5 + 0.5 * Math.sin(t * 1.2)), M: 0x8b5a3c },
22 },
23 sky: (cv, cx, t) => moon(cv, cx, t, { moon: 0xe8f1ff, shade: 0xb5c6de, crater: 0xcfdcee, cloud: 0x2c3550 }),
24 ambient: { kind: 'snow', colors: [0xf4f8ff, 0xd8e4f4], count: 70 },
25 flyer: { kind: 'sprite', frames: [['.X.X.', 'X.X.X'], ['X.X.X', '.X.X.']], color: 0xe6f2ff },
26 yard: {
27 feet: [
28 { rows: ['.W.', 'WKW', 'WWW'], colors: { W: 0xf4f8ff, K: 0x222222 } },
29 { rows: ['.R.', 'WOW', 'WWW'], colors: { W: 0xf4f8ff, O: 0xff8c2a, R: 0xc0392b } },
30 ],
31 props: [
32 { rows: ['..W..', '.WWW.', 'WWWWW'], colors: { W: 0xe8f0fa } },
33 { rows: ['.K.', '.W.', 'WOW', 'WWW'], colors: { W: 0xf4f8ff, K: 0x222222, O: 0xff8c2a } },
34 { rows: ['.WW.', 'WWWW'], colors: { W: 0xdfe9f5 } },
35 ],
36 wanderer: [
37 { rows: ['.KK.', 'KWWK', 'KWWK', '.YY.'], colors: { K: 0x1c1c28, W: 0xf4f4f4, Y: 0xf2a33a } },
38 { rows: ['.KK.', 'KWWK', 'KWWK', 'Y..Y'], colors: { K: 0x1c1c28, W: 0xf4f4f4, Y: 0xf2a33a } },
39 ],
40 },
41 corner: ({ cv, ax, ay, rx, ry }) => frost(cv, ax, ay, rx, ry, -1, 1, 0xa9c8e8),
42 critter: { glyphs: ['✶', '✻'], speed: 0.8 },
43 knot: '·',
44 drifter: '*',
45 greet: { glyph: '✻', color: 0xf2f8ff, thread: null },
46}
47hooks/months/feb.ts 45 lines1// February: a pink night. Clawd under a floating heart holding a rose, hearts
2// rising, roses, a teddy bear, a ribbon of hearts in the corners.
3import { garland, mix } from '../canvas'
4import { heart } from '../scene'
5import type { Theme } from '../theme'
6import { WORDS } from '../words'
7
8const HEARTS = [0xff5c8a, 0xffb3c8, 0xe0245e]
9
10export const february: Theme = {
11 month: 1,
12 key: 'feb',
13 words: WORDS[1],
14 colors: {
15 title: 0xff7aa2, version: 0xb08398, tagline: 0xffb3c8,
16 borderA: 0xb0306a, borderB: 0xffb3c8,
17 corner: 0xd88aa6, cornerDim: 0x8e5a70,
18 critter: 0xff5c8a, drifter: 0xffc2d4, knot: 0xff8fb0, ground: 0x5a2a40,
19 },
20 hat: { rows: ['..HH.HH..', '.HHHHHHH.', '..HHHHH..', '...HHH...', '....H....'], colors: { H: 0xff5c8a } },
21 prop: { rows: ['R', 'G', 'G'], colors: { R: 0xe0245e, G: 0x3f8f46 } },
22 sky: (cv, cx, t) => heart(cv, cx, t, { heart: 0xe0245e, shine: 0xffd6e2 }),
23 ambient: { kind: 'hearts', colors: [0x8e3a5c, 0xb04a74], count: 7 },
24 flyer: { kind: 'sprite', frames: [['XX.XX', '.XXX.'], ['.X.X.', '.XXX.']], color: 0xff7aa2 },
25 yard: {
26 feet: [
27 { rows: ['H.H', 'HHH', '.H.'], colors: { H: 0xe0245e } },
28 { rows: ['RRR', 'RGR', 'RRR'], colors: { R: 0x7a3b2e, G: 0xffd760 } },
29 ],
30 props: [
31 { rows: ['.R.', 'RRR', '.G.'], colors: { R: 0xe0245e, G: 0x3f8f46 } },
32 { rows: ['.P.', 'PPP', '.G.', '.G.'], colors: { P: 0xff9cc0, G: 0x3f8f46 } },
33 { rows: ['R.R', '.G.'], colors: { R: 0xd0306a, G: 0x3f8f46 } },
34 ],
35 wanderer: [{ rows: ['B..B', 'BBBB', 'BHBB', 'B..B'], colors: { B: 0xa0703f, H: 0xff5c8a } }],
36 },
37 corner: ({ cv, ax, ay, rx, ry, t }) =>
38 garland(cv, ax, ay, rx, ry, -1, 1, 0xd88aa6, '♥', i => mix(HEARTS[i % 3], HEARTS[(i + 1) % 3], 0.5 + 0.5 * Math.sin(t * 0.5 + i))),
39 dangle: { glyph: '♥', color: 0xff5c8a },
40 critter: { glyphs: ['♥', '♡'], speed: 0.9 },
41 knot: '·',
42 drifter: '♡',
43 greet: { glyph: '♥', color: 0xff5c8a, thread: '│' },
44}
45hooks/months/mar.ts 44 lines1// March: a green spring day. Clawd in a green top hat with a gold coin, a
2// rainbow and its pot of gold, clovers, a lamb, a clover vine in the corners.
3import { vine } from '../canvas'
4import { rainbow } from '../scene'
5import type { Theme } from '../theme'
6import { WORDS } from '../words'
7
8export const march: Theme = {
9 month: 2,
10 key: 'mar',
11 words: WORDS[2],
12 colors: {
13 title: 0x4cc46a, version: 0x7aa486, tagline: 0xf2d24a,
14 borderA: 0x2f8f4e, borderB: 0xf2d24a,
15 corner: 0x4f9a5c, cornerDim: 0x3a6e45,
16 critter: 0x5fd47a, drifter: 0xe8eef2, knot: 0xf2c230, ground: 0x2f6b3a,
17 },
18 hat: { rows: ['..GGGGG..', '..GGGGG..', '..YYKYY..', '..GGGGG..', 'GGGGGGGGG'], colors: { G: 0x1f7a3a, Y: 0x111111, K: 0xf2c230 } },
19 prop: { rows: ['.', 'Y', 'Y'], colors: { Y: 0xf2c230 } },
20 sky: (cv, cx, t) => rainbow(cv, cx, t, [0xe74c3c, 0xf39c12, 0xf1c40f, 0x2ecc71, 0x3498db, 0x8e44ad], 0xf2c230),
21 flyer: { kind: 'sprite', frames: [['.X.', 'X.X'], ['X.X', '.X.']], color: 0xf2c230 },
22 yard: {
23 feet: [
24 { rows: ['YYY', 'KKK', '.K.'], colors: { Y: 0xf2c230, K: 0x2a2a2a } },
25 { rows: ['G.G', '.G.', '.g.'], colors: { G: 0x3fbf5a, g: 0x2f6b3a } },
26 ],
27 props: [
28 { rows: ['G.G', '.G.', '.g.'], colors: { G: 0x3fbf5a, g: 0x2f6b3a } },
29 { rows: ['GG.GG', '.GGG.', '..g..'], colors: { G: 0x2fa84f, g: 0x2f6b3a } },
30 { rows: ['.Y.', 'G.G'], colors: { Y: 0xf7e26b, G: 0x3fbf5a } },
31 ],
32 wanderer: [
33 { rows: ['.WWW', 'WWWW', 'WWWW', 'K..K'], colors: { W: 0xf2efe6, K: 0x2a2a2a } },
34 { rows: ['.WWW', 'WWWW', 'WWWW', '.KK.'], colors: { W: 0xf2efe6, K: 0x2a2a2a } },
35 ],
36 },
37 corner: ({ cv, ax, ay, rx, ry }) =>
38 vine(cv, ax, ay, rx, ry, -1, 1, 0x3a8a4a, [{ glyph: '☘', color: 0x4cc46a }, { glyph: '·', color: 0x7ad48a }]),
39 critter: { glyphs: ['☘', '♣'], speed: 0.9 },
40 knot: '•',
41 drifter: '☁',
42 greet: { glyph: '◆', color: 0xe74c3c, thread: '│' },
43}
44hooks/months/apr.ts 45 lines1// April: showers. Clawd in bunny ears under an umbrella, rain, a cloud with
2// the sun peeking out, tulips, painted eggs, a bunny, a flowering vine.
3import { vine } from '../canvas'
4import { cloudy } from '../scene'
5import type { Theme } from '../theme'
6import { WORDS } from '../words'
7
8export const april: Theme = {
9 month: 3,
10 key: 'apr',
11 words: WORDS[3],
12 colors: {
13 title: 0xb59cff, version: 0x8a8fb0, tagline: 0x9fe0c8,
14 borderA: 0x7f9cf5, borderB: 0xf5b8e0,
15 corner: 0x6fae7a, cornerDim: 0x4c7a56,
16 critter: 0xffc0e8, drifter: 0x8fb8ff, knot: 0xfff0a0, ground: 0x3f7a4a,
17 },
18 hat: { rows: ['.WW...WW.', '.WP...PW.', '.WP...PW.', '.WW...WW.', '..W...W..'], colors: { W: 0xf4f4f4, P: 0xffb3d0 } },
19 prop: { rows: ['U', 'K', 'K'], colors: { U: 0x5b8def, K: 0x444444 } },
20 sky: (cv, cx, t) => cloudy(cv, cx, t, { cloud: 0x8a92a8, under: 0x6a7288, sun: 0xffd760 }),
21 ambient: { kind: 'rain', colors: [0x6f8fd8, 0x8fb0f0], count: 40 },
22 flyer: { kind: 'sprite', frames: [['X.X', 'XXX'], ['.X.', 'XXX']], color: 0xffa8e0 },
23 yard: {
24 feet: [
25 { rows: ['.P.', 'PYP', '.P.'], colors: { P: 0xb59cff, Y: 0xfff0a0 } },
26 { rows: ['.B.', 'BWB', '.B.'], colors: { B: 0x7fd4f5, W: 0xffc0e8 } },
27 ],
28 props: [
29 { rows: ['R.R', 'RRR', '.G.', '.G.'], colors: { R: 0xff6a8a, G: 0x3f8f46 } },
30 { rows: ['Y.Y', 'YYY', '.G.'], colors: { Y: 0xffd34a, G: 0x3f8f46 } },
31 { rows: ['P.P', 'PPP', '.G.'], colors: { P: 0xc08aff, G: 0x3f8f46 } },
32 ],
33 wanderer: [
34 { rows: ['W.W.', 'W.W.', 'WWWW', 'WW.W'], colors: { W: 0xf0eee8 } },
35 { rows: ['.W.W', '.W.W', 'WWWW', 'W.WW'], colors: { W: 0xf0eee8 } },
36 ],
37 },
38 corner: ({ cv, ax, ay, rx, ry }) =>
39 vine(cv, ax, ay, rx, ry, -1, 1, 0x4f9a5c, [{ glyph: '✿', color: 0xffb3d0 }, { glyph: '❀', color: 0xfff0a0 }]),
40 critter: { glyphs: ['ʚ', 'ɞ'], speed: 1 },
41 knot: 'o',
42 drifter: '☂',
43 greet: { glyph: 'ʚ', color: 0xffc0e8, thread: null },
44}
45hooks/months/may.ts 46 lines1// May: a garden in bloom. Clawd in a sun hat with a watering can, the sun,
2// petals drifting, flowers, a duckling, a snail on the border that never
3// quite catches up.
4import { vine } from '../canvas'
5import { sun } from '../scene'
6import type { Theme } from '../theme'
7import { WORDS } from '../words'
8
9export const may: Theme = {
10 month: 4,
11 key: 'may',
12 words: WORDS[4],
13 colors: {
14 title: 0xffb347, version: 0x9a9a7a, tagline: 0xf7a8c8,
15 borderA: 0x6fbf5a, borderB: 0xffd166,
16 corner: 0x5fa85a, cornerDim: 0x47804a,
17 critter: 0xd9b08c, drifter: 0xffb3d0, knot: 0xbfc8cf, ground: 0x4f8f3f,
18 },
19 hat: { rows: ['...SSS...', '..SSSFS..', '..BBBBB..', 'SSSSSSSSS'], colors: { S: 0xe8c77a, F: 0xff6a8a, B: 0x5fa8d8 } },
20 prop: { rows: ['B', 'C', 'C'], colors: { B: 0x6fb0ff, C: 0x7fa0b0 } },
21 sky: (cv, cx, t) => sun(cv, cx, t, { sun: 0xffd34a, core: 0xfff0a0, ray: 0xffb347 }),
22 ambient: { kind: 'petals', colors: [0xffc0d8, 0xfff0f5, 0xffa8c8], count: 22 },
23 flyer: { kind: 'sprite', frames: [['.W.', 'YKY'], ['W.W', 'YKY']], color: 0xffd34a },
24 yard: {
25 feet: [
26 { rows: ['.CC', 'CCC', 'CC.'], colors: { C: 0x7fa0b0 } },
27 { rows: ['.R.', 'RYR', '.G.'], colors: { R: 0xff6a8a, Y: 0xffe066, G: 0x3f8f46 } },
28 ],
29 props: [
30 { rows: ['.R.', 'RYR', '.G.', '.G.'], colors: { R: 0xff6a8a, Y: 0xffe066, G: 0x3f8f46 } },
31 { rows: ['.P.', 'PYP', '.G.'], colors: { P: 0xb38aff, Y: 0xffe066, G: 0x3f8f46 } },
32 { rows: ['W.W', '.Y.', '.G.'], colors: { W: 0xf4f4f4, Y: 0xffd34a, G: 0x3f8f46 } },
33 ],
34 wanderer: [
35 { rows: ['.YY.', 'YYYO', 'YYY.', '.O..'], colors: { Y: 0xffe066, O: 0xff9a3a } },
36 { rows: ['.YY.', 'YYYO', 'YYY.', '..O.'], colors: { Y: 0xffe066, O: 0xff9a3a } },
37 ],
38 },
39 corner: ({ cv, ax, ay, rx, ry }) =>
40 vine(cv, ax, ay, rx, ry, -1, 1, 0x4f9a4a, [{ glyph: '✿', color: 0xff8fb0 }, { glyph: '❀', color: 0xffe066 }, { glyph: '✿', color: 0xb38aff }]),
41 critter: { glyphs: ['@', '@'], speed: 0.18 },
42 knot: '·',
43 drifter: '✿',
44 greet: { glyph: '✿', color: 0xffb3d0, thread: null },
45}
46hooks/months/jun.ts 46 lines1// June: the beach. Clawd in sunglasses with an ice cream, a big sun, seagulls,
2// sandcastles, a crab, beach bunting in the corners.
3import { garland } from '../canvas'
4import { sun } from '../scene'
5import type { Theme } from '../theme'
6import { WORDS } from '../words'
7
8const FLAGS = [0xff6a5a, 0xffd34a, 0x3fb8e0, 0xf4f4f4]
9
10export const june: Theme = {
11 month: 5,
12 key: 'jun',
13 words: WORDS[5],
14 colors: {
15 title: 0xffc24a, version: 0x8aa8b0, tagline: 0x5fd0e8,
16 borderA: 0x2fa8d8, borderB: 0xf2d29a,
17 corner: 0xc8b48a, cornerDim: 0x8a7a5a,
18 critter: 0xff5a4a, drifter: 0x6fd0f0, knot: 0xf2e6d0, ground: 0xe8cf8f,
19 },
20 hat: { rows: ['..RRRRR..', 'RRRRRRRRR'], colors: { R: 0xff6a5a } },
21 face: { rows: ['.KKKKKKK.'], colors: { K: 0x111111 } },
22 prop: { rows: ['P', 'W', 'C'], colors: { P: 0xffa8c8, W: 0xfff4e0, C: 0xd8a060 } },
23 sky: (cv, cx, t) => sun(cv, cx, t, { sun: 0xffc83a, core: 0xfff3b0, ray: 0xff9a3a }),
24 flyer: { kind: 'sprite', frames: [['X...X', '.X.X.'], ['.....', 'XX.XX']], color: 0xf4f4f4 },
25 yard: {
26 feet: [
27 { rows: ['R..', 'BB.', 'BB.'], colors: { R: 0xc87a3a, B: 0x3fb8e0 } },
28 { rows: ['..S', '.S.', 'S..'], colors: { S: 0x9a9a9a } },
29 ],
30 props: [
31 { rows: ['T.T.T', 'TTTTT', 'TTKTT'], colors: { T: 0xe8c77a, K: 0x8a6a3a } },
32 { rows: ['.S.', 'SSS', '.S.'], colors: { S: 0xff9a7a } },
33 { rows: ['F..', 'T.T', 'TTT'], colors: { T: 0xe8c77a, F: 0xff6a5a } },
34 ],
35 wanderer: [
36 { rows: ['R..R', '.RR.', 'RRRR', 'R..R'], colors: { R: 0xff5a4a } },
37 { rows: ['R..R', 'RRRR', 'RRRR', '.RR.'], colors: { R: 0xff5a4a } },
38 ],
39 },
40 corner: ({ cv, ax, ay, rx, ry }) => garland(cv, ax, ay, rx, ry, -1, 1, 0xc8b48a, '▼', i => FLAGS[i % FLAGS.length]),
41 critter: { glyphs: ['¤', '¤'], speed: 1.1 },
42 knot: '◦',
43 drifter: '≈',
44 greet: { glyph: '●', color: 0xff6a5a, thread: null },
45}
46hooks/months/jul.ts 49 lines1// July: summer nights. Clawd in a party hat with a sparkler, fireworks that
2// open and fade slowly, fireflies, a picnic, a puppy, bunting in the corners.
3import { garland, mix } from '../canvas'
4import { moon } from '../scene'
5import type { Theme } from '../theme'
6import { WORDS } from '../words'
7
8const FLAGS = [0xe0303a, 0xf4f4f4, 0x3a5ad8]
9
10export const july: Theme = {
11 month: 6,
12 key: 'jul',
13 words: WORDS[6],
14 colors: {
15 title: 0xff5a5a, version: 0x8a8fb0, tagline: 0x8fb0ff,
16 borderA: 0xe0303a, borderB: 0x3a5ad8,
17 corner: 0x9a9ab8, cornerDim: 0x5c5c78,
18 critter: 0xf7f06a, drifter: 0xffe08a, knot: 0xffe08a, ground: 0x2f4a2f,
19 },
20 hat: { rows: ['....Y....', '...RRR...', '...WWW...', '..BBBBB..', '..RRRRR..'], colors: { Y: 0xffd34a, R: 0xe0303a, W: 0xf4f4f4, B: 0x3a5ad8 } },
21 prop: {
22 rows: ['S', 'K', 'K'],
23 colors: { S: t => mix(0xffb04a, 0xfff6c0, 0.5 + 0.5 * Math.sin(t * 2)), K: 0x777777 },
24 },
25 sky: (cv, cx, t) => moon(cv, cx, t, { moon: 0xf6eecf, shade: 0xc9bd96, crater: 0xdcd0aa, cloud: 0x26304a }),
26 ambient: { kind: 'fireflies', colors: [0xf7f06a, 0xd8f05a], count: 10 },
27 flyer: { kind: 'firework', colors: [0xff5a5a, 0xf4f4f4, 0x6a8aff, 0xffd34a] },
28 yard: {
29 feet: [
30 { rows: ['RRR', 'WRW', 'GGG'], colors: { R: 0xe0303a, W: 0x111111, G: 0x3f8f46 } },
31 { rows: ['...', 'RWR', 'WRW'], colors: { R: 0xe0303a, W: 0xf4f4f4 } },
32 ],
33 props: [
34 { rows: ['RRRRR', '.W.W.'], colors: { R: 0xe0303a, W: 0xf4f4f4 } },
35 { rows: ['.F.', '.K.', 'KKK'], colors: { F: 0xff9a3a, K: 0x555555 } },
36 { rows: ['RRR', 'GGG'], colors: { R: 0xff5a6a, G: 0x3f8f46 } },
37 ],
38 wanderer: [
39 { rows: ['B..B', 'BBBB', 'BBBK', 'B..B'], colors: { B: 0xc8975a, K: 0x222222 } },
40 { rows: ['B..B', 'BBBB', 'BBBK', '.BB.'], colors: { B: 0xc8975a, K: 0x222222 } },
41 ],
42 },
43 corner: ({ cv, ax, ay, rx, ry }) => garland(cv, ax, ay, rx, ry, -1, 1, 0x9a9ab8, '▼', i => FLAGS[i % FLAGS.length]),
44 critter: { glyphs: ['•', '·'], speed: 1 },
45 knot: '·',
46 drifter: '✦',
47 greet: { glyph: '✦', color: 0xffe08a, thread: null },
48}
49