SLOPSHOPPER

seasons

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…

spinnerrowsguardcommandprompttimer
v0.2.0no licenseupdated 2026-10-17snedea/seasons-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · seasons
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /seasons ⎿ seasons: Decorations are packed away. /seasons brings them back. ✻ Cackling… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

seasons

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

October

  • The logo block is painted over: Clawd wears the month's hat (and holds something while Claude works), the title line takes the month's colors with a tagline that changes now and then, and the sky at the right holds a moon, a sun, a rainbow or a heart. Under the logo, one row of ground with a few props and someone wandering about.
  • The prompt box gets the month's border colors and a decoration hanging from its top right corner, on the right where it stays clear of your draft and the newest text (it steps off the draft's row when a long line reaches it). A critter lives on the top border: it runs to your caret while you type, patrols while Claude works, leaves a little something at every tool call, and tidies up once the turn is done. Something drifts along the bottom border while a turn runs.
  • Every tool call sends something out of Clawd's hat across the top row (bats, birds, geese, paper airplanes, fireworks, shooting stars). Clawd hops when a turn ends.
  • Before your first prompt something comes down into the empty screen to say hello and goes home when you send something.
  • The spinner and the closing line speak the month ("Raising the dead…", "Brewed for 12s"). Every word is listed in docs/SAYINGS.md.

Nothing flashes: every light change is a slow fade. Bash mode keeps the engine's own border color. Terminal only.

The months

JanuaryFebruaryMarch
AprilMayJune
JulyAugustSeptember
OctoberNovemberDecember

Each was taken mid-turn on "Count pi to 1000 digits", with scripts/shoot.sh.

Commands

  • /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.

Install

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.

Turn on the logo decorations

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.

Making a month

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.

Credits

Scene style follows the pixel-art header engine in floweyes.

Source 19 files
hooks/register.tsx 350 lines
1// 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}
350
hooks/canvas.ts 355 lines
1// 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}
355
hooks/months/index.ts 19 lines
1// 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]
19
hooks/scene.ts 501 lines
1// 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}
501
hooks/theme.ts 91 lines
1// 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}
91
hooks/months/jan.ts 47 lines
1// 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}
47
hooks/months/feb.ts 45 lines
1// 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}
45
hooks/months/mar.ts 44 lines
1// 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}
44
hooks/months/apr.ts 45 lines
1// 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}
45
hooks/months/may.ts 46 lines
1// 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}
46
hooks/months/jun.ts 46 lines
1// 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}
46
hooks/months/jul.ts 49 lines
1// 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