SLOPSHOPPER

candy-cane

Weihnachts-Mod: Countdown bis zum 1. Advent, Schnee, Adventskalender, festliche Wartetexte und Silvester. Deutsch und Englisch.

panebandspinnercommandtoastprompt
v0.2.1MITupdated 2026-10-19philipkleudgen/candy-cane-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · candy-cane
│ ┃ candy-cane ✕ › fix the failing auth test and add an audit log call │ ┃ 🎃 22 Tage bis Halloween │ ┃ ⏺ Read(src/auth.ts) │ ┃ Noch 22 Tage bis Halloween ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ … und 52 Tage bis zum 1. Advent ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Bis Halloween ist Herbst. Ab dem 1. November ⎿ 3 pass, 1 fail │ ┃ zählt Candy Cane zum Advent, ab dem 1. │ ┃ Advent kommen Schnee, Türchen und ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Wartetexte. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Automatisch ] [ Voll an ] [ Dezent ] [ Aus │ ┃ › /weihnachten │ ┃ [ DE ] [ EN ] ⎿ candy-cane: Candy Cane · Stufe: Automatisch · DE · Anstrich an · │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢ │ │ 🎃🦇 Noch 22 Tage bis Halloween … und 52 Tage bis zum 1. Advent 🍂 🎃 🍁 │ │◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤ │ 🎃🦇 Noch 22 Tage bis Halloween … und 52 Tage bis zum 1. Advent 🍂 🎃 🍁 │◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤ ╰─────────────────────────────────────────────────────────────────────────────────────────────────── ⟨Claude Code's own drawing⟩
Pane · candy-cane
🎃 22 Tage bis Halloween Noch 22 Tage bis Halloween … und 52 Tage bis zum 1. Advent Bis Halloween ist Herbst. Ab dem 1. November zählt Candy Cane zum Advent, ab dem 1. Advent kommen Schnee, Türchen und Wartetexte. [ Automatisch ] [ Voll an ] [ Dezent ] [ Aus ] [ DE ] [ EN ]
README

Candy Cane 🍬

Eine Saison-Mod für Claude Code: Halloween, Advent, Weihnachten und Silvester. Deutsch und Englisch. A seasonal mod for Claude Code: Halloween, Advent, Christmas and New Year's Eve. German and English.

Candy Cane: Countdown bis zum 1. Advent

Was sie macht

Candy Cane richtet sich nach dem Kalender (Berliner Zeit):

ZeitraumWas passiert
OktoberHerbst und Countdown bis Halloween. Am 31. heißt es „Süßes oder Saures!“
ab 1. NovemberCountdown bis zum 1. Advent, und wie viele Tage es noch bis Heiligabend sind
ab dem 1. AdventSchnee, Lichterkette, Adventskerzen, jeden Tag ein Türchen im Adventskalender und weihnachtliche Wartetexte, solange Claude arbeitet
Weihnachten bis SilvesterFesttagsgrüße, an Silvester ein Countdown bis Mitternacht
ab 2. JanuarDie Mod schläft bis zum nächsten Herbst

Dazu kommen zwei Farbthemen (weihnachten-hell, weihnachten-dunkel). Mit dem Befehl /weihnachten siehst du den Stand und stellst Stufe (auto, an, dezent, aus) und Sprache (de, en) um. Das eigene Seitenfenster öffnet /weihnachten fenster. In der Desktop-App verdrängt es das gerade offene Fenster, deshalb geht es nie von selbst auf.

Installation

  1. Repo klonen, zum Beispiel nach ~/.claude/mods/candy-cane: ``bash git clone https://github.com/philipkleudgen/candy-cane-mod ~/.claude/mods/candy-cane ``
  2. In ~/.claude/settings.json den Ordner eintragen: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/candy-cane" } } ` Wenn du schon andere Mods nutzt, setzt du Candy Cane mit :` getrennt an die erste Stelle. Nur dann kann es die Fenster und Bänder deiner anderen Mods in Saisonfarben einfärben.
  3. Claude Code neu starten.

English

Candy Cane follows the calendar (Berlin time): an autumn countdown to Halloween in October, a countdown to the first Sunday of Advent from November 1st, then snow, a string of lights, Advent candles, a daily door in the Advent calendar and festive spinner texts while Claude is working. On New Year's Eve it counts down to midnight, and from January 2nd it sleeps until next autumn.

All texts come in German and English. Use /weihnachten to switch the language and the intensity.

Install: clone the repo to ~/.claude/mods/candy-cane, add that path to CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json (separate several mods with :), then restart Claude Code.


Eigene Mod oder Claude-Beratung?

Du willst eine eigene Mod für dein Team, Claude Code sauber in eure Abläufe bringen oder einfach wissen, was damit bei euch geht? Melde dich: philipkleudgen.de

Want a custom mod for your team or help getting real work done with Claude? Get in touch at philipkleudgen.de.


Ein Hobbyprojekt, kostenlos und aus Spaß. Kein offizielles Produkt von Anthropic. Claude ist eine Marke von Anthropic. A free hobby project. Not affiliated with or endorsed by Anthropic.

Lizenz: MIT · © 2026 Philip Kleudgen

Source 7 files
hooks/register.tsx 376 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Einstellung, Saison, Stufe } from '../types'
5import { ansage, FESTLICH, grussFuer, istTuerTag, modusFuer, saisonFuer, taktFuer } from './ansage'
6import type { Lage } from './ansage'
7import { ausText, berlin, phase, schluessel } from './kalender'
8import type { Heute } from './kalender'
9import { anstreichen, toastZeichen } from './anstrich'
10import type { Jahreszeit } from './anstrich'
11import { kopf, szene } from './svg'
12import { FERTIG, T, TUEREN, UEBERRASCHUNG, WARTEN, WARTEN_SILVESTER, waehle } from './texte'
13import type { Sprache } from './texte'
14
15// Candy Cane: Weihnachten in Claude Code. Von Mitte Oktober bis zum 1. Advent ein Countdown im Band,
16// ab dem 1. Advent alles (Schnee im Seitenfenster, Türchen, Wartetexte, Überraschungen), bis Neujahr.
17// Ab dem 2. Januar schläft es. /weihnachten schaltet Stufe und Sprache um.
18
19const PANE = 'candy-cane'
20// Kräftige Weihnachtsfarben fürs Band.
21const STREIFEN = { rot: '#D6001C', tief: '#A50016', weiss: '#FFFFFF', gold: '#FFD166' } as const
22// Im Oktober: Kürbisorange auf Nachtschwarz, dazwischen Halloween-Lila.
23const HERBST = { rot: '#E8731C', tief: '#3A1846', weiss: '#1B1020', gold: '#FFB347' } as const
24const farbenFuer = (l: Lage) => (l.phase === 'herbst' ? HERBST : STREIFEN)
25const jahreszeit = (l: Lage) => (l.phase === 'herbst' ? 'herbst' : 'winter') as Jahreszeit
26const BEFEHL = 'weihnachten'
27
28const einstellung = atom({ plugin: 'candy-cane', key: 'einstellung' } as const, { stufe: 'auto', sprache: 'de' } as Einstellung)
29const testDatum = atom({ plugin: 'candy-cane', key: 'testDatum' } as const, null as string | null)
30const takt = atom({ plugin: 'candy-cane', key: 'takt' } as const, '')
31const tuerOffen = atom({ plugin: 'candy-cane', key: 'tuerOffen' } as const, '')
32const saison = atom({ plugin: 'candy-cane', key: 'saison' } as const, null as unknown as Saison)
33
34const jetzt = async ($: EngineInterface, test: string | null): Promise<Heute> => {
35  const vorgabe = test ? ausText(test) : null
36  return vorgabe ?? berlin(await $.clock.now())
37}
38
39const lage = async ($: EngineInterface): Promise<Lage> => {
40  const { stufe, sprache } = await read($, einstellung)
41  await read($, takt)
42  const heute = await jetzt($, await read($, testDatum))
43  const p = phase(heute)
44  return { heute, phase: p, modus: modusFuer(stufe, p), sprache, stufe }
45}
46
47// Hält den öffentlichen Saison-Wert für andere Mods aktuell; schreibt nur, wenn sich etwas ändert.
48const saisonAnsagen = async ($: EngineInterface) => {
49  const neu = saisonFuer(await lage($))
50  const alt = await read($, saison)
51  if (JSON.stringify(alt) !== JSON.stringify(neu)) await update($, saison, () => neu)
52}
53
54// Anstrich für fremde Mods nur in der vollen Saison und solange er nicht abgeschaltet ist.
55// Schon im Countdown: fremde Fenster sehen weihnachtlich aus, bleiben aber sonst, wie sie sind.
56const mitAnstrich = async ($: EngineInterface, l: Lage) => (l.modus === 'voll' || l.modus === 'countdown') && (await read($, einstellung)).anstrich !== false
57
58const THEMA = 'custom:candy-cane:'
59
60// Schaltet in der vollen Saison das Farbthema von Claude Code auf Candy Cane und danach zurück.
61// Lehnt die Engine das ab, bleibt alles, wie es ist; das Thema ist dann über /theme von Hand wählbar.
62const themaAbgleichen = async ($: EngineInterface) => {
63  const l = await lage($)
64  const soll = l.modus === 'voll' && (await read($, einstellung)).thema !== false
65  try {
66    const zeile = (await $.config.list()).find(r => r.key === 'theme')
67    if (!zeile) return
68    const ist = String(zeile.value ?? '')
69    const unseres = ist.startsWith(THEMA)
70    if (soll && !unseres) {
71      await $.store.set('vorherigesThema', ist)
72      const { deny } = await $.config.set({ key: 'theme', value: `${THEMA}weihnachten-${/light/.test(ist) ? 'hell' : 'dunkel'}` })
73      if (deny) $.ui.log(`candy-cane: Farbthema nicht umgeschaltet: ${deny}`, { to: 'debug' })
74    } else if (!soll && unseres) {
75      const vorher = (await $.store.get('vorherigesThema')) as string | undefined
76      await $.config.set({ key: 'theme', value: vorher && !vorher.startsWith(THEMA) ? vorher : 'dark' })
77    }
78  } catch (err) {
79    $.ui.log(`candy-cane: Farbthema: ${String(err)}`, { to: 'debug' })
80  }
81}
82
83const speichern = async ($: EngineInterface, neu: Partial<Einstellung>) => {
84  const alt = await read($, einstellung)
85  const wert = { ...alt, ...neu }
86  await update($, einstellung, () => wert)
87  await $.store.set('einstellung', wert)
88  await saisonAnsagen($)
89  await themaAbgleichen($)
90  return wert
91}
92
93const fensterAuf = async ($: EngineInterface) => {
94  const titel = 'Candy Cane'
95  return $.ui.open({ id: PANE, title: titel })
96}
97
98const tuerZeigen = async ($: EngineInterface, l: Lage) => {
99  if (!istTuerTag(l.heute)) return false
100  const heute = schluessel(l.heute)
101  if ((await read($, tuerOffen)) === heute) return false
102  await update($, tuerOffen, () => heute)
103  await $.store.set('tuerOffen', heute)
104  $.ui.toast(`🎁 ${TUEREN[l.sprache][l.heute.tag - 1]}`)
105  return true
106}
107
108// Einmal am Tag Türchen und Gruß zeigen. Das eigene Fenster öffnet sich nie von selbst, nur mit /weihnachten:
109// es soll die Fenster anderer Mods nicht verdecken.
110const tagesbeginn = async ($: EngineInterface) => {
111  const l = await lage($)
112  if (l.modus === 'aus' || l.modus === 'dezent') return
113  const heute = schluessel(l.heute)
114  if (l.modus !== 'voll') return
115  await tuerZeigen($, l)
116  const gruss = grussFuer(l.heute, l.sprache)
117  if (gruss && (await $.store.get('grussTag')) !== heute) {
118    await $.store.set('grussTag', heute)
119    $.ui.toast(`🎄 ${gruss}`)
120  }
121}
122
123const parseArgs = (args: string) => args.trim().toLowerCase().split(/\s+/).filter(Boolean)
124
125export const register: Register = on => {
126  // Wartetext je Lauf, beim Absenden gezogen, damit er nicht bei jedem Neuzeichnen wechselt.
127  let wartenIndex = 0
128  let laufStart = 0
129
130  on('session.start', async ($, e, next) => {
131    const gespeichert = (await $.store.get('einstellung')) as Einstellung | undefined
132    if (gespeichert && gespeichert.stufe && gespeichert.sprache) await update($, einstellung, () => gespeichert)
133    const offen = (await $.store.get('tuerOffen')) as string | undefined
134    if (offen) await update($, tuerOffen, () => offen)
135
136    await $.command.register({
137      name: BEFEHL,
138      description: 'Candy Cane: Stand zeigen; Stufe auto|an|dezent|aus; Sprache de|en; tür; fenster; test JJJJ-MM-TT',
139      argumentHint: '[auto|an|dezent|aus|de|en|tür|anstrich an|aus|thema an|aus|test <datum>|test aus]',
140    })
141
142    const ticken = async () => {
143      const h = await jetzt($, await read($, testDatum))
144      const neu = taktFuer(h)
145      if ((await read($, takt)) !== neu) {
146        await update($, takt, () => neu)
147        await saisonAnsagen($)
148        await themaAbgleichen($)
149        await tagesbeginn($)
150      }
151    }
152    await ticken()
153    await saisonAnsagen($)
154    await themaAbgleichen($)
155    $.clock.every(30_000, () => void ticken())
156
157    return next(e)
158  })
159
160  on('session.attach', { surface: 'desktop' }, async ($, e, next) => {
161    const r = await next(e)
162    await tagesbeginn($)
163    return r
164  })
165
166  on('command.run', { command: BEFEHL }, async ($, e) => {
167    const [a, b] = parseArgs(e.args)
168    let antwort = ''
169    // Das eigene Fenster verdrängt in der App das Fenster anderer Mods, darum nur auf ausdrücklichen Wunsch.
170    if (!a) {
171      antwort = ''
172    } else if (a === 'fenster' || a === 'window') {
173      await fensterAuf($)
174    } else if (a === 'auto' || a === 'an' || a === 'dezent' || a === 'aus') {
175      await speichern($, { stufe: a })
176    } else if (a === 'on' || a === 'off' || a === 'subtle') {
177      await speichern($, { stufe: a === 'on' ? 'an' : a === 'off' ? 'aus' : 'dezent' })
178    } else if ((a === 'anstrich' || a === 'thema' || a === 'paint' || a === 'theme') && (b === 'an' || b === 'aus' || b === 'on' || b === 'off')) {
179      const wert = b === 'an' || b === 'on'
180      await speichern($, a === 'anstrich' || a === 'paint' ? { anstrich: wert } : { thema: wert })
181    } else if (a === 'de' || a === 'en') {
182      await speichern($, { sprache: a })
183    } else if (a === 'tür' || a === 'tuer' || a === 'door') {
184      const l = await lage($)
185      if (!istTuerTag(l.heute)) antwort = T.tuerVorfreude[l.sprache]
186      else {
187        await update($, tuerOffen, () => '')
188        await tuerZeigen($, l)
189      }
190    } else if (a === 'test') {
191      if (!b || b === 'aus' || b === 'off') await update($, testDatum, () => null)
192      else if (ausText(b)) await update($, testDatum, () => b)
193      else return { text: 'Datum bitte als JJJJ-MM-TT oder JJJJ-MM-TTTHH:MM, z. B. /weihnachten test 2026-12-24' }
194      const h = await jetzt($, await read($, testDatum))
195      await update($, takt, () => taktFuer(h))
196      await saisonAnsagen($)
197      await themaAbgleichen($)
198    } else {
199      return { text: 'Unbekannt. Möglich: auto, an, dezent, aus, de, en, tür, fenster, anstrich an|aus, thema an|aus, test JJJJ-MM-TT, test aus' }
200    }
201    const l = await lage($)
202    const test = await read($, testDatum)
203    const { anstrich, thema } = await read($, einstellung)
204    return { text: antwort || `Candy Cane · ${T.stufe[l.sprache]}: ${T.stufen[l.stufe][l.sprache]} · ${l.sprache.toUpperCase()} · Anstrich ${anstrich === false ? 'aus' : 'an'} · Thema ${thema === false ? 'aus' : 'an'}${test ? ` · Testdatum ${test}` : ''}${a ? '' : ' — umschalten mit /weihnachten auto|an|dezent|aus, Sprache de|en, eigenes Fenster mit /weihnachten fenster'}` }
205  })
206
207  on('prompt.submit', async ($, e, next) => {
208    wartenIndex = Math.floor(Math.random() * 1000)
209    laufStart = await $.clock.now()
210    return next(e)
211  })
212
213  on('turn.complete', async ($, e, next) => {
214    const r = await next(e)
215    const dauer = (await $.clock.now()) - laufStart
216    if (laufStart > 0 && dauer > 60_000) {
217      const l = await lage($)
218      if (l.modus === 'voll' && Math.random() < 0.3) $.ui.toast(`✨ ${waehle(UEBERRASCHUNG[l.sprache], Math.random() * 1000)}`)
219    }
220    return r
221  })
222
223  // Wartetext: auf dem Desktop nur dort, wo die Zeile keinen Arbeitsschritt nennt.
224  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
225    const l = await lage($)
226    const festlich = l.modus === 'voll' || (l.modus === 'dezent' && FESTLICH.includes(l.phase))
227    if (!festlich || e.props.message) return next(e)
228    const schritt = e.props.mode === 'tool-use' || e.props.mode === 'tool-input'
229    if (e.surface !== 'terminal' && schritt && e.props.word !== 'Working') return next(e)
230    const liste = l.phase === 'silvester' ? WARTEN_SILVESTER[l.sprache] : WARTEN[l.sprache]
231    return next({ ...e, props: { ...e.props, word: waehle(liste, wartenIndex) } })
232  })
233
234  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
235    const l = await lage($)
236    if (l.modus !== 'voll') return next(e)
237    return next({ ...e, props: { ...e.props, word: waehle(FERTIG[l.sprache], e.props.durationMs) } })
238  })
239
240  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241    if (e.props.hasSurvey) return next(e)
242    const l = await lage($)
243    if (l.modus === 'aus') return next(e)
244    const a = ansage(l)
245    const { Box, Text } = $.ui.resolve(e)
246    // Bänder anderer Mods liegen darunter: sie bleiben sichtbar, mit Anstrich in Weihnachtsfarben.
247    const fremd = await next(e)
248    const unten = (await mitAnstrich($, l)) ? anstreichen(fremd, jahreszeit(l)) : fremd
249    // Aus App-Bausteinen statt als Grafik: eine schmale Zeile über die volle Breite, auf jeder Oberfläche gleich.
250    const b = a.band
251    const F = farbenFuer(l)
252    const kerzenZeile = b.herbst ? '🍂 🎃 🍁' : b.silvester ? '🎆 🥂 🎆' : b.kerzen !== undefined ? [0, 1, 2, 3].map(i => (i < (b.kerzen ?? 0) ? '🕯️' : '○')).join(' ') : ''
253    // Streifen aus Schrägdreiecken, im Winter rot auf weiß wie eine Zuckerstange, im Oktober orange auf schwarz.
254    const felder = Math.ceil(((e.props as { bodyColumns?: number }).bodyColumns ?? 160) / 2)
255    const streifen = () => (
256      // Kurze Stücke nebeneinander, die nie umbrechen; was über die Breite ragt, schneidet die Zeile ab.
257      // Ein langer Text bräche in der App um oder bekäme ein „…“.
258      <Box flexDirection="row" height={1} overflow="hidden">
259        {Array.from({ length: Math.ceil(felder / 4) }, (_, i) => (
260          <Box key={i} flexShrink={0}>
261            <Text color={F.rot} backgroundColor={F.weiss}>{'◤◢◤◢◤◢◤◢'}</Text>
262          </Box>
263        ))}
264      </Box>
265    )
266    return (
267      <Box flexDirection="column">
268        <Box flexDirection="column" borderStyle="round" borderColor={F.rot}>
269          {streifen()}
270          <Box flexDirection="row" justifyContent="space-between" alignItems="center" backgroundColor={F.tief} paddingX={1}>
271            <Text wrap="truncate-end">
272              <Text>{b.herbst ? '🎃🦇 ' : '🍬🎄 '}</Text>
273              <Text color={b.herbst ? '#FFF1D6' : F.weiss} bold>{b.zeile}</Text>
274              {b.unterzeile ? <Text color={F.gold} italic>{`  ${b.unterzeile}`}</Text> : null}
275            </Text>
276            {kerzenZeile ? <Text color={F.gold} bold>{kerzenZeile}</Text> : null}
277          </Box>
278          {streifen()}
279        </Box>
280        {unten}
281      </Box>
282    )
283  })
284
285  // Fenster anderer Mods: mit Anstrich in Weihnachtsfarben. Das eigene zeichnet der Hook darunter.
286  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
287    if (e.requestId === PANE) return next(e)
288    const baum = await next(e)
289    const l = await lage($)
290    if (!(await mitAnstrich($, l))) return baum
291    const { Box, Text } = $.ui.resolve(e)
292    // Oben ein schmaler Saisonkopf, darunter das fremde Fenster unverändert, nur in Saisonfarben.
293    let oben: JSX.Element
294    if (e.surface === 'terminal') {
295      oben = <Text color={farbenFuer(l).rot} backgroundColor={farbenFuer(l).weiss}>{'◤◢'.repeat(30)}</Text>
296    } else {
297      const { Svg } = $.ui.resolve(e)
298      oben = <Svg source={kopf(ansage(l).bild, l.modus === 'voll' ? 30 : 18)} alt={l.phase === 'herbst' ? 'Herbstabend mit Kürbissen' : 'Winterdorf im Schnee'} />
299    }
300    return (
301      <Box flexDirection="column" gap={1}>
302        {oben}
303        {anstreichen(baum, jahreszeit(l))}
304      </Box>
305    )
306  })
307
308  // Toasts anderer Mods bekommen mit Anstrich das Saison-Zeichen vorn; der Text selbst bleibt.
309  on('ui.toast', async ($, e, next) => {
310    const l = await lage($)
311    if (!(await mitAnstrich($, l))) return next(e)
312    return next({ ...e, text: toastZeichen(e.text, saisonFuer(l).zeichen) })
313  }).catch(($, e, next) => (next.called ? undefined : next(e)))
314
315  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
316    const l = await lage($)
317    const { Box, Text, Button } = $.ui.resolve(e)
318    const s = l.sprache
319    const a = ansage(l)
320    const offen = (await read($, tuerOffen)) === schluessel(l.heute)
321    const test = await read($, testDatum)
322
323    const stufeKnopf = (stufe: Stufe) => (
324      <Button
325        key={`stufe-${stufe}`}
326        label={T.stufen[stufe][s]}
327        variant={l.stufe === stufe ? 'primary' : 'secondary'}
328        onPress={() => void speichern($, { stufe })}
329      />
330    )
331    const spracheKnopf = (sp: Sprache) => (
332      <Button key={`sprache-${sp}`} label={sp.toUpperCase()} variant={s === sp ? 'primary' : 'secondary'} onPress={() => void speichern($, { sprache: sp })} />
333    )
334
335    let bildteil: JSX.Element
336    if (e.surface === 'terminal') {
337      bildteil = <Text color={farbenFuer(l).rot} bold>{l.phase === 'herbst' ? '🎃' : '🎄'} {a.schild?.gross} {a.schild?.klein}</Text>
338    } else {
339      const { Svg } = $.ui.resolve(e)
340      // Wenige Flocken halten die Grafik klein genug für die Desktop-App.
341      const flocken = l.modus === 'voll' ? 55 : 25
342      bildteil = <Svg source={szene({ bild: a.bild, flocken, schild: a.schild })} alt={`${a.schild?.gross ?? ''} ${a.schild?.klein ?? ''}`} isInteractive />
343    }
344
345    return (
346      <Box flexDirection="column" gap={1}>
347        {bildteil}
348        {l.modus === 'aus' ? <Text dimColor>{T.ausserSaison[s]}</Text> : null}
349        <Text bold color="#8E0B20">{a.band.zeile}</Text>
350        {a.band.unterzeile ? <Text italic color="#1F4D3A">{a.band.unterzeile}</Text> : null}
351        {istTuerTag(l.heute) ? (
352          <Box flexDirection="column">
353            <Text bold>🎁 {T.tuerHeute[s]}</Text>
354            {offen
355              ? <Text>{TUEREN[s][l.heute.tag - 1]}</Text>
356              : <Button key="tuer" label={T.tuerZu[s]} variant="primary" onPress={async () => { await tuerZeigen($, await lage($)) }} />}
357          </Box>
358        ) : null}
359        {l.phase === 'herbst' ? <Text dimColor>{T.herbstHinweis[s]}</Text> : null}
360        {l.phase === 'countdown' ? <Text dimColor>{T.countdownHinweis[s]}</Text> : null}
361        <Box flexDirection="row" gap={1} flexWrap="wrap">
362          {stufeKnopf('auto')}
363          {stufeKnopf('an')}
364          {stufeKnopf('dezent')}
365          {stufeKnopf('aus')}
366        </Box>
367        <Box flexDirection="row" gap={1}>
368          {spracheKnopf('de')}
369          {spracheKnopf('en')}
370        </Box>
371        {test ? <Text dimColor>Testdatum: {test} · /weihnachten test aus</Text> : null}
372      </Box>
373    )
374  })
375}
376
hooks/ansage.ts 120 lines
1// Was Band, Schild und Grüße sagen: reine Logik ohne `$`, damit Tests und Vorschau sie nutzen können.
2
3import type { Saison, Stufe } from '../types'
4import { adventssonntag, kerzen, minutenBisMitternacht, neuesJahr, schluessel, tageBis, tageBisAdvent } from './kalender'
5import type { Heute, Phase } from './kalender'
6import type { BandInhalt, SzeneInhalt } from './svg'
7import { GRUSS, T } from './texte'
8import type { Sprache } from './texte'
9
10export type Modus = 'aus' | 'countdown' | 'dezent' | 'voll'
11
12export type Lage = { heute: Heute; phase: Phase; modus: Modus; sprache: Sprache; stufe: Stufe }
13
14export const FESTLICH: Phase[] = ['advent', 'weihnachten', 'zwischen', 'silvester', 'neujahr']
15
16export const modusFuer = (stufe: Stufe, p: Phase): Modus => {
17  if (stufe === 'aus') return 'aus'
18  if (stufe === 'an') return 'voll'
19  if (stufe === 'dezent') return p === 'aus' ? 'aus' : 'dezent'
20  if (p === 'aus') return 'aus'
21  return p === 'countdown' || p === 'herbst' ? 'countdown' : 'voll'
22}
23
24// Der Zeitschlüssel: an Silvester minütlich, sonst täglich.
25export const taktFuer = (h: Heute) => (h.monat === 12 && h.tag === 31 ? `${schluessel(h)}T${h.stunde}:${h.minute}` : schluessel(h))
26
27export const istTuerTag = (h: Heute) => h.monat === 12 && h.tag >= 1 && h.tag <= 24
28
29// Was Band und Schild sagen.
30export const ansage = (l: Lage): { band: BandInhalt; schild: SzeneInhalt['schild']; bild: SzeneInhalt['bild'] } => {
31  const { heute: h, phase: p, sprache: s } = l
32  if (p === 'herbst') {
33    const n = tageBis(h, 10, 31)
34    const advent = tageBisAdvent(h)
35    return {
36      band: { zahl: String(n), zeile: n === 0 ? T.halloween[s] : T.bisHalloween[s](n), unterzeile: s === 'de' ? `… und ${advent} Tage bis zum 1. Advent` : `… and ${advent} days until Advent`, herbst: true },
37      schild: n === 0 ? { gross: 'Halloween', klein: s === 'de' ? 'Süßes oder Saures!' : 'Trick or treat!' } : { gross: s === 'de' ? `${n} Tage` : `${n} days`, klein: s === 'de' ? 'bis Halloween' : 'until Halloween' },
38      bild: 'herbst',
39    }
40  }
41  if (p === 'countdown' || p === 'aus') {
42    // Zwischen 2. Januar und 1. Advent liegt der Advent desselben Jahres immer noch vorn.
43    const n = tageBisAdvent(h)
44    const heilig = tageBis(h, 12, 24)
45    return {
46      band: { zahl: String(n), zeile: T.bisAdvent[s](n), unterzeile: s === 'de' ? `… und ${heilig} Tage bis Heiligabend` : `… and ${heilig} days until Christmas Eve`, kerzen: 0 },
47      schild: { gross: s === 'de' ? `${n} Tage` : `${n} days`, klein: s === 'de' ? 'bis zum 1. Advent' : 'until Advent' },
48      bild: 'dorf',
49    }
50  }
51  if (p === 'advent') {
52    const n = tageBis(h, 12, 24)
53    const k = kerzen(h)
54    return {
55      band: { zahl: String(n), zeile: T.bisHeiligabend[s](n), unterzeile: k > 0 ? (s === 'de' ? `${k}. Advent · Candy Cane` : `Advent ${k} · Candy Cane`) : undefined, kerzen: k },
56      schild: { gross: s === 'de' ? `${n} Tage` : `${n} days`, klein: s === 'de' ? 'bis Heiligabend' : 'until Christmas Eve' },
57      bild: 'dorf',
58    }
59  }
60  if (p === 'weihnachten') {
61    const zeile = h.tag === 24 ? T.heiligabend[s] : T.weihnachtstag[s](h.tag - 24)
62    return {
63      band: { zeile, unterzeile: s === 'de' ? 'Schön, dass du da bist.' : 'So glad you\'re here.', kerzen: 4 },
64      schild: { gross: s === 'de' ? 'Frohe Weihnachten' : 'Merry Christmas', klein: h.tag === 24 ? (s === 'de' ? 'Heiligabend' : 'Christmas Eve') : T.weihnachtstag[s](h.tag - 24) },
65      bild: 'dorf',
66    }
67  }
68  if (p === 'zwischen') {
69    const n = tageBis(h, 12, 31)
70    return {
71      band: { zahl: String(n), zeile: T.zwischen[s](n), kerzen: 4 },
72      schild: { gross: s === 'de' ? 'Zwischen den Jahren' : 'Between the years', klein: s === 'de' ? 'Plätzchenreste und Ruhe' : 'Leftover cookies and quiet' },
73      bild: 'dorf',
74    }
75  }
76  if (p === 'silvester') {
77    const rest = minutenBisMitternacht(h)
78    const std = Math.floor(rest / 60), min = rest % 60
79    const jahr = neuesJahr(h)
80    return {
81      band: { zahl: `${std}:${String(min).padStart(2, '0')}`, zeile: T.silvester[s](std, min, jahr), unterzeile: s === 'de' ? 'Danke für dieses Jahr.' : 'Thank you for this year.', silvester: true },
82      schild: { gross: `${std}:${String(min).padStart(2, '0')}`, klein: s === 'de' ? `bis ${jahr}` : `until ${jahr}` },
83      bild: 'silvester',
84    }
85  }
86  // Neujahr
87  const jahr = neuesJahr(h)
88  return {
89    band: { zeile: T.neujahr[s](jahr), unterzeile: s === 'de' ? 'Candy Cane verabschiedet sich bis zum Herbst.' : 'Candy Cane says goodbye until autumn.', silvester: true },
90    schild: { gross: String(jahr), klein: s === 'de' ? 'Frohes neues Jahr' : 'Happy New Year' },
91    bild: 'silvester',
92  }
93}
94
95// Gruß für besondere Tage, sonst null.
96export const grussFuer = (h: Heute, s: Sprache) => {
97  if (h.monat === 1 && h.tag === 1) return GRUSS.neujahr[s]
98  if (h.monat === 12 && h.tag === 31) return GRUSS.silvester[s]
99  if (h.monat === 12 && h.tag === 26) return GRUSS.weihnachten2[s]
100  if (h.monat === 12 && h.tag === 25) return GRUSS.weihnachten1[s]
101  if (h.monat === 12 && h.tag === 24) return GRUSS.heiligabend[s]
102  const a = adventssonntag(h)
103  if (a > 0) return GRUSS[`advent${a}` as 'advent1'][s]
104  return null
105}
106
107
108const FARBEN: Saison['farben'] = { akzent: '#E0303F', gold: '#E8B04B', gut: '#3FA66B', warn: '#F2994A', schlecht: '#E0303F', info: '#9ED8F0', neutral: '#3A3A3A' }
109
110const FARBEN_HERBST: Saison['farben'] = { akzent: '#E8731C', gold: '#E3A33B', gut: '#7FA34A', warn: '#E8731C', schlecht: '#C0392B', info: '#A77BCA', neutral: '#3A3A3A' }
111
112// Was Candy Cane anderen Mods über die Saison sagt.
113export const saisonFuer = (l: Lage): Saison => ({
114  aktiv: l.modus !== 'aus',
115  phase: l.phase,
116  modus: l.modus,
117  farben: l.phase === 'herbst' ? FARBEN_HERBST : FARBEN,
118  zeichen: l.phase === 'herbst' ? '🎃' : l.phase === 'silvester' || l.phase === 'neujahr' ? '✨' : l.modus === 'countdown' ? '❄' : '🎄',
119})
120
hooks/kalender.ts 99 lines
1// Kalender für Candy Cane: welche Phase heute gilt, in Berliner Zeit.
2// Rein und ohne `$`, damit die Tests jedes Datum durchspielen können.
3
4export type Phase = 'aus' | 'herbst' | 'countdown' | 'advent' | 'weihnachten' | 'zwischen' | 'silvester' | 'neujahr'
5
6// Im Oktober Herbst und Halloween, ab November der Countdown zum 1. Advent.
7export const HERBST_AB = { monat: 10, tag: 1 }
8export const COUNTDOWN_AB = { monat: 11, tag: 1 }
9
10export type Heute = {
11  jahr: number
12  monat: number
13  tag: number
14  stunde: number
15  minute: number
16  // 0 = Sonntag
17  wochentag: number
18}
19
20export const berlin = (ms: number): Heute => {
21  try {
22    const teile = new Intl.DateTimeFormat('en-GB', {
23      timeZone: 'Europe/Berlin',
24      year: 'numeric', month: '2-digit', day: '2-digit',
25      hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
26    }).formatToParts(new Date(ms))
27    const wert = (art: string) => Number(teile.find(t => t.type === art)?.value ?? 0)
28    const jahr = wert('year'), monat = wert('month'), tag = wert('day')
29    return { jahr, monat, tag, stunde: wert('hour'), minute: wert('minute'), wochentag: new Date(Date.UTC(jahr, monat - 1, tag)).getUTCDay() }
30  } catch (err) {
31    // Ohne Intl-Zeitzonen: MEZ als Näherung, eine Stunde Versatz im Sommer stört hier nicht.
32    const d = new Date(ms + 3600_000)
33    return { jahr: d.getUTCFullYear(), monat: d.getUTCMonth() + 1, tag: d.getUTCDate(), stunde: d.getUTCHours(), minute: d.getUTCMinutes(), wochentag: d.getUTCDay() }
34  }
35}
36
37// Ein Testdatum `2026-12-24` oder `2026-12-31T22:30` als Berliner Wandzeit.
38export const ausText = (text: string): Heute | null => {
39  const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/.exec(text.trim())
40  if (!m) return null
41  const jahr = Number(m[1]), monat = Number(m[2]), tag = Number(m[3])
42  const stunde = Number(m[4] ?? 12), minute = Number(m[5] ?? 0)
43  if (monat < 1 || monat > 12 || tag < 1 || tag > 31 || stunde > 23 || minute > 59) return null
44  return { jahr, monat, tag, stunde, minute, wochentag: new Date(Date.UTC(jahr, monat - 1, tag)).getUTCDay() }
45}
46
47const tagNr = (jahr: number, monat: number, tag: number) => Math.round(Date.UTC(jahr, monat - 1, tag) / 86_400_000)
48
49// 4. Advent ist der Sonntag am oder vor dem 24.12., der 1. Advent drei Wochen davor.
50export const ersterAdvent = (jahr: number) => {
51  const heiligabend = new Date(Date.UTC(jahr, 11, 24))
52  const vierter = tagNr(jahr, 12, 24) - heiligabend.getUTCDay()
53  const d = new Date((vierter - 21) * 86_400_000)
54  return { monat: d.getUTCMonth() + 1, tag: d.getUTCDate() }
55}
56
57export const phase = (h: Heute): Phase => {
58  const { jahr, monat, tag } = h
59  if (monat === 1 && tag === 1) return 'neujahr'
60  if (monat === 12 && tag === 31) return 'silvester'
61  if (monat === 12 && tag >= 27) return 'zwischen'
62  if (monat === 12 && tag >= 24) return 'weihnachten'
63  const advent = ersterAdvent(jahr)
64  const heute = tagNr(jahr, monat, tag)
65  if (heute >= tagNr(jahr, advent.monat, advent.tag)) return 'advent'
66  if (heute >= tagNr(jahr, COUNTDOWN_AB.monat, COUNTDOWN_AB.tag)) return 'countdown'
67  if (heute >= tagNr(jahr, HERBST_AB.monat, HERBST_AB.tag)) return 'herbst'
68  return 'aus'
69}
70
71export const tageBis = (h: Heute, monat: number, tag: number) => tagNr(h.jahr, monat, tag) - tagNr(h.jahr, h.monat, h.tag)
72
73export const tageBisAdvent = (h: Heute) => {
74  const a = ersterAdvent(h.jahr)
75  return tageBis(h, a.monat, a.tag)
76}
77
78// Wie viele Adventskerzen heute brennen (0 bis 4).
79export const kerzen = (h: Heute) => {
80  if (h.monat === 12 && h.tag >= 24) return 4
81  const tage = -tageBisAdvent(h)
82  if (tage < 0) return 0
83  return Math.min(4, Math.floor(tage / 7) + 1)
84}
85
86// Ist heute ein Adventssonntag? Dann welcher (1 bis 4), sonst 0.
87export const adventssonntag = (h: Heute) => {
88  const tage = -tageBisAdvent(h)
89  if (tage < 0 || tage > 21 || tage % 7 !== 0) return 0
90  return tage / 7 + 1
91}
92
93export const minutenBisMitternacht = (h: Heute) => (23 - h.stunde) * 60 + (60 - h.minute)
94
95export const schluessel = (h: Heute) => `${h.jahr}-${String(h.monat).padStart(2, '0')}-${String(h.tag).padStart(2, '0')}`
96
97// Das Jahr, das an Silvester beginnt.
98export const neuesJahr = (h: Heute) => (h.monat === 1 ? h.jahr : h.jahr + 1)
99
hooks/anstrich.ts 96 lines
1// Saison-Anstrich für fremde Mods: färbt die Zeichnung anderer Plugins in Weihnachts- oder Herbstfarben um.
2// Nach Farbton, damit die Bedeutung bleibt: Rot bleibt Warnung, Grün bleibt gut, nur festlicher.
3// Rein und ohne `$`; der Baum wird kopiert, nie verändert.
4
5export type Jahreszeit = 'winter' | 'herbst'
6
7// Winter: Weihnachtsfarben. Herbst: Kürbis, Laub und Halloween-Lila. Bedeutung bleibt jeweils gleich.
8const PALETTEN = {
9  winter: { rot: '#E0303F', orange: '#E8794B', gold: '#E8B04B', gruen: '#3FA66B', eis: '#9ED8F0', beere: '#C76B98' },
10  herbst: { rot: '#C0392B', orange: '#E8731C', gold: '#E3A33B', gruen: '#7FA34A', eis: '#A77BCA', beere: '#8E3B8C' },
11}
12
13const zuordnung = (P: (typeof PALETTEN)['winter']) => ({
14  // Themenfarben der Engine, auf die sich Mods beziehen können.
15  thema: { success: P.gruen, error: P.rot, warning: P.gold, claude: P.rot, suggestion: P.eis, permission: P.eis, merged: P.beere } as Record<string, string>,
16  namen: {
17    red: P.rot, redBright: P.rot, green: P.gruen, greenBright: P.gruen,
18    yellow: P.gold, yellowBright: P.gold, blue: P.eis, blueBright: P.eis,
19    cyan: P.eis, cyanBright: P.eis, magenta: P.beere, magentaBright: P.beere,
20  } as Record<string, string>,
21})
22const ZUORDNUNG = { winter: zuordnung(PALETTEN.winter), herbst: zuordnung(PALETTEN.herbst) }
23
24const hex = (s: string): [number, number, number] | null => {
25  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s.trim())
26  if (!m) return null
27  const h = m[1]!.length === 3 ? m[1]!.split('').map(c => c + c).join('') : m[1]!
28  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
29}
30
31// Farbton in Grad und Sättigung (0 bis 1).
32const ton = ([r, g, b]: [number, number, number]) => {
33  const max = Math.max(r, g, b) / 255, min = Math.min(r, g, b) / 255
34  const d = max - min
35  const l = (max + min) / 2
36  const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1))
37  let h = 0
38  if (d !== 0) {
39    const R = r / 255, G = g / 255, B = b / 255
40    if (max === R) h = 60 * (((G - B) / d) % 6)
41    else if (max === G) h = 60 * ((B - R) / d + 2)
42    else h = 60 * ((R - G) / d + 4)
43  }
44  return { h: (h + 360) % 360, s, l }
45}
46
47export const umfaerben = (farbe: string, zeit: Jahreszeit = 'winter'): string => {
48  const PALETTE = PALETTEN[zeit]
49  const { thema, namen } = ZUORDNUNG[zeit]
50  if (thema[farbe]) return thema[farbe]!
51  if (namen[farbe]) return namen[farbe]!
52  const rgb = hex(farbe)
53  if (!rgb) return farbe
54  const { h, s, l } = ton(rgb)
55  // Grau, Schwarz und Weiß bleiben: Text und Flächen sollen lesbar bleiben.
56  if (s < 0.25 || l < 0.12 || l > 0.92) return farbe
57  if (h < 15 || h >= 330) return PALETTE.rot
58  if (h < 40) return PALETTE.orange
59  if (h < 70) return PALETTE.gold
60  if (h < 170) return PALETTE.gruen
61  if (h < 260) return PALETTE.eis
62  return PALETTE.beere
63}
64
65const FARBFELDER = ['color', 'backgroundColor', 'borderColor']
66
67type Knoten = { type?: unknown; props?: Record<string, unknown>; hover?: Record<string, unknown>; children?: unknown[] } & Record<string, unknown>
68
69const farbenIn = (props: Record<string, unknown> | undefined, zeit: Jahreszeit) => {
70  if (!props) return props
71  let neu: Record<string, unknown> | undefined
72  for (const feld of FARBFELDER) {
73    const wert = props[feld]
74    if (typeof wert === 'string') {
75      const um = umfaerben(wert, zeit)
76      if (um !== wert) (neu ??= { ...props })[feld] = um
77    }
78  }
79  return neu ?? props
80}
81
82// Geht den Baum durch und färbt Box, Text, Button und Link um; alles andere (Svg, Bilder, Engine-Knoten) bleibt.
83export const anstreichen = <T>(baum: T, zeit: Jahreszeit = 'winter', tiefe = 0): T => {
84  if (tiefe > 40 || baum === null || typeof baum !== 'object') return baum
85  if (Array.isArray(baum)) return baum.map(k => anstreichen(k, zeit, tiefe + 1)) as T
86  const k = baum as unknown as Knoten
87  if (k.type !== 'Box' && k.type !== 'Text' && k.type !== 'Button' && k.type !== 'Link') return baum
88  const props = farbenIn(k.props, zeit)
89  const hover = farbenIn(k.hover, zeit)
90  const kinder = Array.isArray(k.children) ? k.children.map(c => anstreichen(c, zeit, tiefe + 1)) : k.children
91  return { ...k, props, ...(k.hover ? { hover } : {}), ...(k.children ? { children: kinder } : {}) } as T
92}
93
94// Setzt ein Saison-Zeichen vor einen Toast, außer er beginnt schon mit einem Symbol.
95export const toastZeichen = (text: string, zeichen: string) => (/^[\p{Extended_Pictographic}✨❄]/u.test(text) ? text : `${zeichen} ${text}`)
96
hooks/svg.ts 282 lines
1// SVG-Bausteine für Band und Seitenfenster. Desktop zeichnet sie in einem skriptlosen Rahmen,
2// CSS-Animationen laufen dort: Schneefall, blinkende Lichter, flackernde Kerzen.
3
4
5export const ROT = '#C8102E'
6export const DUNKELROT = '#8E0B20'
7export const TANNE = '#1F4D3A'
8export const CREME = '#FBF6EC'
9export const GOLD = '#E8B04B'
10export const KUERBIS = '#E8731C'
11
12const SCHRIFT = "Georgia, 'Times New Roman', serif"
13
14const xml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
15
16// Fester Zufall, damit jede Zeichnung gleich aussieht und nichts springt.
17const zufall = (saat: number) => {
18  let s = saat >>> 0
19  return () => {
20    s = (s * 1664525 + 1013904223) >>> 0
21    return s / 4294967296
22  }
23}
24
25const STIL = `
26@keyframes fallA { from { transform: translate(0px, -30px) } to { transform: translate(40px, 560px) } }
27@keyframes fallB { from { transform: translate(0px, -30px) } to { transform: translate(-40px, 560px) } }
28@keyframes funkeln { 0%, 100% { opacity: 1 } 50% { opacity: .25 } }
29@keyframes flackern { 0%, 100% { transform: scale(1, 1) } 40% { transform: scale(.9, 1.12) } 70% { transform: scale(1.06, .94) } }
30@keyframes wiegen { 0%, 100% { transform: rotate(-4deg) } 50% { transform: rotate(4deg) } }
31.licht { animation: funkeln 1.6s ease-in-out infinite }
32.flamme { transform-box: fill-box; transform-origin: 50% 100%; animation: flackern .9s ease-in-out infinite }
33.wiegen { transform-box: fill-box; transform-origin: 50% 0%; animation: wiegen 4s ease-in-out infinite }
34`
35
36const streifenMuster = (id: string, breite: number, herbst = false) => `
37<pattern id="${id}" width="${breite}" height="${breite}" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
38  <rect width="${breite}" height="${breite}" fill="${herbst ? '#1B1020' : '#fff'}"/>
39  <rect width="${breite / 2}" height="${breite}" fill="${herbst ? KUERBIS : ROT}"/>
40</pattern>`
41
42// Eine kleine Zuckerstange als Symbol.
43const zuckerstange = (x: number, y: number, h: number) => `
44<g transform="translate(${x} ${y})">
45  <path d="M ${h * 0.18} ${h} L ${h * 0.18} ${h * 0.32} A ${h * 0.2} ${h * 0.2} 0 0 1 ${h * 0.58} ${h * 0.32}" fill="none" stroke="#fff" stroke-width="${h * 0.16}" stroke-linecap="round"/>
46  <path d="M ${h * 0.18} ${h} L ${h * 0.18} ${h * 0.32} A ${h * 0.2} ${h * 0.2} 0 0 1 ${h * 0.58} ${h * 0.32}" fill="none" stroke="${ROT}" stroke-width="${h * 0.16}" stroke-linecap="round" stroke-dasharray="${h * 0.09} ${h * 0.09}"/>
47</g>`
48
49// Lichterkette entlang einer Welle.
50const lichterkette = (breite: number, y: number, anzahl: number, saat: number) => {
51  const farben = [ROT, GOLD, '#2E8B57', '#FFFFFF', '#4AA3DF']
52  const r = zufall(saat)
53  let welle = `M 0 ${y}`
54  const schritt = breite / anzahl
55  for (let i = 0; i < anzahl; i++) welle += ` Q ${i * schritt + schritt / 2} ${y + 9} ${(i + 1) * schritt} ${y}`
56  let lichter = ''
57  for (let i = 0; i < anzahl; i++) {
58    const cx = i * schritt + schritt / 2
59    const farbe = farben[i % farben.length]
60    lichter += `<g class="licht" style="animation-delay:-${(r() * 1.6).toFixed(2)}s"><circle cx="${cx}" cy="${y + 11}" r="8" fill="${farbe}" opacity=".3"/><rect x="${cx - 2.5}" y="${y + 4}" width="5" height="4" rx="1" fill="#4a4a4a"/><ellipse cx="${cx}" cy="${y + 12}" rx="4" ry="5.5" fill="${farbe}"/></g>`
61  }
62  return `<path d="${welle}" fill="none" stroke="${TANNE}" stroke-width="2.5"/>${lichter}`
63}
64
65const kerze = (x: number, y: number, brennt: boolean, verzoegerung: number) => `
66<g transform="translate(${x} ${y})">
67  <rect x="-5" y="0" width="10" height="26" rx="2" fill="${ROT}"/>
68  <rect x="-5" y="0" width="3" height="26" rx="1.5" fill="#fff" opacity=".25"/>
69  <line x1="0" y1="0" x2="0" y2="-4" stroke="#333" stroke-width="1.2"/>
70  ${brennt ? `<ellipse cx="0" cy="-9" rx="7" ry="9" fill="${GOLD}" opacity=".25"/><path class="flamme" style="animation-delay:-${verzoegerung}s" d="M 0 -15 C 4 -9 3 -5 0 -4 C -3 -5 -4 -9 0 -15 Z" fill="#FFC94A"/>` : ''}
71</g>`
72
73export type BandInhalt = {
74  zeile: string
75  unterzeile?: string
76  zahl?: string
77  kerzen?: number
78  silvester?: boolean
79  herbst?: boolean
80}
81
82// Das Band über dem Eingabefeld.
83export const band = (b: BandInhalt) => {
84  const W = 1200, H = 78
85  const mitZahl = !!b.zahl
86  const textX = mitZahl ? 132 : 70
87  const kerzenBlock = b.kerzen !== undefined
88    ? `<g>${[0, 1, 2, 3].map(i => kerze(W - 150 + i * 28, 38, i < (b.kerzen ?? 0), i * 0.3)).join('')}
89       <path d="M ${W - 166} 66 Q ${W - 108} 74 ${W - 46} 66" fill="none" stroke="${TANNE}" stroke-width="7" stroke-linecap="round"/>
90       <circle cx="${W - 150}" cy="66" r="3" fill="${ROT}"/><circle cx="${W - 108}" cy="69" r="3" fill="${ROT}"/><circle cx="${W - 66}" cy="66" r="3" fill="${ROT}"/></g>`
91    : ''
92  const rechts = b.silvester
93    ? [0, 1, 2].map(i => `<g class="licht" style="animation-delay:-${i * 0.5}s" transform="translate(${W - 140 + i * 40} ${34 + (i % 2) * 8})">${Array.from({ length: 8 }, (_, k) => `<line x1="0" y1="0" x2="${(Math.cos(k * Math.PI / 4) * 14).toFixed(1)}" y2="${(Math.sin(k * Math.PI / 4) * 14).toFixed(1)}" stroke="${k % 2 ? GOLD : ROT}" stroke-width="2" stroke-linecap="round"/>`).join('')}</g>`).join('')
94    : kerzenBlock
95  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
96<style>${STIL}</style>
97<defs>${streifenMuster('streifen', 14)}
98<linearGradient id="grund" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFDF8"/><stop offset="1" stop-color="${CREME}"/></linearGradient></defs>
99<rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="14" fill="url(#grund)" stroke="url(#streifen)" stroke-width="5"/>
100<g>${lichterkette(W, 3, 24, 7)}</g>
101${zuckerstange(22, 22, 44)}
102${mitZahl ? `<g><circle cx="98" cy="44" r="25" fill="${ROT}"/><circle cx="98" cy="44" r="21" fill="none" stroke="#fff" stroke-width="1.5" stroke-dasharray="3 3"/>
103<text x="98" y="52" text-anchor="middle" font-family="${SCHRIFT}" font-size="${b.zahl!.length > 2 ? 18 : 24}" font-weight="bold" fill="#fff">${xml(b.zahl!)}</text></g>` : ''}
104<text x="${textX}" y="${b.unterzeile ? 44 : 52}" font-family="${SCHRIFT}" font-size="22" font-weight="bold" fill="${DUNKELROT}">${xml(b.zeile)}</text>
105${b.unterzeile ? `<text x="${textX}" y="66" font-family="${SCHRIFT}" font-size="14" font-style="italic" fill="${TANNE}">${xml(b.unterzeile)}</text>` : ''}
106${rechts}
107</svg>`
108}
109
110export type SzeneInhalt = {
111  bild: 'dorf' | 'silvester' | 'herbst'
112  flocken: number
113  schild?: { gross: string; klein?: string }
114}
115
116// Fallender Schnee: Punkte und ein paar Sterne, jede Flocke mit eigenem Takt.
117const schneefall = (anzahl: number, W: number, laub = false) => {
118  const r = zufall(42)
119  const LAUB = [KUERBIS, '#C0392B', '#E3A33B', '#8E4A1C']
120  let flocken = ''
121  for (let i = 0; i < anzahl; i++) {
122    const x = (r() * W).toFixed(0)
123    const groesse = 1.8 + r() * 3.4
124    const dauer = (7 + r() * 9).toFixed(1)
125    const verz = (r() * 16).toFixed(1)
126    const art = r() < 0.5 ? 'fallA' : 'fallB'
127    const form = laub
128      ? `<g transform="translate(${x} 0) rotate(${(r() * 360).toFixed(0)}) scale(${(groesse / 3).toFixed(2)})"><path d="M 0 -6 C 5 -4 6 3 0 7 C -6 3 -5 -4 0 -6 Z" fill="${LAUB[i % LAUB.length]}"/><line x1="0" y1="-5" x2="0" y2="8" stroke="#5A2E10" stroke-width=".8"/></g>`
129      : groesse > 4.4
130      ? `<g transform="translate(${x} 0)"><path d="M -7 0 L 7 0 M -3.5 -6 L 3.5 6 M -3.5 6 L 3.5 -6" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/></g>`
131      : `<circle cx="${x}" cy="0" r="${groesse.toFixed(1)}" fill="#fff" opacity="${(0.7 + r() * 0.3).toFixed(2)}"/>`
132    flocken += `<g style="animation:${art} ${dauer}s linear -${verz}s infinite" filter="url(#schatten)">${form}</g>`
133  }
134  return flocken
135}
136
137// Die Winternacht als Vektorgrafik: Die Desktop-App zeichnet eingebettete Fotos nicht,
138// ohne dunklen Himmel wäre der Schnee weiß auf weiß.
139const landschaft = (bild: SzeneInhalt['bild'], W: number, H: number) => {
140  if (bild === 'herbst') return herbstabend(W, H)
141  const r = zufall(7)
142  const silvester = bild === 'silvester'
143  let sterne = ''
144  for (let i = 0; i < 38; i++) {
145    sterne += `<circle cx="${(r() * W).toFixed(0)}" cy="${(r() * H * 0.5).toFixed(0)}" r="${(0.6 + r() * 1.3).toFixed(1)}" fill="#FFF8E1" opacity="${(0.4 + r() * 0.6).toFixed(2)}"/>`
146  }
147  const tanne = (x: number, y: number, h: number) => {
148    const b = h * 0.42
149    return `<g transform="translate(${x} ${y})"><rect x="-3" y="-6" width="6" height="10" fill="#4A2E1A"/>`
150      + `<path d="M 0 ${-h} L ${b} -4 L ${-b} -4 Z" fill="#0F4A2E"/>`
151      + `<path d="M 0 ${-h} L ${b * 0.45} ${-h * 0.55} L ${-b * 0.45} ${-h * 0.55} Z" fill="#F4F8FF"/></g>`
152  }
153  const haus = (x: number, y: number, w: number) => {
154    const h = w * 0.7
155    return `<g transform="translate(${x} ${y})"><rect x="0" y="${-h}" width="${w}" height="${h}" fill="#7A3B2E"/>`
156      + `<path d="M ${-6} ${-h} L ${w / 2} ${-h - w * 0.55} L ${w + 6} ${-h} Z" fill="#F4F8FF"/>`
157      + `<rect class="licht" x="${w * 0.18}" y="${-h * 0.72}" width="${w * 0.24}" height="${h * 0.32}" fill="#FFC94A"/>`
158      + `<rect class="licht" style="animation-delay:-.7s" x="${w * 0.58}" y="${-h * 0.72}" width="${w * 0.24}" height="${h * 0.32}" fill="#FFC94A"/></g>`
159  }
160  let baumLichter = ''
161  for (let i = 0; i < 14; i++) {
162    const t = 0.15 + r() * 0.8
163    const bx = 360 + (r() - 0.5) * 2 * 62 * t
164    const by = 352 - 150 + 150 * t
165    const farbe = [ROT, GOLD, '#FFFFFF'][i % 3]
166    baumLichter += `<circle class="licht" style="animation-delay:-${(r() * 1.6).toFixed(2)}s" cx="${bx.toFixed(0)}" cy="${by.toFixed(0)}" r="3.4" fill="${farbe}"/>`
167  }
168  let feuerwerk = ''
169  if (silvester) {
170    for (const [fx, fy, farbe] of [[170, 110, GOLD], [470, 80, ROT], [600, 150, '#FFFFFF']] as const) {
171      for (let k = 0; k < 12; k++) {
172        const w = (k / 12) * Math.PI * 2
173        feuerwerk += `<line x1="${fx + Math.cos(w) * 12}" y1="${fy + Math.sin(w) * 12}" x2="${(fx + Math.cos(w) * 46).toFixed(0)}" y2="${(fy + Math.sin(w) * 46).toFixed(0)}" stroke="${farbe}" stroke-width="2.4" stroke-linecap="round" class="licht" style="animation-delay:-${(k * 0.13).toFixed(2)}s"/>`
174      }
175    }
176  }
177  return `<defs><linearGradient id="himmel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${silvester ? '#050A1E' : '#0A1A3D'}"/><stop offset="1" stop-color="${silvester ? '#1B2550' : '#2F5287'}"/></linearGradient>
178<radialGradient id="mondschein"><stop offset="0" stop-color="#FFF6D5" stop-opacity=".55"/><stop offset="1" stop-color="#FFF6D5" stop-opacity="0"/></radialGradient></defs>
179<rect width="${W}" height="${H}" fill="url(#himmel)"/>
180${sterne}
181${silvester ? feuerwerk : `<circle cx="590" cy="90" r="70" fill="url(#mondschein)"/><circle cx="590" cy="90" r="30" fill="#FFF6D5"/>`}
182<path d="M 0 300 C 120 250 240 280 360 262 C 480 244 600 270 ${W} 248 L ${W} ${H} L 0 ${H} Z" fill="#B9C9E4"/>
183${tanne(60, 292, 70)}${tanne(110, 284, 52)}${tanne(640, 262, 66)}${tanne(680, 266, 48)}
184<path d="M 0 360 C 160 318 280 346 400 332 C 520 318 620 338 ${W} 326 L ${W} ${H} L 0 ${H} Z" fill="#EAF1FB"/>
185${haus(150, 352, 54)}${haus(220, 346, 44)}${haus(470, 340, 50)}${haus(540, 344, 42)}
186<g><rect x="354" y="342" width="12" height="16" fill="#4A2E1A"/><path d="M 360 196 L 424 344 L 296 344 Z" fill="#0F5A34"/><path d="M 360 196 L 392 270 L 328 270 Z" fill="#137040"/>
187<path d="M 360 184 l 4 9 l 10 1 l -7 7 l 2 10 l -9 -5 l -9 5 l 2 -10 l -7 -7 l 10 -1 Z" fill="${GOLD}"/>${baumLichter}</g>
188${tanne(30, 396, 80)}${tanne(700, 390, 74)}
189<path d="M 0 410 C 200 388 520 398 ${W} 384 L ${W} ${H} L 0 ${H} Z" fill="#FFFFFF"/>`
190}
191
192// Herbstabend für Oktober: Dämmerhimmel, Kürbismond, Fledermäuse, kahle Bäume und leuchtende Kürbisse.
193const herbstabend = (W: number, H: number) => {
194  const r = zufall(11)
195  let sterne = ''
196  for (let i = 0; i < 18; i++) {
197    sterne += `<circle cx="${(r() * W).toFixed(0)}" cy="${(r() * H * 0.3).toFixed(0)}" r="${(0.6 + r() * 1.1).toFixed(1)}" fill="#FFF1D6" opacity="${(0.3 + r() * 0.5).toFixed(2)}"/>`
198  }
199  const fledermaus = (x: number, y: number, g: number, v: number) =>
200    `<g class="licht" style="animation-delay:-${v}s" transform="translate(${x} ${y}) scale(${g})"><path d="M 0 0 C -6 -8 -14 -8 -24 -2 C -18 -2 -16 2 -14 6 C -10 2 -6 4 -4 7 C -2 4 2 4 4 7 C 6 4 10 2 14 6 C 16 2 18 -2 24 -2 C 14 -8 6 -8 0 0 Z" fill="#140A18"/></g>`
201  const kahlerBaum = (x: number, y: number, h: number) =>
202    `<g transform="translate(${x} ${y})" stroke="#1B1020" stroke-linecap="round" fill="none"><path d="M 0 0 L 0 ${-h}" stroke-width="${h * 0.09}"/>`
203    + `<path d="M 0 ${-h * 0.45} L ${-h * 0.32} ${-h * 0.8} M 0 ${-h * 0.6} L ${h * 0.3} ${-h * 0.95} M 0 ${-h * 0.8} L ${-h * 0.18} ${-h * 1.05} M ${-h * 0.2} ${-h * 0.68} L ${-h * 0.36} ${-h * 0.66}" stroke-width="${h * 0.04}"/></g>`
204  const haus = (x: number, y: number, w: number) => {
205    const h = w * 0.7
206    return `<g transform="translate(${x} ${y})"><rect x="0" y="${-h}" width="${w}" height="${h}" fill="#4A2A2E"/>`
207      + `<path d="M ${-6} ${-h} L ${w / 2} ${-h - w * 0.55} L ${w + 6} ${-h} Z" fill="#2A1626"/>`
208      + `<rect class="licht" x="${w * 0.18}" y="${-h * 0.72}" width="${w * 0.24}" height="${h * 0.32}" fill="#FFB347"/>`
209      + `<rect class="licht" style="animation-delay:-.7s" x="${w * 0.58}" y="${-h * 0.72}" width="${w * 0.24}" height="${h * 0.32}" fill="#FFB347"/></g>`
210  }
211  const kuerbis = (x: number, y: number, g: number, v: number) =>
212    `<g transform="translate(${x} ${y})"><ellipse cx="0" cy="0" rx="${g * 1.25}" ry="${g}" fill="${KUERBIS}"/>`
213    + `<ellipse cx="0" cy="0" rx="${g * 0.6}" ry="${g}" fill="none" stroke="#B8540E" stroke-width="${g * 0.07}"/><ellipse cx="0" cy="0" rx="${g * 0.15}" ry="${g}" fill="none" stroke="#B8540E" stroke-width="${g * 0.06}"/>`
214    + `<path d="M -${g * 0.08} -${g * 0.95} L -${g * 0.02} -${g * 1.3} L ${g * 0.14} -${g * 1.28} L ${g * 0.1} -${g * 0.95} Z" fill="#4E5A1E"/>`
215    + `<g class="licht" style="animation-delay:-${v}s" fill="#FFD34A"><path d="M -${g * 0.55} -${g * 0.2} L -${g * 0.3} -${g * 0.55} L -${g * 0.08} -${g * 0.2} Z M ${g * 0.08} -${g * 0.2} L ${g * 0.3} -${g * 0.55} L ${g * 0.55} -${g * 0.2} Z"/>`
216    + `<path d="M -${g * 0.65} ${g * 0.15} Q 0 ${g * 0.75} ${g * 0.65} ${g * 0.15} L ${g * 0.35} ${g * 0.3} L ${g * 0.2} ${g * 0.15} L 0 ${g * 0.32} L -${g * 0.2} ${g * 0.15} L -${g * 0.35} ${g * 0.3} Z"/></g></g>`
217  return `<defs><linearGradient id="himmel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1E1033"/><stop offset=".5" stop-color="#5B2A55"/><stop offset="1" stop-color="#E07A3A"/></linearGradient>
218<radialGradient id="mondschein"><stop offset="0" stop-color="#FFB347" stop-opacity=".6"/><stop offset="1" stop-color="#FFB347" stop-opacity="0"/></radialGradient></defs>
219<rect width="${W}" height="${H}" fill="url(#himmel)"/>
220${sterne}
221<circle cx="580" cy="110" r="90" fill="url(#mondschein)"/><circle cx="580" cy="110" r="44" fill="#F6C25A"/>
222${fledermaus(180, 90, 1.3, 0)}${fledermaus(250, 130, 0.9, 0.6)}${fledermaus(470, 70, 1.1, 1.1)}${fledermaus(120, 215, 0.8, 0.4)}${fledermaus(620, 205, 0.9, 0.9)}
223<path d="M 0 290 C 120 250 240 280 360 262 C 480 244 600 270 ${W} 248 L ${W} ${H} L 0 ${H} Z" fill="#3A2238"/>
224${kahlerBaum(70, 290, 110)}${kahlerBaum(660, 262, 120)}${kahlerBaum(600, 270, 70)}
225<path d="M 0 360 C 160 318 280 346 400 332 C 520 318 620 338 ${W} 326 L ${W} ${H} L 0 ${H} Z" fill="#4A2A22"/>
226${haus(130, 352, 54)}${haus(200, 346, 44)}${haus(500, 340, 50)}${haus(570, 344, 42)}
227${kuerbis(360, 334, 44, 0)}${kuerbis(285, 356, 24, 0.5)}${kuerbis(438, 356, 28, 1)}
228<path d="M 0 410 C 200 388 520 398 ${W} 384 L ${W} ${H} L 0 ${H} Z" fill="#2E1A14"/>`
229}
230
231// Die Szene im Seitenfenster: Bild, Zuckerstangenrahmen, fallender Schnee, ein Holzschild.
232export const szene = (s: SzeneInhalt) => {
233  const W = 720, H = 480
234  const herbst = s.bild === 'herbst'
235  const flocken = schneefall(s.flocken, W, herbst)
236  const schild = s.schild
237    ? `<g class="wiegen" transform="translate(0 0)">
238  <line x1="${W / 2 - 110}" y1="${H - 150}" x2="${W / 2 - 90}" y2="${H - 118}" stroke="#5A3A22" stroke-width="3"/>
239  <line x1="${W / 2 + 110}" y1="${H - 150}" x2="${W / 2 + 90}" y2="${H - 118}" stroke="#5A3A22" stroke-width="3"/>
240  <rect x="${W / 2 - 160}" y="${H - 120}" width="320" height="${s.schild.klein ? 92 : 70}" rx="12" fill="${CREME}" stroke="url(#streifenGross)" stroke-width="7"/>
241  <text x="${W / 2}" y="${H - 72}" text-anchor="middle" font-family="${SCHRIFT}" font-size="38" font-weight="bold" fill="${DUNKELROT}">${xml(s.schild.gross)}</text>
242  ${s.schild.klein ? `<text x="${W / 2}" y="${H - 44}" text-anchor="middle" font-family="${SCHRIFT}" font-size="17" font-style="italic" fill="${TANNE}">${xml(s.schild.klein)}</text>` : ''}
243</g>`
244    : ''
245  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
246<style>${STIL}</style>
247<defs>${streifenMuster('streifenGross', 22, herbst)}
248<filter id="schatten" x="-1" y="-1" width="3" height="3"><feDropShadow dx="0" dy="0.6" stdDeviation="0.9" flood-color="#2b3550" flood-opacity=".55"/></filter>
249<clipPath id="rund"><rect x="0" y="0" width="${W}" height="${H}" rx="22"/></clipPath>
250<linearGradient id="dunst" x1="0" y1="0" x2="0" y2="1"><stop offset=".55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient></defs>
251<g clip-path="url(#rund)">
252${landschaft(s.bild, W, H)}
253<rect width="${W}" height="${H}" fill="url(#dunst)"/>
254${flocken}
255${schild}
256</g>
257<rect x="5" y="5" width="${W - 10}" height="${H - 10}" rx="19" fill="none" stroke="url(#streifenGross)" stroke-width="10"/>
258${herbst ? '' : `<g transform="translate(${W / 2} 16)">
259  <path d="M 0 8 C -30 -14 -54 4 -40 18 C -30 26 -12 18 0 8 Z" fill="${ROT}"/>
260  <path d="M 0 8 C 30 -14 54 4 40 18 C 30 26 12 18 0 8 Z" fill="${ROT}"/>
261  <path d="M -4 12 L -22 46 L -10 42 L -4 54 Z M 4 12 L 22 46 L 10 42 L 4 54 Z" fill="${DUNKELROT}"/>
262  <circle cx="0" cy="10" r="8" fill="${DUNKELROT}"/>
263</g>`}
264</svg>`
265}
266
267// Ein schmaler Kopf über fremden Fenstern: Ausschnitt der Szene mit Schnee oder Laub und gestreiftem Rand.
268export const kopf = (bild: SzeneInhalt['bild'], anzahl: number) => {
269  const W = 720, Y = 170, H = 230
270  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 ${Y} ${W} ${H}">
271<style>${STIL}</style>
272<defs>${streifenMuster('streifenKopf', 22, bild === 'herbst')}
273<filter id="schatten" x="-1" y="-1" width="3" height="3"><feDropShadow dx="0" dy="0.6" stdDeviation="0.9" flood-color="#2b3550" flood-opacity=".55"/></filter>
274<clipPath id="rundKopf"><rect x="0" y="${Y}" width="${W}" height="${H}" rx="18"/></clipPath></defs>
275<g clip-path="url(#rundKopf)">
276${landschaft(bild, W, 480)}
277${schneefall(anzahl, W, bild === 'herbst')}
278</g>
279<rect x="4" y="${Y + 4}" width="${W - 8}" height="${H - 8}" rx="15" fill="none" stroke="url(#streifenKopf)" stroke-width="8"/>
280</svg>`
281}
282
hooks/texte.ts 187 lines
1// Alle Texte von Candy Cane, Deutsch und Englisch. Ton: amerikanischer Weihnachtsfilm, mit Absicht zu süß.
2
3export type Sprache = 'de' | 'en'
4
5type Satz = Record<Sprache, string>
6
7// Wartetexte, solange Claude arbeitet.
8export const WARTEN: Record<Sprache, string[]> = {
9  de: [
10    'Geschenke werden gepackt',
11    'Die Plätzchen sind gleich fertig',
12    'Der Bürgermeister schmückt den Baum',
13    'Rentiere werden gestriegelt',
14    'Kakao mit Marshmallows wird gerührt',
15    'Lichterketten werden entwirrt',
16    'Die Wichtel löten noch',
17    'Schleifen werden gebunden',
18    'Der Chor probt ein letztes Mal',
19    'Zimtsterne werden ausgestochen',
20    'Schnee wird bestellt',
21    'Der Schlitten wird vorgewärmt',
22    'Der Wunschzettel wird gelesen',
23    'Mistelzweige werden aufgehängt',
24  ],
25  en: [
26    'Wrapping presents',
27    'The cookies are almost done',
28    'The mayor is trimming the tree',
29    'Brushing the reindeer',
30    'Stirring the hot cocoa',
31    'Untangling the string lights',
32    'The elves are still soldering',
33    'Tying the bows',
34    'The carolers are warming up',
35    'Cutting out gingerbread stars',
36    'Ordering fresh snow',
37    'Warming up the sleigh',
38    'Reading the wish list',
39    'Hanging the mistletoe',
40  ],
41}
42
43export const FERTIG: Record<Sprache, string[]> = {
44  de: ['Eingepackt', 'Gebacken', 'Geschmückt', 'Verschneit', 'Verzaubert', 'Unterm Baum'],
45  en: ['Wrapped', 'Baked', 'Decorated', 'Snowed in', 'Enchanted', 'Under the tree'],
46}
47
48// Silvesterabend und Neujahr bekommen eigene Wartetexte.
49export const WARTEN_SILVESTER: Record<Sprache, string[]> = {
50  de: ['Raketen werden aufgestellt', 'Der Sekt wird kaltgestellt', 'Blei wird gegossen', 'Wunderkerzen werden verteilt', 'Der Countdown wird geprobt'],
51  en: ['Setting up the fireworks', 'Chilling the champagne', 'Handing out sparklers', 'Rehearsing the countdown', 'Polishing the disco ball'],
52}
53
54// Die 24 Türchen. Der 6. ist Nikolaus, der 24. Heiligabend.
55export const TUEREN: Record<Sprache, string[]> = {
56  de: [
57    'Türchen 1: Ab heute ist offiziell Weihnachtszeit. Jede Datei, die du heute anlegst, riecht ein bisschen nach Zimt.',
58    'Türchen 2: Ein heißer Kakao mit doppelt Sahne. Kalorien zählen im Advent nicht, das ist wissenschaftlich unbewiesen.',
59    'Türchen 3: Heute darfst du beim Warten auf einen Build ein Weihnachtslied summen. Leise. Oder laut.',
60    'Türchen 4: Barbaratag. Stell einen Kirschzweig ins Wasser. Wenn er an Heiligabend blüht, läuft nächstes Jahr alles.',
61    'Türchen 5: Fun Fact: In Weihnachtsfilmen schneit es genau dann, wenn sich zwei Menschen endlich verstehen. Genau wie bei Merge-Konflikten.',
62    'Türchen 6: Nikolaus! Stiefel geputzt? Für dich liegt ein Bugfix drin, der von ganz allein funktioniert.',
63    'Türchen 7: Heute ist ein guter Tag, um jemandem ehrlich danke zu sagen. Mit Ausrufezeichen.',
64    'Türchen 8: Die Kleinstadt hat abgestimmt: Du bist Mitarbeiter des Monats. Die Urkunde hängt im Bäckerladen.',
65    'Türchen 9: Ein Lebkuchenherz mit der Aufschrift „Läuft.“ Gefunden zwischen zwei Commits.',
66    'Türchen 10: Mach heute fünf Minuten früher Feierabend und guck aus dem Fenster. Vielleicht schneit es ja.',
67    'Türchen 11: Die Wichtel haben deine Todo-Liste gesehen. Sie sagen: Zwei Punkte darfst du ins neue Jahr schieben.',
68    'Türchen 12: Glühwein-Gutschein. Gültig ab 18 Uhr, einzulösen bei dir selbst.',
69    'Türchen 13: Lucia-Tag. Ein Licht für die dunkelste Zeit des Jahres, und für jeden grünen Test.',
70    'Türchen 14: Heute wird nicht gemeckert, nur gestaunt. Über Schnee, über Lichter, über Code, der beim ersten Mal läuft.',
71    'Türchen 15: Halbzeit im Dezember. Zeit für eine zweite Runde Plätzchen.',
72    'Türchen 16: Ein Rentier hat deinen letzten Pull Request gelesen und vorsichtig genickt.',
73    'Türchen 17: Weihnachtsfilm-Regel Nr. 1: Wer in der Großstadt zu viel arbeitet, findet in der Kleinstadt sein Glück. Du bist gewarnt.',
74    'Türchen 18: Heute ein Lied auf Wiederholung: „Last Christmas“. Wer es zuerst hört, hat verloren.',
75    'Türchen 19: Der Bürgermeister lässt ausrichten: Das Lichterfest findet statt, und du bist eingeladen.',
76    'Türchen 20: Noch vier Tage. Die Geschenke müssen jetzt aber wirklich mal gekauft werden.',
77    'Türchen 21: Wintersonnenwende. Ab morgen werden die Tage wieder länger. Es geht bergauf.',
78    'Türchen 22: Ein handgeschriebener Zettel unterm Teller: „Du hast das dieses Jahr wirklich gut gemacht.“',
79    'Türchen 23: Morgen ist es so weit. Heute noch einmal tief durchatmen und den Laptop zuklappen.',
80    'Türchen 24: Frohe Weihnachten! Heute wird nichts mehr deployed. Nur noch Kerzen angezündet und Plätzchen gegessen.',
81  ],
82  en: [
83    'Door 1: The holiday season is officially open. Every file you create today smells faintly of cinnamon.',
84    'Door 2: A hot cocoa with extra whipped cream. Calories don\'t count in December. Science is still checking.',
85    'Door 3: You are hereby allowed to hum a carol while the build runs. Quietly. Or not.',
86    'Door 4: Put a cherry branch in water today. If it blooms by Christmas Eve, next year is going to be great.',
87    'Door 5: Fun fact: in holiday movies it starts snowing the moment two people finally understand each other. Just like merge conflicts.',
88    'Door 6: St. Nicholas Day! Shoes polished? There\'s a bug fix inside that just works.',
89    'Door 7: A good day to tell someone a sincere thank you. With an exclamation mark.',
90    'Door 8: The town has voted: you are Employee of the Month. The certificate hangs in the bakery window.',
91    'Door 9: A gingerbread heart that says "Nailed it." Found between two commits.',
92    'Door 10: Log off five minutes early today and look out the window. It might be snowing.',
93    'Door 11: The elves have seen your to-do list. They say two items may move to next year.',
94    'Door 12: A voucher for mulled wine. Valid after 6 pm, redeemable with yourself.',
95    'Door 13: St. Lucia\'s Day. A light for the darkest time of the year, and one for every green test.',
96    'Door 14: No grumbling today, only wonder. At snow, at lights, at code that runs the first time.',
97    'Door 15: Halfway through December. Time for a second batch of cookies.',
98    'Door 16: A reindeer read your last pull request and gave a careful nod.',
99    'Door 17: Holiday movie rule #1: whoever works too hard in the big city finds happiness in a small town. Consider yourself warned.',
100    'Door 18: Today\'s song: "Last Christmas". Whoever hears it first loses.',
101    'Door 19: The mayor sends word: the tree lighting is on, and you are invited.',
102    'Door 20: Four days to go. The presents really need buying now.',
103    'Door 21: Winter solstice. From tomorrow the days get longer again. Uphill from here.',
104    'Door 22: A handwritten note under your plate: "You did really well this year."',
105    'Door 23: Tomorrow\'s the day. Take a deep breath and close the laptop.',
106    'Door 24: Merry Christmas! Nothing gets deployed today. Only candles lit and cookies eaten.',
107  ],
108}
109
110// Kleine Überraschungen nach langen Läufen.
111export const UEBERRASCHUNG: Record<Sprache, string[]> = {
112  de: [
113    'Ein Rentier hat deinen Build gelobt.',
114    'Draußen hat es gerade angefangen zu schneien. Rein zufällig.',
115    'Die Wichtel haben mitgezählt: Das war gute Arbeit.',
116    'Jemand hat dir einen Lebkuchen auf den Schreibtisch gelegt.',
117    'Der Bäcker hat dein Lieblingsplätzchen zurückgelegt.',
118    'Ein Schneemann vor dem Fenster winkt dir zu.',
119    'Die Kleinstadt-Zeitung titelt: „Großer Fortschritt im Projekt!“',
120    'Ein Glöckchen hat geklingelt. Irgendwo hat ein Engel seine Flügel bekommen.',
121    'Die Lichterkette blinkt jetzt im Takt deiner Tests.',
122    'Der Weihnachtsmann hat dich auf die Liste der Braven gesetzt. Endgültig.',
123  ],
124  en: [
125    'A reindeer just praised your build.',
126    'It started snowing outside. Purely by coincidence.',
127    'The elves were counting: that was good work.',
128    'Someone left a gingerbread cookie on your desk.',
129    'The baker set aside your favourite cookie.',
130    'A snowman outside your window is waving at you.',
131    'The town paper\'s headline: "Big Progress on the Project!"',
132    'A bell just rang. Somewhere an angel got its wings.',
133    'The string lights now blink in time with your tests.',
134    'Santa moved you to the nice list. For good.',
135  ],
136}
137
138export const GRUSS: Record<'advent1' | 'advent2' | 'advent3' | 'advent4' | 'heiligabend' | 'weihnachten1' | 'weihnachten2' | 'silvester' | 'neujahr', Satz> = {
139  advent1: { de: 'Erster Advent! Die erste Kerze brennt. Ab heute ist hier alles weihnachtlich.', en: 'First Sunday of Advent! The first candle is lit. From today, everything here is festive.' },
140  advent2: { de: 'Zweiter Advent. Zwei Kerzen, doppelt so gemütlich.', en: 'Second Sunday of Advent. Two candles, twice as cozy.' },
141  advent3: { de: 'Dritter Advent. Drei Kerzen, und der Duft von Zimt liegt in der Luft.', en: 'Third Sunday of Advent. Three candles and a hint of cinnamon in the air.' },
142  advent4: { de: 'Vierter Advent. Alle vier Kerzen brennen. Gleich ist es so weit.', en: 'Fourth Sunday of Advent. All four candles are lit. Almost there.' },
143  heiligabend: { de: 'Frohe Weihnachten! Schön, dass du da bist.', en: 'Merry Christmas! So glad you\'re here.' },
144  weihnachten1: { de: 'Erster Weihnachtstag. Reste essen ist heute Pflicht.', en: 'Christmas Day. Leftovers are mandatory today.' },
145  weihnachten2: { de: 'Zweiter Weihnachtstag. Noch ein Stück Stollen?', en: 'Boxing Day. One more slice of fruitcake?' },
146  silvester: { de: 'Silvester! Danke für dieses Jahr. Heute Abend knallt es.', en: 'New Year\'s Eve! Thanks for this year. Tonight we celebrate.' },
147  neujahr: { de: 'Frohes neues Jahr! Candy Cane verabschiedet sich heute und kommt im Herbst wieder.', en: 'Happy New Year! Candy Cane says goodbye today and returns next autumn.' },
148}
149
150export const T = {
151  titel: { de: 'Candy Cane', en: 'Candy Cane' },
152  bisAdvent: { de: (n: number) => (n === 1 ? 'Morgen ist der 1. Advent' : `Noch ${n} Tage bis zum 1. Advent`), en: (n: number) => (n === 1 ? 'Advent starts tomorrow' : `${n} days until Advent`) },
153  bisHalloween: { de: (n: number) => (n === 1 ? 'Morgen ist Halloween' : `Noch ${n} Tage bis Halloween`), en: (n: number) => (n === 1 ? 'Halloween is tomorrow' : `${n} days until Halloween`) },
154  halloween: { de: 'Heute ist Halloween. Süßes oder Saures!', en: 'Happy Halloween. Trick or treat!' },
155  bisHeiligabend: { de: (n: number) => (n === 1 ? 'Morgen ist Heiligabend' : `Noch ${n} Tage bis Heiligabend`), en: (n: number) => (n === 1 ? 'Christmas Eve is tomorrow' : `${n} days until Christmas Eve`) },
156  heiligabend: { de: 'Heiligabend. Frohe Weihnachten!', en: 'Christmas Eve. Merry Christmas!' },
157  weihnachtstag: { de: (n: number) => `${n}. Weihnachtstag`, en: (n: number) => (n === 1 ? 'Christmas Day' : 'Boxing Day') },
158  zwischen: { de: (n: number) => `Zwischen den Jahren · noch ${n} Tage bis Silvester`, en: (n: number) => `Between the years · ${n} days until New Year's Eve` },
159  silvester: {
160    de: (std: number, min: number, jahr: number) => `Noch ${std} Std. ${min} Min. bis ${jahr}`,
161    en: (std: number, min: number, jahr: number) => `${std}h ${min}m until ${jahr}`,
162  },
163  neujahr: { de: (jahr: number) => `Frohes neues Jahr ${jahr}!`, en: (jahr: number) => `Happy New Year ${jahr}!` },
164  kerzen: { de: (n: number) => `${n}. Advent`, en: (n: number) => `Advent ${n}` },
165  tuerZu: { de: 'Türchen öffnen', en: 'Open today\'s door' },
166  tuerHeute: { de: 'Heutiges Türchen', en: 'Today\'s door' },
167  tuerVorfreude: { de: 'Ab dem 1. Dezember öffnet sich hier jeden Tag ein Türchen.', en: 'From December 1st a new door opens here every day.' },
168  stufe: { de: 'Stufe', en: 'Mode' },
169  stufen: {
170    auto: { de: 'Automatisch', en: 'Automatic' },
171    an: { de: 'Voll an', en: 'Full on' },
172    dezent: { de: 'Dezent', en: 'Subtle' },
173    aus: { de: 'Aus', en: 'Off' },
174  },
175  ausserSaison: { de: 'Außer Saison. Candy Cane schläft bis Oktober.', en: 'Out of season. Candy Cane sleeps until October.' },
176  herbstHinweis: {
177    de: 'Bis Halloween ist Herbst. Ab dem 1. November zählt Candy Cane zum Advent, ab dem 1. Advent kommen Schnee, Türchen und Wartetexte.',
178    en: 'Autumn until Halloween. From November 1st Candy Cane counts down to Advent; on the first Sunday of Advent snow, doors and festive spinners arrive.',
179  },
180  countdownHinweis: {
181    de: 'Ab dem 1. Advent schaltet sich alles von allein ein: Schnee, Türchen, Wartetexte und kleine Überraschungen.',
182    en: 'On the first Sunday of Advent everything switches on by itself: snow, doors, festive spinner and little surprises.',
183  },
184}
185
186export const waehle = <X>(liste: X[], zahl: number) => liste[Math.abs(Math.floor(zahl)) % liste.length] as X
187
types/index.d.ts 30 lines
1export type Stufe = 'auto' | 'an' | 'dezent' | 'aus'
2// Öffentlich für andere Mods: Saison und Farben. Liest jeder Mod mit
3// `$.state.get({ plugin: 'candy-cane', key: 'saison' })`; nicht gesetzt oder `aktiv: false` heißt: normale Farben.
4export type Saison = {
5  aktiv: boolean
6  phase: 'aus' | 'herbst' | 'countdown' | 'advent' | 'weihnachten' | 'zwischen' | 'silvester' | 'neujahr'
7  modus: 'aus' | 'countdown' | 'dezent' | 'voll'
8  // Farben für dunklen und hellen Grund gleichermaßen lesbar.
9  farben: { akzent: string; gold: string; gut: string; warn: string; schlecht: string; info: string; neutral: string }
10  // Ein Zeichen, das andere Mods vor Überschriften oder Toasts setzen können.
11  zeichen: string
12}
13// anstrich: fremde Mods umfärben; thema: Claude-Code-Farbthema in der Saison umschalten. Fehlt der Wert, gilt an.
14export type Einstellung = { stufe: Stufe; sprache: 'de' | 'en'; anstrich?: boolean; thema?: boolean }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'candy-cane': {
19      einstellung: Einstellung
20      // Ein vorgetäuschtes Datum für Vorschau und Tests, `2026-12-24` oder `2026-12-31T23:10`.
21      testDatum: string | null
22      // Grober Zeitschlüssel; wechselt er, zeichnen Band und Fenster neu.
23      takt: string
24      // Datum, an dem das Türchen zuletzt geöffnet wurde.
25      tuerOffen: string
26      saison: Saison
27    }
28  }
29}
30