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

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 richtet sich nach dem Kalender (Berliner Zeit):
| Zeitraum | Was passiert |
|---|---|
| Oktober | Herbst und Countdown bis Halloween. Am 31. heißt es „Süßes oder Saures!“ |
| ab 1. November | Countdown bis zum 1. Advent, und wie viele Tage es noch bis Heiligabend sind |
| ab dem 1. Advent | Schnee, Lichterkette, Adventskerzen, jeden Tag ein Türchen im Adventskalender und weihnachtliche Wartetexte, solange Claude arbeitet |
| Weihnachten bis Silvester | Festtagsgrüße, an Silvester ein Countdown bis Mitternacht |
| ab 2. Januar | Die 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.
~/.claude/mods/candy-cane: ``bash git clone https://github.com/philipkleudgen/candy-cane-mod ~/.claude/mods/candy-cane ``~/.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.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.
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
hooks/register.tsx 376 lines1import { 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}
376hooks/ansage.ts 120 lines1// 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})
120hooks/kalender.ts 99 lines1// 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)
99hooks/anstrich.ts 96 lines1// 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}`)
96hooks/svg.ts 282 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
282hooks/texte.ts 187 lines1// 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
187types/index.d.ts 30 lines1export 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