Seasonal Look — Code and Artwork
The seasonal dashboard look (user-facing behaviour: Preferences → Seasonal Look) lives in apps/web: the season calendar and looks in src/lib/seasons/registry.ts, the slots and artwork components in src/components/seasons/.
Artwork source of truth: assets/seasons/<season-id>/ at the repository root (for example assets/seasons/halloween/, with a README.md per season listing each file's use and colour). The files are plain SVG, drawn in-house, with no third-party licence. The React components copy each file's geometry verbatim — ids become useId()-unique, class and data hooks follow the app's CSS, colours may move to CSS — and apps/web/__tests__/components/season-assets-parity.test.tsx fails when a component drifts from its file or when a file in the folder is not registered there. To change a graphic, change the SVG file first, then the component.
Scenery colours and motion
Scenery colours are season tokens, declared per theme in the registry entry (look.tokens.light / look.tokens.dark) and emitted only under html[data-season=<id>]. Halloween uses --season-moon, --season-moon-halo, --season-bat and --season-spider; unlike the base-theme tokens these may carry their own alpha (H S% L% / a), because light and dark need different strengths. Components read them as hsl(var(--season-…)) — never a hard-coded colour.
Motion has two layers, both transform/opacity only:
- CSS (
src/lib/seasons/styles.ts): slow drifts and floats (.season-bat,.season-float), defined only inside@media (prefers-reduced-motion: no-preference)and underhtml[data-season-motion="auto"]. The one exception is the loader's.season-ghost: a loading indicator must always signal activity, so its base rule (outside the media query) is an opacity-only pulse in sequence (0.3 → 1, 1.2 s, staggered 0.2 s); with motion allowed the same elements switch to a bob-and-sway (≈ 7 px, 1.2 s, staggered 0.15 s) — except inside a surface whose own Animations override (Chat, Events, Music) is Reduced or Off (data-motionon the surface root): an ungated rule with one more attribute keeps the pulse there. - Web Animations for anything random — the sidebar logo (
src/lib/seasons/logo-motion.ts) and the Halloween scenery's wing-flap bursts and fly-by (src/lib/seasons/scenery-motion.ts). The timing logic is DOM-free and takes an injected RNG and clock (tests:apps/web/__tests__/seasons-logo-motion.test.ts,seasons-scenery-motion.test.ts). The scenery schedules only whilesceneryMayMove()holds — the season is active at Full, Animations: Automatic, no reduced motion, tab visible — and a stop drops every pending timer, so nothing is caught up. The fly-by chain schedules its next wait only after the current flight has finished, so two fly-bys never overlap. The corner-web spider works the same way (src/lib/seasons/spider-motion.ts,spiderMayMove()= switched on andsceneryMayMove()): one wait → move → wait chain, 20–40 s waits, a crawl along one strand of the web (it stays where it stopped) or, about a quarter of the time, a drop on a thread and back. Each season'sScenerycomponent receives{ active, strength, motion, creatures }fromSeasonDecorations.
Loading indicator
Wrap a standalone loading spinner — page, section, panel, list, modal body — in SeasonalSpinner (src/components/seasons/seasonal-slots.tsx); its children are the regular spinner. In scope it renders a role="status" container with an sr-only "Loading…" (seasons.loading), the regular spinner (data-season-default="full") and per season three aria-hidden copies of the season's Icon (size: sm / md / lg = 16 / 20 / 24 px); outside the dashboard scope it renders only its children. Inline spinners inside buttons and inputs stay the regular spinner — three icons would change the button's width.
Creatures
Every animal a look depicts is declared in its registry entry, in settings order, with its default: look.creatures: [{ id: "bat", default: true }, { id: "spider", default: false }] for Halloween. Ids are shared across seasons (CreatureId) and labelled under seasons.creatures.<id> in messages/{en,de}.json — a test fails when one is missing. Adding an animal is one registry entry, its artwork and its label; the Creatures in the decoration settings row lists seasonCreatureList(inWindow) and needs no change.
- The profile stores only overrides (
me.seasonCreatures, absent key = default);seasonCreatures(list, overrides)turns them into the effective on/off map. SceneryandToastAccentreceive that map ascreaturesand leave a switched-off animal out entirely — no element, no timers.- Every element that draws an animal carries
data-season-creature="<id>". The pre-paint resolver gets each season's defaults throughseasonBootstrapConfig()and writes the hidden ones of the open season to<html data-season-hide="bat spider">; the generated stylesheet hides matching elements, so a switched-off animal never flashes before hydration.
Scope: dashboard and popouts
Two layouts mount the look: (main)/(app)/layout.tsx (the dashboard) and (popout)/layout.tsx (ZAF-1673: /popout/{chat,events,music,obs}, in a browser and inside OBS — there is deliberately no window.obsstudio exclusion). Each renders SeasonHead (stylesheet + pre-paint script) and SeasonProvider (season scope + client resolver); every slot renders only its regular variant outside that scope, so overlays, widgets, extensions, the (fullscreen) editors and the public/marketing routes get no seasonal markup. apps/web/__tests__/components/seasons.test.tsx fails when any other src/app file mounts them.
The popout layout passes popout to both. That switches the resolver to:
isolated— it neither reads nor writes the sharedlumio-seasonmirror cookie. A popout may run a delegated session of another user in the streamer's own browser, and a popout in OBS has a browser of its own, so the only preference source is the represented user's profile: SSR-embedded (popoutSessionfor a delegated session,mefor a logged-in popout — both queries selectSEASON_PROFILE_GQL_FIELDS), then re-read through/api/users/me/seasonwith the popout-aware fetch. No profile → the defaults. Passive profile sync never migrates legacy values or writes preferences; the two banner actions below are the only writes. A broadcast from a dashboard tab makes the popout re-read its own profile, and a popout banner write broadcasts only a request to re-read, never another user's values.urlOverrides—?season=off|on,?seasonStrength=subtle|full,?seasonMotion=off|autooverride the profile for that page, in the pre-paint script and in the client resolver alike. Unknown values are ignored; nothing is stored.
What a popout shows: colours, SeasonalSpinner, the events and chat empty states, the toast accent, and SeasonDecorations compact — a 96 px band behind the header with the season's Scenery in compact mode (Halloween: the corner web scaled to 0.6 with its spider, two bats and a small fly-by bat — flyByKeyframes(…, 24, 0.4) keeps the path inside the band and it runs on every width — no moon). ChatShell and EventsShell render it with seasonScenery (popout clients) and with seasonBanner (the full-height dashboard chat/events pages, which the app shell's page SeasonDecorations skips like every fullscreen page); the shell root then becomes isolate, so the -z-[1] band paints above the shell's opaque bg-card but below its content. The music and OBS popouts mount it on their own relative isolate roots.
Banner. SeasonBanner is one component with three variants. The dashboard shell renders the default (page) variant above the content on every (main)/(app) page except the fullscreen ones. The fullscreen chat and events pages show the bar variant instead, full width directly under their own topbar (ChatShell / EventsShell prop seasonBanner), above their AlertDeck; the overlay editor shows none. Those pages fill the shell's min-h-0 flex-1 slot with h-full and never size themselves to the viewport (h-screen, 100dvh), so every in-flow row shrinks the feed instead of clipping its bottom bar (chat input, events search); apps/web/__tests__/fullscreen-page-height.test.ts guards both rules. variant="compact" is the popout strip: ChatShell / EventsShell render it under their header when seasonScenery is set (it wins over seasonBanner), the music popout at the top of its root; the OBS control popout has none. bar and compact carry the same short copy and differ only in size (STRIP_SIZE): bar uses text-sm with h-8 controls and py-2, compact uses text-xs with h-6 controls and py-1.5, so the controls never touch the row edges, also when the text wraps in a narrow popout. Both strips have a primary-tinted background and a primary bottom border, so they read as a row of their own above the AlertDeck or the preview strip. SeasonPreviewMarker uses the same spacing as compact. Like every slot it is data-season-level="full", so Subtle, ?season=off, off-season and a dismissed instance hide it (and its height) through the generated stylesheet. Compact copy lives in a per-season bannerBodyCompact key (SEASON_MESSAGE_KEYS). × calls dismissSeasonBanner(instance); Switch off is a Button that calls switchOffSeasonLook(instance) — look off + dismissal in one write — and toasts the result: on the dashboard with a Settings link and Undo (restoreSeasonLook(dismissedBefore)), in a popout (useSeasonInPopout()) without Undo.
Emote button. The chat input bar's emote button (multichat.tsx) renders its icon through SeasonBarEmoteIcon({ barRef, busy, libraryOpen }) (season-bar-strip.tsx), which reads the same bar-idle rule as SeasonBarStrip and renders the SeasonalEmoteIcon slot (seasonal-slots.tsx). Its children are the regular Smile (data-season-default="subtle"); per season the slot renders the optional registry asset EmoteIcon (SeasonEmoteIconProps: active, strength, motion, idle, busy, libraryOpen) without a level attribute, so it shows at Subtle and Full, and a season without the asset keeps Smile. Halloween's asset is HalloweenEmoteIcon (halloween-emote-icon.tsx): GhostGlyph at 1.15em in currentColor, aria-hidden; the button carries the label (chat.emoteButton) and aria-expanded. At Full with sceneryMayMove() and the library closed it plays EMOTE_GLANCE_KEYFRAMES (the look-left/right frames of BAR_PEEK_KEYFRAMES without rise and sink, transform only, PEEK_MOTION.glanceMs). It owns no timer: it registers with the page's peek coordinator as a kind: "bar" strip with one anchor and weight: PEEK_MOTION.glanceWeight, so idle glances join the strip ghosts' rotation (anchor weights default to 1). Pointer-enter and focus call coordinator.request(strip, anchor), which starts a glance at once and replaces the pending wait, or refuses while any ghost is moving. The open emote library is passed on its own (libraryOpen={showEmoteLibrary}), apart from busy (inputBarBusy, e.g. the popout chat settings, which keeps the ghost still — no idle or hover glance). While the library is open and sceneryMayMove() allows motion, the ghost sways continuously: EMOTE_SWAY_KEYFRAMES (rest → the glance's left look → its right look → rest, pendulum easing, transform only) loop with iterations: Infinity over PEEK_MOTION.swayMs. The sway holds the coordinator's motion slot through coordinator.hold(strip): a running peek is cancelled, no wait runs and request() is refused until it is released, so it stays the page's one moving ghost. On close it animates from its current computed transform back to rest over PEEK_MOTION.swaySettleMs (ease-out) before it releases the slot, and the rotation resumes with a fresh random wait. If motion stops being allowed while the library is open (Subtle, Animations Off, reduced motion, hidden tab), the sway stops at once and the slot is released. apps/web/__tests__/components/halloween-emote-icon.test.tsx covers the state matrix and the coordination.
Building dashboard features that work with the seasonal look
Every feature in the signed-in dashboard ((main)/(app)) and the dashboard popouts (/popout/*, including inside OBS) must work with the look off and at Full and Subtle strength. Check Animations Off, prefers-reduced-motion, and both light and dark themes. Overlays, widgets, extensions and their UIs, the overlay/widget editors, marketing pages, the login app, and the admin app stay outside the seasonal scope.
The separate Motion guide covers the global Animations in Lumio preference, the stricter Chat/Events/Music override, the CSS and React helpers, and the animation inventory. Global Reduced or Off makes seasonal decoration still even when its own Animations row is Automatic.
| Area | Use this pattern |
|---|---|
| Colours | Use theme tokens such as primary, card, border, muted and foreground so the season's light/dark overrides apply. Keep chart colours, platform colours and --highlight-user unchanged; the registry does not permit seasons to override them. |
| Loading and empty states | Wrap standalone loading states in SeasonalSpinner with the ordinary spinner as its child. Halloween Full shows three ghosts; with animations off or reduced motion — globally, by the OS, or by the surface's own Chat / Events / Music override — their opacity pulses in sequence, so loading remains perceptible. Keep inline button/input spinners ordinary. Wrap a feed's empty line in SeasonalEmptyState kind="events" | "chat" (Events feed, Multichat feed) with the plain line as its child: at Full it shows the season's EmptyIllustration (season-float, still with animations off or reduced motion) and the seasons.<id>.empty{Events,Chat}{Title,Body} copy. A new feed adds a kind (and its keys to SEASON_MESSAGE_KEYS) rather than copying the markup. Show the spinner, not the empty state, while the feed's first read is pending (Multichat: chatFeedBody() over useMultichat().historyLoading). |
| Shared accents | Use SeasonToastAccent for the success-toast accent, SeasonalLogo or SeasonalBrand for Lumio branding, and the shared scenery, banner and strip slots where appropriate. Extend a slot or the registry when a new season needs a variant; feature rendering does not branch on season ids. |
| Decoration | Keep artwork behind content and controls, with pointer-events: none and aria-hidden. Reserve layout space for banners and strips: the footer strip sits above the footer, and SeasonBarStrip occupies its own row at the top of the Events search bar, the Chat input bar and the music popout's search footer. SeasonBarStrip also renders the bar's separator line directly under the band, so the graveyard stands on the line at Full and the line is the bar's top edge when the band is hidden — a host bar must not add its own border-t and mounts the strip without side padding, so the graveyard spans the full container width like the line. The bar ghost stays clipped inside the 16 px band so it cannot cover the last item or a control. Bars are idle when every field is empty, no picker or panel is open (busy) and no input event happened for 4 s (src/lib/seasons/bar-idle.ts); focus does not count. The page's ghost-peek coordinator (src/lib/seasons/peek-coordinator.ts) waits 15–30 s between peeks while a bar strip is eligible and 45–75 s when only the footer is, and shows one ghost per page. Use only transform/opacity for movement; gate decorative motion on Full, Animations Automatic, no reduced-motion request, and page visibility. The logo's blink schedule is capped at 2.5 per second. |
| Popouts | A delegated popout can represent another user and run inside OBS. It reads that user's profile independently of the dashboard mirror cookie. Its only profile writes are dismissing a banner and switching the look off while dismissing that banner; do not assume updateMe, general profile editing, or an Undo action. |
apps/web/__tests__/seasons.test.ts enforces that SeasonProvider and SeasonHead mount only in the (main)/(app) and (popout) layouts, and that overlay, fullscreen editor, marketing, legal and public route trees do not reference seasonal code. Keep that scope check current when moving a layout. For visual review, the Dev & Admin season preview is available to staff with admin:access and devtools:read; a popout URL accepts ?season=off|on&seasonStrength=subtle|full&seasonMotion=off|auto without saving the override. Dashboard UI PRs carry screenshots with the season off and Halloween Full in both dark and light themes; also check Subtle and reduced motion directly.
Preferences storage
The preferences (on/off, strength, animations, creature toggles, dismissed banners) live on the user profile: me.season* / GET /v1/users/me, written via updateMe / PATCH /v1/users/me, and readable on popoutSession / GET /v1/auth/popout/session for popouts. A popout session has exactly two one-way writes: dismiss the banner (popoutDismissSeasonBanner / POST /v1/auth/popout/season-banner/dismiss) and switch the look off (popoutSwitchOffSeasonLook / POST /v1/auth/popout/season-look/off, which also dismisses the current instance). Both take one <seasonId>:<year> instance whose season id must be in user_preferences::SEASON_IDS in apps/api - a new season adds its id there as well as to src/lib/seasons/registry.ts. A popout can never switch the look on or change anything else. The creature map and the dismissed-banner list are replaced on write, so clients always send the full value. There is no WebSocket push for them.
The web app reaches them through /api/users/me/season (GET + PATCH). A popout-window fetch (usePopoutFetch, x-lumio-popout) reads popoutSession; everything else reads me. The PATCH refuses popout-window fetches; a delegated popout's two writes go through POST /api/users/me/season/popout with { "action": "dismiss" | "off", "instance": "<seasonId>:<year>" }, which in turn refuses every request without the popout header. A logged-in (cookie) popout has a first-party session and writes the same two actions through the normal PATCH. The mapping between the snake_case wire fields and the resolver's SeasonPrefs is in src/lib/seasons/profile.ts.
The look must be right before React runs, so the resolver in src/lib/seasons/bootstrap.ts also runs as an inline script during HTML parse. It reads the lumio-season mirror cookie (non-httpOnly, 1 year, the same approach as lumio-tz). On a full dashboard page load the (main)/(app) layout embeds the freshly SSR-loaded profile into that script (SeasonHead profile=…); the script prefers it over the cookie and rewrites the cookie, so a first visit on a new device never flashes the defaults. After mount, SeasonProvider re-reads the profile, mirrors it into the cookie, and re-reads again when the tab returns to the foreground (at most every five minutes). Writes are optimistic: the cookie and the <html> attributes change first, the PATCH follows, and a failed write reverts to the server value (setSeason*, switchOffSeasonLook and restoreSeasonLook resolve false then, so the banner can show an error toast). The banner dismissal is the exception: it is never reverted — a failed one stays hidden for the page session and is retried silently on the next profile sync. Other tabs of the same browser are told through a BroadcastChannel (lumio:season): "change" means "re-apply the mirror cookie"; "profile", posted by a popout after one of its writes, means "re-read your own profile". A popout never posts a value, so a delegated popout of another user cannot leak its preferences into the browser's dashboard tabs.
The earlier per-browser localStorage keys (lumio:season:*) are only read by a one-time migration. If the profile still holds every default, the browser's explicit values are written to the profile once, and the keys are then deleted. A popout never runs this migration.
Dev & Admin season preview
The Dev & Admin menu (src/components/dev-menu/dev-menu.tsx, ZAF-1669) lets platform admins preview a season in their own browser. The gate is canUseDevMenu(me.adminPermissions) in src/lib/devtools.ts — admin:access and devtools:read, deliberately not the account-scoped <Gate>. The shell renders the footer button, the dialog and the badge, and registers the Ctrl/⌘ + Alt + Shift + D listener, only when it holds. New tools are entries in DEV_TOOLS in that file.
The preview is one localStorage value under SEASON_PREVIEW_KEY (lumio:devtools:season-preview, outside the legacy lumio:season:* keys the migration deletes): { "season": "<id>" | "none" } or { "date": "YYYY-MM-DD" } — one model at a time. The resolver honours it only when its config carries preview: true, which the (main)/(app) layout passes to both SeasonHead (so the preview paints without a flash) and SeasonProvider for allowed users only; for anyone else a stored value is ignored. A forced season bypasses the calendar, a simulated date replaces "today", and either one bypasses the user's Seasonal look: Off. A season without a look is not in the resolver's config, so forcing it (or a date inside it) renders nothing. seasonOnDate() runs the same resolver over the full calendar to name the season for a date in the menu and the badge. Nothing is sent to the server and there is no audit event.
The preview belongs to the browser, so it reaches the popouts opened in the same browser too (ZAF-1717). The (popout) layout passes previewAllowed to its SeasonHead and SeasonProvider exactly like the dashboard, computed by popoutSeasonPreviewAllowed() (src/lib/seasons/popout-preview.ts) from the browser's own login, never from the user a popout token represents: a logged-in popout (the normal case) reads adminPermissions on its own me; a delegated popout session makes one extra me { adminPermissions } read with the login cookie only (serverGqlSSR), and without a login cookie — OBS, a guest browser — there is no read and no preview. OBS also has its own browser storage, so a preview started on the dashboard never shows up there. While a preview is active a popout renders SeasonPreviewMarker (same devMenu.seasonPreview strings as the badge) as a compact, in-flow strip under the header next to the compact season banner — never over the feed or the input/search bar; its End ends the preview for the whole browser. Starting, changing or ending the preview anywhere updates every other open tab and popout of the browser through the storage event. The popout URL overrides apply on top: ?season=off wins even over a preview, ?seasonStrength= and ?seasonMotion= apply as usual.