+
diff --git a/package.json b/package.json
index 0ca7a80..a3a74e0 100644
--- a/package.json
+++ b/package.json
@@ -12,12 +12,13 @@
"build:background": "node scripts/build-background.mjs",
"build:credits": "node scripts/build-credits.mjs",
"build:coin-bubble": "node scripts/build-coin-bubble.mjs",
+ "build:seasons": "node scripts/build-seasons.mjs",
"sync:contributions": "node scripts/sync-contributions.mjs",
"sync:readme": "node scripts/sync-readme.mjs",
- "pretest": "npm run build:access && npm run build:projects && npm run build:background && npm run build:coin-bubble && npm run build:credits && npm run sync:contributions && npm run sync:readme",
+ "pretest": "npm run build:access && npm run build:projects && npm run build:background && npm run build:coin-bubble && npm run build:seasons && npm run build:credits && npm run sync:contributions && npm run sync:readme",
"test": "node --test \"tests/**/*.test.mjs\" \"packages/*/test/*.test.mjs\"",
"test:android": "playwright test",
- "predev": "npm run build:access && npm run build:projects && npm run build:background && npm run build:coin-bubble && npm run build:credits && npm run sync:contributions && npm run sync:readme",
+ "predev": "npm run build:access && npm run build:projects && npm run build:background && npm run build:coin-bubble && npm run build:seasons && npm run build:credits && npm run sync:contributions && npm run sync:readme",
"prebuild": "npm test && npm run sync:devto",
"build": "nuxt build",
"start": "nuxt start",
diff --git a/scripts/build-seasons.mjs b/scripts/build-seasons.mjs
new file mode 100644
index 0000000..3cf6818
--- /dev/null
+++ b/scripts/build-seasons.mjs
@@ -0,0 +1,21 @@
+// Writes data/seasons.generated.mjs from config.toml [seasons] before every build, and fails the
+// build on a bad setting (same as the other config sections).
+import { readFile, writeFile } from 'node:fs/promises'
+import { parse } from 'smol-toml'
+import { buildSeasonsConfig, seasonsModule, themeFor } from '../utils/seasons.mjs'
+import { dayIn } from '../utils/coinBubble.mjs'
+
+const root = new URL('../', import.meta.url)
+const toml = parse((await readFile(new URL('config.toml', root), 'utf8').catch(() => '')) || '')
+const { config, problems } = buildSeasonsConfig(toml)
+
+if (problems.length) {
+ console.error(`[build-seasons] Can't build the seasons config:\n ${problems.join('\n ')}`)
+ process.exit(1)
+}
+
+await writeFile(new URL('data/seasons.generated.mjs', root), seasonsModule(config))
+const today = themeFor(config, dayIn(new Date()))
+console.log(
+ `[build-seasons] ${config.enabled ? `${config.weeks.length} weekly themes, ${config.events.length} event(s); today: ${today?.name ?? 'none'}` : 'off'}`
+)
diff --git a/tests/app/seasons.test.mjs b/tests/app/seasons.test.mjs
new file mode 100644
index 0000000..3ca9e72
--- /dev/null
+++ b/tests/app/seasons.test.mjs
@@ -0,0 +1,84 @@
+// Seasonal themes (config.toml [seasons]): a theme for every week of the year, dated events
+// taking over while they run, and the drifting emoji's layout.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { parse } from 'smol-toml'
+import { buildSeasonsConfig, monthDay, particleLayout, themeFor } from '../../utils/seasons.mjs'
+
+const toml = parse(await readFile(new URL('../../config.toml', import.meta.url), 'utf8'))
+const { config, problems } = buildSeasonsConfig(toml)
+
+test("the site's config.toml [seasons] builds", () => {
+ assert.deepEqual(problems, [])
+ assert.equal(config.weeks.length, 52)
+})
+
+test('every day of the year has a theme, leap days included', () => {
+ for (const year of [2026, 2028]) {
+ for (let d = new Date(Date.UTC(year, 0, 1)); d.getUTCFullYear() === year; d.setUTCDate(d.getUTCDate() + 1)) {
+ const day = d.toISOString().slice(0, 10)
+ assert.ok(themeFor(config, day), day)
+ }
+ }
+ assert.equal(themeFor(config, '2028-02-29'), themeFor(config, '2028-02-28'))
+})
+
+test('October is spooky, and All Things Open takes over during the event', () => {
+ assert.match(themeFor(config, '2026-10-31').name, /Halloween/)
+ assert.ok(themeFor(config, '2026-10-31').emoji.includes('🎃'))
+ assert.match(themeFor(config, '2026-10-16').name, /Spooky/)
+ for (const day of ['2026-10-19', '2026-10-20', '2026-10-21']) assert.equal(themeFor(config, day).name, 'All Things Open')
+ assert.notEqual(themeFor(config, '2026-10-22').name, 'All Things Open')
+ assert.notEqual(themeFor(config, '2027-10-20').name, 'All Things Open') // dated: one year only
+})
+
+test('GitHub Universe takes over Oct 28–30, 2026, and Halloween is still Halloween', () => {
+ for (const day of ['2026-10-28', '2026-10-29', '2026-10-30']) {
+ const theme = themeFor(config, day)
+ assert.equal(theme.name, 'GitHub Universe', day)
+ assert.ok(theme.emoji.includes('🐙'))
+ assert.deepEqual(theme.colors, ['#a371f7', '#3fb950'])
+ }
+ assert.notEqual(themeFor(config, '2026-10-27').name, 'GitHub Universe')
+ assert.match(themeFor(config, '2026-10-31').name, /Halloween/)
+ assert.notEqual(themeFor(config, '2027-10-29').name, 'GitHub Universe') // dated: one year only
+})
+
+test('a few holidays land in their week', () => {
+ assert.match(themeFor(config, '2026-01-01').name, /New Year/)
+ assert.match(themeFor(config, '2026-02-14').name, /Valentine/)
+ assert.match(themeFor(config, '2026-03-17').name, /Patrick/)
+ assert.match(themeFor(config, '2026-07-04').name, /Fourth of July/)
+ assert.match(themeFor(config, '2026-12-25').name, /Holidays/)
+ assert.match(themeFor(config, '2026-12-31').name, /New Year's Eve/)
+})
+
+test('weeks must start on Jan 1 and run in order', () => {
+ const week = from => ({ from, name: 'x', emoji: ['⭐'], colors: ['#ffffff', '#000000'] })
+ assert.ok(buildSeasonsConfig({ seasons: { week: [week('01-08')] } }).problems.length)
+ assert.ok(buildSeasonsConfig({ seasons: { week: [week('01-01'), week('03-01'), week('02-01')] } }).problems.length)
+ assert.deepEqual(buildSeasonsConfig({ seasons: { enabled: true, week: [week('01-01'), week('06-01')] } }).problems, [])
+})
+
+test('off, or no date, means no theme', () => {
+ assert.equal(themeFor({ ...config, enabled: false }, '2026-10-31'), null)
+ assert.equal(themeFor(config, undefined), null)
+ assert.equal(monthDay('02-29'), null)
+ assert.equal(monthDay('12-31'), '12-31')
+})
+
+test('particles cycle through the emoji and stay on screen', () => {
+ const theme = themeFor(config, '2026-10-31')
+ const values = [0, 0.5, 0.999]
+ let i = 0
+ const layout = particleLayout(theme, 12, () => values[i++ % values.length])
+ assert.equal(layout.length, 12)
+ assert.deepEqual(layout.slice(0, theme.emoji.length).map(p => p.emoji), theme.emoji)
+ for (const p of layout) {
+ assert.ok(p.x >= 0 && p.x <= 100)
+ assert.ok(p.duration >= 14 && p.duration <= 28)
+ assert.ok(p.delay <= 0 && -p.delay <= p.duration)
+ }
+ assert.deepEqual(particleLayout(null, 12), [])
+})
diff --git a/tests/security/payloads.mjs b/tests/security/payloads.mjs
index 3265973..fb390aa 100644
--- a/tests/security/payloads.mjs
+++ b/tests/security/payloads.mjs
@@ -121,6 +121,19 @@ export const HOSTILE_README = [
// Long input for catastrophic-backtracking checks
export const REDOS = 'a'.repeat(50_000) + '!'
+// Colours from config.toml [seasons], bound into the background's SVG gradient
+export const HOSTILE_COLORS = [
+ 'red',
+ '#fff',
+ '#ffffff;}',
+ '#12345g',
+ 'url(javascript:alert(1))',
+ 'expression(alert(1))',
+ '#ffffff" onload="alert(1)',
+ 'var(--x)',
+ ' #ffffff',
+]
+
// The id on the social link hand-off page (/out/
): only known ids lead anywhere
export const OUTBOUND_IDS = [
'https://evil.example',
diff --git a/tests/security/seasons-config.test.mjs b/tests/security/seasons-config.test.mjs
new file mode 100644
index 0000000..4f349f5
--- /dev/null
+++ b/tests/security/seasons-config.test.mjs
@@ -0,0 +1,60 @@
+// config.toml [seasons] is written into a generated module at build time
+// (scripts/build-seasons.mjs). Its emoji are shown as page text and its colours are bound into
+// the background's SVG gradient. Only known settings with checked values get through.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { buildSeasonsConfig, seasonsModule, SEASONS_DEFAULTS } from '../../utils/seasons.mjs'
+import { HOSTILE_COLORS, JS_BREAKOUTS, PROTOTYPE_KEYS, XSS_HTML } from './payloads.mjs'
+
+const week = (theme = {}) => ({ from: '01-01', name: 'Snow', emoji: ['❄️'], colors: ['#93c5fd', '#e0f2fe'], ...theme })
+const build = (seasons) => buildSeasonsConfig({ seasons: { enabled: true, ...seasons } })
+
+test('unknown or hostile keys fail the build and never reach the module', () => {
+ for (const key of [...PROTOTYPE_KEYS, ...JS_BREAKOUTS, 'html', 'script']) {
+ const { config, problems } = buildSeasonsConfig({ seasons: { [key]: 1 } })
+ assert.ok(problems.length >= 1, key)
+ assert.deepEqual(Object.keys(config).sort(), Object.keys(SEASONS_DEFAULTS).sort())
+ const nested = build({ week: [week({ [key]: 1 })] })
+ assert.ok(nested.problems.length >= 1, key)
+ assert.ok(!Object.hasOwn(nested.config.weeks[0] ?? {}, key), key)
+ }
+})
+
+test('hostile colours are refused', () => {
+ for (const color of [...HOSTILE_COLORS, ...XSS_HTML, ...JS_BREAKOUTS]) {
+ const { config, problems } = build({ week: [week({ colors: [color, '#ffffff'] })] })
+ assert.ok(problems.length >= 1, color)
+ assert.equal(config.weeks.length, 0, color)
+ }
+})
+
+test('markup, scripts and words are refused as emoji', () => {
+ for (const emoji of [...XSS_HTML, ...JS_BREAKOUTS, 'abc', '"', '<', ' ', '', 42, null]) {
+ const { config, problems } = build({ week: [week({ emoji: [emoji] })] })
+ assert.ok(problems.length >= 1, JSON.stringify(emoji))
+ assert.equal(config.weeks.length, 0)
+ }
+})
+
+test('markup is refused as a name, and dates must be real days', () => {
+ for (const name of [...XSS_HTML, '', 'x'.repeat(41)]) {
+ assert.ok(build({ week: [week({ name })] }).problems.length >= 1, name)
+ }
+ for (const from of ['02-29', '13-01', '1-1', '01-01; x', '__proto__', 20260101]) {
+ assert.ok(build({ week: [week({ from })] }).problems.length >= 1, String(from))
+ }
+ for (const [from, to] of [['2026-10-21', '2026-10-19'], ['2026-02-30', '2026-03-01'], ['x', 'y']]) {
+ const event = { name: 'Event', from, to, emoji: ['⭐'], colors: ['#ffffff', '#000000'] }
+ assert.ok(build({ week: [week()], event: [event] }).problems.length >= 1, `${from} ${to}`)
+ }
+})
+
+test('hostile names stay strings in the generated module: nothing runs', async () => {
+ for (const payload of JS_BREAKOUTS.filter(p => !/[<>]/.test(p))) {
+ const { config, problems } = build({ week: [week({ name: payload })] })
+ assert.deepEqual(problems, [])
+ const mod = await import(`data:text/javascript,${encodeURIComponent(seasonsModule(config))}`)
+ assert.equal(mod.default.weeks[0].name, payload)
+ assert.equal(globalThis.pwned, undefined)
+ }
+})
diff --git a/utils/seasons.mjs b/utils/seasons.mjs
new file mode 100644
index 0000000..7df445f
--- /dev/null
+++ b/utils/seasons.mjs
@@ -0,0 +1,156 @@
+// Seasonal themes: themed emoji drifting over the background (components/SeasonalParticles.vue)
+// and the topographic lines in the season's colours (components/TopoBackground.vue). Read from
+// config.toml [seasons] by scripts/build-seasons.mjs; anything wrong fails the build, like the
+// other config sections. A theme for every week of the year, plus dated events that take over
+// while they run. Picked in the browser from today's date, so the themes change without a rebuild.
+import { calendarDay } from './coinBubble.mjs'
+
+export const SEASONS_DEFAULTS = {
+ enabled: false,
+ particles: 12,
+ weeks: [],
+ events: []
+}
+
+export const MOTIONS = ['fall', 'rise'] // drift down (leaves, snow) or float up (bubbles, ghosts)
+export const MAX_EMOJI = 8
+export const MAX_NAME = 40
+const MAX_EMOJI_LENGTH = 16 // one emoji, with skin tone / ZWJ sequences / flags
+const MONTH_DAY = /^(\d{2})-(\d{2})$/
+const COLOR = /^#[0-9a-f]{6}$/i
+// One emoji (or a short symbol): no markup, quotes, spaces or escapes
+const EMOJI_UNSAFE = /[<>&"'`\\=\s]/
+const ALPHANUMERIC = /^[a-z0-9]+$/i
+
+// "MM-DD" for a real day of the year (Feb 29 isn't one: not every year has it), or null
+export const monthDay = value => {
+ const match = typeof value === 'string' ? MONTH_DAY.exec(value) : null
+ if (!match) return null
+ const [m, d] = [Number(match[1]), Number(match[2])]
+ const date = new Date(Date.UTC(2025, m - 1, d))
+ return date.getUTCMonth() === m - 1 && date.getUTCDate() === d ? value : null
+}
+
+// The theme's checked settings, or null when any is wrong (the build fails then anyway)
+const checkTheme = (theme, where, problems) => {
+ const out = {}
+ const before = problems.length
+ if (typeof theme !== 'object' || theme === null || Array.isArray(theme)) {
+ problems.push(`${where} must be a table`)
+ return null
+ }
+ const name = typeof theme.name === 'string' ? theme.name.replace(/\s+/g, ' ').trim() : ''
+ if (name && name.length <= MAX_NAME && !/[<>]/.test(name)) out.name = name
+ else problems.push(`${where} name must be 1 to ${MAX_NAME} characters of plain text`)
+
+ const emoji = Array.isArray(theme.emoji) ? theme.emoji : []
+ const goodEmoji = emoji.every(
+ e => typeof e === 'string' && e.length > 0 && e.length <= MAX_EMOJI_LENGTH && !EMOJI_UNSAFE.test(e) && !ALPHANUMERIC.test(e)
+ )
+ if (emoji.length >= 1 && emoji.length <= MAX_EMOJI && goodEmoji) out.emoji = [...emoji]
+ else problems.push(`${where} emoji must be a list of 1 to ${MAX_EMOJI} emoji`)
+
+ const colors = Array.isArray(theme.colors) ? theme.colors : []
+ if (colors.length === 2 && colors.every(c => typeof c === 'string' && COLOR.test(c))) out.colors = colors.map(c => c.toLowerCase())
+ else problems.push(`${where} colors must be two colours like "#f97316"`)
+
+ const motion = theme.motion ?? 'fall'
+ if (MOTIONS.includes(motion)) out.motion = motion
+ else problems.push(`${where} motion must be ${MOTIONS.map(m => `"${m}"`).join(' or ')}`)
+ return problems.length === before ? out : null
+}
+
+const THEME_KEYS = new Set(['name', 'emoji', 'colors', 'motion'])
+const unknownKeys = (table, allowed, where, problems) => {
+ if (typeof table !== 'object' || table === null) return
+ for (const key of Object.keys(table)) if (!allowed.has(key)) problems.push(`${where} ${JSON.stringify(key)} isn't a setting`)
+}
+
+export const buildSeasonsConfig = (toml = {}) => {
+ const section = toml?.seasons ?? {}
+ const config = { ...SEASONS_DEFAULTS, weeks: [], events: [] }
+ const problems = []
+ if (typeof section !== 'object' || Array.isArray(section)) {
+ return { config, problems: ['[seasons] must be a table of settings'] }
+ }
+
+ for (const [key, value] of Object.entries(section)) {
+ if (key === 'enabled') {
+ if (typeof value === 'boolean') config.enabled = value
+ else problems.push('[seasons] enabled must be true or false')
+ } else if (key === 'particles') {
+ if (Number.isInteger(value) && value >= 0 && value <= 40) config.particles = value
+ else problems.push('[seasons] particles must be a whole number from 0 to 40')
+ } else if (key === 'week') {
+ if (!Array.isArray(value)) {
+ problems.push('[seasons] week must be a list of [[seasons.week]] tables')
+ continue
+ }
+ value.forEach((week, i) => {
+ const where = `[[seasons.week]] #${i + 1}`
+ unknownKeys(week, new Set([...THEME_KEYS, 'from']), where, problems)
+ const theme = checkTheme(week, where, problems)
+ const from = monthDay(week?.from)
+ if (!from) problems.push(`${where} from must be a day like "10-29" (month-day, not Feb 29)`)
+ if (theme && from) config.weeks.push({ from, ...theme })
+ })
+ } else if (key === 'event') {
+ if (!Array.isArray(value)) {
+ problems.push('[seasons] event must be a list of [[seasons.event]] tables')
+ continue
+ }
+ value.forEach((event, i) => {
+ const where = `[[seasons.event]] #${i + 1}`
+ unknownKeys(event, new Set([...THEME_KEYS, 'from', 'to']), where, problems)
+ const theme = checkTheme(event, where, problems)
+ const from = calendarDay(event?.from)
+ const to = calendarDay(event?.to)
+ if (!from || !to || from > to) problems.push(`${where} from and to must be dates like 2026-10-19, from first`)
+ else if (theme) config.events.push({ from, to, ...theme })
+ })
+ } else {
+ problems.push(`[seasons] ${JSON.stringify(key)} isn't a setting`)
+ }
+ }
+
+ // The weeks run in order from Jan 1, each until the next one starts, so every day has a theme
+ const starts = config.weeks.map(week => week.from)
+ if (config.weeks.length && starts[0] !== '01-01') problems.push('[[seasons.week]] the first week must start on "01-01"')
+ for (let i = 1; i < starts.length; i++) {
+ if (starts[i] <= starts[i - 1]) problems.push(`[[seasons.week]] #${i + 1} must start after #${i} (${starts[i]} isn't after ${starts[i - 1]})`)
+ }
+ if (config.enabled && !config.weeks.length) problems.push('[seasons] needs [[seasons.week]] themes when enabled')
+ return { config, problems }
+}
+
+// The theme for a day ("YYYY-MM-DD"): an event running that day, or else that day's week. Null
+// when seasons are off. Feb 29 belongs to the week running on Feb 28.
+export const themeFor = (config, day) => {
+ if (!config?.enabled || typeof day !== 'string') return null
+ const event = config.events.find(e => e.from <= day && day <= e.to)
+ if (event) return event
+ const md = day.slice(5)
+ let week = null
+ for (const w of config.weeks) if (w.from <= md) week = w
+ return week
+}
+
+// Where each particle starts and how it moves, picked at random for every page load. `random`
+// returns 0 to 1 (Math.random), so tests can pass their own.
+export const particleLayout = (theme, count, random = Math.random) =>
+ Array.from({ length: theme ? count : 0 }, (_, i) => {
+ const duration = 14 + random() * 14 // seconds to cross the screen
+ return {
+ id: i,
+ emoji: theme.emoji[i % theme.emoji.length],
+ x: Math.round(random() * 1000) / 10, // % across
+ size: Math.round((1 + random() * 1.1) * 100) / 100, // rem
+ duration: Math.round(duration * 10) / 10,
+ delay: -Math.round(random() * duration * 10) / 10, // already on screen at load
+ sway: Math.round((2.5 + random() * 3) * 10) / 10, // seconds per side-to-side
+ spin: Math.round((random() - 0.5) * 720) // degrees over the whole crossing
+ }
+ })
+
+export const seasonsModule = config =>
+ `// Generated by scripts/build-seasons.mjs from config.toml [seasons]. Don't edit.\nexport default ${JSON.stringify(config, null, 2)}\n`