Compare commits

..

58 Commits

Author SHA1 Message Date
jared a0fcdf74da feat(denoise): autoGainControl=false for the ML tier + docs
CI / Build & Quality Checks (push) Successful in 11m16s
CI / Trigger Desktop Build (push) Successful in 10s
- CallEmbed sets `autoGainControl=false` for the ML noise-suppression tier so
  the browser's auto gain control doesn't fight the in-source ML model; the
  browser/off tiers keep AGC on.
- Docs: refresh the LOTUS_FEATURES noise-suppression section (browser-native
  default, quality-ordered dropdown, DFN3 ML default, attenuation floor,
  gate-after-ML, DFN level 60, AGC-off, the reliability fixes) and LOTUS_TODO
  P5-30 (mark tuning/reliability/AGC done; record GTCRN as researched-and-deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:46:39 -04:00
jared ebc782b16c feat(denoise): browser-native default, quality-ordered model picker, wire native-NS
CI / Build & Quality Checks (push) Successful in 11m15s
CI / Trigger Desktop Build (push) Successful in 18s
- Model dropdown is now ordered by quality/CPU, best first (DeepFilterNet 3 →
  DTLN → RNNoise → Speex); fix RNNoise's inaccurate "High" voice-quality label.
- When a user opts into the ML tier, default to the highest-quality model
  (DeepFilterNet 3). The tier default stays browser-native (known-good, best
  perceived in testing so far).
- Wire the "Series Suppression" (native-NS-before-ML) toggle into the real call
  path — it was applied only in the settings tester, so the tester could sound
  better than the actual call. Default it OFF (a single NS stage is best
  practice; it's an opt-in test aid).
- isMLDenoiseSupported now also requires WebAssembly, so ML isn't offered on
  strict-CSP shells where it would silently fall back to the raw mic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:02:41 -04:00
jared 7939dc92d4 docs(call): cover soundboard/quality/permissions in user-facing docs
- README "Calls & Voice": add the in-call soundboard, per-user call quality
  settings, and admin room call-permissions bullets.
- LOTUS_TODO: mark the soundboard UI as built (was "cinny UI remains / dormant").
- HANDOFF_ELEMENT_CALL_FORK: add a COMPLETE status banner to the §12.1 host
  checklist; fix stale denoise specifics (all four models are in-source;
  flag is lotusDenoiseSource=1, not lotusDenoise=ml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:43:49 -04:00
jared 7c06b27c73 feat(call): in-call soundboard, quality controls, room call-permissions
CI / Build & Quality Checks (push) Successful in 10m49s
CI / Trigger Desktop Build (push) Successful in 8s
Element Call is now consumed as our self-built fork
(@lotusguild/element-call-embedded); wire up its previously-dormant
capabilities and document the fork as live.

Soundboard (P5-15): a call-bar button plays user-uploaded audio clips into the
call as a real published track (io.lotus.inject_audio) plus local playback.
Clips are uploadable like emoji/sticker packs, stored in io.lotus.soundboard
account data (synced across devices). Gated by a Settings toggle + volume.

Quality controls (P5-31): per-user mic/screenshare bitrate + screenshare
framerate (Settings -> Calls), applied via io.lotus.set_quality clamped to any
room cap. Room admins set caps and hard call-permissions (allow_screenshare /
allow_camera) in Room Settings -> Voice; the call bar hides blocked buttons.

- New: CallSoundboard, useSoundboard, soundboardClips; RoomQuality,
  useCallQuality, callQuality (+ unit tests).
- Optimistic-write RoomQuality admin UI (no stale-state clobber).
- Docs: mark EC fork live across README/FEATURES/TODO/BUGS/TESTING; add D2
  manual-test steps.

Numeric quality caps are client-cooperative; screenshare/camera permissions are
hard-enforced server-side (see LotusGuild/matrix voice-limit-guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:34:17 -04:00
jared 02b2ce8109 feat(chat-bg): redesign 19 chat backgrounds as modular per-pattern files
Same treatment as the seasonal themes: split the 502-line chatBackground.ts
Record into one premium module per background under lotus/backgrounds/ (each
exposes a tuned dark + light ChatBgVariants), one Opus agent per background
against a shared brief. chatBackground.ts now assembles DARK/LIGHT from the
modules; getChatBg is unchanged. Carbon + Aurora are kept inline as-is (user
favorites); none stays the empty layer.

Every redesign: layered oklch palettes, seamless tiling with worked-out tile
math (integer-multiple periods; edge-wrapping inline-SVG data-URIs for
circuit/hexgrid/waves/herringbone/chevron/tactical), independently-tuned
dark+light (not a recolor), and low "felt-not-read" opacity so chat text stays
WCAG-AA legible. The 5 animated backgrounds (rain, star drift, grid pulse,
aurora flow, fireflies) each colocate a vanilla-extract keyframe .css.ts,
animate only background-position for a jump-free loop, and — since getChatBg
strips animation for reduced-motion — render a finished static frame too.

Redesigned: blueprint, stars, topographic, herringbone, crosshatch, chevron,
polka, triangles, plaid, tactical, circuit, hexgrid, waves, neon, anim-rain,
anim-stars, anim-pulse, anim-aurora, anim-fireflies.

Gates: tsc clean, ESLint clean, Prettier clean, build OK, 551 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:23:54 -04:00
jared 26f998d243 feat(seasonal): redesign all 11 seasonal themes as modular per-theme overlays
CI / Build & Quality Checks (push) Successful in 11m7s
CI / Trigger Desktop Build (push) Successful in 12s
Split the 808-line SeasonalEffect monolith into one self-contained module per
theme under seasonal/themes/ (<Theme>.tsx + <Theme>.css.ts), and gave every
theme a premium, research-backed redesign (one Opus agent per theme against a
shared brief). SeasonalEffect now just imports the 11 overlays and dispatches;
the orphaned shared Seasonal.css.ts is removed (each theme owns its keyframes).

Each overlay: layered oklch palettes, GPU-only animation (transform/opacity),
`contain: layout paint style` to kill repaint flicker, ≤~40-element perf budget,
particles seeded once via useMemo (no per-frame state), a gorgeous STATIC
prefers-reduced-motion form (the settings preview thumbnail), WCAG-AA-preserving
low opacities, and no new deps / no external assets (inline SVG data-URIs,
Tauri/CSP-safe).

Themes: Halloween, Christmas, New Year, Autumn, April Fools, Lunar New Year,
Valentines, St. Patrick's, Earth Day, Deep Space, Arcade.

Gates: tsc clean, ESLint clean, Prettier clean, build OK, 551 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:41:58 -04:00
jared f816049fdf feat(seasonal): show Auto activation dates in settings + single-source schedule
Settings never told the user which days "Auto" turns each seasonal theme on.
Extracted the date windows out of getActiveSeason into a shared SEASON_SCHEDULE
(seasonSchedule.ts) — the single source of truth for both the runtime Auto
selector and the settings UI, so displayed dates can't drift from real activation.

- seasonal/types.ts: SeasonTheme + SeasonalOverlayProps (leaf module).
- seasonal/seasonSchedule.ts: priority-ordered SEASON_SCHEDULE with human date
  ranges + SEASON_DATE_RANGES + getActiveSeason (behavior-preserving refactor).
- SeasonalEffect.tsx: consume the shared type/selector; re-export SeasonTheme.
- General.tsx: per-theme date caption under each swatch ("Oct 15 – Nov 1"), Auto
  reads "By calendar", and the section description explains it.
- seasonSchedule.test.ts (6): representative day per theme, overlap priority
  (Deep Space > Autumn, New Year > Lunar), inclusive boundaries, off-season null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:28:28 -04:00
jared eafa353364 feat(decorations): allow VITE_DECORATION_CDN override; close N127
- avatarDecorations: resolve the decoration CDN base from VITE_DECORATION_CDN at
  runtime, falling back to the DECORATION_CDN literal (kept intact so the sync
  script + tests still parse it). Lets a deploy repoint the CDN without a code
  edit. Guarded for the tsx test runner (import.meta.env undefined there).
- LOTUS_BUGS: close N127 — the denoise dev-injection gap dissolved with the A7
  cutover (no getUserMedia shim is injected anymore; denoise is in-source in the
  EC fork), so there is nothing to inject in dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:18:09 -04:00
jared 353bb59393 test(utils): cover scheduledMessages + lotusDenoiseUtils; fix AudioWorklet detect
- scheduledMessages.test.ts (9): pins the MSC4140 request shape (PUT to the room
  send endpoint with the org.matrix.msc4140.delay query, POST cancel/restart to
  /delayed_events with the unstable prefix), the delay-floor math (Math.max(1000,
  round(sendAt-now)) — "now"/past targets still yield a valid >=1000ms delay),
  rounding, and url-encoding.
- lotusDenoiseUtils.test.ts (9): model-catalog data integrity + isMLDenoiseSupported
  feature detection across AudioContext/webkit/getUserMedia.
- Bug found + fixed: isMLDenoiseSupported used `!!AudioWorkletNode`, a bare global
  reference that throws ReferenceError (not returns false) on a browser with
  AudioContext but no AudioWorkletNode binding. Switched to `typeof` so the
  detection helper reports unsupported instead of throwing. Regression test proven
  to fail on the old code.

Suite now 545 tests (4th real bug caught by the prevention work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:01:25 -04:00
jared 1daa8aa9b1 test(callSounds): cover join/leave sound design + AudioContext lifecycle
callSounds.ts had no tests despite 106 lines of user-facing audio logic. Adds 13
tests (mocking AudioContext) pinning: the chime/soft/retro join+leave melodies
(frequencies, oscillator types, stagger), the click-avoidance gain envelope and
osc->gain->destination wiring, and the defensive contracts — unknown style is a
no-op that never creates a context, a throwing AudioContext constructor is
swallowed, and the shared context is reused / recreated-when-closed / resumed-
when-suspended. Suite is now 527 tests; refreshed the stale count in LOTUS_BUGS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:49:02 -04:00
jared 5af024f7e7 docs: consolidate EC test checklist into LOTUS_TESTING.md (§D2)
CI / Build & Quality Checks (push) Successful in 11m28s
CI / Trigger Desktop Build (push) Successful in 20s
Fold the Element Call fork Phase-2 feature tests into the canonical testing
guide as §D2 (denoise reconnect/device-switch/4 models, event-driven
speaker/mute, focus-during-screenshare, in-call decorations, transparency,
+ the dormant #3/#7). Each item keeps a plain / outcome for non-dev
testers, so the standalone ELEMENT_CALL_TEST_CHECKLIST.md is removed — all
in one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:40:02 -04:00
jared 84ce9843ff docs: log E2EE key-sync issues (KE-1..4) + tester checklist
CI / Build & Quality Checks (push) Failing after 12m1s
CI / Trigger Desktop Build (push) Has been skipped
LOTUS_BUGS.md: new Encryption/E2EE section tagged EXTREME complexity +
planning-session-required for a senior-engineer deep dive — OTK upload
conflict storm (KE-1), Element Call media-key distribution failures causing
audio/video dropouts (KE-2), a timeline decryption error (KE-3), and
MatrixRTC delayed-event timeouts (KE-4). All observed live 2026-06-30; not
caused by the EC fork work. Plus a non-developer ELEMENT_CALL_TEST_CHECKLIST.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:37:01 -04:00
jared efcee88f05 docs(test): add OIDC/MSC3861 test section + local MAS dev loop
CI / Build & Quality Checks (push) Successful in 13m42s
CI / Trigger Desktop Build (push) Successful in 33s
LOTUS_TESTING.md section N (N1-N6): OIDC login flow, session-persist-on-reload,
token refresh, logout revocation, account-management link, and the non-OIDC
regression check. Backed by dev/oidc-test/ — a runnable local Matrix
Authentication Service + Synapse(msc3861) loop (compose skeleton, the Synapse
experimental_features delta, and the public/config.json override) so the flow
can be verified without a mozilla.org tester.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:04:24 -04:00
jared 0b307037e0 docs(todo): P4-6 OIDC client-side built, awaiting live verification
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:13:40 -04:00
jared 67bd05fc96 feat(auth): OIDC phase 4/5/6 — token refresh, logout revocation, account link
- initMatrix.ts: import the shared Session type; when a session has a refresh
  token + oidc metadata, wire a LotusOidcTokenRefresher via createClient's
  refreshToken + tokenRefreshFunction (reactive 401 refresh). Rust crypto is
  unaffected (still keyed on userId/deviceId).
- client/oidcTokenRefresher.ts: OidcTokenRefresher subclass that persists rotated
  tokens back to the fallback session.
- client/oidcLogout.ts + logoutClient: best-effort revoke access+refresh tokens at
  the issuer's revocation_endpoint on logout (tolerant of failure).
- settings/account/OidcManageAccount.tsx: MSC2965 "Manage account" deep-link,
  shown only when authMetadata is present (OIDC servers); mirrors OtherDevices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:12:13 -04:00
jared dd6b0bccb3 feat(auth): OIDC phase 3 — authorization-code callback route
- oidc/OidcCallback.tsx: standalone page that exchanges code+state via
  completeAuthorizationCodeGrant (SDK validates state = CSRF), derives
  user_id/device_id from the new access token via whoami(), persists the OIDC
  session (refresh token + expiry + issuer/clientId/redirectUri/idTokenClaims),
  then full-page-reloads at the app root. Minimal UI (no Overlay/portal) so it
  needs no app providers.
- App.tsx: short-circuit — render OidcCallback before the RouterProvider when the
  path is the OIDC callback (redirect_uris can't contain a fragment, so it must
  live outside the hash router). The nginx SPA catch-all already serves index.html
  for it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:05:22 -04:00
jared a50d3e7ca7 feat(auth): OIDC phase 2 — login initiation (discover/register/authorize)
- oidc/oidcState.ts (pure, +3 tests): dynamic-registration cache (by issuer +
  redirectUri, corrupt-tolerant) and parseOidcCallbackParams (success/error/invalid).
- oidc/oidcLoginUtil.ts: getOrRegisterClientId (cache + registerOidcClient) and
  startOidcLogin (discoverAndValidateOIDCIssuerWellKnown -> generateOidcAuthorization
  Url -> redirect; invalidates the cache on failure). redirectUri is the
  deterministic getOidcCallbackUrl(), and the SDK returns clientId/issuer on
  callback, so no hand-rolled transient state is needed.
- login/OidcLogin.tsx: native-OIDC button mirroring SSOLogin + TokenLogin async/error.
- login/Login.tsx: issuer-gated — when discovery advertises an issuer, render
  OidcLogin and suppress password/legacy-SSO; non-OIDC servers unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:01:35 -04:00
jared d3d2f9a448 feat(auth): OIDC phase 4a — session persistence for refresh/expiry/oidc metadata
setFallbackSession gains an optional `extra` arg (password call sites unchanged)
persisting cinny_refresh_token, cinny_expires_at (absolute), and
cinny_oidc_{issuer,client_id,redirect_uri,id_token_claims}. getFallbackSession
reads them back (expiry as remaining lifetime); removeFallbackSession + re-save
clear stale OIDC keys. Session type gains `oidc?: OidcSessionMeta`. +2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:55:30 -04:00
jared 98ad5674a8 feat(auth): OIDC phase 0+1 — discovery, flow detection, client config
Toward MSC3861/MSC2965 next-gen-auth login (P4-6), client-only.
- cs-api.ts: type the stable `m.authentication` well-known key + getOidcIssuer()
  (stable preferred over the unstable msc2965 key; {} for non-OIDC servers).
- useParsedLoginFlows.ts: getOidcCompatibilityFlag() (MSC3824 oauth_aware_preferred
  / delegated_oidc_compatibility) as a secondary OIDC hint.
- New pages/auth/oidc/oidcConfig.ts: dynamic-registration client metadata + the
  non-hash callback URL (redirect_uris can't contain a fragment).
- paths.ts: OIDC_CALLBACK_PATH.
- 8 unit tests for the pure helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:51:23 -04:00
jared 30d0331174 fix(ui): isMacOS always returned false on Macs + plugin-logic tests (+49)
Coverage work found a 3rd real bug: isMacOS() compared os.name against the
legacy 'Mac OS' string, but ua-parser-js v2 reports 'macOS' — so it was dead,
and Mac users saw "Ctrl + k" instead of "⌘ + k" in the editor toolbar, search,
and settings shortcut hints. Now accepts both 'macOS' and 'Mac OS'.

Suites (via subagent, verified): via-servers (10 — power/popularity server
selection), bad-words (9), syntaxHighlight tokenize (14), plugins/utils
getEmoticonSearchStr (5), imageCompression formatFileSize/isCompressible (5),
user-agent (6, now asserting the fixed behavior).

Full suite now 501 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:58:06 -04:00
jared 24662fa994 test: localStorage-backed state modules (+38)
CI / Build & Quality Checks (push) Successful in 11m15s
CI / Trigger Desktop Build (push) Successful in 10s
Via subagent, no bugs:
- state/utils/atomWithLocalStorage (9): get/set helpers + atom write-through.
- state/scheduledMessages (6): Map<->Record round-trip, persistence, mount-gated
  hydration (atomWithStorage w/o getOnInit — modeled with a subscription).
- state/spaceRooms (9): Set dedupe + no-write-when-unchanged + serialization.
- state/navToActivePath (8): per-user Map<->Object serialization.
- state/callPreferences (6): the privacy rule forcing video=false on load+persist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:53:52 -04:00
jared 230ef8ed7c test: markdown parser subsystem (58) + custom-emoji readers (32)
Via subagents, probe-verified against real output, no bugs:
- markdown: internal/utils (11), inline/runner (7), inline/parser (21 — bold/
  italic/underline/strike/code/spoiler/link, nesting, precedence, URL lookbehind),
  block/parser (19 — headings/code-fences/quotes/lists/<br>/escapes). Closes the
  biggest coverage hole (core message rendering).
- custom-emoji: PackMetaReader (6), PackImageReader (7), PackImagesReader (4),
  utils equality+makeImagePacks (5), recent-emoji promote/increment/100-cap (10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:52:48 -04:00
jared 160c09e525 test: add suites for 8 simple state reducers + msgContent (+50)
Via subagent, all verified, no bugs:
- state/toast (7), room-list/roomList (6), inviteList (6), room-list/utils
  compareRoomsEqual (6), backupRestore (6), callEmbed dispose-on-replace (6),
  closedNavCategories factory + makeNavCategoryId (8).
- features/room/msgContent (5): getAudioMsgContent/getFileMsgContent incl.
  encrypted (content.file) vs plain (content.url) branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:43:33 -04:00
jared 589d45e0a0 test: add suites for list, roomToParents, roomToUnread reducers (+43)
Via subagent, verified against real behavior (all use jotai store + enableMapSet):
- state/list (11): createListAtom PUT/DELETE/REPLACE (single + array, identity).
- state/room/roomToParents (10): INITIALIZE/PUT/DELETE incl. cycle-skip and
  orphan-cleanup pruning of zero-parent children.
- state/room/roomToUnread (22): unreadInfoToUnread, unreadEqual, and the
  roomToUnreadAtom reducer — leaf/overwrite/equal-guard, multi-level parent
  roll-up with `from` recording, RESET rebuild, DELETE subtract/prune.

No bugs (noted a latent never-hit string-spread in deleteUnreadInfo's `from ??
roomId` fallback; left as-is). Suite growing toward full pure-logic coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:42:36 -04:00
jared acd355bb5a docs(bugs): test suite at 231 tests; 2 real bugs caught by coverage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:35:05 -04:00
jared 6e59395fb8 test: lotus decorations, call caps, crypto, featureCheck, typing, markdown (+34)
Subagent batch (no bugs found) + markdown:
- lotus/avatarDecorations (8): decorationUrl, CDN shape, ALL_DECORATIONS
  flattening, data invariants (unique category ids + slugs, slug charset).
- plugins/call/utils (7): getCallCapabilities — static caps + room/user/device
  scoped state-keys.
- utils/matrix-crypto (3): verifiedDevice via a stubbed CryptoApi.
- utils/featureCheck (3): checkIndexedDBSupport success/error/throw paths.
- state/typingMembers (8): add/dedup-by-latest-ts/per-room-scope/delete reducer
  via a jotai store (enableMapSet, mirroring app startup).
- plugins/markdown/utils (5): inline + block escape/unescape round-trips.

Full suite now 231 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:32:53 -04:00
jared 9f4516c6a8 test: add suites for state/sessions, recentSearches, upload (+17)
Via subagent, all verified against real behavior:
- state/sessions (5): fallback-session round-trip across the four cinny_* keys,
  missing-key → undefined for each required key, removeFallbackSession clears all.
- state/recentSearches (6): addRecentSearch prepend, case-sensitive dedupe +
  move-to-front, trim, ignore empty/whitespace, cap at 10.
- state/upload (6): the createUploadAtom reducer driven through a real jotai
  store — idle→loading→progress(gated)→success/error, file ref preserved.

No bugs found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:29:36 -04:00
jared 0bd2273bee test: add suites for utils/room (40) + plugins/matrix-to (7)
- utils/room (40, via subagent): 28 helpers — state-event accessors, m.direct
  parsing, space/room classification, parent/child graph (incl. cycle safety),
  mute-rule + notification logic, unread info, reply trimming, member display/
  avatar/search, reaction/edit/mention extraction, room-icon branches. SDK/
  crypto-heavy helpers skipped. No bugs found.
- plugins/matrix-to (7): matrix.to permalink build + parse for user/room/event
  including via-server round-trips and negative cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:28:32 -04:00
jared d37fa1584c test: add suite for utils/keyboard handlers (+4)
Covers onTabPress (Tab-only), preventScrollWithArrowKey (arrows-only),
onEnterOrSpace (Enter/Space gate the callback), and stopPropagation's
editable-element check (does not swallow keys when an input/textarea/
contenteditable is focused) via mock events + a document.activeElement stub.
Full suite now 133 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:50:55 -04:00
jared e17cb09269 fix(settings): don't crash on load when localStorage is blocked + tests (+6)
Prevention work found a real bug: getSettings() runs at module load, and its
catch block called localStorage.removeItem() — but we often reach that catch
*because* localStorage access threw (blocked storage / private mode / sandboxed
context). The removeItem then re-threw, producing an uncaught error that crashed
the whole app at startup. Guarded the cleanup in its own try/catch.

New state/settings suite (6) covers the legacy-boolean callNoiseSuppression
migration, denoise-model/ringtone-id coercion of unknown values, default merge,
malformed JSON, and the blocked-storage regression.

Full suite now 129 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:46:51 -04:00
jared 4d55e45962 docs(bugs): test suite at 123 tests, now a hard CI gate
CI / Build & Quality Checks (push) Successful in 10m49s
CI / Trigger Desktop Build (push) Successful in 11s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:26:58 -04:00
jared e3532064b5 test: add suites for accentColor (color math) + matrix-uia (auth flows) (+15)
- utils/accentColor (8): hexToRgb parsing, lighten/darken channel math, rgba
  clamping, WCAG relativeLuminance (black=0/white=1), contrastingText threshold,
  varNameFromToken, and derivePrimaryPalette's full 10-token output.
- utils/matrix-uia (7): UIA flow helpers — getSupportedUIAFlows,
  completed/params/session/errcode/error accessors, getUIAFlowForStages
  (incl. the single-extra-dummy rule), has/requiredStageInFlows, and
  getLoginTermUrl language fallback.

Full suite now 123 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:23:36 -04:00
jared 1e37b20c6a ci: promote unit tests to a hard gate
108 deterministic pure-logic tests now block the build job (and thus deploy) on
failure, alongside the Build step. Moved out of the informational quality-checks
section and dropped continue-on-error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:17:20 -04:00
jared 4f03775e04 docs(bugs): test suite at 108 tests across 11 modules
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:07:41 -04:00
jared 9678b02aba test: add suite for utils/sort room-list comparators (+5)
Covers byTsOldToNew, byOrderKey (undefined-last + the no-equality-branch
quirk for two present keys), and the factory comparators
factoryRoomIdByUnreadCount / factoryRoomIdByActivity / factoryRoomIdByAtoZ
(activity-desc, unread-desc, A–Z case-insensitive with leading-# stripped)
using minimal MatrixClient mocks. Full suite now 108 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:04:45 -04:00
jared a926487f5e fix(utils): findAndReplace infinite loop on non-global regex + tests (+28)
Prevention work surfaced a real latent bug: findAndReplace looped forever
(OOM) on any non-global regex with a match — `match` was only reassigned
inside `if (regex.global)`, so a non-global regex never advanced. Fixed by
treating a non-global regex as a single match (`match = regex.global ?
regex.exec(text) : null`) and added a regression test. Latent in practice
(all current callers pass global regexes), but a crash waiting to happen.

New suites (tsx + node:test), verified empirically:
- utils/findAndReplace (10, incl. the regression)
- utils/AsyncSearch (9): normalize + matchQuery (the timer-based class is
  skipped — needs window.performance/setTimeout, unavailable in node)
- utils/ASCIILexicalTable (10): orderKeys gap-filling + invariants

Full suite now 103 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:15:39 -04:00
jared ae1d30bc5a test: add suites for time, matrix, mimeTypes, and search filters (+47 tests)
Expands pure-logic coverage (harness: tsx + node:test):
- utils/time (21): date/time formatters — exact values where timezone-independent,
  structure/regex where locale/tz-sensitive (written via subagent).
- utils/matrix (13): pure id/mxc helpers (isUserId/isRoomId/isRoomAlias/
  getMxIdLocalPart/getMxIdServer/isServerName + room-version gates). (subagent)
- utils/mimeTypes (7): getBlobSafeMimeType allowlist+remap, safeFile rewrap,
  mimeTypeToExt, getFileNameExt/WithoutExt edge cases.
- message-search filters (6): filterGroupsByMsgType (union, empty-group drop,
  non-string msgtype) + filterGroupsByPinned (disabled passthrough, pinned-only).

All assertions verified against actual runtime behavior. Suite now 74 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:27:57 -04:00
jared a7d145aa70 docs(bugs): manifest:false verified OK (not a bug)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:39:06 -04:00
jared 472d4ba008 test: add XSS-prevention suite for utils/sanitize
8 tests locking in security-critical behavior of sanitizeCustomHtml /
sanitizeText: script-content removal, event-handler stripping, javascript:
link neutralization, anchor hardening (noreferrer/noopener/_blank), non-mxc
<img> → link conversion, and the N100 <pre class> language-* restriction.
Verified against actual sanitize-html behavior. Suite now 27 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:29:08 -04:00
jared 2a0478cad8 docs(bugs): N105 fixed → Needs Verification
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:13:34 -04:00
jared cee0c591e2 fix(pwa): N105 — notification clicks work after the tab is closed
OS notifications were shown via page-level `new Notification()` whose onclick
only works while the originating tab is alive — clicking a notification after
closing the tab did nothing.

- New `showOsNotification()` (utils/dom) prefers `registration.showNotification()`
  so the notification is service-worker-owned and persists; falls back to
  `new Notification()` (with the previous onclick) when no SW is available, so
  worst case is unchanged behaviour.
- sw.ts gains a `notificationclick` handler: focuses an existing app window and
  forwards the target path, or opens the app if none is open.
- ClientNonUIFeatures forwards the SW `notificationClick` message to react-router
  `navigate()` (works for both hash and browser router configs), and uses a
  per-room `tag` to coalesce notifications (replacing the old notifRef.close()
  dedup a SW notification can't hold).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:09:57 -04:00
jared 68b6ffffd7 docs(bugs): test-suite gap now seeded (harness + 19 tests)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:50:50 -04:00
jared 9bc8c4b47f test: add suite for utils/regex (sanitizeForRegex, URL/EMAIL/JUMBO_EMOJI)
Second pure-logic suite — another zero-import module. 4 tests covering regex
metacharacter escaping (with round-trip), the http(s) URL pattern, email
validation, and the jumbo-emoji matcher. Total suite now 19 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:49:54 -04:00
jared e80ebd35cb test: add unit-test harness (tsx + node:test) + first suite for utils/common
Addresses the "no automated test suite" gap. Chose Node's built-in test runner
via tsx rather than vitest: the project is on Vite 8.0.14, ahead of vitest's
supported Vite range, so vitest would fight peer deps. tsx is build-independent.

- `npm test` → `node --import tsx --test $(find src -name '*.test.ts')` (works on
  Node 20 local + 24 CI without relying on --test glob support).
- src/app/utils/common.test.ts: 15 tests covering the pure helpers (bytesToSize,
  time formatters, binarySearch, parseGeoUri, slash trimmers, nameInitials,
  randomStr, suffixRename, splitWithSpace, promise-settled helpers, etc.) —
  asserts actual behavior, traced from source.
- common.ts: folds import made `import type` (it's types only) so the module is
  pure and testable without loading folds/CSS.
- tsconfig excludes *.test.ts (tsx transpiles tests; eslint isn't type-aware so
  it still lints them); added an informational CI "Unit tests" step (promote to a
  hard gate by dropping continue-on-error).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:45:22 -04:00
jared 36343baecc call: lint/format cleanup for lotus EC wiring
CI / Build & Quality Checks (push) Successful in 10m27s
CI / Trigger Desktop Build (push) Successful in 25s
Resolve the eslint/prettier failures from the previous commit (non-blocking
in CI, but real): drop the banned `void` operator on fire-and-forget
transport.send().catch() calls, prefix the now-unused _denoiseNativeNS
param, and run prettier on the touched files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:52:45 -04:00
jared 89cf171efc call: consume self-built Element Call fork + activate Lotus features
CI / Build & Quality Checks (push) Successful in 11m5s
CI / Trigger Desktop Build (push) Successful in 25s
Switch to @lotusguild/element-call-embedded@0.20.1-lotus.1 (our self-built
fork) and turn on the source-level features it adds:

- #1 denoise CUTOVER: in-source ML denoise (lotusDenoiseSource=1) replaces
  the build-time getUserMedia shim — removed the shim injection from
  vite.config.js (denoise/ assets still shipped; the processor loads them).
  Survives reconnects (fixes A7).
- #2 call-state: CallEmbed consumes io.lotus.call_state; useCallSpeakers /
  useRemoteAllMuted prefer it over scraping EC's DOM (DOM fallback kept;
  empty payloads ignored).
- #4 focus: CallControl.focusCameraParticipant sends io.lotus.focus_participant
  (works during screenshare), replacing the DOM tile-click hack.
- #5 theming: lotusTransparent=1 (native transparent background).
- #6 decorations: LotusDecorationPusher sends each member's decoration URL
  via io.lotus.decorations -> rendered on in-call tiles.

#3 soundboard / #7 quality ship dormant (EC-ready; no host UI sends them yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:33:52 -04:00
jared 149ec8e4e4 docs: add Element Call fork handoff + tag all EC-FORK references
CI / Build & Quality Checks (push) Successful in 10m28s
CI / Trigger Desktop Build (push) Successful in 7s
Captures the plan to fork element-hq/element-call and build it from source for
true ownership of the in-call experience (decorations, focus/screenshare,
reconnect mic, native theming, call-audio injection) — none of which are fixable
against the prebuilt @element-hq/element-call-embedded bundle we ship today.

- New HANDOFF_ELEMENT_CALL_FORK.md: self-contained plan for a fresh session
  (current architecture, full file inventory, phases, new-repo decision, the
  denoise-shim interaction, doc corrections).
- Tagged every related note with [EC-FORK] + links: README (For Developers),
  LOTUS_BUGS (EC limitations), LOTUS_TODO (soundboard, denoise, soundboard
  cross-origin correction), LOTUS_FEATURES (call section).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:50:10 -04:00
jared d1cd963e4b docs(bugs): record live-test results + EC iframe limitations
Verified passing: A2, B1-B4, C1, C3, D. Re-fixed and awaiting re-test: A1
(ringtone loudness), A3/A4 (caller decline notice), G1 (All-muted badge).
Documented A5/A6/A7 as known Element Call iframe-boundary limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:23:13 -04:00
jared 5ef0a1fd3e fix(call): ringtone loudness, caller decline notice, All-Muted badge
Three issues from live testing:
- A1: the 'classic' ringtone (call.ogg, mastered near full scale) was much
  louder than the synthesized styles. Attenuate it (CLASSIC_GAIN 0.45) so all
  ringtones sit at a comparable level.
- A3/A4: the caller had no indication when a DM/group callee declined — their
  UI kept "ringing" until the notification lifetime expired. IncomingCallListener
  now listens for RTCDecline events for a call we're hosting in the room and
  toasts the caller ("<name> declined your call").
- G1: the PiP "All muted" badge fired when any single remote participant muted.
  useRemoteAllMuted now returns true only when there is >=1 remote and every
  remote participant is muted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:13:40 -04:00
jared 6ace96f2cf docs(bugs): native-cinny audit fully closed (nits done)
CI / Build & Quality Checks (push) Successful in 10m32s
CI / Trigger Desktop Build (push) Successful in 20s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:15:49 -04:00
jared 2d71f2ce30 refactor(ui): name the global overlay z-index layers (native-cinny nit)
Centralized the global floating-UI stacking values into styles/zIndex.ts
(inCallBanner 9990 < seasonalEffect 9997 < nightLight 9998 < toast 10001;
folds modals sit at 9999 between). Same values, no behavior change — just
removes the magic numbers and documents the layering so future overlays don't
collide. Component-internal small z-index stays local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:10:29 -04:00
jared 2c3dba55e6 fix(ui): use folds Text priority instead of raw opacity (native-cinny nit)
Replaced raw style={{ opacity: N }} de-emphasis on folds <Text> with the
`priority` prop across search, schedule, profile, and tray UI. Left the cases
that aren't Text-priority candidates (an Icon opacity, a Box-row opacity, and a
Text with an explicit color token).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:44:57 -04:00
jared c7a04dcc70 fix(ui): poll checkmark uses folds Icon instead of Unicode glyph (native-cinny nit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:20:52 -04:00
jared 4b14c15518 docs(bugs): timezone select + lightbox done; only native-cinny nits remain
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:49:26 -04:00
jared c68ef346bf fix(ui): MediaGallery lightbox uses folds Overlay + FocusTrap (native-cinny audit 8/N)
The full-screen media viewer was a raw <div role="dialog"> rendered in place
with manual focus. Wrapped it in folds Overlay (portal + backdrop, proper
stacking) and FocusTrap (focus management), keeping its own arrow/Escape key
handling. The light-on-dark chrome (#fff over the forced-black media stage) is
kept — it's a justified, always-dark media-viewer scrim, not theme chrome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:49:24 -04:00
jared c5d7fcc303 fix(ui): timezone picker uses folds SettingsSelect (native-cinny audit 7/N)
Replaced the last raw native <select> (Profile timezone, colorScheme:'dark')
with SettingsSelect. Added an optional `disabled` prop to SettingsSelect for
the saving state. handleSubmit reads the `timezone` state (not the native form
field) so submission is unaffected; the now-unused handleSelectChange was
removed. No raw <select> elements remain in the settings UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:51:13 -04:00
jared 9bf56d5748 docs(bugs): track remaining native-cinny polish items
CI / Build & Quality Checks (push) Successful in 10m31s
CI / Trigger Desktop Build (push) Successful in 29s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:44:27 -04:00
jared d5ce56930b refactor(ui): extract shared SettingsSelect; replace raw <select> (native-cinny audit 6/N)
Extracted the folds-native dropdown (Button+PopOut+Menu) from General.tsx into a
shared components/settings-select/SettingsSelect.tsx, and used it to replace raw
native <select> elements (which render OS-styled and broke under non-default
themes via colorScheme:'dark'):
- Profile "auto-clear after" select
- PushRuleEditor add-rule mode select (dropped the now-unused handleModeChange)

The form-tied timezone <select> in Profile is left for a follow-up (it's wired
to native form submission + a disabled state and needs more care).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:43:18 -04:00
188 changed files with 16671 additions and 1814 deletions
+6
View File
@@ -47,6 +47,12 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
VITE_APP_VERSION: ${{ github.sha }}
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
# A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Quality checks (informational — pre-existing issues exist) ───────
- name: TypeScript
run: npm run typecheck
+2 -1
View File
@@ -1,2 +1,3 @@
legacy-peer-deps=true
save-exact=true
save-exact=true
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
+666
View File
@@ -0,0 +1,666 @@
# HANDOFF — Forking & Self-Building Element Call ("Lotus Call")
> **Audience:** a fresh Claude/engineer session with **no prior context** on this
> project. Read this top-to-bottom before touching anything. This document is the
> single source of truth for the Element Call (EC) fork initiative.
>
> **Status:** **PHASE 02 IMPLEMENTED (build-verified, not yet live-tested)**
> (2026-06-30). The fork exists, builds, is published, and cinny consumes it
> (Phase 0/1). **All 7 Phase-2 EC features are implemented on the fork's `lotus`
> branch**, each additive + flag-gated, build+typecheck-clean, per-feature
> reviewed (+ a holistic multi-agent review), and pushed. **None are live-tested
> yet** — every one needs the `LOTUS_TESTING.md` §D sweep, and the **cinny host
> side must be wired** (set flags / send actions / handle call_state) — see §12.
> See **§9** Phase 0/1 results, **§10** cutover, **§11** Phase-2 seams, **§12**
> Phase-2 status + cinny integration checklist. Created 2026-06 from `LotusGuild/cinny`.
---
## 9. Phase 0 Results (verified 2026-06-29)
**Decisions taken with the user:** scope = Phase 0 recon; consumption model =
**private npm package** (§5 option 1). Recommended registry = **Gitea's built-in
npm registry** (`code.lotusguild.org`) — zero new infra.
### 9.1 Version → tag → commit mapping (LOCKED)
| Source | Value |
| :--------------------------------------------------- | :----------------------------------------- |
| cinny `package.json` pin | `@element-hq/element-call-embedded@0.20.1` |
| Bundle self-report (`VITE_APP_VERSION`/`appVersion`) | `embedded-v0.20.1` |
| npm registry `gitHead` for 0.20.1 | `2d74c48151d9edc01c65a22a91478aac81bf24d0` |
| GitHub tag `v0.20.1` → commit | `2d74c48…`**same commit** |
**Fork from upstream tag `v0.20.1` (commit `2d74c48`).** The embedded package
version equals the element-call release tag; repo `package.json` version is
`0.0.0` and the real version is stamped at publish time from the tag.
### 9.2 The shipped npm dist is a CLEAN upstream build
No `lotus`/`denoise`/`rnnoise` strings anywhere in
`node_modules/@element-hq/element-call-embedded/dist`. **All Lotus customization
(denoise shim) is injected at cinny build time, not baked into the package** — so
swapping the source does not disturb cinny's denoise injection layer. The
ringtone/reaction assets (`baduntss`, `cat`, `clap`, `call_declined`, …) are
upstream EC's own, not ours.
### 9.3 Build toolchain & mechanism
- **Node `24`** (`.node-version`), **pnpm `10.33.0`** (`packageManager` field,
via corepack).
- Build: **`pnpm run build:embedded`** = `vite build --config
vite-embedded.config.ts` with `NODE_OPTIONS=--max-old-space-size=16384`.
- Output dir is **repo-root `dist/`**; CI stages it into **`embedded/web/dist`**
(the `embedded/web/` dir holds the publish template: `package.json`, README,
both LICENSE files).
- Publish workflow upstream = `.github/workflows/publish-embedded-packages.yaml`:
builds → `npm version <tag> --no-git-tag-version` → `npm publish --provenance
--access public` to npmjs as `@element-hq/element-call-embedded`. (Also
Android/Maven + iOS/SwiftPM — irrelevant; we are web-only.)
### 9.4 Build reproduction — PARITY CONFIRMED
Cloned `element-call@v0.20.1` to `/root/code/element-call` (shallow), built with
isolated Node 24 / pnpm 10.33.0 (system Node 20 / cinny untouched). Result vs the
shipped npm dist:
- **137 of 147 files byte-identical** (same Vite content-hash): all CSS, fonts,
wasm, audio, JSON locale files, and `IndexedDBWorker`.
- **Only 5 JS chunks differ** (`index`, `pako.esm`, `polyfill-force`,
`rust-crypto`, `spa`) — **cause isolated to the version define**: our local
build baked `appVersion:\`dev\``(because`VITE_APP_VERSION`was unset) vs the
npm build's`appVersion:\`embedded-v0.20.1\``. `index.html` is identical modulo
the hashed asset filenames. **Benign** — our CI sets the version from the git
tag, so a tagged CI build will match.
### 9.5 Fork CI (drafted)
`.gitea/workflows/ci.yml` is staged in the clone (models cinny's
`.gitea/workflows/ci.yml` + upstream's publish flow). Linux-only (`ubuntu-latest`)
— the Windows worker is for cinny-desktop/Tauri, not the EC web bundle. Build job
on PR/push to `lotus`; publish job on `v*` tag → `@lotusguild/element-call-embedded`
to the Gitea npm registry (needs `secrets.GITEA_NPM_TOKEN`).
### 9.6 Phase 1 — DONE (2026-06-29)
1. ✅ **Fork repo live:** `code.lotusguild.org/LotusGuild/element-call` (public,
AGPL), default branch `lotus`, full history (7018 commits) + tag `v0.20.1`.
Branch `lotus` = `v0.20.1` + 2-file diff (CI workflow + embedded package
rename).
2. ✅ **Package published:** `@lotusguild/element-call-embedded@0.20.1` on the
Gitea npm registry (published manually from the version-faithful build while
the admin token was available). **Publicly readable** (unauth `npm install`
works → devs/CI need no token to consume; only publishing needs one).
3. ✅ **cinny wired & built clean** (Node 24): `.npmrc` scope line +
`package.json` dep + `vite.config.js` `viteStaticCopy` src. `npm install`
swapped the package (resolved from Gitea), `npm run build` succeeded,
`dist/public/element-call/` populated, bundle reports `appVersion:
embedded-v0.20.1`, **denoise shim injected + all denoise assets copied**
(injection layer unchanged). **These cinny edits are staged in the working
tree, NOT committed/pushed** — pushing triggers CI → desktop → deploy, so it's
gated on the §D live test (see §10).
### 9.8 Reproducibility note (important)
A from-source rebuild is **NOT byte-identical** to upstream's npm tarball.
137/147 files match exactly (CSS, fonts, wasm, audio, worker); the 5 JS chunks
(`index`, `pako.esm`, `polyfill-force`, `rust-crypto`, `spa`) differ because the
rolldown/oxc **minifier mangles export names differently** across build
environments (and the version-define is one input). This is normal and benign —
the code is functionally equivalent. **Do not chase byte-parity; the §D live call
test is the real parity gate.**
### 9.9 Remaining follow-ups (not blocking the cutover)
- **CI publishing:** `.gitea/workflows/ci.yml` publishes on a `v*` tag but needs
(a) a Gitea Actions runner for `LotusGuild/element-call`, and (b) a **durable**
`GITEA_NPM_TOKEN` repo secret with package read/write (the admin token used for
the manual publish is being deleted, so it was deliberately NOT baked in). Until
then, publishing is manual (`npm version <tag>` in `embedded/web` →
`npm publish`).
- Decide rebase cadence vs upstream (0.20.2 / 0.20.3 already out — see §9.1).
### 9.7 Ready-to-apply artifacts (staged 2026-06-29)
**Fork side — already committed** on branch `lotus` in `/root/code/element-call`
(remote `lotus` = `code.lotusguild.org/LotusGuild/element-call.git`, push deferred
until the repo exists). Minimal 2-file diff vs tag `v0.20.1`:
`.gitea/workflows/ci.yml` (new) + `embedded/web/package.json` (rename to
`@lotusguild/element-call-embedded`). Push with:
`git push -u lotus lotus && git push lotus v0.20.1` (and tag `v0.20.1` on our side
to trigger the first publish, or push our own `v0.20.1` tag).
**cinny side — NOT yet applied** (applying before the package is published breaks
`npm ci`). Exactly 3 edits + a lockfile regen:
1. `.npmrc` — append the scoped-registry line:
```
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
```
(CI/auth: `//code.lotusguild.org/api/packages/LotusGuild/npm/:_authToken=${GITEA_NPM_TOKEN}`
— inject via env in CI, do not commit a plaintext token.)
2. `package.json:104` —
`"@element-hq/element-call-embedded": "0.20.1"` →
`"@lotusguild/element-call-embedded": "0.20.1"`.
3. `vite.config.js:25` — `viteStaticCopy` src:
`node_modules/@element-hq/element-call-embedded/dist` →
`node_modules/@lotusguild/element-call-embedded/dist`.
**`stripBase: 4` stays unchanged** — `node_modules/@lotusguild/element-call-embedded/dist`
is still exactly 4 leading segments. (Update the comment's path reference too.)
4. `package-lock.json` — regenerated by `npm install`, not hand-edited (drops the
`registry.npmjs.org/@element-hq/...` resolved URL for the Gitea one).
The denoise injection (`lotusDenoise()` in `vite.config.js`) is **unchanged** — it
keys off `dist/public/element-call/index.html`, which our fork's bundle still
produces identically (verified: `index.html` byte-identical modulo asset hashes).
---
## 0. TL;DR / The Goal
We embed **Element Call** (the Matrix group-VoIP/video app) inside Lotus Chat to
power voice/video channels. Today we consume Element's **pre-compiled npm
bundle** and can only steer it from the outside (a limited widget API + fragile
same-origin DOM hacks). Several in-call problems are **unfixable from outside**
because they live in EC's compiled JS.
**We want true ownership: fork `element-hq/element-call`, build it from source
ourselves, host our build, and replace the npm bundle with our fork.** Then
every in-call behavior becomes editable code.
**This requires standing up a brand-new repo and build pipeline for our EC fork.**
---
## 1. Why fork? (What we cannot fix today)
These came out of live testing and are documented in `LOTUS_BUGS.md` →
"Known Element Call iframe limitations":
| Issue | What's wrong | Why outside-fixes fail |
| :----------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A6** — avatar decorations in-call | Our profile-decoration overlays don't appear on in-call video tiles | The video grid is rendered by EC's React app inside the iframe. We can only inject overlay DOM (fragile) — we can't make it a first-class part of the tile. |
| **A5** — focus camera / fullscreen during screenshare | Can't reliably spotlight a participant's camera while someone screenshares | EC's **layout logic** (screenshare priority, spotlight) is compiled JS we don't control. We currently DOM-click tiles as a hack. |
| **A7** — mic dead after EC's "Reconnect" | After EC's own mid-call reconnect, the local mic isn't re-published | EC's reconnect/track-republish path is internal. (Partly entangled with our denoise shim — see §6.) |
| Native theming | EC's UI doesn't match Lotus design; we inject CSS hacks | Real theming needs source-level component/token changes. |
| Decorations, custom controls, custom layouts, branding | all blocked | all require source access |
**Bottom line:** the iframe is **same-origin** (we self-host it), so we can read
and even write its DOM — but we **do not own its source**, so we can't change its
**behavior/logic**, only poke at its rendered output. Forking removes that wall.
---
## 2. How EC is integrated TODAY (the current architecture)
Understand this fully before changing it — the fork must slot into the same
integration seams.
### 2.1 Where the EC bundle comes from
- npm package: **`@element-hq/element-call-embedded`**, pinned to **`0.20.1`** in
`cinny/package.json` (line ~104).
- It ships a **pre-built `dist/`**. At cinny build time,
`vite-plugin-static-copy` copies that `dist/` flat into
**`public/element-call/`** (see `cinny/vite.config.js`, the `copyFiles`
target with `rename: { stripBase: 4 }` — note the stripBase gotcha documented
there; getting this wrong 404s the widget).
- It is **NOT committed** to git (`git ls-files public/element-call` → 0). It's a
build artifact materialized from `node_modules`.
### 2.2 How EC is loaded & controlled
- The widget iframe `src` is **same-origin**:
`${BASE_URL}/public/element-call/index.html?<params>` (see
`cinny/src/app/plugins/call/CallEmbed.ts`, `getWidget()` /
`getIframe()`). Sandbox: `allow-forms allow-scripts allow-same-origin
allow-popups allow-modals allow-downloads`; `allow="microphone; camera;
display-capture; autoplay; clipboard-write;"`.
- **Control surface #1 — the official widget API** (`matrix-widget-api`):
`ClientWidgetApi` + a custom `CallWidgetDriver`. This is the robust,
version-stable channel (theme change, hangup, capabilities, timeline events).
Files: `plugins/call/CallEmbed.ts`, `plugins/call/CallWidgetDriver.ts`,
`plugins/call/utils.ts` (capabilities), `plugins/call/CallControl.ts`.
- **Control surface #2 — same-origin DOM poking** (fragile, version-coupled):
reading `iframe.contentDocument` to detect speakers/mute state and
`.click()`-ing tiles to focus a camera. Files:
`hooks/useCallSpeakers.ts` (reads `[data-muted]`, `[data-video-fit]`),
`plugins/call/CallControl.ts` (`focusCameraParticipant` — tile selectors).
**These selectors break on every EC version bump.** A fork lets us replace
these hacks with real APIs/props.
- **Control surface #3 — URL params + build-time injection** for our denoise
shim (see §6).
### 2.3 Full file inventory (everything that touches EC in cinny)
Plugin / core:
- `src/app/plugins/call/CallEmbed.ts` — iframe creation, widget API wiring, theme sync, hangup, load watchdog/self-heal, denoise URL params.
- `src/app/plugins/call/CallControl.ts` — control state + **DOM-poking** (`focusCameraParticipant`, spotlight).
- `src/app/plugins/call/CallControl.tsx` _(call-status variant)_ and `features/call-status/CallControl.tsx`.
- `src/app/plugins/call/CallWidgetDriver.ts` — widget driver (capabilities, event relay).
- `src/app/plugins/call/utils.ts` — widget capabilities set.
- `src/app/plugins/call/hooks.ts`, `index.ts` — plugin exports/hooks.
- `src/app/state/callEmbed.ts` — jotai atoms for the active embed.
React / UI:
- `src/app/components/CallEmbedProvider.tsx` — the big one: incoming-call ring/banner, RTCNotification + **RTCDecline** listeners, PiP, mute badges, fullscreen, ringtones.
- `src/app/features/call/CallView.tsx` — prescreen lobby vs joined (the iframe placement target), load-error recovery UI.
- `src/app/features/call/CallControls.tsx` — in-call control bar (mic/cam/deafen/screenshare/fullscreen/more/PiP).
- `src/app/features/call/CallMemberCard.tsx` — **lobby** participant roster (this is where `AvatarDecoration` works today; in-call grid is EC's).
- `src/app/features/call/PrescreenControls.tsx` — join controls.
- `src/app/features/call-status/*` — `CallStatus.tsx`, `MemberGlance.tsx` (the "Focus camera" menu lives here), `LiveChip.tsx`.
- `src/app/features/room-nav/RoomNavItem.tsx`, `features/room/Room.tsx`, `features/room/RoomViewHeader.tsx`, `pages/client/space/Space.tsx`, `pages/CallStatusRenderer.tsx`, `pages/Router.tsx` — call entry points / status surfacing.
Hooks:
- `src/app/hooks/useCallEmbed.ts`, `useCall.ts`, `useCallSpeakers.ts` (DOM-poking), `useCallJoinLeaveSounds.ts`, `useAfkAutoMute.ts`.
Build:
- `cinny/vite.config.js` — `copyFiles` (EC dist copy) + `lotusDenoise()` plugin (denoise asset copy + index.html shim injection, in `closeBundle`).
Utils:
- `src/app/utils/ringtones.ts`, `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`.
---
## 3. Hosting / infra context (the OTHER repo)
There are **two repos**:
1. **`LotusGuild/cinny`** (`/root/code/cinny`) — this Lotus Chat fork. Consumes EC.
2. **`LotusGuild/matrix`** (`/root/code/matrix`) — the **infra/homeserver** repo.
Subdirs: `livekit/` (the SFU EC talks to), `deploy/`, `draupnir/`,
`hookshot/`, `landing/`, `matrixbot/`, `systemd/`. Gitea remote
`code.lotusguild.org/LotusGuild/matrix`, branch `main`.
EC needs a **LiveKit SFU** + the **livekit-jwt-service**; those live in
`matrix/livekit/`. A self-hosted EC build must be configured to point at our
homeserver (`matrix.lotusguild.org` / synapse) and our LiveKit. EC's runtime
`config.json` (homeserver, livekit URL, feature flags) is part of what we'll own
once we build it ourselves.
Deployment today: `chat.lotusguild.org` (the cinny web build, which embeds EC at
`/public/element-call/`). cinny-desktop (`LotusGuild/cinny-desktop`, a Tauri
wrapper, bumped by cinny CI) embeds the same.
---
## 4. The plan (proposed — confirm with the user before executing)
### Decision: **YES, create a new repo.** `LotusGuild/element-call`
Rationale: EC is a large standalone app (React + LiveKit client SDK + matrixRTC +
its own Vite build + heavy deps). Keep it out of cinny so cinny's build stays
clean — cinny keeps consuming a **built EC `dist/`**, exactly as today, just
sourced from **our fork** instead of npm.
### Phase 0 — Recon (no code)
- Fork `github.com/element-hq/element-call` → `LotusGuild/element-call` on Gitea.
- Pin to the upstream tag matching **0.20.1** (`element-call-embedded` 0.20.1's
corresponding `element-call` release) so behavior matches what's shipping now.
Verify the embedded-package version ↔ element-call repo tag mapping.
- Read EC's own build docs: it builds the "embedded" widget bundle (the thing
currently published as `@element-hq/element-call-embedded`). Reproduce that
build locally and confirm the output matches `public/element-call/` today.
- **License:** element-call is **AGPL-3.0**, same as Lotus Chat — compatible.
Our fork must remain AGPL and publish source.
### Phase 1 — Reproduce current behavior from our fork (parity, no features)
- Build our fork's embedded bundle; wire cinny to consume it instead of the npm
package (see §5 for the consumption options). Smoke-test: a call works exactly
as today (web + desktop), denoise shim still injects, widget API + theme still
work. **No behavior change yet** — this de-risks the swap.
### Phase 2 — Replace the outside hacks with source-level features
Tackle the §1 issues in EC's source:
- **A6:** render avatar decorations as part of the video-tile component
(read decoration data we pass in via widget data / URL param / a small bridge).
- **A5:** fix focus/spotlight + screenshare-coexistence in EC's layout code;
expose a clean widget action so cinny can trigger it (kill the DOM `.click()`).
- **A7:** fix mic re-publish on reconnect; reconcile with our denoise shim (§6) —
ideally move denoise INTO the fork as a real audio-processing step instead of a
`getUserMedia` monkeypatch.
- Native Lotus theming/branding at the source (kill the injected-CSS hacks).
- Then retire the DOM-poking in `useCallSpeakers.ts` / `CallControl.ts` in favor
of real widget messages.
### Phase 3 — Maintenance posture
- Decide rebase cadence vs. upstream element-call releases. Keep customizations
isolated (feature flags / minimal-diff patches) to ease rebasing.
- CI in the new repo builds + publishes the embedded dist as a versioned
artifact; cinny CI consumes a pinned version.
---
## 5. How cinny should consume the fork (pick one — decide with user)
1. **Private npm package** (mirror the current model): our fork's CI publishes
`@lotusguild/element-call-embedded` to a registry; cinny depends on it and
`viteStaticCopy` keeps working almost unchanged. _Cleanest swap; needs a
registry._
2. **Git submodule + build in cinny CI:** add the fork as a submodule, build it
during cinny's build, copy its `dist/` to `public/element-call/`. _No
registry; heavier cinny CI._
3. **CI artifact copy:** fork CI uploads a `dist` tarball; cinny CI downloads a
pinned version at build. _Decoupled; needs artifact plumbing._
**Recommendation: Option 1** — it changes the least in cinny (just swap the
package name in `package.json` + the `viteStaticCopy` src path) and preserves the
clean cinny/EC separation.
---
## 6. The denoise shim — critical interaction (don't break this)
Lotus ships ML noise suppression by **injecting a same-origin pre-init shim into
EC's `index.html` at build time** (cinny `vite.config.js` → `lotusDenoise()`,
`closeBundle`). The shim monkeypatches `getUserMedia` **before EC captures the
mic** and routes audio through RNNoise/Speex/DTLN AudioWorklets, then EC/LiveKit
publishes the processed track. It's activated via URL params
(`lotusDenoise=ml&lotusModel=…&lotusGate=…`) set in `CallEmbed.ts`.
- Assets copied to `public/element-call/denoise/` at build (sapphi RNNoise/Speex/
gate worklets + `@workadventure/noise-suppression` DTLN tree).
- Related: `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`,
`settings/general/DenoiseTester.tsx`, `VoiceMessageRecorder.tsx`.
- **Known issues:** denoise quality is still poor (tracked separately); and the
mic-after-reconnect bug (A7) is suspected to involve the shim's getUserMedia
patch handing back a stale processed stream when EC re-acquires the mic.
**Once we own the fork, the right move is to make denoise a first-class
audio-processing stage inside EC** (not an index.html monkeypatch) — more robust,
survives reconnects, and removes the build-time injection hack. Until then, the
fork's `index.html` must remain injectable the same way, or the shim must be
re-homed into the fork.
---
## 7. Doc-accuracy notes / corrections for the new session
- `LOTUS_TODO.md` (~line 533) calls EC a **"cross-origin iframe"** — **outdated.**
EC is **same-origin** today (self-hosted under our domain;
`iframe.sandbox` includes `allow-same-origin`; we read `contentDocument`), and
**as of 2026-06-29 we own the fork's source** (`@lotusguild/element-call-embedded`).
The _practical_ point it made still holds _until we ship the audio-inject API_:
**LiveKit's `LocalAudioTrack` lives in EC's module scope**, not on `window`, so
cinny can't reach it even same-origin — which is why the in-call soundboard had
to be local-playback-only. **The fork removes this wall:** EC can expose a real
`io.lotus.inject_audio` widget action (Phase 2) that mixes into the published
track from inside its own module scope.
- `LOTUS_FEATURES.md` documents the EC upgrade history (0.16.3 → 0.19.4 →
0.20.1), the dark-mode CSS injection, and AFK auto-mute — all relevant prior
art for what the fork must preserve.
- `LOTUS_TESTING.md` §D is the **EC regression sweep** to re-run after the fork
swap (Phase 1 parity check).
---
## 8. First actions for the new session
1. Read this file, then skim §2.3's files in `cinny` to internalize the seams.
2. Confirm with the user: new repo name, consumption model (§5), rebase cadence.
3. Phase 0: fork element-call, map 0.20.1 ↔ element-call tag, reproduce the
embedded build locally, diff against `public/element-call/`.
4. Phase 1: wire cinny to the fork, run `LOTUS_TESTING.md` §D parity sweep.
5. Only then start Phase 2 features (A5/A6/A7, theming, denoise-in-source).
**Cross-references:** `LOTUS_BUGS.md` (EC limitations + verify queue),
`LOTUS_TODO.md` (denoise/soundboard constraints), `LOTUS_FEATURES.md` (EC history),
`LOTUS_TESTING.md` §D (regression sweep). Infra: `/root/code/matrix` (`livekit/`,
`deploy/`).
---
## 10. Live cutover — the remaining steps (Phase 1 finish)
The fork is published and cinny builds against it locally (§9.6). What's left to
go live:
1. **Run `LOTUS_TESTING.md` §D** against a local cinny build (`npm run build` is
already proven; serve `dist/` or `npm run dev`). Verify a real call: join,
mic/cam, screenshare, theme sync, denoise on, widget hangup — web first.
2. **Commit the cinny edits** (currently staged, uncommitted in the working tree):
`.npmrc`, `package.json`, `package-lock.json`, `vite.config.js`. Suggested
message: `chore(call): consume self-built @lotusguild/element-call-embedded`.
3. **Push to `lotus`** → cinny CI builds, then `trigger-desktop` bumps
cinny-desktop → Tauri release. Re-run §D on **cinny-desktop** (the path where
the old `stripBase` bug bit — verify the widget loads, not a 404).
4. Only then start **Phase 2** (A5/A6/A7, theming, denoise-in-source).
---
## 11. Phase 2 — implementation seams (mapped 2026-06-29)
The exact integration points for each Phase 2 item, found by reading the EC fork
- cinny source. **All of these are media-path / in-call features that cannot be
functionally verified without a live Matrix + LiveKit call** — implement each as
a minimal, **feature-flagged, additive** diff (no behavior change unless cinny
opts in), build-verify the fork (`pnpm build:embedded`, ~15s) AND cinny
(`npm run build`), then gate shipping on `LOTUS_TESTING.md` §D.
**Shared widget channel (the backbone for #2/#3/#4/#7):**
- EC→cinny: `widget.api.transport.send("io.lotus.<x>", data)` (see
`element-call/src/widget.ts`).
- cinny→EC actions: add the action name to the `lazyActions` allow-list in
`widget.ts` (the array at ~L101) and handle it in EC; cinny sends via
`this.call.transport.send(...)`.
- cinny receives EC→cinny actions via the existing `listenAction(type, cb)`
helper in `plugins/call/CallEmbed.ts:626` (auto-replies `{}` so the transport
doesn't time out — same pattern as `io.element.device_mute`).
**#2 mute/speaker events** — Source: subscribe to `vm.userMedia$`
(`CallViewModel`), per member `speaking$` + `audioEnabled$`
(`state/media/UserMediaViewModel.ts:47-48`); aggregate and
`transport.send("io.lotus.call_state", {participants:[{id,speaking,audioEnabled}]})`.
Mount in `room/InCallView.tsx` via `useEffect` guarded by `widget !== null`.
cinny: `listenAction("io.lotus.call_state")` in `CallEmbed.ts`, feed
`hooks/useCallSpeakers.ts` → delete its `contentDocument` `[data-muted]` /
`[data-video-fit]` scrape. _Additive, low risk._
**#4 spotlight/focus** — EC: add `io.lotus.focus_participant` to the `lazyActions`
list (`widget.ts`), drive `vm`'s spotlight (`spotlightSpeaker$` /
`spotlight$` in `CallViewModel.ts:898/1001`) to pin a given identity, coexisting
with `hasRemoteScreenShares$` (L1008). cinny: replace
`CallControl.ts` `focusCameraParticipant` `.click()` walk with
`transport.send("io.lotus.focus_participant", {userId})`. _Additive, low risk._
**#3 audio-inject** — EC: add `io.lotus.inject_audio` action; mix an
`AudioBufferSourceNode` into the published mic track. The local publish path is
`state/CallViewModel/localMember/Publisher.ts` + `LocalMember.ts` (LiveKit
`localParticipant`); create a `MediaStreamAudioDestinationNode`, mix mic + clip,
`replaceTrack`. cinny soundboard calls the action instead of local-only playback.
_Medium; touches publish path → live-test carefully._
**#1 denoise-in-source** — replace the cinny `lotusDenoise()` `getUserMedia`
monkeypatch with a real processing stage in EC's mic capture
(`Publisher.ts`/`LocalMember.ts`; note EC has a `TrackProcessorContext` +
`BlurBackgroundTransformer` precedent in `livekit/`). EC re-runs it on every
(re)publish → fixes A7. Remove `vite.config.js` `lotusDenoise()` + URL params in
`CallEmbed.ts`; move `denoise/` assets into the fork. _Highest value, highest
risk — most live testing._
**#5 theming** — add a Lotus/TDS theme in EC's theme system (`src/useTheme.ts` +
EC theme tokens / CSS); driven by the existing `setTheme()` channel cinny already
calls (`CallEmbed.ts:277`). Bake transparent background. Delete cinny's
`applyStyles()` injection + `background:none !important`. _Medium._
**#6 in-call decorations** — render the decoration APNG in EC's tile component
(`tile/GridTile.tsx`); pass slugs via widget member data. cinny already has the
decoration data + `AvatarDecoration` (lobby `CallMemberCard.tsx`). _Medium-Large._
**#7 quality controls** — set audio `maxBitrate` via
`RTCRtpSender.setParameters` and screenshare `getDisplayMedia` constraints in
EC's publish path (`Publisher.ts`); configurable via `config.json` / a widget
message. Keep the server `voice-limit-guard` as enforcement. _Medium._
**Rollback:** revert the 4 cinny files (restores `@element-hq/...@0.20.1` from
npmjs). The fork repo/package can stay; nothing else depends on it until pushed.
### Local repro/build environment (this session, 2026-06-29)
- Upstream cloned + our `lotus` branch at `/root/code/element-call` (remote
`lotus` → Gitea; origin → github upstream, now un-shallowed/full history).
- Isolated **Node 24.18.0** lives in the session scratchpad (system Node is 20);
cinny's `.node-version` is `24.13.1`, so use Node 24 to build cinny too.
- Build the embedded bundle: in `/root/code/element-call`, with Node 24 + pnpm
10.33.0 on PATH, `VITE_APP_VERSION=embedded-v0.20.1 pnpm run build:embedded`
→ output in `dist/`; stage to `embedded/web/dist` before publishing.
---
## 12. Phase 2 — IMPLEMENTED on the fork (2026-06-30)
All 7 EC features are on the `lotus` branch of `LotusGuild/element-call`, each
**additive + feature-flagged** (a vanilla call with no `lotus*` params / no Lotus
actions behaves exactly like upstream), build + `tsc` clean, per-feature reviewed
(fixes applied) and holistically reviewed. **Not yet live-tested** — all need the
`LOTUS_TESTING.md` §D sweep.
Fork modules live under `element-call/src/lotus/*`; mounts are `useEffect`s in
`src/room/InCallView.tsx`. Custom widget actions are in `src/lotus/lotusActions.ts`
(toWidget ones allow-listed in `src/widget.ts`).
| # | Feature | Enable via | EC module |
| :-- | :------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) |
| 4 | Focus/spotlight a participant (works during screenshare) | action `io.lotus.focus_participant {userId | null}` | `lotusFocus.ts` + `CallViewModel` spotlight override |
| 3 | Soundboard audio-inject (heard by peers) | URL `lotusAudioInject=1` + action `io.lotus.inject_audio {url,volume?}` | `lotusAudioInject.ts` |
| 7 | Audio/screenshare quality caps | action `io.lotus.set_quality {audioMaxBitrate?,screenshareMaxBitrate?,screenshareMaxFramerate?}` | `lotusQuality.ts` |
| 5 | Transparent bg + Lotus theme | URL `lotusTransparent=1` / `lotusTheme=1` | `useTheme.ts` + `index.css` |
| 6 | In-call avatar decorations | action `io.lotus.decorations {decorations:{userId:url}}` | `lotusDecorations.ts` + `MediaView.tsx` |
| 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
via a crafted link); audio-inject gated behind `lotusAudioInject=1`; decoration
roster capped. Only `https`/`blob` URLs accepted for inject/decoration assets.
### 12.1 cinny host integration checklist (REQUIRED to light these up)
> ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
> focus_participant, decorations, and transparent background are active; the
> in-source denoise cutover is done (flag `lotusDenoiseSource=1`, **all four**
> models in-source); and the two formerly-dormant capabilities now have cinny
> UI — **soundboard** (`io.lotus.inject_audio`, P5-15) and **quality controls +
> room permissions** (`io.lotus.set_quality` + `io.lotus.room_quality`, P5-31,
> with server-side enforcement in `LotusGuild/matrix`). See `LOTUS_FEATURES.md`
> → "Element Call — Self-Built Fork". The checklist is kept below as the record
> of what was wired. (One open denoise item tracked separately: the "Series
> Suppression" native-NS toggle is not wired to the real call path.)
The EC side is additive and dormant until cinny opts in. Host work (in
`src/app/plugins/call/CallEmbed.ts` unless noted) — **done**:
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
> is joined (`CallEmbed.onCallJoined` / `this.joined`). Those actions are
> allow-listed at EC app-init (so `preventDefault` suppresses the auto-error)
> but their handlers only mount with `InCallView` (post-join). Sending earlier
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and
> flush on join, or no-op before join.
>
> Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
> `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
> the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
> in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
> real calls — open item). **F7** — no widget _capability_ changes needed;
> custom actions bypass capability checks.
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
`lotusAudioInject=1` as desired. (Denoise sets `lotusDenoiseSource=1` + `lotusModel`/`lotusGate`/`lotusGateThreshold` in the `ml` tier.)
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
without a reply the fork's sends time out every 250ms. Feed the payload into
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
3. **Send actions** via `this.call.transport.send(...)`:
`io.lotus.focus_participant` (replace `CallControl.focusCameraParticipant`s
`.click()`), `io.lotus.inject_audio` (from the soundboard), `io.lotus.set_quality`
(from quality settings), `io.lotus.decorations` (push the MSC4133 decoration
map; resolve mxc→https first).
4. **#1 denoise cutover**: once verified, STOP injecting the `lotusDenoise()`
shim in `cinny/vite.config.js` and remove the `index.html` injection — the
fork now does denoise in-source. Keep shipping the `denoise/` assets (the
fork loads `./denoise/…` at runtime) until those move into the fork build.
5. Re-run `LOTUS_TESTING.md` §D for each feature; only then ship.
### 12.2 Holistic multi-agent review — outstanding follow-ups (non-blocking)
Four aspect-agents reviewed the whole fork. Criticals were fixed in-branch (the
denoise restart-silence/A7 bug; the `lotusDenoiseBase` code-load vector;
audio-inject opt-in gate; #6 rendering in the wrong component; #7 simulcast cap).
Remaining, deliberately deferred:
- **Denoise H2 (double-processing):** if cinny is set to `lotusDenoise=ml` while
ALSO still injecting its build-time `getUserMedia` shim, audio is denoised
twice. The #1 cutover MUST remove the cinny-side injection (it currently has
none injected into the iframe — keep it that way). Hard requirement, not code.
- **Denoise M1 (perf):** in-source uses non-SIMD `rnnoise.wasm`; the reference
preferred SIMD with detection. Perf-only; add SIMD detection later.
- **dtln/deepfilternet (F3): RESOLVED** — all four models
(rnnoise/speex/dtln/deepfilternet) are now implemented in
`lotusDenoiseProcessor.ts` (faithful port of cinny's `build/lotus-denoise.js`
pipeline). This also fixed a real bug (the gate worklet name was `noiseGate`;
correct is the hyphenated `noise-gate`) and added per-model sample rates
(DTLN 16 kHz, others 48 kHz), context `resume()`, and SIMD wasm selection.
Still needs live §D testing per model, and depends on cinny shipping the
DTLN (`denoise/workadventure/`) + DeepFilterNet (`denoise/deepfilternet/`)
asset trees (it already does).
- **Rebase-fragility (build agent MED):** the `CallViewModel` spotlight override
edits hot upstream lines (renamed `spotlightSpeaker$`→`autoSpotlightSpeaker$`).
For cheaper future rebases, refactor it into a `src/lotus/lotusSpotlight.ts`
wrapper that takes the upstream stream and returns the overridden one, leaving
upstream's definition byte-identical (a single import + two token swaps).
- **Denoise asset coupling (build agent HIGH):** the fork loads `./denoise/*`
shipped by cinny, not by the fork build (documented in the processor). Add an
integration smoke-check that `GET …/element-call/denoise/rnnoise.wasm` == 200,
and pin the `@sapphi-red/web-noise-suppressor` version both repos expect.
- **Unconditional effect registration (build agent LOW):** focus/audio-inject/
quality/decorations register widget handlers on every embedded call (true
no-ops for a non-Lotus host). Intentional; gate behind a coarse `lotus=1` flag
if strict zero-footprint is desired.
- **Privacy (security agent):** decoration/inject URLs accept any `https`; ideally
restrict to the homeserver media origin host-side. Call-state exposes
userId/deviceId/speaking to the (trusted, same-origin) host — documented.
**Nothing here blocks the §D live test — but every feature still needs it.**
### 12.3 Safe rollout when prod is the only test environment
Every Phase-2 feature is now **dormant by default** — with the flags cinny sets
today, the fork behaves identically to the parity build (`#1` was decoupled onto
`lotusDenoiseSource=1` so it no longer collides with the host's `lotusDenoise=ml`
shim). This enables a low-risk incremental rollout even without a staging env:
1. **Ship dormant first.** Publish the `lotus` branch (e.g. `0.20.1-lotus.1`),
bump cinny's pin, deploy. With no Lotus flags set / no Lotus actions sent,
this is upstream-equivalent (only inert, holistically-reviewed code runs).
"Testing" here = confirm a normal call still works.
2. **Enable ONE feature at a time**, each independently revertable:
- URL-flag features (#2 `lotusCallState`, #5 `lotusTransparent`/`lotusTheme`,
#1 `lotusDenoiseSource`): add the flag in `CallEmbed.getWidget`, deploy,
test that one feature, roll back just that flag if needed.
- Action features (#3,#4,#6,#7): wire the host send + (for #2) the
`listenAction` ack, gated on join (§12.1 F1).
3. **#1 denoise cutover is a coordinated 2-step** (do together): set
`lotusDenoiseSource=1` AND remove the `lotusDenoise()` shim injection +
`lotusDenoise=ml` param in cinny — otherwise audio is denoised twice.
Roll back = revert both.
4. Baseline is always upstream-equivalent, so any single feature can be disabled
by flipping its flag/send off without touching the rest.
**Blocker to step 1:** publishing the `lotus` branch needs a Gitea npm token
(the admin token used for the `0.20.1` parity publish was deleted). Either
provide a token for a manual `npm publish`, or stand up the Gitea Actions runner
- `GITEA_NPM_TOKEN` secret so a `v0.20.1-lotus.1` tag auto-publishes.
+104 -22
View File
@@ -15,24 +15,55 @@ step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :------------------------------------------------------- | :--------------------------------------------------- | :---- |
| #1 | Camera focus during screenshare ("Focus camera" menu) | `CallControl.ts`, `MemberGlance.tsx` | A5 |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #3 | Avatar decorations on call tiles | `call/CallMemberCard.tsx` | A6 |
| #4 | DM/group ringtone selection + in-call banner | `CallEmbedProvider.tsx`, `ringtones.ts` | A1A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP mute badge attribution (you vs. all-muted) | `CallEmbedProvider.tsx` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| ID | Item | File / area | Test |
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
---
## 🧩 Element Call source-level items — now actionable via the fork
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
> self-build Element Call (`LotusGuild/element-call` →
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
> row once verified live.
The in-call participant grid is rendered **inside EC's app** — now editable source
(previously a prebuilt npm bundle we could only style around). Status of the items
from testing:
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
sends an `io.lotus.focus_participant` widget action that pins a participant in
EC's layout (coexisting with / overriding the screenshare spotlight); the old
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
(re)publish, so reconnects keep denoise alive natively. The build-time
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
blast radius — everyone's mic; verify D2-1 carefully.**
---
@@ -40,7 +71,59 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
### Calls / Audio
- **N127 — ML denoise shim is never injected in `vite dev`.** The `lotusDenoise` plugin injects only on `closeBundle` (build), so ML noise suppression is silently inactive during local dev. Add a dev-mode injection (`configureServer` / `transformIndexHtml`). Dev-only impact.
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
> a dedicated cross-system planning session with the homeserver owner. Capture
> full client console + a synapse-side trace for the same call before starting.
> **None of these are caused by the EC fork work** (the issues reproduce on the
> old build; the local mic/denoise path is unrelated to key distribution).
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}`
firing **continuously** (many/sec). The client repeatedly tries to publish an
OTK at a key id the server already holds **with a different value**, i.e. the
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
the crypto outgoing-request loop and is the prime suspect for the downstream
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
to-device key events). _Investigate:_ device/key-store reset-or-restore
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
Synapse OTK bug. Repro signature: grep console for `already exists`.
**Extreme — planning session.**
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
`MissingKey: missing key at index N for participant @user`, `skipping decryption
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
rust-crypto `WARN … Received an unexpected encrypted to-device event …
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
these aren't being received/decrypted in order, so remote LiveKit audio/video
can't be decrypted — **this is the "friend's audio cuts out occasionally"
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
session.**
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
`[MembershipManager] Network local timeout error while sending event, immediate
retry … AbortError: Restart delayed event timed out before the HS responded`,
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
call membership and missed leave events. May be partly **homeserver
responsiveness**; correlate with synapse latency/load. Include in the same
planning session since it shares the call-reliability + HS-interaction surface.
### Security & Privacy
@@ -50,10 +133,9 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
### PWA / Offline / Notifications
- **N105 — Service worker has no `notificationclick` handler** — notification clicks are broken when the tab is closed. Needs `showNotification()` via the SW + a `notificationclick` listener.
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- **`manifest: false`** in `vite.config.js` — may block correct PWA install if not handled externally.
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
### Dependencies & Build
@@ -62,7 +144,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
### Code Hygiene / DevEx
- **No automated test suite** (`src/`) — no unit/integration tests configured.
- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
+132 -13
View File
@@ -322,9 +322,104 @@ Users can set a custom background color for `@mention` chips that highlight thei
## Voice / Video Call Improvements
### Element Call Upgrade
> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
> `LotusGuild/element-call`), served same-origin — no longer the upstream
> pre-built npm bundle. Several in-call behaviors below are now first-class
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
> work list are in
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
`@element-hq/element-call-embedded`. The iframe prints
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
upstream) — the quickest way to confirm a deploy landed the fork.
All custom behavior lives in the fork's `src/lotus/` modules and is **additive
and dormant by default**, gated by URL flags / widget actions the host opts into,
so a stock EC config is byte-for-byte upstream behavior.
**Active (cinny drives them today):**
| # | Feature | Mechanism | Replaces (old hack) |
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html`**removed**. Fixes mic-dead-after-reconnect. |
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts`**removed** |
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
| # | Capability | Widget action | cinny surface |
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| P5-15 | **Audio inject** | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below |
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
> Both were dormant capabilities; cinny now drives them (armed via
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
> client cap is best-effort.
### In-Call Soundboard (P5-15)
A soundboard button (🔔) in the call controls bar opens a popout of the user's
clips. Clicking one **injects it into the call as a real published LiveKit
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
plays it locally for the presser (LiveKit doesn't loop your own track back).
- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
`io.lotus.soundboard` account data event, so they **sync across all your
devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
inline.
- Authenticated media can't be fetched from the widget's realm, so the host
resolves each mxc clip → an authenticated download → a same-session `blob:`
object URL and hands that to the widget.
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
slider. The button is hidden when disabled.
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
`features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
### Call Quality Controls (P5-31)
Discord-style encoding controls applied to the local tracks via the fork's
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
encodings, re-applied on every re-publish/reconnect).
- **User settings** (Settings → General → Calls): Microphone Bitrate,
Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
(`io.lotus.room_quality` state event); every Lotus client clamps its per-user
quality to `min(user setting, room cap)`.
- Applied by the `useCallQuality` hook on join and whenever settings/caps
change; `utils/callQuality.ts` builds the payload (unit-tested).
**Server-enforced call permissions (hard, ALL clients).** The same
`io.lotus.room_quality` event carries a **publish-source policy**
(`allow_screenshare`, `allow_camera`) enforced server-side by
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
new joins, and a background reconcile loop revokes an **in-progress**
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
flipping the policy — so it kills active shares mid-call, not just future ones.
- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
grant, room config, server `limit:`, or admin API; stock Element Call ignores
room metadata for publish quality. Numeric caps are therefore inherently
**cooperative** — our fork honors them, which is the design above. The
publish-source policy is the one genuine hard, cross-client lever, and it's
implemented.
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
the fork).
### Camera Default Off
@@ -417,7 +512,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
**Advanced Features & Test Options:**
- **Multiple ML Models:** Toggle between **RNNoise** (standard hybrid) and **Speex** (legacy DSP-based) to compare artifact levels and suppression strength.
- **Multiple ML Models:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
@@ -426,20 +521,44 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
**Open-Source Model Roadmap:**
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) |
| :--- | :--- | :--- | :--- |
| **RNNoise** | Poor | Moderate | < 5% |
| **DTLN** | Good | High | 10-20% |
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ |
**Open-Source Models (all now in-source in the EC fork):**
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
| :--- | :--- | :--- | :--- | :--- |
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
| **DTLN** | Good | High | 10-20% | 16 kHz |
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
| **Speex** | Poor | Low | < 5% | 48 kHz |
> **Note:** DeepFilterNet 3 is planned for future inclusion in the desktop build where larger binaries and higher CPU overhead are more acceptable.
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
> rather than ever going silent). The model picker selects between them.
> **Update (2026-07) — quality, reliability & AEC/AGC:**
>
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
> a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
> under the denoised signal so suppression can't fully collapse the noise
> floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
> would comb-filter, so they rely on their own level. The **noise gate now runs
> after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
> `lotusDenoiseFloor` param.
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
> **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
> gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
> playback stays on standard elements — no AEC-defeat vector.)
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
> `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
> (transient failures recover), activation off the local participant (works
> solo), and init/build-failure leak fixes.
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
> open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.
### Files
- `build/lotus-denoise.js` — multi-model getUserMedia shim
- `vite.config.js``lotusDenoise()` plugin (copies assets for RNNoise, Speex, and NoiseGate)
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → widget URL params
- **EC fork** `src/lotus/lotusDenoise.ts` + `lotusDenoiseProcessor.ts` — in-source LiveKit `TrackProcessor` (RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated by `lotusDenoiseSource=1`. (The old build-time `getUserMedia` shim `build/lotus-denoise.js` is **removed**.)
- `vite.config.js``lotusDenoise()` plugin (now only **copies model assets** for the fork to load; no longer injects a shim)
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → `lotusDenoiseSource` widget URL param
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
+163
View File
@@ -207,6 +207,125 @@ If any control does nothing, that usually means an EC DOM selector changed — c
---
## D2. Element Call **fork** — Phase 2 feature sweep (👥 2 people) — `0.20.1-lotus.1`
> The whole EC iframe is now our **self-built fork** (`@lotusguild/element-call-embedded@0.20.1-lotus.1`).
> Five features are **active** (the host sets their flags / sends their actions); two ship **dormant**.
> **Confirm you're on the fork first:** EC iframe console prints `Element Call embedded-v0.20.1-lotus.1`
> (the old build prints `embedded-v0.20.1`). If it says the old version, the web deploy hasn't landed —
> the fork features won't be present, so don't test D2 yet.
> For non-dev testers, each item below also states the plain "✅ good if / ❌ tell us if" outcome.
### D2-1. Denoise **in-source** — survives reconnect (fixes A7) ⭐ highest risk (everyone's mic)
Flag: cinny sets `lotusDenoiseSource=1` when ML denoise is selected (the old build-time getUserMedia
shim is **removed**). This is the single change with the widest blast radius — test deliberately.
- [ ] **Audio flows, no silence** with ML denoise on (baseline, also §D line 204).
- [ ] **Reconnect (the A7 fix):** in a call with ML denoise on, kill network ~10 s (devtools → Offline)
so EC shows "Connection lost / Reconnect", then restore. **Mic still works AND still denoised**
afterward, **without** End+rejoin. _(This is the exact bug that was reintroduced then fixed; if it
regresses, mic dies on every reconnect.)_
- [ ] **Mic device switch mid-call** (Settings → change microphone): audio keeps working (same
`restart()` path as reconnect).
- [ ] **Mute → unmute** a few times: audio returns each time.
- [ ] **Each model** if the picker offers them: `rnnoise` (default), `speex`, `dtln`, `deepfilternet`
each loads + denoises, no silence. (All four are in-source now; DTLN runs at 16 kHz, others 48 kHz.)
- [ ] **No double-processing:** audio isn't over-suppressed/artifacted (would mean the old shim is still
injected alongside the in-source engine).
- **Rollback if bad for everyone:** revert the cinny deploy commit (restores the shim + `@element-hq` parity).
### D2-2. Speaking + mute indicators from widget **events** (#2)
Flag: `lotusCallState=1`. cinny now reads speaker/mute state from `io.lotus.call_state` events instead of
scraping EC's DOM (DOM fallback retained). Overlaps **G1**.
- [ ] **Speaking glow** lights the **correct** person when they talk (you, then your friend).
- [ ] **PiP "All muted" / "You muted" badge** points at the right person and updates on mute/unmute.
### D2-3. Focus camera **during a screenshare** (#4 / A5)
Action: cinny sends `io.lotus.focus_participant` (the DOM `.click()` hack is gone). Overlaps **A5 / G2**.
- [ ] Person A screenshares; Person B camera on; **MemberGlance → Focus camera** on B → B's camera is
spotlighted **alongside/over** the shared screen (not ignored).
- [ ] Camera-**off** target = graceful (no error, no kick out of the screenshare).
### D2-4. In-call avatar decorations (#6) — **NEW, beyond A6**
Action: cinny pushes `io.lotus.decorations`. **A6 only covered the lobby roster** and called in-call EC
tiles out of scope — that's now in scope.
- [ ] A participant with a **Profile decoration** joins **camera off** → the decoration ring renders on
their **in-call video-tile avatar** (inside EC, not just the lobby), correctly sized/positioned.
- [ ] Decoration tracks the right person across grid/spotlight layout changes; disappears when they leave.
### D2-5. Native transparent background (#5)
Flag: `lotusTransparent=1` (native, replacing the injected `background:none !important`).
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
see-through, or layout breakage (also covered loosely by §D2 "looks right").
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
**you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.
### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**
Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
General → Voice → Call Quality Caps**.
- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
still shares. ❌ tell us if any setting kills audio/screenshare.
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
(best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.
> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.
### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
not just our client hiding a button.
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
server-blocked for all clients; **microphones still work**.
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
on stock Element. ✅ good if the share drops within ~35 s; ❌ tell us if it keeps going.
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
---
# Backlog of previously-fixed-but-unverified items
> Sections AD above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** in `LOTUS_BUGS.md` / `LOTUS_TODO.md`. They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way AD are; do them as you have the right device handy.
@@ -410,6 +529,50 @@ Settings → Appearance → theme picker → try each of the 5 new themes.
---
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
delta + a `config.json` override) OR against **mozilla.org** with a real account.
### N1. OIDC login flow (the core test) — needs a MAS homeserver
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
### N2. Session persists across reload (token storage)
After N1, hard-refresh the page.
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
### N3. Token refresh (long-lived session)
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
### N4. Logout revokes at the issuer
Log out from Settings.
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
### N5. Account-management deep-link
Settings → Account.
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
### N6. Non-OIDC regression — password login unchanged
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
---
## Priority if you're short on time
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
+83 -49
View File
@@ -48,6 +48,9 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then th
| Desktop — proactive update notifications (Tauri) | J1 |
| Remind Me Later | K1 |
| Mobile Bookmarks access | E5 |
| In-Call Soundboard (P5-15, uploadable clips → real call inject) | D2-7 |
| Call Quality Controls (P5-31, user + room-admin caps) | D2-8 |
| Call Permissions (P5-31, hard server-side screenshare/camera policy) | D2-9 |
---
@@ -72,32 +75,32 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
### Confirmed facts
| Finding | Impact |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
| Finding | Impact |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| ~~Cindy CANNOT inject audio into EC call stream~~ **UNBLOCKED by EC fork**`io.lotus.inject_audio` widget action publishes a clip as a real call track | In-call soundboard CAN now mix into the call (no longer local-only); needs cinny UI to drive the action |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
---
@@ -241,14 +244,20 @@ Features:
---
### [ ] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) (EXTREME COMPLEXITY, LOW PRIORITY)
### [~] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) — CLIENT-SIDE BUILT, awaiting live verification
**Spec:** MSC3861, merged Matrix spec v1.15. Uses Matrix Authentication Service (MAS).
**Context:** ~80% of homeserver users have LLDAP/Authelia/SSO accounts. SSO is currently enabled on `matrix.lotusguild.org` but accounts are not yet linked. This would allow users to log in via their SSO credentials.
**What:** OAuth 2.0 / OIDC login flow, token refresh, account management page linking Matrix identity to SSO identity.
**EXTREME COMPLEXITY** — requires: MAS deployment/configuration on the homeserver, significant auth flow changes in the client, token refresh handling, session management overhaul.
**[SERVER CHECK]** — Before any client work, audit whether MAS is already deployed on `compute-storage-01`. Check: `pct exec 151 -- systemctl status matrix-authentication-service` or similar.
**Complexity:** Extreme. Multi-sprint project. Plan separately.
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
**Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
- Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
- Login: `pages/auth/oidc/{oidcConfig,oidcLoginUtil,oidcState}.ts` (dynamic registration + cache, PKCE authorize), `login/OidcLogin.tsx`, issuer-gated `Login.tsx`.
- Callback: `oidc/OidcCallback.tsx` + `App.tsx` short-circuit (non-hash redirect path).
- Session/refresh: `state/sessions.ts` OIDC fields, `client/{oidcTokenRefresher,oidcLogout}.ts`, `initMatrix.ts` wiring.
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
---
@@ -260,11 +269,17 @@ Features:
---
### [ ] P5-15 · In-Call Soundboard
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature.
**Complexity:** High.
**What:** Soundboard button in the call controls bar → popout grid of the user's clips; clicking one plays it **into the call** as a real published track (peers hear it) and locally (presser hears it). Clips are **user-uploadable, just like custom emojis/stickers**.
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
**Shipped (cinny):**
- Clips stored in `io.lotus.soundboard` account data → **synced across devices like emoji/sticker packs** (`useSoundboard` hook; `AccountDataEvent.LotusSoundboard`).
- Upload audio (≤1 MB, ≤40 clips) → `mx.uploadContent` → mxc; play resolves mxc → authed download → `blob:` object URL (the widget can't fetch authenticated media itself) → `control.injectAudio(url, volume)` + local playback.
- `CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
**Remaining:** a dedicated Settings management page (optional — upload/delete already live in the popout); a small default clip set; live verification (D2-7). Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`, `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
**Complexity:** Medium — done.
---
@@ -280,25 +295,42 @@ Features:
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta".
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_BUGS.md` A7, `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
**Model Roadmap (priority order):**
**Models — all in-source in the fork:**
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Effort: self-host `df_bg.wasm` + DFN3 ONNX model, wire a 48 kHz worklet.
- [ ] **Desktop-only / HW-gated:** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in Tauri Rust backend + bridge a virtual mic into the webview. Must detect capability and only offer on supported hardware; web falls back to RNNoise.
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams``ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~34 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
---
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
### [~] P5-31 · Granular Voice & Screenshare Quality Controls — IMPLEMENTED (⚠️ awaiting live verification, D2-8)
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
**Complexity:** Extreme.
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
**Shipped (cinny):**
1. **User settings** (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (`callAudioBitrate` / `screenshareBitrate` / `screenshareFramerate`).
2. **Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
3. **Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()``set_quality`, already shipped).
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
---
@@ -531,6 +563,8 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
- Pass the destination's `.stream` to the call bridge.
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
>
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
---
+23
View File
@@ -52,6 +52,9 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (130 min); a toast confirms the action
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
- Soundboard: upload your own short audio clips (like custom emojis — they sync across your devices) and play them into a call so everyone hears them
- Call quality settings: cap your microphone bitrate, screenshare bitrate, and screenshare framerate — handy on a slow connection (Settings → Calls)
- Room call permissions: admins can turn off screen sharing or make a room audio-only (no cameras) — enforced server-side for every Matrix client, and it stops an in-progress share within seconds of being switched off
### Customization & Appearance
@@ -144,6 +147,26 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
avatar decorations on EC video tiles, and a native transparent background.
**Built but dormant (need cinny UI):** real call-audio injection
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
(`io.lotus.set_quality`).
The full plan and integration map is in
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting +
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs
for the **`[EC-FORK]`** tag to find every related note.
### Build
```bash
+112
View File
@@ -0,0 +1,112 @@
# Local OIDC / next-gen-auth (MSC3861) test loop
The Lotus client gained MSC3861/MSC2965 OIDC login (P4-6). lotusguild's own
homeserver is **not** MSC3861, so to exercise the flow without a mozilla.org
tester you need a local homeserver that delegates auth to a **Matrix
Authentication Service (MAS)**. This is the dev loop.
> Status: the Lotus-client side is unit-tested + gate-green; this server loop is
> the manual end-to-end check. It hasn't been run in CI (no container runtime
> there), so treat version pins as a starting point and bump as needed.
## 1. Stand up MAS + Synapse
The simplest path is the **upstream MAS docker-compose quickstart** — it's
maintained and handles key generation + the database:
<https://element-hq.github.io/matrix-authentication-service/setup/installation.html>
(`docker compose` section). Use it to get MAS + Synapse + Postgres running, then
apply the two Lotus-specific deltas below.
A minimal `compose.yaml` skeleton (generate MAS keys first — do **not** hand-write them):
```yaml
services:
postgres:
image: postgres:16
environment: { POSTGRES_USER: synapse, POSTGRES_PASSWORD: pw, POSTGRES_DB: synapse }
mas:
image: ghcr.io/element-hq/matrix-authentication-service:latest
command: server
ports: ['8090:8080'] # MAS issuer on http://localhost:8090
volumes: ['./mas:/data']
# First run once: `docker compose run --rm mas config generate -o /data/config.yaml`
# then edit /data/mas/config.yaml (see §1a) before `up`.
synapse:
image: ghcr.io/element-hq/synapse:latest
ports: ['8008:8008'] # client/federation API
volumes: ['./synapse:/data']
depends_on: [postgres, mas]
```
### 1a. MAS `config.yaml` — the parts that matter
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
```yaml
http:
public_base: http://localhost:8090/
issuer: http://localhost:8090/
database:
uri: postgresql://synapse:pw@postgres/synapse
matrix:
homeserver: localhost # the server_name
endpoint: http://synapse:8008/
secret: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
clients:
- client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
passwords: # so you can create a local test account in the MAS UI
enabled: true
```
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
See `synapse-msc3861.yaml` in this folder; the key block is:
```yaml
experimental_features:
msc3861:
enabled: true
issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" # == MAS clients[].client_secret
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" # == MAS matrix.secret
account_management_url: "http://localhost:8090/account"
```
Create a test user via the MAS UI (`http://localhost:8090/`) or
`docker compose exec mas mas-cli manage register-user`.
Sanity check discovery (the client relies on this):
```bash
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
```
## 2. Point the Lotus dev client at it
Run the client: `npm start` (vite dev). Override `public/config.json` so the
local server is selectable and custom servers are allowed:
```json
{
"defaultHomeserver": 0,
"homeserverList": ["localhost:8008"],
"allowCustomHomeservers": true,
"hashRouter": { "enabled": false, "basename": "/" }
}
```
Dynamic client registration handles the redirect URI automatically — it's
`<vite-origin>/auth/oidc/callback` (e.g. `http://localhost:5173/auth/oidc/callback`),
and MAS allows `http://localhost` redirects in dev.
## 3. Run the checklist
See **section N** of `../../LOTUS_TESTING.md` for the actual pass/fail steps
(login redirect, callback, session-persist-on-reload, token refresh, logout
revocation, account-management link, and the non-OIDC-regression check).
## Files here
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
- `config.local.json` — the Lotus `public/config.json` override.
+7
View File
@@ -0,0 +1,7 @@
{
"defaultHomeserver": 0,
"homeserverList": ["localhost:8008"],
"allowCustomHomeservers": true,
"featuredCommunities": { "openAsDefault": false, "spaces": [], "rooms": [], "servers": [] },
"hashRouter": { "enabled": false, "basename": "/" }
}
+16
View File
@@ -0,0 +1,16 @@
# Synapse experimental-features delta to delegate auth to a local MAS (MSC3861).
# Merge this into your test homeserver.yaml. The client_secret + admin_token MUST
# match the MAS config (clients[].client_secret and matrix.secret respectively).
experimental_features:
msc3861:
enabled: true
issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
account_management_url: "http://localhost:8090/account"
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
# Lotus client's getOidcIssuer() reads to switch into the OIDC flow.
+27 -7
View File
@@ -77,7 +77,7 @@
"ua-parser-js": "2.0.10"
},
"devDependencies": {
"@element-hq/element-call-embedded": "0.20.1",
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -108,6 +108,7 @@
"husky": "9.1.7",
"lint-staged": "17.0.5",
"prettier": "3.8.3",
"tsx": "4.22.4",
"typescript": "6.0.3",
"vite": "8.0.14",
"vite-plugin-pwa": "1.3.0",
@@ -1788,12 +1789,6 @@
"node": ">=v18"
}
},
"node_modules/@element-hq/element-call-embedded": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz",
"integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==",
"dev": true
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2693,6 +2688,12 @@
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.20.1-lotus.1",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
"integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
@@ -12357,6 +12358,25 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+3 -1
View File
@@ -16,6 +16,7 @@
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
@@ -101,7 +102,7 @@
"ua-parser-js": "2.0.10"
},
"devDependencies": {
"@element-hq/element-call-embedded": "0.20.1",
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -132,6 +133,7 @@
"husky": "9.1.7",
"lint-staged": "17.0.5",
"prettier": "3.8.3",
"tsx": "4.22.4",
"typescript": "6.0.3",
"vite": "8.0.14",
"vite-plugin-pwa": "1.3.0",
+32 -2
View File
@@ -37,6 +37,7 @@ import {
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -44,6 +45,7 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallQuality } from '../hooks/useCallQuality';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -64,6 +66,7 @@ import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
@@ -323,7 +326,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
position: 'fixed',
top: config.space.S400,
right: config.space.S400,
zIndex: 9990,
zIndex: zIndices.inCallBanner,
width: toRem(300),
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
padding: config.space.S300,
@@ -404,6 +407,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate();
const setToast = useSetAtom(toastQueueAtom);
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
@@ -423,6 +427,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
await event.getDecryptionPromise();
}
// Caller-side: a participant declined a call we're hosting in this room.
// Without this the caller's UI keeps "ringing" until the notification
// lifetime expires, with no indication the callee said no.
if (event.getType() === EventType.RTCDecline) {
const decliner = event.getSender();
if (
data.liveEvent &&
room &&
decliner &&
decliner !== mx.getSafeUserId() &&
callEmbed?.roomId === room.roomId
) {
const declinerName =
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
setToast({
id: `rtc-decline-${event.getId() ?? decliner}`,
displayName: declinerName,
body: 'Declined your call',
roomName: room.name,
roomId: room.roomId,
});
}
return;
}
if (
!room ||
event.getType() !== EventType.RTCNotification ||
@@ -485,7 +514,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
setCallInfo(info);
},
[mx, directs],
[mx, directs, callEmbed, setToast],
);
useEffect(() => {
@@ -556,6 +585,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent(
embed,
useCallback(() => {
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, useEffect, useState } from 'react';
import { Box, color, config, Text, toRem } from 'folds';
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { RoomEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -339,11 +339,7 @@ export function PollContent({
transition: 'all 0.15s',
}}
>
{selected && isMultiple ? (
<Text as="span" size="T200" style={{ lineHeight: 1 }}>
</Text>
) : null}
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text}
-136
View File
@@ -1,136 +0,0 @@
import { keyframes } from '@vanilla-extract/css';
/** Generic fall: particles drop from top to bottom with a slight rotate. */
export const animSeasonFall = keyframes({
'0%': { transform: 'translateY(-20px) translateX(0) rotate(0deg)', opacity: '0' },
'5%': { opacity: '1' },
'90%': { opacity: '0.8' },
'100%': { transform: 'translateY(110vh) translateX(25px) rotate(360deg)', opacity: '0' },
});
/** Leaf fall: exaggerated horizontal sway as the leaf tumbles down. */
export const animLeafFall = keyframes({
'0%': { transform: 'translateY(-20px) translateX(0) rotate(-20deg)', opacity: '0' },
'8%': { opacity: '0.85' },
'25%': { transform: 'translateY(25vh) translateX(35px) rotate(40deg)' },
'50%': { transform: 'translateY(50vh) translateX(-25px) rotate(130deg)' },
'75%': { transform: 'translateY(75vh) translateX(45px) rotate(260deg)' },
'92%': { opacity: '0.6' },
'100%': { transform: 'translateY(110vh) translateX(5px) rotate(380deg)', opacity: '0' },
});
/** Float up: hearts / embers rise from the bottom. */
export const animFloatUp = keyframes({
'0%': { transform: 'translateY(0) scale(0.6) translateX(0)', opacity: '0' },
'8%': { opacity: '0.9' },
'50%': { transform: 'translateY(-50vh) scale(1) translateX(15px)' },
'85%': { opacity: '0.4' },
'100%': { transform: 'translateY(-105vh) scale(1.3) translateX(-10px)', opacity: '0' },
});
/** Bob: lanterns gently rise and fall with a slight tilt. */
export const animBob = keyframes({
'0%': { transform: 'translateY(0px) rotate(-4deg)' },
'50%': { transform: 'translateY(-18px) rotate(4deg)' },
'100%': { transform: 'translateY(0px) rotate(-4deg)' },
});
/** Lantern tassel sway (used on the tassel element only). */
export const animTasselSway = keyframes({
'0%': { transform: 'rotate(-8deg)' },
'50%': { transform: 'rotate(8deg)' },
'100%': { transform: 'rotate(-8deg)' },
});
/** Glitch jitter: rapid position jumps that feel like a signal error. */
export const animGlitch = keyframes({
'0%': { transform: 'translate(0, 0)' },
'2%': { transform: 'translate(-4px, 2px)' },
'4%': { transform: 'translate(4px, -2px)' },
'6%': { transform: 'translate(0, 0)' },
'48%': { transform: 'translate(0, 0)' },
'50%': { transform: 'translate(3px, -3px)' },
'52%': { transform: 'translate(-3px, 3px)' },
'54%': { transform: 'translate(0, 0)' },
'78%': { transform: 'translate(0, 0)' },
'80%': { transform: 'translate(-5px, 1px)' },
'82%': { transform: 'translate(0, 0)' },
'100%': { transform: 'translate(0, 0)' },
});
/** Glitch color: hue + saturation spikes that look like a corrupted signal. */
export const animGlitchColor = keyframes({
'0%': { filter: 'hue-rotate(0deg) saturate(1)' },
'8%': { filter: 'hue-rotate(180deg) saturate(3)' },
'9%': { filter: 'hue-rotate(0deg) saturate(1)' },
'55%': { filter: 'hue-rotate(0deg) saturate(1)' },
'57%': { filter: 'hue-rotate(90deg) saturate(2)' },
'58%': { filter: 'hue-rotate(0deg) saturate(1)' },
'80%': { filter: 'hue-rotate(0deg) saturate(1)' },
'82%': { filter: 'hue-rotate(270deg) saturate(2.5)' },
'83%': { filter: 'hue-rotate(0deg) saturate(1)' },
'100%': { filter: 'hue-rotate(0deg) saturate(1)' },
});
/** Glitch scanline: a horizontal band sweeps across, flickering. */
export const animGlitchScan = keyframes({
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100vh)' },
});
/** Burst: circle expands outward from a point and fades — firework petal. */
export const animBurst = keyframes({
'0%': { transform: 'scale(0) rotate(0deg)', opacity: '1' },
'50%': { opacity: '0.7' },
'100%': { transform: 'scale(1) rotate(45deg)', opacity: '0' },
});
/** Firework trail: a small dot rockets upward before bursting. */
export const animRocket = keyframes({
'0%': { transform: 'translateY(0)', opacity: '1' },
'100%': { transform: 'translateY(-40vh)', opacity: '0' },
});
/** Deep space warp: stars streak from center outward. */
export const animWarp = keyframes({
'0%': { transform: 'scale(0.05) translate(0, 0)', opacity: '0' },
'10%': { opacity: '1' },
'100%': { transform: 'scale(4) translate(0, 0)', opacity: '0' },
});
/** Arcade scanline flicker. */
export const animScanline = keyframes({
'0%': { opacity: '0.12' },
'50%': { opacity: '0.04' },
'100%': { opacity: '0.12' },
});
/** Arcade pixel blink: decorative corner glyphs blink. */
export const animPixelBlink = keyframes({
'0%, 49%': { opacity: '1' },
'50%, 100%': { opacity: '0' },
});
/** Gold shimmer: a shine sweeps across a metallic surface. */
export const animGoldShimmer = keyframes({
'0%': { backgroundPosition: '-300% 0' },
'100%': { backgroundPosition: '300% 0' },
});
/** Clover drift: gentle fall with a slow spin. */
export const animCloverDrift = keyframes({
'0%': { transform: 'translateY(-20px) rotate(0deg)', opacity: '0' },
'5%': { opacity: '0.7' },
'90%': { opacity: '0.5' },
'100%': { transform: 'translateY(110vh) rotate(720deg)', opacity: '0' },
});
/** Earth Day leaf sway: gentle horizontal oscillation for ambient leaf particles. */
export const animEarthLeafDrift = keyframes({
'0%': { transform: 'translateY(-10px) translateX(0) rotate(0deg)', opacity: '0' },
'8%': { opacity: '0.6' },
'30%': { transform: 'translateY(30vh) translateX(20px) rotate(90deg)' },
'60%': { transform: 'translateY(60vh) translateX(-15px) rotate(200deg)' },
'90%': { opacity: '0.4' },
'100%': { transform: 'translateY(110vh) translateX(10px) rotate(340deg)', opacity: '0' },
});
+19 -713
View File
@@ -1,719 +1,25 @@
import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import {
animSeasonFall,
animLeafFall,
animFloatUp,
animBob,
animTasselSway,
animGoldShimmer,
animCloverDrift,
animEarthLeafDrift,
animWarp,
animScanline,
animPixelBlink,
} from './Seasonal.css';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { getActiveSeason } from './seasonSchedule';
import { HalloweenOverlay } from './themes/Halloween';
import { ChristmasOverlay } from './themes/Christmas';
import { NewYearOverlay } from './themes/NewYear';
import { AutumnOverlay } from './themes/Autumn';
import { AprilFoolsOverlay } from './themes/AprilFools';
import { LunarNewYearOverlay } from './themes/LunarNewYear';
import { ValentinesOverlay } from './themes/Valentines';
import { StPatricksOverlay } from './themes/StPatricks';
import { EarthDayOverlay } from './themes/EarthDay';
import { DeepSpaceOverlay } from './themes/DeepSpace';
import { ArcadeOverlay } from './themes/Arcade';
export type SeasonTheme =
| 'halloween'
| 'christmas'
| 'newyear'
| 'autumn'
| 'aprilfools'
| 'lunar'
| 'valentines'
| 'stpatricks'
| 'earthday'
| 'deepspace'
| 'arcade';
function getActiveSeason(now: Date): SeasonTheme | null {
const m = now.getMonth() + 1; // 1-12
const d = now.getDate();
// New Year takes highest priority (Dec 31 Jan 2)
if ((m === 12 && d === 31) || (m === 1 && d <= 2)) return 'newyear';
// Valentine's Day (Feb 1015)
if (m === 2 && d >= 10 && d <= 15) return 'valentines';
// St. Patrick's Day (March 1518)
if (m === 3 && d >= 15 && d <= 18) return 'stpatricks';
// April Fool's (April 1)
if (m === 4 && d === 1) return 'aprilfools';
// Earth Day (April 2023)
if (m === 4 && d >= 20 && d <= 23) return 'earthday';
// Lunar New Year (Jan 22 Feb 5, approximate fixed window)
if ((m === 1 && d >= 22) || (m === 2 && d <= 5)) return 'lunar';
// International Video Game Day (Sept 12)
if (m === 9 && d === 12) return 'arcade';
// World Space Week (Oct 410)
if (m === 10 && d >= 4 && d <= 10) return 'deepspace';
// Halloween (Oct 15 Nov 1)
if ((m === 10 && d >= 15) || (m === 11 && d === 1)) return 'halloween';
// Christmas (Dec 1030)
if (m === 12 && d >= 10) return 'christmas';
// Autumn (Sept 21 Oct 31, excluding Halloween/Deep Space windows above)
if ((m === 9 && d >= 21) || (m === 10 && d <= 14)) return 'autumn';
return null;
}
// ─── Individual theme overlays ────────────────────────────────────────────────
function HalloweenOverlay({ reduced }: { reduced: boolean }) {
const particles = Array.from({ length: 22 });
return (
<>
{/* Dark purple ambient tint */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(25,0,45,0.22)',
backgroundImage:
'radial-gradient(ellipse at 50% 50%, rgba(100,0,180,0.08) 0%, transparent 70%)',
}}
/>
{/* Spider web corners */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '160px',
height: '160px',
backgroundImage: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><g stroke='rgba(180,120,255,0.35)' stroke-width='0.7' fill='none'><line x1='0' y1='0' x2='80' y2='80'/><line x1='40' y1='0' x2='80' y2='80'/><line x1='80' y1='0' x2='80' y2='80'/><line x1='0' y1='40' x2='80' y2='80'/><line x1='0' y1='80' x2='80' y2='80'/><ellipse cx='80' cy='80' rx='20' ry='20'/><ellipse cx='80' cy='80' rx='40' ry='40'/><ellipse cx='80' cy='80' rx='60' ry='60'/><ellipse cx='80' cy='80' rx='80' ry='80'/></g></svg>")`,
backgroundRepeat: 'no-repeat',
opacity: 0.7,
}}
/>
{/* Falling purple/orange particles */}
{!reduced &&
particles.map((_, i) => {
const isOrange = i % 3 === 0;
const size = 4 + (i % 3) * 2;
const left = (i * 4597 + 137) % 100;
const duration = 8 + (i % 7) * 1.5;
const delay = (i * 0.45) % 7;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-8px',
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
backgroundColor: isOrange ? 'rgba(255,100,0,0.75)' : 'rgba(160,0,255,0.7)',
boxShadow: isOrange ? '0 0 8px rgba(255,100,0,0.5)' : '0 0 8px rgba(160,0,255,0.5)',
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
}}
/>
);
})}
</>
);
}
function ChristmasOverlay({ reduced }: { reduced: boolean }) {
const flakes = Array.from({ length: 28 });
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 0%, rgba(220,240,255,0.06) 0%, transparent 60%)',
}}
/>
{!reduced &&
flakes.map((_, i) => {
const size = 3 + (i % 4) * 2;
const left = (i * 3571 + 251) % 100;
const duration = 10 + (i % 8) * 2;
const delay = (i * 0.55) % 10;
const drift = ((i % 5) - 2) * 12;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-10px',
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
backgroundColor: 'rgba(255,255,255,0.82)',
boxShadow: '0 0 4px rgba(200,230,255,0.6)',
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
transform: `translateX(${drift}px)`,
}}
/>
);
})}
</>
);
}
// Replaced flashing burst rays with gentle falling confetti
function NewYearOverlay({ reduced }: { reduced: boolean }) {
const confetti = Array.from({ length: 24 });
const colors = ['#ffd700', '#ff4466', '#00d4ff', '#aa44ff', '#ff8800', '#ffffff'];
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(10,5,0,0.10)',
backgroundImage:
'radial-gradient(ellipse at 50% 50%, rgba(255,200,0,0.04) 0%, transparent 70%)',
}}
/>
{/* Gentle falling confetti */}
{!reduced &&
confetti.map((_, i) => {
const c = colors[i % colors.length];
const left = (i * 4597 + 137) % 100;
const size = 3 + (i % 3) * 2;
const duration = 8 + (i % 7) * 1.5;
const delay = (i * 0.4) % 8;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-8px',
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
borderRadius: i % 2 === 0 ? '50%' : '1px',
backgroundColor: c,
boxShadow: `0 0 ${size + 2}px ${c}`,
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
opacity: 0.7 + (i % 3) * 0.1,
}}
/>
);
})}
{/* Slow gold shimmer */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(105deg, transparent 30%, rgba(255,215,0,0.05) 50%, transparent 70%)',
backgroundSize: '200% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 5s linear infinite`,
}}
/>
</>
);
}
function AutumnOverlay({ reduced }: { reduced: boolean }) {
const leaves = Array.from({ length: 18 });
const colors = [
'rgba(220,80,20,0.75)',
'rgba(200,120,0,0.7)',
'rgba(180,50,10,0.7)',
'rgba(230,150,0,0.65)',
'rgba(160,80,0,0.6)',
];
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 100%, rgba(180,80,0,0.06) 0%, transparent 60%)',
}}
/>
{!reduced &&
leaves.map((_, i) => {
const left = (i * 5381 + 179) % 100;
const duration = 12 + (i % 6) * 2;
const delay = (i * 0.65) % 12;
const size = 10 + (i % 4) * 4;
const col = colors[i % colors.length];
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-15px',
left: `${left}%`,
width: `${size}px`,
height: `${size * 0.7}px`,
borderRadius: '50% 0 50% 0',
backgroundColor: col,
boxShadow: `0 0 4px ${col}`,
animation: `${animLeafFall} ${duration}s ease-in ${delay}s infinite`,
}}
/>
);
})}
</>
);
}
// Replaced aggressive glitch with playful confetti rain
function AprilFoolsOverlay({ reduced }: { reduced: boolean }) {
const particles = Array.from({ length: 20 });
const symbols = ['?', '!', '¿', '‽', '?', '!'];
const colors = [
'rgba(255,80,80,0.55)',
'rgba(255,200,0,0.55)',
'rgba(80,200,80,0.55)',
'rgba(80,80,255,0.55)',
'rgba(200,80,200,0.55)',
'rgba(80,200,200,0.55)',
];
return (
<>
{/* Subtle rainbow stripe along top edge */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
backgroundImage:
'linear-gradient(90deg, rgba(255,0,0,0.4), rgba(255,165,0,0.4), rgba(255,255,0,0.4), rgba(0,200,0,0.4), rgba(0,0,255,0.4), rgba(128,0,128,0.4))',
opacity: 0.7,
}}
/>
{/* Gentle falling punctuation symbols */}
{!reduced &&
particles.map((_, i) => {
const left = (i * 5381 + 179) % 100;
const duration = 11 + (i % 5) * 2.5;
const delay = (i * 0.55) % 10;
const col = colors[i % colors.length];
const sym = symbols[i % symbols.length];
const size = 12 + (i % 3) * 5;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
color: col,
fontWeight: 700,
fontFamily: 'monospace',
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
userSelect: 'none',
}}
>
{sym}
</div>
);
})}
</>
);
}
// Reduced to 4 lanterns, subtler tint and shimmer
function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
const lanterns = Array.from({ length: 4 }); // was 9
return (
<>
{/* Very subtle red silk tint */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(140,0,0,0.05)',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
'repeating-linear-gradient(135deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
].join(','),
}}
/>
{/* Slow gold shimmer */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(100deg, transparent 25%, rgba(255,200,0,0.05) 45%, rgba(255,220,50,0.07) 50%, rgba(255,200,0,0.05) 55%, transparent 75%)',
backgroundSize: '300% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 8s linear infinite`,
}}
/>
{/* 4 floating lanterns */}
{lanterns.map((_, i) => {
const left = 10 + ((i * 4603 + 311) % 75);
const top = 10 + ((i * 2311 + 97) % 50);
const duration = 3.5 + (i % 4) * 0.7;
const delay = i * 0.9;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: `${left}%`,
top: `${top}%`,
animation: reduced
? 'none'
: `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
}}
>
<div
style={{
width: '18px',
height: '5px',
backgroundColor: '#ffd700',
borderRadius: '2px',
margin: '0 auto',
boxShadow: '0 0 4px rgba(255,215,0,0.6)',
}}
/>
<div
style={{
width: '24px',
height: '32px',
backgroundColor: '#cc0000',
borderRadius: '50%',
border: '1.5px solid #ffd700',
boxShadow: '0 0 14px rgba(200,0,0,0.5), inset 0 0 10px rgba(255,200,0,0.2)',
margin: '1px auto',
}}
/>
<div
style={{
width: '18px',
height: '5px',
backgroundColor: '#ffd700',
borderRadius: '2px',
margin: '0 auto',
}}
/>
<div
style={{
width: '2px',
height: '14px',
backgroundColor: '#ffd700',
margin: '0 auto',
animation: reduced
? 'none'
: `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
transformOrigin: 'top center',
}}
/>
</div>
);
})}
</>
);
}
function ValentinesOverlay({ reduced }: { reduced: boolean }) {
const hearts = Array.from({ length: 18 });
const colors = [
'rgba(255,100,140,0.8)',
'rgba(255,150,180,0.65)',
'rgba(220,70,110,0.7)',
'rgba(255,180,200,0.55)',
];
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 100%, rgba(255,100,140,0.06) 0%, transparent 55%)',
}}
/>
{!reduced &&
hearts.map((_, i) => {
const left = 3 + ((i * 6271 + 443) % 94);
const duration = 9 + (i % 6) * 1.8;
const delay = (i * 0.6) % 9;
const size = 14 + (i % 4) * 5;
const col = colors[i % colors.length];
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
bottom: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
color: col,
filter: 'drop-shadow(0 0 4px rgba(255,100,140,0.4))',
animation: `${animFloatUp} ${duration}s ease-in ${delay}s infinite`,
userSelect: 'none',
}}
>
</div>
);
})}
</>
);
}
function StPatricksOverlay({ reduced }: { reduced: boolean }) {
const clovers = Array.from({ length: 18 });
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(ellipse at 50% 0%, rgba(0,160,60,0.07) 0%, transparent 50%)',
'radial-gradient(ellipse at 50% 100%, rgba(0,130,50,0.05) 0%, transparent 40%)',
].join(','),
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
backgroundImage:
'linear-gradient(90deg, transparent 0%, #ffd700 20%, #fff4a0 40%, #ffd700 60%, transparent 100%)',
backgroundSize: '300% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 3s linear infinite`,
}}
/>
{!reduced &&
clovers.map((_, i) => {
const left = (i * 4129 + 223) % 100;
const duration = 14 + (i % 6) * 2;
const delay = (i * 0.7) % 12;
const size = 14 + (i % 3) * 6;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
opacity: 0.45 + (i % 3) * 0.1,
filter: 'drop-shadow(0 0 3px rgba(0,180,60,0.3))',
animation: `${animCloverDrift} ${duration}s linear ${delay}s infinite`,
userSelect: 'none',
}}
>
</div>
);
})}
</>
);
}
function EarthDayOverlay({ reduced }: { reduced: boolean }) {
const leaves = Array.from({ length: 16 });
const leafEmoji = ['🌿', '🍃', '🌱', '🍀'];
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(ellipse at 30% 70%, rgba(60,160,60,0.07) 0%, transparent 50%)',
'radial-gradient(ellipse at 70% 30%, rgba(100,180,80,0.05) 0%, transparent 45%)',
].join(','),
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '3px',
backgroundImage:
'linear-gradient(180deg, transparent 0%, rgba(60,160,60,0.4) 20%, rgba(80,180,60,0.6) 50%, rgba(60,160,60,0.4) 80%, transparent 100%)',
}}
/>
{!reduced &&
leaves.map((_, i) => {
const left = 3 + ((i * 5023 + 317) % 92);
const duration = 13 + (i % 5) * 2;
const delay = (i * 0.75) % 11;
const size = 14 + (i % 3) * 5;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
opacity: 0.5 + (i % 3) * 0.1,
animation: `${animEarthLeafDrift} ${duration}s ease-in ${delay}s infinite`,
userSelect: 'none',
}}
>
{leafEmoji[i % leafEmoji.length]}
</div>
);
})}
</>
);
}
function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
const stars = Array.from({ length: 24 });
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,0,8,0.3)',
backgroundImage: [
'radial-gradient(ellipse at 30% 40%, rgba(80,0,180,0.10) 0%, transparent 50%)',
'radial-gradient(ellipse at 70% 60%, rgba(0,60,180,0.10) 0%, transparent 50%)',
'radial-gradient(ellipse at 50% 20%, rgba(120,0,200,0.07) 0%, transparent 40%)',
].join(','),
}}
/>
{!reduced &&
stars.map((_, i) => {
const angle = (i / stars.length) * 360;
const duration = 2.5 + (i % 5) * 0.4;
const delay = (i * 0.18) % 2.5;
const period = 3 + (i % 4) * 0.5;
const size = 1 + (i % 3);
const starColors = [
'rgba(200,180,255,0.9)',
'rgba(150,200,255,0.8)',
'rgba(255,255,255,0.7)',
];
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: `${80 + i * 6}px`,
height: `${size}px`,
backgroundColor: starColors[i % starColors.length],
transformOrigin: '0 50%',
transform: `rotate(${angle}deg)`,
boxShadow: `0 0 ${size * 2}px ${starColors[i % starColors.length]}`,
animation: `${animWarp} ${duration}s ease-out ${delay}s ${period}s infinite`,
opacity: 0,
}}
/>
);
})}
</>
);
}
function ArcadeOverlay({ reduced }: { reduced: boolean }) {
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'repeating-linear-gradient(0deg, rgba(0,0,0,0.12) 0px, rgba(0,0,0,0.12) 1px, transparent 1px, transparent 3px)',
animation: reduced ? 'none' : `${animScanline} 3s ease-in-out infinite`,
}}
/>
{(['0,0', '0,auto', 'auto,0', 'auto,auto'] as const).map((corner, i) => {
const [t, b] = corner.split(',');
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: t === '0' ? '8px' : undefined,
bottom: b === '0' ? '8px' : undefined,
left: i % 2 === 0 ? '8px' : undefined,
right: i % 2 === 1 ? '8px' : undefined,
fontFamily: 'monospace',
fontSize: '11px',
color: 'rgba(0,255,136,0.5)',
letterSpacing: '0.05em',
animation: reduced ? 'none' : `${animPixelBlink} ${1 + i * 0.3}s step-end infinite`,
userSelect: 'none',
}}
>
{['[■]', '[■]', '[■]', '[■]'][i]}
</div>
);
})}
<div
aria-hidden="true"
style={{
position: 'absolute',
bottom: '16px',
left: '50%',
transform: 'translateX(-50%)',
fontFamily: 'monospace',
fontSize: '12px',
letterSpacing: '0.2em',
color: 'rgba(255,220,0,0.4)',
animation: reduced ? 'none' : `${animPixelBlink} 1.2s step-end infinite`,
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
INSERT COIN
</div>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 50%, transparent 60%, rgba(0,0,0,0.35) 100%)',
}}
/>
</>
);
}
// SeasonTheme + the date-window logic now live in leaf modules (single source
// of truth, shared with the settings UI). Re-exported here for existing
// importers that still reach for it from this file.
export type { SeasonTheme };
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
@@ -758,7 +64,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
pointerEvents: 'none',
// Below the Night Light overlay (9998) so seasonal particles are tinted
// by it, and below modals (9999) so dialogs are never obscured.
zIndex: 9997,
zIndex: zIndices.seasonalEffect,
overflow: 'hidden',
}}
>
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
import { SeasonTheme } from './types';
// Date(year, monthIndex0, day)
const on = (monthIndex0: number, day: number): Date => new Date(2026, monthIndex0, day);
test('each theme activates on a representative day in its window', () => {
const cases: Array<[Date, SeasonTheme]> = [
[on(11, 31), 'newyear'], // Dec 31
[on(0, 1), 'newyear'], // Jan 1
[on(0, 25), 'lunar'], // Jan 25
[on(1, 3), 'lunar'], // Feb 3
[on(1, 12), 'valentines'], // Feb 12
[on(2, 16), 'stpatricks'], // Mar 16
[on(3, 1), 'aprilfools'], // Apr 1
[on(3, 21), 'earthday'], // Apr 21
[on(8, 12), 'arcade'], // Sep 12
[on(8, 25), 'autumn'], // Sep 25
[on(9, 20), 'halloween'], // Oct 20
[on(10, 1), 'halloween'], // Nov 1
[on(11, 15), 'christmas'], // Dec 15
];
for (const [date, expected] of cases) {
assert.equal(getActiveSeason(date), expected, `${date.toDateString()} -> ${expected}`);
}
});
test('priority order resolves overlapping windows (Deep Space outranks Autumn)', () => {
// Oct 4-10 is inside Autumn's Oct<=14 window too; Deep Space comes first.
assert.equal(getActiveSeason(on(9, 5)), 'deepspace'); // Oct 5
// Oct 12 is past Deep Space -> falls through to Autumn.
assert.equal(getActiveSeason(on(9, 12)), 'autumn');
});
test('New Year outranks Lunar New Year on Jan 1-2', () => {
assert.equal(getActiveSeason(on(0, 1)), 'newyear');
// Jan 22+ is past New Year -> Lunar.
assert.equal(getActiveSeason(on(0, 22)), 'lunar');
});
test('returns null on an off-season day', () => {
assert.equal(getActiveSeason(on(5, 15)), null); // Jun 15
assert.equal(getActiveSeason(on(6, 4)), null); // Jul 4
});
test('window boundaries are inclusive at both ends', () => {
assert.equal(getActiveSeason(on(1, 10)), 'valentines'); // Feb 10 start
assert.equal(getActiveSeason(on(1, 15)), 'valentines'); // Feb 15 end
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
});
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
assert.equal(SEASON_SCHEDULE.length, 11);
const themes = SEASON_SCHEDULE.map((e) => e.theme);
assert.equal(new Set(themes).size, 11); // unique
for (const t of themes) {
assert.ok(
typeof SEASON_DATE_RANGES[t] === 'string' && SEASON_DATE_RANGES[t].length > 0,
`missing date range for ${t}`,
);
}
});
@@ -0,0 +1,95 @@
import { SeasonTheme } from './types';
/**
* Single source of truth for when each seasonal theme auto-activates.
*
* Both `getActiveSeason` (the runtime "Auto" selector) and the settings UI read
* this list, so the date windows shown to the user can never drift from the
* dates actually used. Order matters: it is the activation PRIORITY — the first
* entry whose window matches wins (e.g. Deep Space outranks Autumn in their
* early-October overlap).
*/
export type SeasonScheduleEntry = {
theme: SeasonTheme;
/** Human-readable activation window for display in settings. */
dateRange: string;
/** Whether this theme is active on the given month (1-12) and day (1-31). */
matches: (month: number, day: number) => boolean;
};
export const SEASON_SCHEDULE: SeasonScheduleEntry[] = [
{
theme: 'newyear',
dateRange: 'Dec 31 Jan 2',
matches: (m, d) => (m === 12 && d === 31) || (m === 1 && d <= 2),
},
{
theme: 'valentines',
dateRange: 'Feb 10 15',
matches: (m, d) => m === 2 && d >= 10 && d <= 15,
},
{
theme: 'stpatricks',
dateRange: 'Mar 15 18',
matches: (m, d) => m === 3 && d >= 15 && d <= 18,
},
{
theme: 'aprilfools',
dateRange: 'Apr 1',
matches: (m, d) => m === 4 && d === 1,
},
{
theme: 'earthday',
dateRange: 'Apr 20 23',
matches: (m, d) => m === 4 && d >= 20 && d <= 23,
},
{
theme: 'lunar',
dateRange: 'Jan 22 Feb 5',
matches: (m, d) => (m === 1 && d >= 22) || (m === 2 && d <= 5),
},
{
theme: 'arcade',
dateRange: 'Sep 12',
matches: (m, d) => m === 9 && d === 12,
},
{
theme: 'deepspace',
dateRange: 'Oct 4 10',
matches: (m, d) => m === 10 && d >= 4 && d <= 10,
},
{
theme: 'halloween',
dateRange: 'Oct 15 Nov 1',
matches: (m, d) => (m === 10 && d >= 15) || (m === 11 && d === 1),
},
{
theme: 'christmas',
dateRange: 'Dec 10 30',
matches: (m, d) => m === 12 && d >= 10,
},
{
theme: 'autumn',
dateRange: 'Sep 21 Oct 14',
matches: (m, d) => (m === 9 && d >= 21) || (m === 10 && d <= 14),
},
];
/** Map of theme → human-readable activation window (for settings captions). */
export const SEASON_DATE_RANGES: Record<SeasonTheme, string> = SEASON_SCHEDULE.reduce(
(acc, entry) => {
acc[entry.theme] = entry.dateRange;
return acc;
},
{} as Record<SeasonTheme, string>,
);
/**
* The seasonal theme that should be active on `now`, or null if none. First
* matching entry in SEASON_SCHEDULE priority order wins.
*/
export function getActiveSeason(now: Date): SeasonTheme | null {
const month = now.getMonth() + 1; // 1-12
const day = now.getDate();
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
}
@@ -0,0 +1,71 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Doodle float-up — a hand-drawn glyph drifts gently upward while bobbing
* side to side and lazily rotating, like a thought balloon escaping the page.
* GPU-only: transform + opacity exclusively. A tall translateY lets one set of
* keyframes serve every doodle; per-element duration/delay/scale add variety.
*/
export const animDoodleFloat = keyframes({
'0%': { transform: 'translate3d(0, 8vh, 0) rotate(-8deg) scale(0.85)', opacity: '0' },
'10%': { opacity: '1' },
'35%': { transform: 'translate3d(16px, -28vh, 0) rotate(6deg) scale(1)' },
'65%': { transform: 'translate3d(-14px, -64vh, 0) rotate(-5deg) scale(1.04)' },
'90%': { opacity: '0.8' },
'100%': { transform: 'translate3d(10px, -112vh, 0) rotate(7deg) scale(1.1)', opacity: '0' },
});
/**
* Confetti tumble — a small chip falls while flipping. Reuses a single tall
* translateY; the flip (rotate + scaleX) sells the paper tumble cheaply.
*/
export const animConfettiTumble = keyframes({
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg) scaleX(1)', opacity: '0' },
'8%': { opacity: '1' },
'50%': { transform: 'translate3d(18px, 50vh, 0) rotate(220deg) scaleX(-1)' },
'92%': { opacity: '0.9' },
'100%': { transform: 'translate3d(-12px, 112vh, 0) rotate(440deg) scaleX(1)', opacity: '0' },
});
/**
* Playful wobble — an almost-imperceptible skew/rotate of a faux tint layer so
* the whole scene feels gently "tickled". Tiny amplitude keeps it from being
* disorienting. Transform only, stays on the compositor.
*/
export const animWobble = keyframes({
'0%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
'50%': { transform: 'rotate(0.5deg) skewX(0.4deg) scale(1.01)' },
'100%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
});
/**
* Pastel aurora drift — a soft rainbow wash high in the scene slides and
* breathes. translateX + opacity (never background-position) to stay on GPU.
*/
export const animRainbowDrift = keyframes({
'0%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
'50%': { transform: 'translate3d(5%, 0, 0) scaleY(1.06)', opacity: '0.8' },
'100%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
});
/**
* Googly-eye look-around — the pupil layer nudges around its socket, giving
* each eye a cheeky wandering gaze. Small translate only.
*/
export const animGoogly = keyframes({
'0%': { transform: 'translate3d(1.5px, 1px, 0)' },
'20%': { transform: 'translate3d(-1.5px, 1.5px, 0)' },
'45%': { transform: 'translate3d(1px, -1.5px, 0)' },
'70%': { transform: 'translate3d(-1px, -0.5px, 0)' },
'100%': { transform: 'translate3d(1.5px, 1px, 0)' },
});
/**
* Sly wink/sparkle — a four-point glint that twinkles open and shut, scaling
* and fading like a sly little wink. Transform + opacity only.
*/
export const animSparkle = keyframes({
'0%, 100%': { transform: 'scale(0.2) rotate(0deg)', opacity: '0' },
'40%': { transform: 'scale(1) rotate(35deg)', opacity: '0.9' },
'60%': { transform: 'scale(0.95) rotate(45deg)', opacity: '0.7' },
});
@@ -0,0 +1,409 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animDoodleFloat,
animConfettiTumble,
animWobble,
animRainbowDrift,
animGoogly,
animSparkle,
} from './AprilFools.css';
// Deterministic pseudo-random so the scene is identical on every mount and the
// reduced-motion preview thumbnail is stable. Large primes spread the values.
const rand = (seed: number) => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Bright-but-soft pastel rainbow in oklch. Kept luminous and gentle so the
// doodles read as crayon pastel over chat without ever fighting the text.
const PASTELS = [
'oklch(0.85 0.12 20)', // pink
'oklch(0.88 0.12 90)', // butter yellow
'oklch(0.82 0.12 160)', // mint
'oklch(0.8 0.12 260)', // periwinkle
'oklch(0.84 0.12 320)', // lilac
'oklch(0.86 0.11 50)', // peach
];
// Inline-SVG data-URI doodle glyphs, drawn hand-sketch style (round caps,
// open paths). `enc()` keeps them CSP-safe — no external assets, no base64.
const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
// A single rough stroke wrapper helper for the glyph SVGs.
const stroke = (color: string, body: string) =>
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' ` +
`stroke='${color}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`;
// Question mark — the playful "huh?" centerpiece doodle.
const glyphQuestion = (c: string) =>
stroke(
c,
`<path d='M11 11 q0 -6 6 -6 q6 0 6 5 q0 4 -5 6 q-2 1 -2 4'/>` +
`<circle cx='16' cy='27' r='0.6' fill='${c}'/>`,
);
// Exclamation / "bang" — a surprised little doodle.
const glyphBang = (c: string) =>
stroke(c, `<path d='M16 5 L16 20'/><circle cx='16' cy='27' r='0.6' fill='${c}'/>`);
// Squiggle — a loopy scribble that adds whimsy.
const glyphSquiggle = (c: string) => stroke(c, `<path d='M5 18 q4 -10 8 0 t8 0 t8 0'/>`);
// Five-point doodle star (open-stroke, hand-drawn look).
const glyphStar = (c: string) =>
stroke(
c,
`<path d='M16 5 L19.4 13 L28 13.6 L21.4 19.2 L23.5 27.6 L16 22.8 L8.5 27.6 ` +
`L10.6 19.2 L4 13.6 L12.6 13 Z'/>`,
);
// A tiny heart doodle for extra grin.
const glyphHeart = (c: string) =>
stroke(c, `<path d='M16 26 C6 18 7 8 16 12 C25 8 26 18 16 26 Z'/>`);
const GLYPHS = [glyphQuestion, glyphBang, glyphSquiggle, glyphStar, glyphHeart, glyphQuestion];
type Doodle = {
left: number;
size: number;
glyph: string;
duration: number;
delay: number;
startTop: number; // used for the static (reduced) scatter
opacity: number;
};
type Confetti = {
left: number;
size: number;
color: string;
duration: number;
delay: number;
startTop: number;
ratio: number; // chip aspect
round: boolean;
};
type Eye = {
left: number;
top: number;
size: number;
duration: number;
delay: number;
};
type Spark = {
left: number;
top: number;
size: number;
color: string;
duration: number;
delay: number;
};
export function AprilFoolsOverlay({ reduced }: SeasonalOverlayProps) {
// ~16 drifting doodles. Built once; per-element timing creates the variety.
const doodles = useMemo<Doodle[]>(() => {
const count = 16;
const out: Doodle[] = [];
for (let i = 0; i < count; i += 1) {
const color = PASTELS[i % PASTELS.length];
out.push({
left: rand(i + 0.1) * 96 + 2,
size: 18 + rand(i + 0.3) * 22,
glyph: enc(GLYPHS[i % GLYPHS.length](color)),
duration: 16 + rand(i + 0.5) * 12,
delay: -rand(i + 0.7) * 26,
startTop: rand(i + 0.9) * 92 + 4,
opacity: 0.5 + rand(i + 0.2) * 0.32,
});
}
return out;
}, []);
// ~14 confetti chips in a couple of falling bands.
const confetti = useMemo<Confetti[]>(() => {
const count = 14;
const out: Confetti[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
left: rand(i + 3.1) * 98 + 1,
size: 5 + rand(i + 3.3) * 6,
color: PASTELS[(i + 2) % PASTELS.length],
duration: 10 + rand(i + 3.5) * 9,
delay: -rand(i + 3.7) * 18,
startTop: rand(i + 3.9) * 96 + 2,
ratio: 0.45 + rand(i + 3.2) * 0.8,
round: rand(i + 3.6) > 0.6,
});
}
return out;
}, []);
// A few googly eyes peeking from corners/edges — the cheeky surprise.
const eyes = useMemo<Eye[]>(() => {
const anchors = [
{ left: 6, top: 12 },
{ left: 90, top: 20 },
{ left: 80, top: 82 },
{ left: 14, top: 74 },
];
return anchors.map((a, i) => ({
left: a.left,
top: a.top,
size: 22 + rand(i + 5.1) * 12,
duration: 3 + rand(i + 5.3) * 2.5,
delay: -rand(i + 5.5) * 3,
}));
}, []);
// Sly winking sparkles scattered sparsely.
const sparks = useMemo<Spark[]>(() => {
const count = 5;
const out: Spark[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
left: rand(i + 7.1) * 90 + 5,
top: rand(i + 7.3) * 84 + 8,
size: 12 + rand(i + 7.5) * 12,
color: PASTELS[(i + 1) % PASTELS.length],
duration: 4 + rand(i + 7.7) * 3,
delay: -rand(i + 7.9) * 5,
});
}
return out;
}, []);
// Four-point glint used for the winking sparkles.
const sparkGlint = (c: string) =>
enc(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
`<path d='M12 0 C13 8 16 11 24 12 C16 13 13 16 12 24 C11 16 8 13 0 12 C8 11 11 8 12 0 Z' fill='${c}'/></svg>`,
);
return (
<>
{/* Soft pastel ambient wash — layered oklch radials for depth. Very low
opacity so chat text keeps WCAG-AA contrast. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
'radial-gradient(110% 70% at 18% -8%, oklch(0.85 0.12 20 / 0.1) 0%, transparent 55%)',
'radial-gradient(95% 65% at 86% 0%, oklch(0.82 0.12 160 / 0.09) 0%, transparent 58%)',
'radial-gradient(120% 80% at 50% 112%, oklch(0.8 0.12 260 / 0.1) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.88 0.12 90 / 0.05) 0%, transparent 30%, transparent 78%, oklch(0.84 0.12 320 / 0.06) 100%)',
].join(','),
}}
/>
{/* Faux wobble layer — a near-invisible pastel haze that gently skews so
the whole scene feels playfully "tickled". Tiny amplitude = not
nauseating. backdrop-filter is one cheap layer for a candy bloom. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: '-2%',
contain: 'layout paint style',
backdropFilter: 'saturate(1.06) brightness(1.01)',
WebkitBackdropFilter: 'saturate(1.06) brightness(1.01)',
backgroundImage:
'radial-gradient(130% 120% at 50% 45%, transparent 60%, oklch(0.86 0.11 50 / 0.05) 80%, oklch(0.8 0.12 260 / 0.08) 100%)',
transformOrigin: '50% 50%',
animation: reduced ? 'none' : `${animWobble} 14s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform',
}}
/>
{/* Pastel rainbow aurora high up — soft band of the full palette. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '-8%',
left: '-10%',
right: '-10%',
height: '42%',
contain: 'layout paint style',
mixBlendMode: 'screen',
filter: 'blur(30px)',
opacity: reduced ? 0.6 : undefined,
backgroundImage: [
'radial-gradient(50% 100% at 18% 0%, oklch(0.85 0.12 20 / 0.16) 0%, transparent 72%)',
'radial-gradient(50% 100% at 40% 0%, oklch(0.88 0.12 90 / 0.14) 0%, transparent 72%)',
'radial-gradient(50% 100% at 62% 0%, oklch(0.82 0.12 160 / 0.14) 0%, transparent 72%)',
'radial-gradient(50% 100% at 84% 0%, oklch(0.8 0.12 260 / 0.16) 0%, transparent 72%)',
].join(','),
animation: reduced ? 'none' : `${animRainbowDrift} 20s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Drifting doodles. Motion: rise from below. Reduced: static scatter. */}
{doodles.map((d, i) => {
const common: React.CSSProperties = {
position: 'absolute',
left: `${d.left}%`,
width: `${d.size}px`,
height: `${d.size}px`,
backgroundImage: d.glyph,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundPosition: 'center',
opacity: d.opacity,
filter: 'drop-shadow(0 1px 1px oklch(0.4 0.05 300 / 0.18))',
};
if (reduced) {
return (
<div
key={`doodle-${i}`}
aria-hidden="true"
style={{
...common,
top: `${d.startTop}%`,
transform: `rotate(${(rand(i + 11) - 0.5) * 24}deg)`,
}}
/>
);
}
return (
<div
key={`doodle-${i}`}
aria-hidden="true"
style={{
...common,
top: 0,
animation: `${animDoodleFloat} ${d.duration}s ease-in-out ${d.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
);
})}
{/* Light confetti — tumbling pastel chips. */}
{confetti.map((c, i) => {
const common: React.CSSProperties = {
position: 'absolute',
left: `${c.left}%`,
width: `${c.size}px`,
height: `${c.size * c.ratio}px`,
background: c.color,
borderRadius: c.round ? '50%' : '1px',
opacity: 0.75,
};
if (reduced) {
return (
<div
key={`confetti-${i}`}
aria-hidden="true"
style={{
...common,
top: `${c.startTop}%`,
transform: `rotate(${rand(i + 13) * 360}deg)`,
}}
/>
);
}
return (
<div
key={`confetti-${i}`}
aria-hidden="true"
style={{
...common,
top: 0,
animation: `${animConfettiTumble} ${c.duration}s linear ${c.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
);
})}
{/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
{eyes.map((e, i) => (
<div
key={`eye-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${e.left}%`,
top: `${e.top}%`,
width: `${e.size}px`,
height: `${e.size}px`,
marginLeft: `${-e.size / 2}px`,
marginTop: `${-e.size / 2}px`,
borderRadius: '50%',
background:
'radial-gradient(circle at 38% 32%, oklch(0.99 0.005 90 / 0.85) 0%, oklch(0.95 0.01 90 / 0.7) 62%, oklch(0.75 0.02 90 / 0.6) 100%)',
boxShadow: 'inset 0 0 0 1.5px oklch(0.45 0.03 300 / 0.35)',
opacity: 0.6,
}}
>
{/* Pupil */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: `${e.size * 0.4}px`,
height: `${e.size * 0.4}px`,
marginLeft: `${-e.size * 0.2}px`,
marginTop: `${-e.size * 0.2}px`,
borderRadius: '50%',
background:
'radial-gradient(circle at 36% 30%, oklch(0.5 0.04 300 / 0.95) 0%, oklch(0.28 0.04 300 / 0.95) 70%)',
animation: reduced
? 'none'
: `${animGoogly} ${e.duration}s ease-in-out ${e.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
{/* Catchlight */}
<div
style={{
position: 'absolute',
left: '22%',
top: '20%',
width: '28%',
height: '28%',
borderRadius: '50%',
background: 'oklch(0.99 0.005 90 / 0.85)',
}}
/>
</div>
</div>
))}
{/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
{sparks.map((s, i) => (
<div
key={`spark-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${s.left}%`,
top: `${s.top}%`,
width: `${s.size}px`,
height: `${s.size}px`,
backgroundImage: sparkGlint(s.color),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundPosition: 'center',
filter: `drop-shadow(0 0 3px ${s.color.replace(')', ' / 0.5)')})`,
opacity: reduced ? 0.8 : undefined,
transform: reduced ? 'scale(0.95) rotate(40deg)' : undefined,
animation: reduced
? 'none'
: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
</>
);
}
@@ -0,0 +1,121 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Arcade overlay keyframes — retro synthwave CRT.
*
* Every animation touches ONLY `transform` and `opacity` so the compositor can
* run them on the GPU without triggering layout or paint. keyframes() returns
* the generated animation-name string, which is applied inline in Arcade.tsx.
*
* Motion philosophy: a neon perspective grid scrolls toward the viewer, a soft
* CRT scanline field breathes, the whole screen glows and flickers ever so
* faintly, sparse pixel sparkles drift up, and an "INSERT COIN" blip pulses.
* The grid scroll is done with a translateY on a tiled, perspective-projected
* plane — never background-position — so it rides the compositor.
*/
/**
* The neon grid plane is laid out twice its visible height and tiled with the
* horizontal rule lines. Translating it up by exactly one tile makes the lines
* appear to flow continuously toward the viewer (the horizon). Because the
* plane sits under a `perspective` transform, the lines also accelerate as they
* approach, giving a true receding-grid illusion. Pure transform.
*/
export const animGridScroll = keyframes({
'0%': { transform: 'translateZ(0) translateY(0)' },
'100%': { transform: 'translateZ(0) translateY(50%)' },
});
/**
* Slow vertical drift of the fine scanline field — a couple of pixels so the
* raster looks like it's gently rolling, the way a real CRT does. Transform
* only; the line texture itself never moves on the GPU's paint layer.
*/
export const animScanRoll = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'100%': { transform: 'translate3d(0, 4px, 0)' },
});
/**
* The overall CRT screen-glow breathes: a barely-there opacity swell that keeps
* the static neon tint feeling alive and powered-on. Opacity only.
*/
export const animScreenGlow = keyframes({
'0%': { opacity: '0.72' },
'50%': { opacity: '1' },
'100%': { opacity: '0.72' },
});
/**
* A faint, irregular CRT brightness flicker laid over the glow — the classic
* unstable-tube shimmer. Kept extremely shallow so it never distracts or harms
* readability. Opacity only.
*/
export const animCrtFlicker = keyframes({
'0%': { opacity: '0.94' },
'12%': { opacity: '1' },
'20%': { opacity: '0.9' },
'34%': { opacity: '0.98' },
'52%': { opacity: '0.92' },
'70%': { opacity: '1' },
'83%': { opacity: '0.95' },
'100%': { opacity: '0.94' },
});
/**
* Chromatic-aberration twin: the magenta/cyan fringe layers nudge a sub-pixel
* apart and back so the edges shimmer with RGB split, like a misconverged tube.
* transform + opacity only.
*/
export const animChromaShift = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
'50%': { transform: 'translate3d(1.5px, 0, 0)', opacity: '0.8' },
'100%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
});
/**
* Pixel sparkle drift: a tiny neon speck rises and twinkles like a coin-burst
* particle floating up off the grid. transform + opacity, single tall path.
*/
export const animSparkleDrift = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
'12%': { opacity: '1' },
'50%': { transform: 'translate3d(8px, -42vh, 0) scale(1)', opacity: '0.85' },
'78%': { transform: 'translate3d(-6px, -70vh, 0) scale(0.8)', opacity: '0.5' },
'92%': { opacity: '0.18' },
'100%': { transform: 'translate3d(6px, -92vh, 0) scale(0.55)', opacity: '0' },
});
/**
* Independent pixel twinkle layered on the drift so specks blink on/off like a
* low-res sprite. Stepped opacity for a crisp 8-bit feel.
*/
export const animSparkleTwinkle = keyframes({
'0%, 44%': { opacity: '1' },
'50%, 94%': { opacity: '0.35' },
'100%': { opacity: '1' },
});
/**
* "INSERT COIN" blink: the classic attract-mode pulse. Stepped so it reads as a
* hard retro blink rather than a soft fade, but with a brief bright swell.
* Opacity + a hair of scale for a CRT bloom feel.
*/
export const animCoinBlink = keyframes({
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
});
/**
* Score-blip pulse for the corner HUD glyph: a quick pop then settle, like a
* counter ticking up. transform + opacity.
*/
export const animScoreBlip = keyframes({
'0%': { opacity: '0.4', transform: 'scale(1)' },
'50%': { opacity: '0.85', transform: 'scale(1.12)' },
'100%': { opacity: '0.4', transform: 'scale(1)' },
});
@@ -0,0 +1,382 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animGridScroll,
animScanRoll,
animScreenGlow,
animCrtFlicker,
animChromaShift,
animSparkleDrift,
animSparkleTwinkle,
animCoinBlink,
animScoreBlip,
} from './Arcade.css';
/**
* ArcadeOverlay — retro synthwave CRT.
*
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
* z-index, so this component only returns absolutely-positioned aria-hidden
* children and never sets position:fixed / z-index / pointer-events.
*
* Composition (back to front):
* 1. near-black synthwave ambient wash (magenta sky-glow up top, cyan/purple
* pool toward the floor) — layered oklch gradients for depth
* 2. a neon perspective grid receding to a vanishing point on the horizon,
* scrolling toward the viewer via transform translateY (never bg-position)
* 3. a soft horizon sun-glow + thin neon horizon line where the grid meets sky
* 4. drifting pixel sparkles / neon coin-burst specks rising off the grid
* 5. fine CRT scanlines, gently rolling
* 6. a faint chromatic-aberration fringe at the screen edges
* 7. a glowing "INSERT COIN" blip + a corner SCORE HUD glyph
* 8. a CRT vignette + screen-glow that frames and protects central text
*
* All motion is transform/opacity only (compositor-friendly). When `reduced` is
* true we render a static-but-gorgeous scene: a still neon grid, steady
* scanlines + vignette, and a steady "INSERT COIN" — no `animation` anywhere,
* no flicker. The settings preview always passes reduced=true, so the still
* form stands on its own.
*/
// Synthwave neon palette in oklch. Saturated where it glows, but every layer is
// held at low opacity so it tints rather than takes over the chat beneath.
const NEON_MAGENTA = 'oklch(0.65 0.25 350)';
const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
// lines fanning toward a top-center vanishing point. The plane is then placed
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
// tile up by one tile-height (animGridScroll → translateY 50%) loops seamlessly.
function gridDataUri(): string {
const lines: string[] = [];
// Horizontal rules — denser toward the top (the horizon) for a perspective
// feel even before the CSS rotateX is applied.
const rows = [0, 16, 34, 54, 76, 100, 126, 156, 190, 228, 270, 316, 366, 420, 478, 540];
rows.forEach((y) => {
lines.push(
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.9'/>`,
);
});
// Vertical lines fanning out from the top-center vanishing point.
for (let i = -7; i <= 7; i += 1) {
const topX = 300 + i * 6; // tight near the horizon
const botX = 300 + i * 95; // wide at the foreground
lines.push(
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.8'/>`,
);
}
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600' ` +
`preserveAspectRatio='none'>${lines.join('')}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
type Sparkle = {
left: number; // vw
bottom: number; // % up from floor where it spawns
size: number; // px
duration: number; // s
delay: number; // s
twinkle: number; // s
hue: 'magenta' | 'cyan';
opacity: number;
};
// Hand-placed still sparkles for the reduced/static scene — a few neon specks
// resting low over the grid, away from the busy chat center.
const RESTING_SPARKLES: ReadonlyArray<{
left: number;
bottom: number;
size: number;
hue: 'magenta' | 'cyan';
opacity: number;
}> = [
{ left: 12, bottom: 18, size: 4, hue: 'cyan', opacity: 0.5 },
{ left: 26, bottom: 30, size: 3, hue: 'magenta', opacity: 0.42 },
{ left: 78, bottom: 22, size: 4, hue: 'magenta', opacity: 0.5 },
{ left: 88, bottom: 34, size: 3, hue: 'cyan', opacity: 0.4 },
{ left: 50, bottom: 14, size: 3, hue: 'cyan', opacity: 0.38 },
];
const GRID_URI = gridDataUri();
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo<Sparkle[]>(() => {
const COUNT = 16;
return Array.from({ length: COUNT }, (_, i) => ({
left: (i * 6.27 + 4) % 100,
bottom: (i * 3.7) % 28, // spawn in the lower third (over the grid)
size: 2 + (i % 3), // 2..4 px pixels
duration: 14 + (i % 6) * 2.2,
delay: -((i * 1.83) % 16),
twinkle: 1.4 + (i % 4) * 0.5,
hue: i % 2 === 0 ? 'cyan' : 'magenta',
opacity: 0.45 + (i % 3) * 0.12,
}));
}, []);
const sparkleColor = (hue: 'magenta' | 'cyan') => (hue === 'cyan' ? NEON_CYAN : NEON_MAGENTA);
return (
<>
{/* 1. Near-black synthwave ambient wash. Magenta sky-glow up top, a
cyan/purple pool toward the floor, and an overall dark vertical
grade. Layered oklch gradients give depth at very low opacity. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
].join(','),
contain: 'layout paint style',
}}
/>
{/* 2. The neon perspective grid. A wide, tall plane is tilted away from
the viewer with `perspective` + rotateX so its rule lines recede to
a vanishing point at the top (the horizon). It lives in the lower
half of the screen — the "floor". The inner plane scrolls upward by
one tile via transform translateY, which reads as the grid flowing
toward the viewer. Pure transform; never background-position. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
opacity: reduced ? 0.5 : 0.62,
contain: 'layout paint style',
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
/>
</div>
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
where the grid meets the sky, with a thin bright rule on top of it
to seal the vanishing point. Static (no motion) either way. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
left: '50%',
top: '38%',
width: '70%',
height: '34%',
transform: 'translate(-50%, -50%)',
backgroundImage:
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
contain: 'layout paint style',
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
left: '12%',
right: '12%',
top: '38%',
height: '1.5px',
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
opacity: 0.55,
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
}}
/>
{/* 4. Drifting pixel sparkles / neon coin-burst specks. Tiny square
neon pixels rising off the grid and twinkling. The static scene uses
a small resting set instead. */}
{reduced
? RESTING_SPARKLES.map((s, i) => (
<div
key={`rest-spark-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${s.left}%`,
bottom: `${s.bottom}%`,
width: `${s.size}px`,
height: `${s.size}px`,
background: sparkleColor(s.hue),
opacity: s.opacity,
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
}}
/>
))
: sparkles.map((s, i) => (
<div
key={`spark-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${s.left}%`,
bottom: `${s.bottom}%`,
width: `${s.size}px`,
height: `${s.size}px`,
willChange: 'transform, opacity',
animation: `${animSparkleDrift} ${s.duration}s linear ${s.delay}s infinite`,
}}
>
<div
style={{
width: '100%',
height: '100%',
background: sparkleColor(s.hue),
opacity: s.opacity,
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
animation: `${animSparkleTwinkle} ${s.twinkle}s step-end infinite`,
}}
/>
</div>
))}
{/* 5. Fine CRT scanlines. A repeating 1px dark rule field over the whole
screen, gently rolling downward on the compositor (transform only).
Held faint so text stays crisp. The pattern is in a child taller
than the frame so the roll never reveals an edge. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
mixBlendMode: 'multiply',
opacity: 0.5,
contain: 'layout paint style',
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: '-8px',
bottom: '-8px',
backgroundImage:
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
}}
/>
</div>
{/* 6. Chromatic-aberration fringe. Two thin edge-glows — magenta and cyan —
offset a sub-pixel apart at the screen border so the frame shimmers
with an RGB split, like a misconverged tube. Animated only; in the
static scene it sits as a steady fringe. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
boxShadow: `inset 2px 0 14px oklch(0.65 0.25 350 / 0.16), inset -2px 0 14px oklch(0.80 0.15 200 / 0.16)`,
opacity: reduced ? 0.6 : undefined,
willChange: reduced ? undefined : 'transform, opacity',
contain: 'layout paint style',
animation: reduced ? 'none' : `${animChromaShift} 4.5s ease-in-out infinite`,
}}
/>
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
Static scene shows it steady (no blink). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
bottom: '5%',
left: '50%',
transform: 'translateX(-50%)',
fontFamily: '"Courier New", monospace',
fontSize: '12px',
fontWeight: 700,
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
INSERT COIN
</div>
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
very low opacity so it reads as ambient chrome, not UI. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '2.5%',
left: '2%',
fontFamily: '"Courier New", monospace',
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div>
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
with a faint magenta tube-glow swell. The vignette protects central
chat-text contrast. Static scene holds it steady; live scene adds a
shallow breathing glow + irregular flicker, both opacity-only. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(125% 95% at 50% 46%, oklch(0.72 0.18 340 / 0.05) 0%, transparent 40%)',
'radial-gradient(120% 115% at 50% 50%, transparent 50%, oklch(0.10 0.05 310 / 0.20) 84%, oklch(0.06 0.04 305 / 0.34) 100%)',
].join(','),
contain: 'layout paint style',
opacity: reduced ? 1 : undefined,
willChange: reduced ? undefined : 'opacity',
animation: reduced
? 'none'
: `${animScreenGlow} 8s ease-in-out infinite, ${animCrtFlicker} 5.5s steps(1, end) infinite`,
}}
/>
</>
);
}
@@ -0,0 +1,91 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU without triggering
* layout or paint. keyframes() returns the generated animation-name string,
* which is applied inline in Autumn.tsx.
*
* Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
* with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
* drift up through the light; the whole frame has a barely-there warm pulse.
*/
/**
* A leaf falls from above to below the viewport while continuously rotating.
* A single tall translateY serves every leaf — per-leaf duration/delay/scale
* create the parallax variety. Horizontal travel is intentionally small here
* because the real lateral motion comes from the sway wrapper below.
*/
export const animLeafFall = keyframes({
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
'8%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
'92%': { opacity: '0.85' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
});
/**
* Lateral sway applied to a leaf's wrapper so the descent reads as wind
* catching the blade. Decoupled from the fall so the two compose into an
* organic, non-repeating-looking path.
*/
export const animLeafSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(34px, 0, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/**
* A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
* mimics the blade catching air as it spins. Cheap, transform-only.
*/
export const animLeafFlutter = keyframes({
'0%': { transform: 'rotate(-8deg) scaleX(1)' },
'50%': { transform: 'rotate(8deg) scaleX(0.82)' },
'100%': { transform: 'rotate(-8deg) scaleX(1)' },
});
/**
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
* translateX + opacity (never background-position) so it stays on the
* compositor. Scale on Y makes the beam subtly elongate as it brightens.
*/
export const animSunShaft = keyframes({
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
});
/**
* Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
* pulsing softly in brightness as it catches the sun. transform + opacity.
*/
export const animMoteDrift = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
'15%': { opacity: '0.85' },
'40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
'70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
'90%': { opacity: '0.2' },
'100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
});
/**
* Independent twinkle for motes — a brightness flicker layered on the drift so
* specks shimmer as if turning in the light. Opacity only.
*/
export const animMoteTwinkle = keyframes({
'0%': { opacity: '0.5' },
'50%': { opacity: '1' },
'100%': { opacity: '0.5' },
});
/**
* Barely-there breathing of the warm vignette frame so the static tint feels
* alive without any distracting motion. Opacity only.
*/
export const animEmberPulse = keyframes({
'0%': { opacity: '0.82' },
'50%': { opacity: '1' },
'100%': { opacity: '0.82' },
});
@@ -0,0 +1,310 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLeafFall,
animLeafSway,
animLeafFlutter,
animSunShaft,
animMoteDrift,
animMoteTwinkle,
animEmberPulse,
} from './Autumn.css';
/**
* AutumnOverlay — warm falling leaves.
*
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
* z-index, so this component only returns absolutely-positioned aria-hidden
* children and never sets position:fixed / z-index / pointer-events.
*
* Composition (back to front):
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
* 2. soft angled sun shafts breathing high across the scene
* 3. drifting pollen / dust motes catching the light
* 4. maple & oak leaf silhouettes tumbling and rotating as they fall
* 5. a warm low-saturation vignette that frames + protects text contrast
*
* All motion is transform/opacity only (compositor-friendly). When `reduced`
* is true we render a static-but-gorgeous scene: a handful of leaves at rest,
* still sun shafts, and the warm vignette — no `animation` anywhere. The
* settings preview always passes reduced=true, so the still form stands alone.
*/
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
// chat text underneath. Each leaf picks a tone for variety.
const LEAF_TONES = [
'oklch(0.75 0.15 70)', // amber
'oklch(0.55 0.16 40)', // rust
'oklch(0.82 0.13 85)', // warm gold
'oklch(0.62 0.16 55)', // burnt orange
'oklch(0.5 0.14 35)', // deep ember
];
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
const MAPLE_PATH =
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
const OAK_PATH =
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
// A faint vein line gives the blade depth without extra DOM nodes.
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
`</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
type Leaf = {
kind: 'maple' | 'oak';
uri: string;
left: number; // vw column
size: number; // px
duration: number; // s — fall time
delay: number; // s
swayDuration: number; // s — wrapper sway
flutterDuration: number; // s — inner flutter
tilt: number; // deg — resting rotation (used for static scene)
opacity: number;
};
type Mote = {
left: number;
bottom: number;
size: number;
duration: number;
delay: number;
twinkle: number;
opacity: number;
};
// A few hand-placed leaves at rest for the reduced/static scene — arranged so
// they read as "settled" near edges and corners, never over the busy center.
const RESTING_LEAVES: ReadonlyArray<{
kind: 'maple' | 'oak';
left: number;
top: number;
size: number;
tilt: number;
tone: number;
opacity: number;
}> = [
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
];
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
const { leaves, motes } = useMemo(() => {
const LEAF_COUNT = 16;
const MOTE_COUNT = 12;
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
const tone = LEAF_TONES[i % LEAF_TONES.length];
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
const size = 22 + sizeBucket * 9; // 22..49 px
return {
kind,
uri: leafDataUri(kind, tone),
left: (i * 6.13 + 4) % 100,
size,
// Larger (nearer) leaves fall a touch faster; all slow + cozy.
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
swayDuration: 5 + (i % 5) * 0.8,
flutterDuration: 1.6 + (i % 4) * 0.45,
tilt: ((i * 53) % 70) - 35,
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
};
});
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
left: (i * 8.7 + 5) % 100,
bottom: (i * 4.3) % 30, // start in lower third, drift up
size: 2 + (i % 3),
duration: 16 + (i % 6) * 2.4,
delay: -((i * 2.1) % 16),
twinkle: 2.2 + (i % 4) * 0.6,
opacity: 0.4 + (i % 3) * 0.12,
}));
return { leaves: builtLeaves, motes: builtMotes };
}, []);
return (
<>
{/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
depth: a warm low-sun glow from the upper-left, a rust pool at the
base, and a faint gold core. Kept very low opacity. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
].join(','),
contain: 'layout paint style',
}}
/>
{/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
late-afternoon light raking across the room. */}
{[
{ left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
{ left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
{ left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
].map((shaft, i) => (
<div
key={`shaft-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: '-30%',
left: `${shaft.left}%`,
width: `${shaft.w}vw`,
height: '160%',
transformOrigin: 'top center',
transform: `rotate(${shaft.rotate}deg)`,
backgroundImage:
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
filter: 'blur(14px)',
mixBlendMode: 'screen',
opacity: reduced ? shaft.opacity * 0.85 : undefined,
willChange: reduced ? undefined : 'transform, opacity',
contain: 'layout paint style',
animation: reduced
? 'none'
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
}}
/>
))}
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
them — stillness reads cleaner at rest. */}
{!reduced &&
motes.map((m, i) => (
<div
key={`mote-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${m.left}%`,
bottom: `${m.bottom}%`,
width: `${m.size}px`,
height: `${m.size}px`,
willChange: 'transform, opacity',
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background:
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
opacity: m.opacity,
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
}}
/>
</div>
))}
{/* 4. Falling / resting maple & oak leaves. */}
{reduced
? RESTING_LEAVES.map((leaf, i) => (
<div
key={`rest-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
opacity: leaf.opacity,
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
}}
/>
))
: leaves.map((leaf, i) => (
// Sway wrapper: horizontal wind motion, decoupled from the fall.
<div
key={`leaf-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${leaf.left}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
willChange: 'transform',
contain: 'layout paint style',
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
}}
>
{/* Fall wrapper: vertical descent + tumble rotation. */}
<div
style={{
width: '100%',
height: '100%',
willChange: 'transform, opacity',
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
}}
>
{/* Inner blade: the actual silhouette + flutter wobble. */}
<div
style={{
width: '100%',
height: '100%',
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
}}
/>
</div>
</div>
))}
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
edges — protecting central chat text contrast. Breathes faintly. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
contain: 'layout paint style',
opacity: reduced ? 1 : undefined,
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
}}
/>
</>
);
}
@@ -0,0 +1,56 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Snowfall — a flake drifts downward while swaying horizontally and slowly
* rotating. GPU-only: animates transform + opacity exclusively. The vertical
* travel uses a tall translateY so a single keyframe set serves all flakes;
* per-flake duration/delay/scale create the parallax variety.
*/
export const animSnowFall = keyframes({
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
'8%': { opacity: '1' },
'50%': { transform: 'translate3d(14px, 50vh, 0) rotate(180deg)' },
'92%': { opacity: '0.85' },
'100%': { transform: 'translate3d(-10px, 112vh, 0) rotate(360deg)', opacity: '0' },
});
/**
* Gentle lateral sway applied to a flake's wrapper so the drift reads as wind,
* decoupled from the fall so the two combine into an organic path.
*/
export const animSnowSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(18px, 0, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/**
* String-light breathing — bokeh orbs softly pulse in brightness and scale,
* like incandescent bulbs warming and cooling. Opacity + transform only.
*/
export const animBulbBreathe = keyframes({
'0%': { transform: 'scale(0.92)', opacity: '0.55' },
'50%': { transform: 'scale(1.08)', opacity: '0.95' },
'100%': { transform: 'scale(0.92)', opacity: '0.55' },
});
/**
* Aurora shimmer — a wide soft band high in the scene slowly slides and
* breathes. Uses translateX + opacity (never background-position) so it stays
* on the compositor.
*/
export const animAurora = keyframes({
'0%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
'50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
'100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
});
/**
* Vignette frost — a barely-there breathing of the cold frame so the static
* tint feels alive without distracting motion.
*/
export const animFrostPulse = keyframes({
'0%': { opacity: '0.85' },
'50%': { opacity: '1' },
'100%': { opacity: '0.85' },
});
@@ -0,0 +1,256 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animSnowFall,
animSnowSway,
animBulbBreathe,
animAurora,
animFrostPulse,
} from './Christmas.css';
// Deterministic pseudo-random so the scene is identical every mount (no React
// state per frame). Large primes keep the distribution well spread.
const rand = (seed: number) => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
const BULB_COLORS = [
'oklch(0.85 0.12 85)', // warm gold
'oklch(0.72 0.15 28)', // soft red
'oklch(0.95 0.03 230)', // icy white
'oklch(0.78 0.13 150)', // pine green
'oklch(0.8 0.1 235)', // cool blue
];
type Flake = {
left: number;
size: number;
duration: number;
delay: number;
swayDuration: number;
opacity: number;
blur: number;
};
type Bulb = {
left: number;
top: number;
size: number;
color: string;
duration: number;
delay: number;
};
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
const flakes = useMemo<Flake[]>(() => {
const bands = [
{ count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
{ count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
{ count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
];
const out: Flake[] = [];
let s = 1;
bands.forEach((b) => {
for (let i = 0; i < b.count; i += 1) {
const r1 = rand(s);
const r2 = rand(s + 0.37);
const r3 = rand(s + 0.71);
const r4 = rand(s + 0.91);
out.push({
left: r1 * 100,
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
delay: -r4 * (b.dur[1] + 4),
swayDuration: 4 + r2 * 5,
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
blur: b.blur,
});
s += 1;
}
});
return out;
}, []);
// Bokeh string lights strung along the very top edge, gently sagging.
const bulbs = useMemo<Bulb[]>(() => {
const count = 9;
const out: Bulb[] = [];
for (let i = 0; i < count; i += 1) {
const t = i / (count - 1);
// Two-segment garland sag so the lights drape rather than sit in a line.
const sag = Math.sin(t * Math.PI * 2) * 3.2;
out.push({
left: 4 + t * 92,
top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
size: 12 + rand(i + 5) * 8,
color: BULB_COLORS[i % BULB_COLORS.length],
duration: 3.4 + rand(i + 2) * 2.6,
delay: -rand(i + 9) * 3,
});
}
return out;
}, []);
return (
<>
{/* Deep night-blue ambient wash — layered radial + linear oklch gradients
for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
].join(','),
}}
/>
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
single cheap layer for a faint icy haze around the rim. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.04)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
backgroundImage:
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '-6%',
left: '-10%',
right: '-10%',
height: '40%',
contain: 'layout paint style',
mixBlendMode: 'screen',
filter: 'blur(26px)',
opacity: reduced ? 0.6 : undefined,
backgroundImage: [
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
].join(','),
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* String-light wire — a faint catenary line the bulbs hang from. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '14%',
contain: 'layout paint style',
backgroundImage:
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
}}
/>
{/* Bokeh string lights — soft blurred orbs that breathe. */}
{bulbs.map((b, i) => (
<div
key={`bulb-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${b.left}%`,
top: `${b.top}%`,
width: `${b.size}px`,
height: `${b.size}px`,
marginLeft: `${-b.size / 2}px`,
borderRadius: '50%',
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
filter: 'blur(0.5px)',
opacity: reduced ? 0.9 : undefined,
animation: reduced
? 'none'
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
{!reduced &&
flakes.map((f, i) => (
<div
key={`snow-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${f.left}%`,
width: `${f.size}px`,
height: `${f.size}px`,
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
willChange: 'transform',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background:
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
opacity: f.opacity,
filter: f.blur ? `blur(${f.blur}px)` : undefined,
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
</div>
))}
{/* Static dusting of snow for the reduced-motion / preview scene — a
sparse scatter so the thumbnail still reads as snowfall. */}
{reduced &&
flakes.map((f, i) => {
const fy = rand(i + 0.5) * 96 + 2;
return (
<div
key={`snow-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${f.left}%`,
top: `${fy}%`,
width: `${f.size}px`,
height: `${f.size}px`,
borderRadius: '50%',
background:
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
opacity: f.opacity,
filter: f.blur ? `blur(${f.blur}px)` : undefined,
}}
/>
);
})}
</>
);
}
@@ -0,0 +1,73 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Deep Space overlay keyframes. Everything here animates ONLY transform/opacity
* so the compositor can run it cheaply. The `keyframes()` helper returns the
* generated class name string, which the component splices into inline
* `animation` shorthands.
*/
/** Cosmos breathe: the whole nebula backdrop drifts and dims almost imperceptibly. */
export const animCosmosDrift = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
'50%': { transform: 'translate3d(-1.5%, 1%, 0) scale(1.04)', opacity: '1' },
'100%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
});
/** Nebula cloud drift: a single blurred cloud floats slowly across its layer. */
export const animNebulaA = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
'50%': { transform: 'translate3d(4%, -3%, 0) scale(1.08)' },
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
});
export const animNebulaB = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
'50%': { transform: 'translate3d(-5%, 2.5%, 0) scale(1)' },
'100%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
});
/** Galaxy spiral: an exceptionally slow rotation of a distant pinwheel. */
export const animGalaxySpin = keyframes({
'0%': { transform: 'rotate(0deg) scale(1)' },
'50%': { transform: 'rotate(180deg) scale(1.03)' },
'100%': { transform: 'rotate(360deg) scale(1)' },
});
/** Tiny star twinkle: gentle opacity + micro-scale pulse. */
export const animTwinkle = keyframes({
'0%': { transform: 'scale(0.85)', opacity: '0.35' },
'50%': { transform: 'scale(1)', opacity: '1' },
'100%': { transform: 'scale(0.85)', opacity: '0.35' },
});
/** Bright star pulse: a slower, fuller bloom for the few hero stars. */
export const animStarPulse = keyframes({
'0%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
'50%': { transform: 'scale(1.15) rotate(45deg)', opacity: '1' },
'100%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
});
/** Parallax depth: a star layer drifts as if the viewer is gliding through space. */
export const animParallaxNear = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'100%': { transform: 'translate3d(-3%, 1.5%, 0)' },
});
export const animParallaxFar = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'100%': { transform: 'translate3d(-1.2%, 0.6%, 0)' },
});
/**
* Comet streak: a thin meteor crosses the field on a diagonal, fading in then
* out. The element is rotated by the component; this only translates along its
* own local X axis (its length direction) and fades.
*/
export const animComet = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0' },
'6%': { opacity: '1' },
'40%': { opacity: '0.9' },
'60%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
'100%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
});
@@ -0,0 +1,364 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animCosmosDrift,
animNebulaA,
animNebulaB,
animGalaxySpin,
animTwinkle,
animStarPulse,
animParallaxNear,
animParallaxFar,
animComet,
} from './DeepSpace.css';
/**
* Deep Space overlay — a cosmic, awe-inspiring ambient mode. Layered oklch
* radial gradients build a deep violet void seeded with drifting magenta/cyan
* nebula clouds and a faint distant galaxy spiral. A parallax starfield sits at
* two depths (a dense field of tiny twinkling stars plus a handful of brighter
* hero stars), and slow comet streaks cross the sky occasionally.
*
* Palette (oklch): deep cosmic violet oklch(0.2 0.12 300), nebula magenta
* oklch(0.55 0.2 330), cyan oklch(0.75 0.13 200), starlight white
* oklch(0.98 0.02 280).
*
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
* pointer-events:none container at the right z-index. We only return
* absolutely-positioned aria-hidden children at low opacity — no z-index,
* position:fixed, or pointer-events here — kept well below opaque so chat text
* stays WCAG-AA legible.
*
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a still
* nebula, a static starfield, a frozen galaxy and one frozen comet streak) with
* no `animation` at all. The settings preview always passes reduced=true.
*/
const STAR_TINTS = [
'oklch(0.98 0.02 280)', // starlight white
'oklch(0.9 0.07 230)', // cool cyan-white
'oklch(0.88 0.08 330)', // faint magenta-white
] as const;
const HERO_TINTS = [
'oklch(0.92 0.06 200)', // cyan starlight
'oklch(0.9 0.09 330)', // magenta starlight
'oklch(0.98 0.02 280)', // pure starlight
] as const;
type Star = {
top: number;
left: number;
size: number;
color: string;
duration: number;
delay: number;
staticOpacity: number;
};
type HeroStar = Star;
type Comet = {
top: number;
left: number;
length: number;
angle: number;
color: string;
duration: number;
delay: number;
};
// Deterministic pseudo-random so the memoized scene is stable across renders.
const rand = (seed: number) => {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
};
// A four-point gleam (sparkle) as an inline SVG data-URI — CSP-safe, no assets.
const gleamUri = (color: string) =>
`url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 C12.6 7.4 16.6 11.4 24 12 C16.6 12.6 12.6 16.6 12 24 C11.4 16.6 7.4 12.6 0 12 C7.4 11.4 11.4 7.4 12 0 Z' fill='${color}'/></svg>`,
)}")`;
function makeStars(count: number, seedBase: number): Star[] {
return Array.from({ length: count }, (_, i) => {
const s = seedBase + i;
return {
top: rand(s + 1) * 100,
left: rand(s + 101) * 100,
size: 1 + Math.floor(rand(s + 201) * 2), // 12px tiny stars
color: STAR_TINTS[i % STAR_TINTS.length],
duration: 2.6 + rand(s + 301) * 3.4,
delay: rand(s + 401) * 5,
staticOpacity: 0.4 + rand(s + 501) * 0.55,
};
});
}
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
const heroStars = useMemo<HeroStar[]>(
() =>
Array.from({ length: 6 }, (_, i) => {
const s = 3000 + i;
return {
top: 6 + rand(s + 1) * 78,
left: 6 + rand(s + 101) * 88,
size: 9 + Math.floor(rand(s + 201) * 9), // 917px gleams
color: HERO_TINTS[i % HERO_TINTS.length],
duration: 4 + rand(s + 301) * 4,
delay: rand(s + 401) * 5,
staticOpacity: 0.85,
};
}),
[],
);
const comets = useMemo<Comet[]>(
() =>
Array.from({ length: 3 }, (_, i) => {
const s = 4000 + i;
return {
top: 8 + rand(s + 1) * 44,
left: -10 + rand(s + 101) * 30,
length: 120 + Math.floor(rand(s + 201) * 120),
angle: 18 + rand(s + 301) * 16, // gentle downward diagonal
color: i % 2 === 0 ? 'oklch(0.92 0.06 200)' : 'oklch(0.9 0.09 330)',
duration: 7 + rand(s + 401) * 5,
delay: 2 + i * 6 + rand(s + 501) * 4,
};
}),
[],
);
return (
<>
{/* Deep cosmic void — layered oklch radial gradients for depth. A barely
perceptible drift gives the whole field life without distraction. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: '-6%',
contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
backgroundImage: [
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
'radial-gradient(100% 80% at 88% 28%, oklch(0.75 0.13 200 / 0.08) 0%, transparent 55%)',
'radial-gradient(150% 130% at 50% 118%, oklch(0.18 0.1 300 / 0.22) 0%, transparent 70%)',
].join(','),
animation: reduced ? 'none' : `${animCosmosDrift} 26s ease-in-out infinite`,
}}
/>
{/* Drifting nebula clouds — blurred radial gradients in magenta + cyan. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '-12%',
left: '-14%',
width: '70%',
height: '70%',
contain: 'layout paint style',
filter: 'blur(42px)',
background:
'radial-gradient(closest-side, oklch(0.55 0.2 330 / 0.16) 0%, oklch(0.45 0.18 320 / 0.06) 45%, transparent 72%)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animNebulaA} 34s ease-in-out infinite`,
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
bottom: '-16%',
right: '-12%',
width: '74%',
height: '74%',
contain: 'layout paint style',
filter: 'blur(46px)',
background:
'radial-gradient(closest-side, oklch(0.75 0.13 200 / 0.13) 0%, oklch(0.6 0.14 240 / 0.05) 48%, transparent 74%)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animNebulaB} 40s ease-in-out infinite`,
}}
/>
{/* A third, central violet wash to bind the two color clouds together. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '20%',
left: '28%',
width: '50%',
height: '50%',
contain: 'layout paint style',
filter: 'blur(50px)',
background:
'radial-gradient(closest-side, oklch(0.35 0.16 305 / 0.12) 0%, transparent 70%)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animNebulaA} 46s ease-in-out infinite reverse`,
}}
/>
{/* Faint distant galaxy spiral — an inline conic-ish swirl from layered
radial gradients, blurred and very slowly rotating. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '12%',
right: '14%',
width: '180px',
height: '180px',
contain: 'layout paint style',
borderRadius: '50%',
filter: 'blur(6px)',
opacity: reduced ? 0.5 : 0.6,
background: [
'radial-gradient(closest-side, oklch(0.95 0.04 280 / 0.35) 0%, oklch(0.7 0.16 320 / 0.12) 22%, transparent 40%)',
'conic-gradient(from 0deg, transparent 0deg, oklch(0.7 0.16 320 / 0.16) 60deg, transparent 130deg, oklch(0.75 0.13 200 / 0.12) 230deg, transparent 300deg)',
].join(','),
maskImage: 'radial-gradient(closest-side, #000 0%, #000 55%, transparent 80%)',
WebkitMaskImage: 'radial-gradient(closest-side, #000 0%, #000 55%, transparent 80%)',
willChange: reduced ? undefined : 'transform',
transform: reduced ? 'rotate(28deg)' : undefined,
animation: reduced ? 'none' : `${animGalaxySpin} 120s linear infinite`,
}}
/>
{/* Far parallax starfield — dense, faint, tiny twinkling stars. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animParallaxFar} 60s ease-in-out infinite alternate`,
}}
>
{farStars.map((s, i) => (
<div
key={`f${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${s.top}%`,
left: `${s.left}%`,
width: `${s.size}px`,
height: `${s.size}px`,
borderRadius: '50%',
backgroundColor: s.color,
boxShadow: `0 0 ${s.size * 2}px ${s.color}`,
opacity: reduced ? s.staticOpacity : undefined,
transform: reduced ? 'scale(0.95)' : undefined,
animation: reduced
? 'none'
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
}}
/>
))}
</div>
{/* Near parallax starfield — sparser, brighter, drifts a touch faster. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animParallaxNear} 48s ease-in-out infinite alternate`,
}}
>
{nearStars.map((s, i) => (
<div
key={`n${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${s.top}%`,
left: `${s.left}%`,
width: `${s.size + 1}px`,
height: `${s.size + 1}px`,
borderRadius: '50%',
backgroundColor: s.color,
boxShadow: `0 0 ${(s.size + 1) * 2.5}px ${s.color}`,
opacity: reduced ? Math.min(1, s.staticOpacity + 0.15) : undefined,
transform: reduced ? 'scale(1)' : undefined,
animation: reduced
? 'none'
: `${animTwinkle} ${s.duration * 0.85}s ease-in-out ${s.delay}s infinite`,
}}
/>
))}
</div>
{/* Hero stars — a few bright four-point gleams that pulse slowly. */}
{heroStars.map((s, i) => (
<div
key={`h${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${s.top}%`,
left: `${s.left}%`,
width: `${s.size}px`,
height: `${s.size}px`,
backgroundImage: gleamUri(s.color),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
filter: `drop-shadow(0 0 4px ${s.color})`,
opacity: reduced ? s.staticOpacity : undefined,
transform: reduced ? 'scale(1) rotate(20deg)' : undefined,
animation: reduced
? 'none'
: `${animStarPulse} ${s.duration}s ease-in-out ${s.delay}s infinite`,
}}
/>
))}
{/* Comet / warp streaks. In reduced mode, freeze a single streak mid-flight
so the static thumbnail reads as a living cosmos. */}
{(reduced ? comets.slice(0, 1) : comets).map((c, i) => (
<div
key={`c${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${c.top}%`,
left: `${c.left}%`,
width: `${c.length}px`,
height: '2px',
transformOrigin: '0 50%',
// Outer wrapper holds the rotation; inner element does the travel so
// the streak always moves along its own length axis.
transform: `rotate(${c.angle}deg)`,
}}
>
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: '2px',
background: `linear-gradient(90deg, transparent 0%, ${c.color} 80%, oklch(0.98 0.02 280 / 0.95) 100%)`,
boxShadow: `0 0 6px ${c.color}`,
willChange: reduced ? undefined : 'transform, opacity',
opacity: reduced ? 0.85 : undefined,
transform: reduced ? 'translate3d(46%, 0, 0)' : undefined,
animation: reduced
? 'none'
: `${animComet} ${c.duration}s ease-in ${c.delay}s infinite`,
}}
/>
</div>
))}
</>
);
}
@@ -0,0 +1,70 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Earth Day overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU — no layout/paint thrash.
* keyframes() returns the generated animation-name string, applied inline.
*
* Motif: verdant, hopeful nature. Leaves tumble, seeds/spores drift, pollen
* motes glow and pulse, soft sun rays breathe from above, the blue-marble
* Earth gently respires in a corner.
*/
/** Falling leaf: tumbles down with a wide pendular sway and slow spin. */
export const animLeafTumble = keyframes({
'0%': { transform: 'translate3d(0, -10vh, 0) rotate(-18deg)', opacity: '0' },
'8%': { opacity: '0.7' },
'28%': { transform: 'translate3d(4vw, 22vh, 0) rotate(60deg)' },
'52%': { transform: 'translate3d(-3vw, 48vh, 0) rotate(165deg)' },
'76%': { transform: 'translate3d(5vw, 74vh, 0) rotate(280deg)' },
'90%': { opacity: '0.5' },
'100%': { transform: 'translate3d(1vw, 112vh, 0) rotate(360deg)', opacity: '0' },
});
/** Tiny seed / spore: drifts slowly downward, swaying like dandelion fluff. */
export const animSeedDrift = keyframes({
'0%': { transform: 'translate3d(0, -6vh, 0) rotate(0deg)', opacity: '0' },
'12%': { opacity: '0.55' },
'40%': { transform: 'translate3d(3vw, 34vh, 0) rotate(140deg)' },
'70%': { transform: 'translate3d(-2.5vw, 64vh, 0) rotate(250deg)' },
'88%': { opacity: '0.4' },
'100%': { transform: 'translate3d(2vw, 110vh, 0) rotate(360deg)', opacity: '0' },
});
/** Pollen mote: floats gently upward in a soft serpentine path. */
export const animPollenFloat = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.75)', opacity: '0' },
'14%': { opacity: '0.9' },
'38%': { transform: 'translate3d(10px, -22vh, 0) scale(1)' },
'64%': { transform: 'translate3d(-10px, -46vh, 0) scale(0.92)', opacity: '0.7' },
'90%': { opacity: '0.2' },
'100%': { transform: 'translate3d(6px, -72vh, 0) scale(0.7)', opacity: '0' },
});
/** Soft brightness twinkle layered on each pollen mote's glow. */
export const animPollenGlow = keyframes({
'0%': { opacity: '0.55' },
'50%': { opacity: '1' },
'100%': { opacity: '0.55' },
});
/** Sun rays from above: slow breathing of opacity + a faint scale shimmer. */
export const animRayBreathe = keyframes({
'0%': { transform: 'scaleY(1)', opacity: '0.4' },
'50%': { transform: 'scaleY(1.05)', opacity: '0.7' },
'100%': { transform: 'scaleY(1)', opacity: '0.4' },
});
/** Green aurora veil: a wide, slow horizontal sway with a gentle swell. */
export const animAuroraSway = keyframes({
'0%': { transform: 'translate3d(-6%, 0, 0) scale(1.1)', opacity: '0.45' },
'50%': { transform: 'translate3d(6%, -2%, 0) scale(1.2)', opacity: '0.7' },
'100%': { transform: 'translate3d(-6%, 0, 0) scale(1.1)', opacity: '0.45' },
});
/** Blue-marble Earth: a barely-perceptible respiration of its halo. */
export const animEarthRespire = keyframes({
'0%': { transform: 'scale(1)', opacity: '0.85' },
'50%': { transform: 'scale(1.04)', opacity: '1' },
'100%': { transform: 'scale(1)', opacity: '0.85' },
});
@@ -0,0 +1,319 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLeafTumble,
animSeedDrift,
animPollenFloat,
animPollenGlow,
animRayBreathe,
animAuroraSway,
animEarthRespire,
} from './EarthDay.css';
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
// Verdant, hopeful nature: living leaf greens, soft sky + deep ocean blues,
// and a warm sun highlight. Kept low-alpha so chat text stays WCAG-AA legible.
const LEAF_GREEN = 'oklch(0.65 0.15 145)';
const LEAF_DEEP = 'oklch(0.52 0.14 150)';
const LEAF_LIME = 'oklch(0.78 0.16 130)';
const SKY_BLUE = 'oklch(0.70 0.10 230)';
const OCEAN_BLUE = 'oklch(0.55 0.12 240)';
const SUN_WARM = 'oklch(0.92 0.10 95)';
const POLLEN_GOLD = 'oklch(0.88 0.13 95)';
// Soft, translucent tints for the ambient gradient washes.
const LEAF_GREEN_SOFT = 'oklch(0.65 0.15 145 / 0.10)';
const LEAF_LIME_SOFT = 'oklch(0.78 0.16 130 / 0.08)';
const SKY_BLUE_SOFT = 'oklch(0.70 0.10 230 / 0.07)';
const SUN_SOFT = 'oklch(0.92 0.10 95 / 0.10)';
const AURORA_TINT = 'oklch(0.74 0.16 155 / 0.22)';
// ─── Inline SVG leaf, drawn once (CSP-safe data-URI, no external assets) ───────
// A simple veined leaf silhouette. Color is baked per-variant so we can tint
// individual falling leaves without a runtime filter.
function leafUri(fill: string, vein: string): string {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'>` +
`<path fill='${fill}' d='M14 1C7 5 2 11 2 18c0 5 4 9 9 9 7 0 15-7 15-19 0-3-1-6-2-6-3 1-6 2-10 0C12 1 13 1 14 1z'/>` +
`<path fill='none' stroke='${vein}' stroke-width='0.9' stroke-linecap='round' ` +
`d='M11 26C13 18 17 9 23 3M11 26c-1-4-2-7-4-9M13 20c2-1 4-2 6-5M12 14c2-1 3-2 5-5'/>` +
`</svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
}
// Three leaf tints, generated once at module load.
const LEAF_URIS = [
leafUri('oklch(0.65 0.15 145 / 0.9)', 'oklch(0.40 0.10 150 / 0.7)'),
leafUri('oklch(0.78 0.16 130 / 0.9)', 'oklch(0.50 0.12 140 / 0.7)'),
leafUri('oklch(0.52 0.14 150 / 0.9)', 'oklch(0.34 0.08 155 / 0.7)'),
];
export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
// ── Deterministic per-mount generation — never per-frame React state. ──
// Tumbling leaves (the heaviest motif → kept modest).
const leaves = useMemo(
() =>
Array.from({ length: 10 }, (_, i) => ({
left: (i * 6173 + 137) % 96,
size: 16 + (i % 4) * 6,
duration: 16 + (i % 5) * 2.5,
delay: (i * 1.7) % 16,
uri: LEAF_URIS[i % LEAF_URIS.length],
opacity: 0.45 + (i % 3) * 0.12,
})),
[],
);
// Tiny drifting seeds / spores — small, faint, slow.
const seeds = useMemo(
() =>
Array.from({ length: 8 }, (_, i) => ({
left: (i * 4099 + 53) % 98,
size: 2 + (i % 2),
duration: 18 + (i % 4) * 3,
delay: (i * 2.3) % 18,
})),
[],
);
// Glowing pollen motes rising from below, catching the light.
const pollen = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => ({
left: (i * 5279 + 89) % 100,
bottom: (i * 2731 + 31) % 32,
size: 3 + (i % 3),
duration: 13 + (i % 6) * 2,
delay: (i * 0.9) % 13,
twinkle: 2.6 + (i % 5) * 0.5,
})),
[],
);
// Sun rays fanning down from the top — a few soft angled beams.
const rays = useMemo(
() =>
Array.from({ length: 5 }, (_, i) => ({
left: 12 + i * 18,
rotate: -14 + i * 7,
width: 60 + (i % 3) * 26,
duration: 8 + (i % 3) * 2,
delay: i * 1.3,
opacity: 0.32 + (i % 3) * 0.08,
})),
[],
);
return (
<>
{/* ── Base wash: layered green/sky gradients for verdant depth ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
// warm sun glow spilling from top-center
`radial-gradient(60vmax 42vmax at 50% -8%, ${SUN_SOFT} 0%, transparent 60%)`,
// verdant canopy glow rising from the lower-left
`radial-gradient(52vmax 52vmax at 14% 100%, ${LEAF_GREEN_SOFT} 0%, transparent 62%)`,
// lime highlight upper-right for freshness
`radial-gradient(40vmax 40vmax at 86% 18%, ${LEAF_LIME_SOFT} 0%, transparent 58%)`,
// cool sky tint at the very top to pair with the Earth
`radial-gradient(70vmax 30vmax at 70% 4%, ${SKY_BLUE_SOFT} 0%, transparent 64%)`,
].join(', '),
opacity: 0.9,
}}
/>
{/* ── Green aurora veil drifting near the top ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
left: '-12%',
right: '-12%',
top: '-8%',
height: '46vh',
contain: 'layout paint style',
backgroundImage: `radial-gradient(60% 100% at 50% 0%, ${AURORA_TINT} 0%, transparent 72%)`,
filter: 'blur(26px)',
willChange: reduced ? undefined : 'transform, opacity',
transformOrigin: '50% 0%',
opacity: reduced ? 0.55 : undefined,
transform: reduced ? 'translate3d(0, 0, 0) scale(1.15)' : undefined,
animation: reduced ? 'none' : `${animAuroraSway} 24s ease-in-out infinite`,
}}
/>
{/* ── Soft sun rays fanning down from above ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
overflow: 'hidden',
}}
>
{rays.map((r, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-10%',
left: `${r.left}%`,
width: `${r.width}px`,
height: '95vh',
transformOrigin: '50% 0%',
transform: `rotate(${r.rotate}deg)`,
backgroundImage: `linear-gradient(180deg, ${SUN_WARM} 0%, transparent 70%)`,
filter: 'blur(8px)',
mixBlendMode: 'screen',
opacity: r.opacity,
willChange: reduced ? undefined : 'transform, opacity',
animation: reduced
? 'none'
: `${animRayBreathe} ${r.duration}s ease-in-out ${r.delay}s infinite`,
}}
/>
))}
</div>
{/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
right: '-6%',
bottom: '-10%',
width: '300px',
height: '300px',
contain: 'layout paint style',
willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(1.02)' : undefined,
opacity: reduced ? 0.9 : undefined,
animation: reduced ? 'none' : `${animEarthRespire} 18s ease-in-out infinite`,
}}
>
{/* atmospheric rim halo */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: '-14%',
borderRadius: '50%',
backgroundImage: `radial-gradient(circle at 50% 50%, transparent 58%, ${SKY_BLUE} 68%, transparent 80%)`,
filter: 'blur(10px)',
opacity: 0.5,
}}
/>
{/* the globe itself — oceans, land, soft terminator shadow */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
backgroundImage: [
// continents (green landmasses)
`radial-gradient(26% 30% at 38% 40%, ${LEAF_GREEN} 0%, transparent 60%)`,
`radial-gradient(22% 26% at 64% 58%, ${LEAF_DEEP} 0%, transparent 62%)`,
`radial-gradient(16% 18% at 50% 74%, ${LEAF_LIME} 0%, transparent 65%)`,
// ocean base
`radial-gradient(circle at 42% 38%, ${SKY_BLUE} 0%, ${OCEAN_BLUE} 55%, oklch(0.40 0.10 250) 100%)`,
].join(', '),
// soft day/night terminator from the lower-right
boxShadow: 'inset -22px -26px 50px oklch(0.18 0.05 250 / 0.7)',
opacity: 0.42,
}}
/>
</div>
{/* ── Rising, glowing pollen motes ── */}
{pollen.map((p, i) => (
<div
key={`p${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${p.left}%`,
bottom: `${p.bottom}%`,
willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(0.95)' : undefined,
opacity: reduced ? 0.6 : undefined,
animation: reduced
? 'none'
: `${animPollenFloat} ${p.duration}s ease-in ${p.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
width: `${p.size}px`,
height: `${p.size}px`,
borderRadius: '50%',
backgroundColor: POLLEN_GOLD,
boxShadow: `0 0 ${p.size * 2.6}px ${POLLEN_GOLD}`,
animation: reduced ? 'none' : `${animPollenGlow} ${p.twinkle}s ease-in-out infinite`,
}}
/>
</div>
))}
{/* ── Drifting seeds / spores (skip entirely when reduced) ── */}
{!reduced &&
seeds.map((s, i) => (
<div
key={`s${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: '-6%',
left: `${s.left}%`,
width: `${s.size}px`,
height: `${s.size}px`,
borderRadius: '50%',
backgroundColor: 'oklch(0.96 0.02 120 / 0.85)',
boxShadow: '0 0 6px oklch(0.92 0.04 120 / 0.6)',
willChange: 'transform, opacity',
animation: `${animSeedDrift} ${s.duration}s linear ${s.delay}s infinite`,
}}
/>
))}
{/* ── Tumbling leaves ── */}
{leaves.map((l, i) => (
<div
key={`l${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: '-10%',
left: `${l.left}%`,
width: `${l.size}px`,
height: `${l.size}px`,
backgroundImage: l.uri,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
willChange: reduced ? undefined : 'transform, opacity',
opacity: l.opacity,
// Static leaves are scattered down the column so the still scene
// reads as a gentle leaf-fall frozen mid-air.
transform: reduced
? `translate3d(${(i % 2 ? 1 : -1) * 3}vw, ${6 + i * 9}vh, 0) rotate(${
(i * 47) % 360
}deg)`
: undefined,
animation: reduced
? 'none'
: `${animLeafTumble} ${l.duration}s ease-in ${l.delay}s infinite`,
}}
/>
))}
</>
);
}
@@ -0,0 +1,56 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Halloween overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU without layout/paint.
* keyframes() returns the generated animation-name string, applied inline.
*/
/** Slow breathing of the sickly moon-glow vignette. */
export const animMoonPulse = keyframes({
'0%': { transform: 'scale(1)', opacity: '0.55' },
'50%': { transform: 'scale(1.06)', opacity: '0.8' },
'100%': { transform: 'scale(1)', opacity: '0.55' },
});
/** Low fog band: drifts sideways while gently rising and swelling. */
export const animFogDrift = keyframes({
'0%': { transform: 'translate3d(-12%, 6%, 0) scale(1.1)', opacity: '0' },
'15%': { opacity: '0.5' },
'50%': { transform: 'translate3d(6%, -2%, 0) scale(1.25)', opacity: '0.65' },
'85%': { opacity: '0.45' },
'100%': { transform: 'translate3d(18%, 4%, 0) scale(1.1)', opacity: '0' },
});
/** A bat flaps slowly across the sky in a shallow arc. */
export const animBatGlide = keyframes({
'0%': { transform: 'translate3d(-12vw, 8vh, 0) scale(0.9)', opacity: '0' },
'10%': { opacity: '0.7' },
'45%': { transform: 'translate3d(45vw, -4vh, 0) scale(1)' },
'80%': { transform: 'translate3d(85vw, 6vh, 0) scale(0.95)', opacity: '0.6' },
'100%': { transform: 'translate3d(112vw, 2vh, 0) scale(0.9)', opacity: '0' },
});
/** The bat's wings beat — fast vertical squash of the wing element. */
export const animWingFlap = keyframes({
'0%': { transform: 'scaleY(1) scaleX(1)' },
'50%': { transform: 'scaleY(0.35) scaleX(1.08)' },
'100%': { transform: 'scaleY(1) scaleX(1)' },
});
/** Will-o'-wisp ember: floats upward, swaying, pulsing in brightness. */
export const animEmberFloat = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
'12%': { opacity: '0.85' },
'35%': { transform: 'translate3d(14px, -28vh, 0) scale(1)' },
'65%': { transform: 'translate3d(-12px, -55vh, 0) scale(0.9)', opacity: '0.7' },
'90%': { opacity: '0.25' },
'100%': { transform: 'translate3d(8px, -82vh, 0) scale(0.6)', opacity: '0' },
});
/** Soft twinkle for embers — independent opacity flicker layered on top. */
export const animEmberTwinkle = keyframes({
'0%': { opacity: '0.6' },
'50%': { opacity: '1' },
'100%': { opacity: '0.6' },
});
@@ -0,0 +1,267 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animMoonPulse,
animFogDrift,
animBatGlide,
animWingFlap,
animEmberFloat,
animEmberTwinkle,
} from './Halloween.css';
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
// Deep haunted indigo, sickly toxic-green moon glow, warm ember orange.
const PURPLE_DEEP = 'oklch(0.20 0.12 300)';
const PURPLE_FAINT = 'oklch(0.28 0.10 300 / 0.45)';
const TOXIC_GREEN = 'oklch(0.80 0.18 150)';
const TOXIC_GREEN_SOFT = 'oklch(0.72 0.16 150 / 0.35)';
const EMBER_ORANGE = 'oklch(0.70 0.18 50)';
const FOG_TINT = 'oklch(0.45 0.06 280 / 0.32)';
// A corner cobweb, drawn once as an inline SVG data-URI (CSP-safe, no assets).
// strokeWidth kept hairline so it reads as gossamer thread, not a cage.
const cobwebUri = (() => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'>` +
`<g fill='none' stroke='rgba(196,176,224,0.32)' stroke-width='0.8'>` +
// radial threads
`<line x1='0' y1='0' x2='180' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='90'/>` +
`<line x1='0' y1='0' x2='90' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='40'/>` +
`<line x1='0' y1='0' x2='40' y2='180'/>` +
// concentric catch-threads (gentle sag via quadratic curves)
`<path d='M40 0 Q22 22 0 40'/>` +
`<path d='M85 0 Q48 48 0 85'/>` +
`<path d='M130 0 Q74 74 0 130'/>` +
`<path d='M180 0 Q104 104 0 180'/>` +
`<path d='M180 60 Q120 120 60 180'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
// A single silhouetted bat, inline SVG. Wings are separate so the wrapper can
// glide while an inner element flaps independently — we re-use one body shape.
function BatSilhouette() {
return (
<svg
width="46"
height="22"
viewBox="0 0 46 22"
aria-hidden="true"
style={{ display: 'block', overflow: 'visible' }}
>
<path
fill="oklch(0.12 0.04 300 / 0.85)"
d="M23 6c1.6 0 2.7 1.3 3 3 .9-1.4 2.4-2.6 4.2-2.6-.5 1-.4 2.1.2 2.9 2-2.4 5.4-4 8.6-3.7-1.5 1-2.3 2.6-2.4 4.3 1.3-.8 3-1 4.4-.4-2.2.8-3.9 2.5-5.2 4.5-2 3-4.8 5-8.3 4.4-1.9-.3-3.4-1.6-4.5-3.2-1.1 1.6-2.6 2.9-4.5 3.2-3.5.6-6.3-1.4-8.3-4.4-1.3-2-3-3.7-5.2-4.5 1.4-.6 3.1-.4 4.4.4-.1-1.7-.9-3.3-2.4-4.3 3.2-.3 6.6 1.3 8.6 3.7.6-.8.7-1.9.2-2.9 1.8 0 3.3 1.2 4.2 2.6.3-1.7 1.4-3 3-3z"
/>
</svg>
);
}
export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic per-mount generation — never per-frame React state.
const embers = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => {
const green = i % 3 === 0; // ~1/3 toxic-green wisps, rest warm embers
return {
left: (i * 6151 + 113) % 100,
bottom: (i * 3137 + 47) % 28, // start near floor
size: 3 + (i % 4),
duration: 11 + (i % 6) * 2.2,
delay: (i * 0.83) % 11,
twinkle: 2.4 + (i % 5) * 0.6,
color: green ? TOXIC_GREEN : EMBER_ORANGE,
};
}),
[],
);
const bats = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
top: 8 + i * 13,
duration: 22 + i * 7,
delay: i * 6.5,
flap: 0.5 + i * 0.12,
scale: 0.7 + i * 0.18,
})),
[],
);
const fogBands = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
bottom: -6 + i * 9,
duration: 26 + i * 8,
delay: i * 5,
height: 130 + i * 30,
})),
[],
);
return (
<>
{/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundColor: 'transparent',
backgroundImage: [
// sickly moon glow, upper-right
`radial-gradient(38vmax 38vmax at 78% 14%, ${TOXIC_GREEN_SOFT} 0%, transparent 58%)`,
// cold counter-glow lower-left for depth
`radial-gradient(46vmax 46vmax at 12% 92%, ${PURPLE_FAINT} 0%, transparent 60%)`,
// overall indigo→black wash, darker toward edges (vignette)
`radial-gradient(120% 120% at 50% 30%, transparent 32%, ${PURPLE_DEEP} 100%)`,
`linear-gradient(180deg, ${PURPLE_DEEP} 0%, transparent 45%)`,
].join(', '),
opacity: 0.5,
}}
/>
{/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
right: '12%',
width: '160px',
height: '160px',
borderRadius: '50%',
willChange: 'transform, opacity',
backgroundImage: `radial-gradient(circle at 42% 40%, ${TOXIC_GREEN} 0%, oklch(0.55 0.14 150 / 0.5) 38%, transparent 72%)`,
filter: 'blur(2px)',
backdropFilter: 'saturate(1.15)',
animation: reduced ? 'none' : `${animMoonPulse} 9s ease-in-out infinite`,
}}
/>
{/* ── Low drifting fog bands ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
overflow: 'hidden',
}}
>
{fogBands.map((f, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: '-15%',
right: '-15%',
bottom: `${f.bottom}%`,
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
? 'none'
: `${animFogDrift} ${f.duration}s ease-in-out ${f.delay}s infinite`,
}}
/>
))}
</div>
{/* ── Will-o'-wisps / floating embers ── */}
{embers.map((e, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: `${e.left}%`,
bottom: `${e.bottom}%`,
willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(0.9)' : undefined,
opacity: reduced ? 0.4 : undefined,
animation: reduced
? 'none'
: `${animEmberFloat} ${e.duration}s ease-in ${e.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
width: `${e.size}px`,
height: `${e.size}px`,
borderRadius: '50%',
backgroundColor: e.color,
boxShadow: `0 0 ${e.size * 2.5}px ${e.color}`,
animation: reduced
? 'none'
: `${animEmberTwinkle} ${e.twinkle}s ease-in-out infinite`,
}}
/>
</div>
))}
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
{!reduced &&
bats.map((b, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: `${b.top}%`,
left: 0,
willChange: 'transform, opacity',
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
transform: `scale(${b.scale})`,
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
}}
>
<BatSilhouette />
</div>
</div>
))}
{/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
opacity: 0.7,
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
right: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
transform: 'scaleX(-1)',
opacity: 0.7,
}}
/>
</>
);
}
@@ -0,0 +1,109 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Lunar New Year overlay keyframes — red paper lanterns, drifting gold plum
* blossoms, and a coiling dragon. Every animation touches ONLY `transform` and
* `opacity`, so the compositor runs them on the GPU with zero layout/paint.
* keyframes() returns the generated animation-name string, applied inline by the
* component. Static structure (gradients, SVG data-URIs, geometry) lives in the
* component; this module is motion only.
*/
/**
* Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy
* cycle, as if buoyed by warm air. translateY + a whisper of scale only; the
* per-lantern duration/delay desynchronise the swarm.
*/
export const animLanternBob = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
'50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' },
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
});
/**
* Lantern pendulum — a gentle rotational sway about the top mount, so each
* lantern rocks like it hangs from a string. Pairs with the bob on a different
* period to read as organic drift rather than a metronome.
*/
export const animLanternSway = keyframes({
'0%': { transform: 'rotate(-2.4deg)' },
'50%': { transform: 'rotate(2.4deg)' },
'100%': { transform: 'rotate(-2.4deg)' },
});
/**
* Tassel sway — the silk tassel under a lantern trails its parent's motion with
* a wider, slightly lagging swing. transformOrigin is the top of the tassel.
*/
export const animTasselSway = keyframes({
'0%': { transform: 'rotate(5deg)' },
'50%': { transform: 'rotate(-5deg)' },
'100%': { transform: 'rotate(5deg)' },
});
/**
* Lantern inner glow — the warm light inside each lantern swells and dims, like
* a candle breathing. Opacity + scale only.
*/
export const animGlowBreathe = keyframes({
'0%': { transform: 'scale(0.94)', opacity: '0.55' },
'50%': { transform: 'scale(1.06)', opacity: '0.9' },
'100%': { transform: 'scale(0.94)', opacity: '0.55' },
});
/**
* Petal drift — a gold plum-blossom petal falls the full height while spinning
* and swaying. A tall translateY lets one keyframe set serve every petal;
* per-petal duration/delay/scale create the parallax variety.
*/
export const animPetalFall = keyframes({
'0%': { transform: 'translate3d(0, -10vh, 0) rotateZ(0deg) rotateY(0deg)', opacity: '0' },
'10%': { opacity: '0.9' },
'50%': { transform: 'translate3d(3vw, 52vh, 0) rotateZ(190deg) rotateY(180deg)' },
'90%': { opacity: '0.8' },
'100%': {
transform: 'translate3d(-2.4vw, 114vh, 0) rotateZ(380deg) rotateY(360deg)',
opacity: '0',
},
});
/**
* Lateral petal sway on the wrapper, decoupled from the fall so the two combine
* into an organic wind-borne path rather than a straight drop.
*/
export const animPetalSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(2.8vw, 0, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/**
* Dragon drift — the gold dragon silhouette breathes and undulates almost
* imperceptibly across the scene. translate + scale + opacity only, very slow.
*/
export const animDragonDrift = keyframes({
'0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
'50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' },
'100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
});
/**
* Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
* the static base feels alive without distracting motion.
*/
export const animLacquerPulse = keyframes({
'0%': { opacity: '0.82' },
'50%': { opacity: '1' },
'100%': { opacity: '0.82' },
});
/**
* Gold ember rise — tiny sparks of lantern light float gently upward and fade,
* like motes drifting off the flames. translateY + opacity only.
*/
export const animEmberRise = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
'15%': { opacity: '0.85' },
'80%': { opacity: '0.5' },
'100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' },
});
@@ -0,0 +1,483 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLanternBob,
animLanternSway,
animTasselSway,
animGlowBreathe,
animPetalFall,
animPetalSway,
animDragonDrift,
animLacquerPulse,
animEmberRise,
} from './LunarNewYear.css';
// Deterministic pseudo-random so the scene is identical every mount (no React
// state per frame). Large primes keep the distribution well spread.
const rand = (seed: number): number => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Core oklch palette — auspicious crimson/vermilion lanterns, imperial gold
// trim and blossoms, over a deep lacquer-red ambient tint. Kept luminous and
// gentle so everything reads as soft ambient glow, never solid paint.
const CRIMSON = 'oklch(0.50 0.20 25)';
const VERMILION = 'oklch(0.58 0.21 30)';
const GOLD = 'oklch(0.82 0.14 85)';
const GOLD_HI = 'oklch(0.92 0.10 92)';
// A coiling dragon silhouette in imperial gold, rendered once as an inline SVG
// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
// suggestion of a head, mane and tail arcing across the upper scene.
const dragonUri = ((): string => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
`<defs>` +
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
`</linearGradient>` +
`</defs>` +
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
// Sinuous body — a thick tapering serpentine ribbon.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='26' opacity='0.5'/>` +
// Inner highlight running along the body for a calligraphic sheen.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='7' opacity='0.7'/>` +
// Head + horn flourish at the leading end.
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
`stroke-width='9' opacity='0.6'/>` +
// Mane / whisker strokes flaring back from the head.
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
`stroke-width='5' opacity='0.45'/>` +
// Tail wisps.
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
`stroke-width='5' opacity='0.45'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
type Lantern = {
left: number;
top: number;
scale: number;
bobDuration: number;
swayDuration: number;
delay: number;
opacity: number;
};
type Petal = {
left: number;
size: number;
duration: number;
delay: number;
swayDuration: number;
opacity: number;
blur: number;
hue: number;
};
type Ember = {
left: number;
bottom: number;
size: number;
duration: number;
delay: number;
};
// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
// one cheap element. Returned as a data-URI background painted on a square.
const blossomUri = ((): string => {
const petals = Array.from({ length: 5 }, (_, i) => {
const a = (i * 72 * Math.PI) / 180;
const cx = 16 + Math.cos(a - Math.PI / 2) * 8;
const cy = 16 + Math.sin(a - Math.PI / 2) * 8;
return `<circle cx='${cx.toFixed(1)}' cy='${cy.toFixed(1)}' r='5.4' />`;
}).join('');
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'>` +
`<g fill='oklch(0.86 0.13 88)' opacity='0.92'>${petals}</g>` +
`<circle cx='16' cy='16' r='3.2' fill='oklch(0.94 0.10 95)'/>` +
`</svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
// Paper lanterns strung across the upper third, gently staggered in depth.
const lanterns = useMemo<Lantern[]>(() => {
const slots = [
{ left: 9, top: 7, scale: 1.0 },
{ left: 27, top: 13, scale: 0.82 },
{ left: 46, top: 6, scale: 1.12 },
{ left: 64, top: 15, scale: 0.78 },
{ left: 82, top: 9, scale: 0.95 },
{ left: 92, top: 20, scale: 0.7 },
];
return slots.map((s, i) => ({
left: s.left,
top: s.top,
scale: s.scale,
bobDuration: 7 + rand(i + 1) * 4,
swayDuration: 5.5 + rand(i + 4) * 3,
delay: -rand(i + 7) * 6,
opacity: 0.78 + rand(i + 2) * 0.18,
}));
}, []);
// Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
// near large/bright/fast) for depth.
const petals = useMemo<Petal[]>(() => {
const bands = [
{ count: 9, size: [9, 14], dur: [15, 21], op: [0.4, 0.6], blur: 0.6 },
{ count: 8, size: [15, 24], dur: [10, 14], op: [0.6, 0.85], blur: 0 },
];
const out: Petal[] = [];
let s = 1;
bands.forEach((b) => {
for (let i = 0; i < b.count; i += 1) {
const r1 = rand(s);
const r2 = rand(s + 0.37);
const r3 = rand(s + 0.71);
const r4 = rand(s + 0.91);
out.push({
left: r1 * 100,
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
delay: -r4 * (b.dur[1] + 4),
swayDuration: 5 + r2 * 5,
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
blur: b.blur,
hue: 82 + r4 * 10,
});
s += 1;
}
});
return out;
}, []);
// A few gold embers rising from the lanterns (motion scene only).
const embers = useMemo<Ember[]>(
() =>
Array.from({ length: 7 }, (_, i) => ({
left: 8 + rand(i + 11) * 84,
bottom: 8 + rand(i + 21) * 30,
size: 1.6 + rand(i + 31) * 2.2,
duration: 9 + rand(i + 41) * 6,
delay: -rand(i + 51) * 12,
})),
[],
);
return (
<>
{/* Deep lacquer-red ambient wash — layered radial + linear oklch gradients
for depth and a warm crimson lantern-glow from above. Low-opacity so
chat text stays legible (WCAG-AA). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
].join(','),
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Imperial-gold dragon silhouette arcing across the upper scene — a
single composited SVG layer, blurred and screen-blended so it reads as
an ethereal gilt apparition, never a hard graphic. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
left: '-6%',
right: '-6%',
height: '46%',
contain: 'layout paint style',
backgroundImage: dragonUri,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundSize: 'contain',
mixBlendMode: 'screen',
filter: 'blur(1.1px)',
opacity: reduced ? 0.52 : undefined,
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
backdrop-filter for a silken haze around the rim. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.05)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
backgroundImage:
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
}}
/>
{/* The garland string the lanterns hang from — a faint warm line. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '6%',
contain: 'layout paint style',
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
')',
' / 0.14)',
)} 0%, transparent 70%)`,
}}
/>
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
trailing tassel. */}
{lanterns.map((l, i) => {
const W = 30 * l.scale;
const H = 38 * l.scale;
const cap = Math.max(8, W * 0.5);
return (
<div
key={`lantern-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${l.left}%`,
top: `${l.top}%`,
marginLeft: `${-W / 2}px`,
transformOrigin: 'top center',
opacity: l.opacity,
animation: reduced
? 'none'
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
<div
style={{
animation: reduced
? 'none'
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
{/* short cord from the string to the top cap */}
<div
style={{
width: '2px',
height: `${10 * l.scale}px`,
margin: '0 auto',
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
}}
/>
{/* top gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
}}
/>
{/* lantern body */}
<div
style={{
position: 'relative',
width: `${W}px`,
height: `${H}px`,
margin: `${1 * l.scale}px auto`,
borderRadius: '50% / 42%',
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
')',
' / 0.95)',
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
')',
' / 0.5)',
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
overflow: 'hidden',
}}
>
{/* breathing inner candle glow */}
<div
style={{
position: 'absolute',
left: '50%',
top: '52%',
width: `${W * 0.6}px`,
height: `${H * 0.55}px`,
marginLeft: `${-W * 0.3}px`,
marginTop: `${-H * 0.275}px`,
borderRadius: '50%',
background: `radial-gradient(circle, ${GOLD_HI.replace(
')',
' / 0.9)',
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
filter: 'blur(1px)',
animation: reduced
? 'none'
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
l.delay
}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* vertical paper ribs */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
W / 6 - 0.6
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
')',
' / 0.18)',
)} ${W / 6}px)`,
}}
/>
</div>
{/* bottom gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
}}
/>
{/* swaying silk tassel */}
<div
style={{
width: `${2 * l.scale}px`,
height: `${16 * l.scale}px`,
margin: '0 auto',
transformOrigin: 'top center',
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
borderRadius: '1px',
animation: reduced
? 'none'
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
/>
</div>
</div>
);
})}
{/* Drifting gold plum-blossom petals (motion only). Static settled
blossoms render below for the reduced/preview scene. */}
{!reduced &&
petals.map((p, i) => (
<div
key={`petal-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${p.left}%`,
width: `${p.size}px`,
height: `${p.size}px`,
animation: `${animPetalSway} ${p.swayDuration}s ease-in-out ${p.delay}s infinite`,
willChange: 'transform',
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: blossomUri,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
opacity: p.opacity,
filter: p.blur ? `blur(${p.blur}px)` : undefined,
animation: `${animPetalFall} ${p.duration}s linear ${p.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
</div>
))}
{/* Static settled blossoms for the reduced-motion / preview scene — a
serene scatter so the thumbnail still reads as a blossom drift. */}
{reduced &&
petals.slice(0, 12).map((p, i) => {
const py = rand(i + 0.5) * 92 + 4;
return (
<div
key={`petal-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${p.left}%`,
top: `${py}%`,
width: `${p.size}px`,
height: `${p.size}px`,
backgroundImage: blossomUri,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
opacity: p.opacity,
transform: `rotate(${rand(i + 3) * 360}deg)`,
filter: p.blur ? `blur(${p.blur}px)` : undefined,
}}
/>
);
})}
{/* Gold embers rising off the lanterns (motion only). */}
{!reduced &&
embers.map((e, i) => (
<div
key={`ember-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${e.left}%`,
bottom: `${e.bottom}%`,
width: `${e.size}px`,
height: `${e.size}px`,
borderRadius: '50%',
background: `radial-gradient(circle, ${GOLD_HI} 0%, ${GOLD.replace(
')',
' / 0.7)',
)} 50%, transparent 80%)`,
boxShadow: `0 0 5px ${GOLD.replace(')', ' / 0.6)')}`,
animation: `${animEmberRise} ${e.duration}s ease-in ${e.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
))}
</>
);
}
@@ -0,0 +1,87 @@
import { keyframes } from '@vanilla-extract/css';
/**
* New Year overlay keyframes — a midnight celebration. Every animation touches
* ONLY `transform` and `opacity` so the compositor runs them on the GPU with no
* layout/paint. keyframes() returns the generated animation-name string, which
* is applied inline by the component. Heavy/static structure (gradients, SVG
* data-URIs, geometry) lives in the component; this module is motion only.
*/
/**
* Firework burst — a thin spark ring expands from a pinpoint, brightens, then
* fades as it grows. Scale + opacity only; the ring is a radial-gradient border
* supplied inline. Long pauses between bursts come from a low keyframe-duty:
* the ring spends most of the cycle collapsed and invisible.
*/
export const animBurst = keyframes({
'0%': { transform: 'scale(0.05)', opacity: '0' },
'4%': { transform: 'scale(0.12)', opacity: '0.95' },
'22%': { transform: 'scale(1)', opacity: '0.55' },
'34%': { transform: 'scale(1.25)', opacity: '0' },
'100%': { transform: 'scale(1.25)', opacity: '0' },
});
/**
* Burst core flash — the bright pinpoint at a firework's origin pops just before
* the ring blooms, then quickly dims. Pairs with animBurst on the same cadence.
*/
export const animCoreFlash = keyframes({
'0%': { transform: 'scale(0.2)', opacity: '0' },
'3%': { transform: 'scale(1)', opacity: '1' },
'14%': { transform: 'scale(0.6)', opacity: '0' },
'100%': { transform: 'scale(0.6)', opacity: '0' },
});
/**
* Champagne shimmer sweep — a wide soft gold band glides diagonally across the
* scene and breathes in brightness. translateX + opacity (never
* background-position) keep it on the compositor.
*/
export const animShimmer = keyframes({
'0%': { transform: 'translate3d(-120%, 0, 0) skewX(-12deg)', opacity: '0' },
'12%': { opacity: '0.7' },
'50%': { opacity: '0.5' },
'88%': { opacity: '0.6' },
'100%': { transform: 'translate3d(120%, 0, 0) skewX(-12deg)', opacity: '0' },
});
/**
* Confetti fall — a small sliver tumbles the full height while spinning on two
* axes, fading in at the top and out at the bottom. A tall translateY lets one
* keyframe set serve every sliver; per-piece duration/delay/scale add variety.
*/
export const animConfettiFall = keyframes({
'0%': { transform: 'translate3d(0, -10vh, 0) rotateZ(0deg) rotateX(0deg)', opacity: '0' },
'8%': { opacity: '0.9' },
'50%': { transform: 'translate3d(2.2vw, 52vh, 0) rotateZ(220deg) rotateX(180deg)' },
'92%': { opacity: '0.85' },
'100%': {
transform: 'translate3d(-1.8vw, 114vh, 0) rotateZ(440deg) rotateX(360deg)',
opacity: '0',
},
});
/**
* Lateral confetti sway on the wrapper, decoupled from the fall so the two
* combine into an organic drifting path rather than a straight drop.
*/
export const animConfettiSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(2.4vw, 0, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/** Star twinkle — a sparkle pulses in brightness and size, like a glint. */
export const animTwinkle = keyframes({
'0%': { transform: 'scale(0.5) rotate(0deg)', opacity: '0.2' },
'50%': { transform: 'scale(1) rotate(45deg)', opacity: '0.95' },
'100%': { transform: 'scale(0.5) rotate(0deg)', opacity: '0.2' },
});
/** Barely-there breathing of the midnight tint so the static base feels alive. */
export const animSkyPulse = keyframes({
'0%': { opacity: '0.82' },
'50%': { opacity: '1' },
'100%': { opacity: '0.82' },
});
@@ -0,0 +1,303 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animBurst,
animCoreFlash,
animShimmer,
animConfettiFall,
animConfettiSway,
animTwinkle,
animSkyPulse,
} from './NewYear.css';
/**
* New Year overlay — a midnight celebration. Layered oklch gradients sink the
* app into a deep navy night; fireworks bloom as expanding spark rings, a
* champagne-gold shimmer sweeps across, confetti slivers tumble down, and
* sparkle stars twinkle. All motion is transform/opacity only.
*
* Palette (oklch): midnight navy oklch(0.20 0.07 260), champagne gold
* oklch(0.85 0.13 90), bursts in magenta oklch(0.7 0.22 350), cyan
* oklch(0.8 0.15 200), and gold.
*
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
* pointer-events:none container at the right z-index. We only return
* absolutely-positioned aria-hidden children at low opacity — no z-index,
* position:fixed, or pointer-events here — kept well below opaque so chat text
* stays WCAG-AA legible.
*
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a frozen
* firework bloom mid-burst, scattered gold confetti, a still shimmer band) with
* no `animation` at all. The settings preview always passes reduced=true.
*/
const BURST_HUES = [
// [ring oklch, core oklch]
['oklch(0.7 0.22 350)', 'oklch(0.88 0.14 350)'], // magenta
['oklch(0.8 0.15 200)', 'oklch(0.92 0.1 200)'], // cyan
['oklch(0.85 0.13 90)', 'oklch(0.95 0.09 95)'], // gold
['oklch(0.75 0.2 30)', 'oklch(0.9 0.12 40)'], // warm coral
] as const;
const CONFETTI_COLORS = [
'oklch(0.85 0.13 90)', // champagne gold
'oklch(0.7 0.22 350)', // magenta
'oklch(0.8 0.15 200)', // cyan
'oklch(0.9 0.06 90)', // pale gold
'oklch(0.78 0.18 30)', // coral
] as const;
type Burst = {
top: number;
left: number;
size: number;
ring: string;
core: string;
duration: number;
delay: number;
};
type Confetto = {
left: number;
w: number;
h: number;
color: string;
round: boolean;
fallDur: number;
swayDur: number;
delay: number;
};
type Star = {
top: number;
left: number;
size: number;
color: string;
duration: number;
delay: number;
};
// Deterministic pseudo-random so the memoized scene is stable across renders.
const rand = (seed: number) => {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
};
// A four-point sparkle (gleam) as an inline SVG data-URI — CSP-safe, no assets.
const sparkleUri = (color: string) =>
`url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 L14 10 L24 12 L14 14 L12 24 L10 14 L0 12 L10 10 Z' fill='${color}'/></svg>`,
)}")`;
export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
const bursts = useMemo<Burst[]>(
() =>
// Bursts cluster in the upper two-thirds of the sky, away from typical text.
Array.from({ length: 7 }, (_, i) => {
const hue = BURST_HUES[i % BURST_HUES.length];
return {
top: 8 + rand(i + 1) * 48,
left: 8 + rand(i + 11) * 84,
size: 130 + Math.floor(rand(i + 21) * 110),
ring: hue[0],
core: hue[1],
duration: 6.5 + rand(i + 31) * 4,
delay: rand(i + 41) * 9,
};
}),
[],
);
const confetti = useMemo<Confetto[]>(
() =>
Array.from({ length: 20 }, (_, i) => ({
left: rand(i + 101) * 100,
w: 4 + Math.floor(rand(i + 111) * 4),
h: 7 + Math.floor(rand(i + 121) * 7),
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
round: i % 4 === 0,
fallDur: 9 + rand(i + 131) * 7,
swayDur: 3 + rand(i + 141) * 3,
delay: rand(i + 151) * 10,
})),
[],
);
const stars = useMemo<Star[]>(
() =>
Array.from({ length: 9 }, (_, i) => ({
top: 4 + rand(i + 201) * 64,
left: 4 + rand(i + 211) * 92,
size: 8 + Math.floor(rand(i + 221) * 10),
color: i % 2 === 0 ? 'oklch(0.85 0.13 90)' : 'oklch(0.92 0.06 200)',
duration: 3 + rand(i + 231) * 3,
delay: rand(i + 241) * 4,
})),
[],
);
return (
<>
{/* Midnight sky — layered oklch gradients for depth, with a faint breathe. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.07 260 / 0.12)',
backgroundImage: [
'radial-gradient(120% 90% at 50% -10%, oklch(0.32 0.1 280 / 0.16) 0%, transparent 60%)',
'radial-gradient(90% 70% at 18% 8%, oklch(0.7 0.22 350 / 0.07) 0%, transparent 55%)',
'radial-gradient(90% 70% at 84% 4%, oklch(0.8 0.15 200 / 0.07) 0%, transparent 55%)',
'radial-gradient(140% 120% at 50% 120%, oklch(0.2 0.07 260 / 0.14) 0%, transparent 70%)',
].join(','),
animation: reduced ? 'none' : `${animSkyPulse} 9s ease-in-out infinite`,
}}
/>
{/* Champagne-gold shimmer sweep. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '55%',
contain: 'layout paint style',
backgroundImage:
'linear-gradient(100deg, transparent 0%, oklch(0.85 0.13 90 / 0.05) 38%, oklch(0.95 0.09 95 / 0.1) 50%, oklch(0.85 0.13 90 / 0.05) 62%, transparent 100%)',
transform: reduced ? 'translate3d(30%, 0, 0) skewX(-12deg)' : undefined,
opacity: reduced ? 0.45 : undefined,
animation: reduced ? 'none' : `${animShimmer} 11s ease-in-out infinite`,
}}
/>
{/* Fireworks — expanding spark rings + a core flash. In reduced mode we
freeze the first burst mid-bloom and drop the rest. */}
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => (
<div
key={`b${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${b.top}%`,
left: `${b.left}%`,
width: `${b.size}px`,
height: `${b.size}px`,
marginLeft: `${-b.size / 2}px`,
marginTop: `${-b.size / 2}px`,
}}
>
{/* Spark ring */}
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
willChange: reduced ? undefined : 'transform, opacity',
background: `radial-gradient(circle, transparent 56%, ${b.ring} 64%, transparent 74%)`,
transform: reduced ? 'scale(0.82)' : undefined,
opacity: reduced ? 0.6 : undefined,
animation: reduced
? 'none'
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
}}
/>
{/* Inner secondary ring for a fuller bloom */}
<div
style={{
position: 'absolute',
inset: '18%',
borderRadius: '50%',
background: `radial-gradient(circle, transparent 50%, ${b.ring} 60%, transparent 72%)`,
transform: reduced ? 'scale(0.7)' : undefined,
opacity: reduced ? 0.4 : undefined,
animation: reduced
? 'none'
: `${animBurst} ${b.duration}s ease-out ${b.delay + 0.12}s infinite`,
}}
/>
{/* Core flash */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: '14%',
height: '14%',
marginLeft: '-7%',
marginTop: '-7%',
borderRadius: '50%',
background: `radial-gradient(circle, ${b.core} 0%, transparent 70%)`,
transform: reduced ? 'scale(0.9)' : undefined,
opacity: reduced ? 0.85 : undefined,
animation: reduced
? 'none'
: `${animCoreFlash} ${b.duration}s ease-out ${b.delay}s infinite`,
}}
/>
</div>
))}
{/* Twinkling sparkle stars. */}
{stars.map((s, i) => (
<div
key={`s${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${s.top}%`,
left: `${s.left}%`,
width: `${s.size}px`,
height: `${s.size}px`,
backgroundImage: sparkleUri(s.color),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
transform: reduced ? 'scale(0.9) rotate(30deg)' : undefined,
opacity: reduced ? 0.75 : undefined,
animation: reduced
? 'none'
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
}}
/>
))}
{/* Falling confetti slivers. In reduced mode, a still scatter at varied
heights so the static thumbnail reads as a celebration in progress. */}
{confetti.map((c, i) => {
const staticTop = reduced ? 6 + rand(i + 301) * 78 : undefined;
return (
<div
key={`c${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: reduced ? `${staticTop}%` : 0,
left: `${c.left}%`,
willChange: reduced ? undefined : 'transform',
animation: reduced
? 'none'
: `${animConfettiSway} ${c.swayDur}s ease-in-out ${c.delay}s infinite`,
}}
>
<div
style={{
width: `${c.w}px`,
height: reduced && c.round ? `${c.w}px` : `${c.h}px`,
borderRadius: c.round ? '50%' : '1px',
backgroundColor: c.color,
opacity: reduced ? 0.8 : 0.85,
transform: reduced ? `rotate(${Math.floor(rand(i + 311) * 360)}deg)` : undefined,
animation: reduced
? 'none'
: `${animConfettiFall} ${c.fallDur}s ease-in ${c.delay}s infinite`,
}}
/>
</div>
);
})}
</>
);
}
@@ -0,0 +1,67 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Clover tumble — a shamrock silhouette drifts down while tumbling on two axes.
* GPU-only: a single tall translateY plus rotate; per-clover duration/delay and
* a decoupled sway (below) create organic, non-repeating paths. The horizontal
* offsets stay small so clovers fall roughly in their column.
*/
export const animCloverTumble = keyframes({
'0%': { transform: 'translate3d(0, -10vh, 0) rotate(0deg)', opacity: '0' },
'8%': { opacity: '1' },
'50%': { transform: 'translate3d(12px, 50vh, 0) rotate(220deg)' },
'92%': { opacity: '0.8' },
'100%': { transform: 'translate3d(-8px, 114vh, 0) rotate(420deg)', opacity: '0' },
});
/**
* Lateral sway applied to a clover's wrapper so the descent reads as a leaf
* caught by a breeze, decoupled from the fall for an organic combined path.
*/
export const animCloverSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(20px, 0, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/**
* Verdant ambiance breathe — the emerald wash and vignette gently swell so the
* static tint feels alive without distracting motion. Opacity only.
*/
export const animVerdantBreathe = keyframes({
'0%': { opacity: '0.8' },
'50%': { opacity: '1' },
'100%': { opacity: '0.8' },
});
/**
* Rainbow shimmer — the soft arc in the corner slowly slides and breathes.
* Uses translate + scale + opacity (never background-position) so it stays on
* the compositor.
*/
export const animRainbowShimmer = keyframes({
'0%': { transform: 'translate3d(-3%, 1%, 0) scale(1)', opacity: '0.45' },
'50%': { transform: 'translate3d(3%, -1%, 0) scale(1.04)', opacity: '0.7' },
'100%': { transform: 'translate3d(-3%, 1%, 0) scale(1)', opacity: '0.45' },
});
/**
* Gold coin glint — a metallic disc tilts and brightens as a struck-light
* flicker, then settles. Transform + opacity only so it composites cheaply.
*/
export const animCoinGlint = keyframes({
'0%': { transform: 'scale(0.9) rotate(-8deg)', opacity: '0.35' },
'20%': { transform: 'scale(1.06) rotate(0deg)', opacity: '0.9' },
'45%': { transform: 'scale(0.94) rotate(6deg)', opacity: '0.5' },
'100%': { transform: 'scale(0.9) rotate(-8deg)', opacity: '0.35' },
});
/**
* Sparkle mote twinkle — a tiny golden point pulses in scale and brightness
* like a struck spark of luck. Opacity + transform only.
*/
export const animMoteTwinkle = keyframes({
'0%': { transform: 'scale(0.5)', opacity: '0.1' },
'50%': { transform: 'scale(1.25)', opacity: '0.95' },
'100%': { transform: 'scale(0.5)', opacity: '0.1' },
});
@@ -0,0 +1,325 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animCloverTumble,
animCloverSway,
animVerdantBreathe,
animRainbowShimmer,
animCoinGlint,
animMoteTwinkle,
} from './StPatricks.css';
// Deterministic pseudo-random so the scene is identical every mount (no React
// state per frame). Large primes keep the distribution well spread.
const rand = (seed: number) => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Shamrock (three-leaf) and lucky four-leaf clover silhouettes as inline SVG
// data-URIs — pure CSS, no external assets, Tauri/CSP-safe. The `fill` color is
// baked per-variant in oklch-adjacent sRGB (data-URIs can't carry oklch), kept
// luminous green so the glyphs read as foliage even at low opacity.
const cloverSvg = (leaves: 3 | 4, fill: string) => {
// Each leaf is a heart-ish lobe; petals arranged radially around the stem.
const heart = 'M0,-2 C5,-12 18,-9 14,2 C12,8 4,9 0,3 C-4,9 -12,8 -14,2 C-18,-9 -5,-12 0,-2 Z';
// Rotations for the lobes; 3-leaf = 120° spread, 4-leaf = 90° spread.
const rots = leaves === 4 ? [0, 90, 180, 270] : [-90, 30, 150];
const lobes = rots
.map((r) => `<path d="${heart}" transform="rotate(${r}) translate(0 -12)"/>`)
.join('');
const stem = `<path d="M0,8 C-1,18 2,26 0,34" stroke="${
fill
}" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-26 -26 52 64">` +
`<g fill="${fill}">${lobes}</g>${stem}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
};
// Three foliage greens for parallax depth — far/dim through near/bright. These
// are the sRGB siblings of the brief's oklch emerald / shamrock-green targets.
const CLOVER_FILLS = [
'#1f9e54', // deep shamrock (far)
'#2db866', // emerald (mid)
'#48d97f', // bright clover (near)
];
type Clover = {
left: number;
size: number;
duration: number;
delay: number;
swayDuration: number;
opacity: number;
blur: number;
fill: string;
leaves: 3 | 4;
// Resting position + tilt for the static (reduced) settled scene.
restTop: number;
restRot: number;
};
type Coin = {
left: number;
top: number;
size: number;
duration: number;
delay: number;
};
type Mote = {
left: number;
top: number;
size: number;
duration: number;
delay: number;
};
export function StPatricksOverlay({ reduced }: SeasonalOverlayProps) {
// Three parallax bands of clovers: far (small/slow/dim) -> near (large/fast).
// ~22 clovers total; one lucky four-leaf seeded in for charm.
const clovers = useMemo<Clover[]>(() => {
const bands = [
{ count: 8, size: [12, 18], dur: [20, 26], op: [0.22, 0.34], blur: 0.8, fill: 0 },
{ count: 8, size: [18, 26], dur: [15, 20], op: [0.34, 0.5], blur: 0.4, fill: 1 },
{ count: 6, size: [26, 38], dur: [11, 15], op: [0.46, 0.62], blur: 0, fill: 2 },
];
const out: Clover[] = [];
let s = 1;
bands.forEach((b) => {
for (let i = 0; i < b.count; i += 1) {
const r1 = rand(s);
const r2 = rand(s + 0.37);
const r3 = rand(s + 0.71);
const r4 = rand(s + 0.91);
const r5 = rand(s + 1.13);
out.push({
left: r1 * 100,
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
delay: -r4 * (b.dur[1] + 5),
swayDuration: 5 + r2 * 6,
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
blur: b.blur,
// The single lucky four-leaf: one mid-band clover.
leaves: s === 10 ? 4 : 3,
fill: CLOVER_FILLS[b.fill],
restTop: 6 + r5 * 88,
restRot: (r4 - 0.5) * 80,
});
s += 1;
}
});
return out;
}, []);
// Gold-coin glints scattered low — a faint pot-of-gold sparkle. ~5 discs.
const coins = useMemo<Coin[]>(() => {
const count = 5;
const out: Coin[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
left: 8 + rand(i + 40) * 84,
top: 58 + rand(i + 47) * 36,
size: 8 + rand(i + 51) * 9,
duration: 4 + rand(i + 55) * 3,
delay: -rand(i + 61) * 6,
});
}
return out;
}, []);
// Golden sparkle motes drifting through the scene. ~7 points.
const motes = useMemo<Mote[]>(() => {
const count = 7;
const out: Mote[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
left: rand(i + 70) * 100,
top: 8 + rand(i + 77) * 82,
size: 2 + rand(i + 83) * 3,
duration: 3 + rand(i + 89) * 3.5,
delay: -rand(i + 97) * 6,
});
}
return out;
}, []);
return (
<>
{/* Emerald ambient wash — layered radial + linear oklch gradients for
depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
'radial-gradient(120% 85% at 50% -12%, oklch(0.60 0.16 150 / 0.16) 0%, transparent 56%)',
'radial-gradient(90% 65% at 12% 112%, oklch(0.55 0.15 145 / 0.12) 0%, transparent 60%)',
'radial-gradient(80% 60% at 92% 108%, oklch(0.82 0.14 90 / 0.07) 0%, transparent 62%)',
'linear-gradient(180deg, oklch(0.62 0.15 150 / 0.05) 0%, transparent 24%, transparent 82%, oklch(0.5 0.14 148 / 0.08) 100%)',
].join(','),
}}
/>
{/* Verdant vignette frame — green edges, clear center. A single cheap
backdrop-filter adds a faint warm-emerald haze around the rim. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.05)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
backgroundImage:
'radial-gradient(140% 125% at 50% 44%, transparent 50%, oklch(0.6 0.13 150 / 0.07) 74%, oklch(0.48 0.14 148 / 0.17) 100%)',
animation: reduced ? 'none' : `${animVerdantBreathe} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Soft rainbow shimmer arc tucked into the top-right corner — a faint
luck-of-the-Irish band. Heavily blurred + screen-blended so it reads
as light, never as a hard stripe over chat. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '-22%',
right: '-18%',
width: '62%',
height: '62%',
contain: 'layout paint style',
mixBlendMode: 'screen',
filter: 'blur(30px)',
opacity: reduced ? 0.6 : undefined,
// Concentric arc bands — red through violet, all low alpha.
backgroundImage: [
'radial-gradient(closest-side at 78% 28%, transparent 58%, oklch(0.7 0.18 28 / 0.16) 62%, transparent 67%)',
'radial-gradient(closest-side at 78% 28%, transparent 63%, oklch(0.82 0.16 80 / 0.16) 67%, transparent 72%)',
'radial-gradient(closest-side at 78% 28%, transparent 68%, oklch(0.85 0.17 130 / 0.16) 72%, transparent 77%)',
'radial-gradient(closest-side at 78% 28%, transparent 73%, oklch(0.72 0.15 230 / 0.15) 77%, transparent 82%)',
'radial-gradient(closest-side at 78% 28%, transparent 78%, oklch(0.6 0.16 300 / 0.13) 82%, transparent 87%)',
].join(','),
animation: reduced ? 'none' : `${animRainbowShimmer} 20s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Gold-coin glints — small metallic discs that catch the light. */}
{coins.map((c, i) => (
<div
key={`coin-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${c.left}%`,
top: `${c.top}%`,
width: `${c.size}px`,
height: `${c.size}px`,
borderRadius: '50%',
background:
'radial-gradient(circle at 36% 32%, oklch(0.97 0.06 95 / 0.95) 0%, oklch(0.82 0.14 90 / 0.85) 45%, oklch(0.68 0.13 78 / 0.4) 78%, transparent 100%)',
boxShadow: `0 0 ${c.size * 0.9}px ${c.size * 0.35}px oklch(0.82 0.14 90 / 0.4)`,
opacity: reduced ? 0.85 : undefined,
animation: reduced
? 'none'
: `${animCoinGlint} ${c.duration}s ease-in-out ${c.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
{/* Golden sparkle motes — tiny four-point glints of luck. */}
{motes.map((m, i) => (
<div
key={`mote-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${m.left}%`,
top: `${m.top}%`,
width: `${m.size}px`,
height: `${m.size}px`,
borderRadius: '50%',
background:
'radial-gradient(circle, oklch(0.98 0.05 95 / 0.95) 0%, oklch(0.85 0.13 88 / 0.6) 50%, transparent 100%)',
boxShadow: '0 0 6px oklch(0.85 0.13 88 / 0.6)',
opacity: reduced ? 0.9 : undefined,
animation: reduced
? 'none'
: `${animMoteTwinkle} ${m.duration}s ease-in-out ${m.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
{/* Drifting clovers (motion only) — three parallax bands tumbling down.
Settled static scatter is rendered below for reduced/preview. */}
{!reduced &&
clovers.map((c, i) => (
<div
key={`clover-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${c.left}%`,
width: `${c.size}px`,
height: `${c.size * 1.2}px`,
animation: `${animCloverSway} ${c.swayDuration}s ease-in-out ${c.delay}s infinite`,
willChange: 'transform',
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: cloverSvg(c.leaves, c.fill),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundPosition: 'center',
opacity: c.opacity,
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
c.blur ? ` blur(${c.blur}px)` : ''
}`,
animation: `${animCloverTumble} ${c.duration}s linear ${c.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
</div>
))}
{/* Static settled clovers for the reduced-motion / preview scene — a
gentle scatter resting at varied tilts so the thumbnail reads as a
lucky, still field of shamrocks. */}
{reduced &&
clovers.map((c, i) => (
<div
key={`clover-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${c.left}%`,
top: `${c.restTop}%`,
width: `${c.size}px`,
height: `${c.size * 1.2}px`,
backgroundImage: cloverSvg(c.leaves, c.fill),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundPosition: 'center',
transform: `rotate(${c.restRot}deg)`,
opacity: c.opacity,
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
c.blur ? ` blur(${c.blur}px)` : ''
}`,
}}
/>
))}
</>
);
}
@@ -0,0 +1,70 @@
import { keyframes } from '@vanilla-extract/css';
/**
* Heart rise — a soft heart drifts gently upward while bobbing sideways and
* breathing in scale, like a balloon caught in a warm draft. GPU-only: animates
* transform + opacity exclusively. The tall translateY lets one keyframe set
* serve every heart; per-heart duration/delay/scale supply the variety.
*/
export const animHeartRise = keyframes({
'0%': { transform: 'translate3d(0, 8vh, 0) scale(0.7) rotate(-6deg)', opacity: '0' },
'10%': { opacity: '1' },
'50%': { transform: 'translate3d(18px, -46vh, 0) scale(1) rotate(5deg)' },
'88%': { opacity: '0.85' },
'100%': { transform: 'translate3d(-12px, -108vh, 0) scale(1.12) rotate(-4deg)', opacity: '0' },
});
/**
* Heart bob — a small lateral sway applied to each heart's wrapper so the rise
* reads as a wandering draft, decoupled from the vertical travel so the two
* combine into an organic path. Transform only.
*/
export const animHeartBob = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(16px, 0, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/**
* Petal tumble — a rose petal falls while swaying horizontally and tumbling on
* its own axis, the way a real petal flutters. Opacity + transform only.
*/
export const animPetalTumble = keyframes({
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
'8%': { opacity: '0.9' },
'30%': { transform: 'translate3d(30px, 28vh, 0) rotate(120deg)' },
'60%': { transform: 'translate3d(-26px, 62vh, 0) rotate(250deg)' },
'92%': { opacity: '0.7' },
'100%': { transform: 'translate3d(14px, 112vh, 0) rotate(380deg)', opacity: '0' },
});
/**
* Bokeh breathe — dreamy blush orbs softly pulse in scale and brightness, like
* soft-focus lights drifting in and out of focus. Opacity + transform only.
*/
export const animBokehBreathe = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0.45' },
'50%': { transform: 'translate3d(0, -10px, 0) scale(1.12)', opacity: '0.9' },
'100%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0.45' },
});
/**
* Blush pulse — a barely-there breathing of the warm vignette so the static
* tint feels alive and tender without distracting motion. Opacity only.
*/
export const animBlushPulse = keyframes({
'0%': { opacity: '0.82' },
'50%': { opacity: '1' },
'100%': { opacity: '0.82' },
});
/**
* Sparkle glint — a faint highlight winks on and off with a gentle scale, a
* romantic twinkle that never strobes. Transform + opacity only.
*/
export const animSparkle = keyframes({
'0%': { transform: 'scale(0.4) rotate(0deg)', opacity: '0' },
'15%': { transform: 'scale(1) rotate(45deg)', opacity: '0.9' },
'35%': { transform: 'scale(0.55) rotate(90deg)', opacity: '0' },
'100%': { transform: 'scale(0.4) rotate(90deg)', opacity: '0' },
});
@@ -0,0 +1,405 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animHeartRise,
animHeartBob,
animPetalTumble,
animBokehBreathe,
animBlushPulse,
animSparkle,
} from './Valentines.css';
// Deterministic pseudo-random so the scene is identical every mount (no React
// state per frame). Large primes keep the distribution well spread.
const rand = (seed: number) => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Romantic oklch palette — rose, blush pink, warm red, soft cream. Kept
// luminous and gentle so everything reads as soft ambient glow over chat.
const ROSE = 'oklch(0.7 0.15 10)';
const BLUSH = 'oklch(0.9 0.06 350)';
const WARM_RED = 'oklch(0.6 0.18 20)';
const CREAM = 'oklch(0.96 0.03 60)';
const HEART_COLORS = [ROSE, BLUSH, WARM_RED, 'oklch(0.78 0.13 5)'];
const PETAL_COLORS = [
'oklch(0.66 0.16 12)', // rose
'oklch(0.74 0.13 6)', // lighter rose
'oklch(0.6 0.18 20)', // warm red
];
// Inline SVG (data-URI) so it is fully Tauri/CSP-safe — no external assets.
// A soft heart with a gradient fill and a cream highlight glint.
const heartSvg = (fill: string, glint: string) => {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>
<defs><radialGradient id='g' cx='38%' cy='32%' r='75%'>
<stop offset='0%' stop-color='${glint}'/><stop offset='55%' stop-color='${fill}'/>
<stop offset='100%' stop-color='${fill}' stop-opacity='0.85'/></radialGradient></defs>
<path fill='url(%23g)' d='M16 28C16 28 3 19.5 3 11.2 3 6.8 6.4 4 10 4c2.6 0 4.7 1.5 6 3.6C17.3 5.5 19.4 4 22 4c3.6 0 7 2.8 7 7.2C29 19.5 16 28 16 28z'/></svg>`;
return `url("data:image/svg+xml,${svg.replace(/\n/g, '').replace(/#/g, '%23')}")`;
};
// A single rose petal — a soft teardrop/ovate shape with an inner crease,
// gently asymmetric so the tumble reads as a real petal.
const petalSvg = (fill: string) => {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 32'>
<defs><linearGradient id='p' x1='0' y1='0' x2='1' y2='1'>
<stop offset='0%' stop-color='${fill}' stop-opacity='0.6'/>
<stop offset='100%' stop-color='${fill}'/></linearGradient></defs>
<path fill='url(%23p)' d='M12 1C5 8 2 16 4 24c1.4 5.4 6 7 8 7s6.6-1.6 8-7C22 16 19 8 12 1z'/>
<path d='M12 4C9 11 8 18 11 30' stroke='${fill}' stroke-opacity='0.35' stroke-width='1' fill='none'/></svg>`;
return `url("data:image/svg+xml,${svg.replace(/\n/g, '').replace(/#/g, '%23')}")`;
};
type Heart = {
left: number;
size: number;
duration: number;
delay: number;
bobDuration: number;
opacity: number;
blur: number;
image: string;
restTop: number; // static resting position for reduced scene
};
type Petal = {
left: number;
size: number;
duration: number;
delay: number;
opacity: number;
image: string;
rotate: number;
restTop: number;
};
type Bokeh = {
left: number;
top: number;
size: number;
color: string;
duration: number;
delay: number;
};
type Sparkle = {
left: number;
top: number;
size: number;
duration: number;
delay: number;
};
export function ValentinesOverlay({ reduced }: SeasonalOverlayProps) {
// Three parallax bands of hearts: far (small/slow/dim) -> near (large/fast).
const hearts = useMemo<Heart[]>(() => {
const bands = [
{ count: 4, size: [12, 18], dur: [20, 26], op: [0.3, 0.5], blur: 0.8 },
{ count: 4, size: [18, 26], dur: [15, 19], op: [0.5, 0.72], blur: 0.3 },
{ count: 3, size: [26, 38], dur: [12, 15], op: [0.62, 0.85], blur: 0 },
];
const out: Heart[] = [];
let s = 1;
bands.forEach((b) => {
for (let i = 0; i < b.count; i += 1) {
const r1 = rand(s);
const r2 = rand(s + 0.37);
const r3 = rand(s + 0.71);
const r4 = rand(s + 0.91);
const fill = HEART_COLORS[Math.floor(r4 * HEART_COLORS.length) % HEART_COLORS.length];
out.push({
left: r1 * 96 + 2,
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
delay: -r4 * (b.dur[1] + 5),
bobDuration: 5 + r2 * 5,
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
blur: b.blur,
image: heartSvg(fill, CREAM),
restTop: 6 + r3 * 86,
});
s += 1;
}
});
return out;
}, []);
// Drifting rose petals tumbling down — a gentle counter-motion to the hearts.
const petals = useMemo<Petal[]>(() => {
const count = 8;
const out: Petal[] = [];
for (let i = 0; i < count; i += 1) {
const r1 = rand(i + 40);
const r2 = rand(i + 40.5);
const r3 = rand(i + 40.9);
const fill = PETAL_COLORS[i % PETAL_COLORS.length];
out.push({
left: r1 * 98,
size: 9 + r2 * 9,
duration: 14 + r3 * 9,
delay: -r1 * 22,
opacity: 0.45 + r2 * 0.35,
image: petalSvg(fill),
rotate: r3 * 360,
restTop: 4 + r2 * 90,
});
}
return out;
}, []);
// Dreamy blush bokeh orbs scattered across the scene, softly breathing.
const bokeh = useMemo<Bokeh[]>(() => {
const count = 7;
const out: Bokeh[] = [];
for (let i = 0; i < count; i += 1) {
const r1 = rand(i + 70);
const r2 = rand(i + 70.4);
const r3 = rand(i + 70.8);
out.push({
left: r1 * 94 + 3,
top: r2 * 88 + 4,
size: 70 + r3 * 130,
color: i % 2 === 0 ? BLUSH : 'oklch(0.82 0.1 355)',
duration: 9 + r3 * 7,
delay: -r1 * 10,
});
}
return out;
}, []);
// Faint sparkle glints — sparse, never strobing.
const sparkles = useMemo<Sparkle[]>(() => {
const count = 5;
const out: Sparkle[] = [];
for (let i = 0; i < count; i += 1) {
const r1 = rand(i + 200);
const r2 = rand(i + 200.5);
const r3 = rand(i + 200.9);
out.push({
left: r1 * 92 + 4,
top: r2 * 80 + 6,
size: 6 + r3 * 8,
duration: 5 + r3 * 4,
delay: -r1 * 9,
});
}
return out;
}, []);
return (
<>
{/* Warm romantic ambient wash — layered radial + linear oklch gradients
for depth. Low opacity so chat text stays legible (WCAG-AA). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
'radial-gradient(120% 80% at 50% 112%, oklch(0.7 0.15 10 / 0.12) 0%, transparent 58%)',
'radial-gradient(90% 70% at 15% -8%, oklch(0.9 0.06 350 / 0.1) 0%, transparent 60%)',
'radial-gradient(90% 70% at 88% 0%, oklch(0.6 0.18 20 / 0.07) 0%, transparent 62%)',
'linear-gradient(180deg, oklch(0.96 0.03 60 / 0.04) 0%, transparent 30%, transparent 72%, oklch(0.66 0.16 12 / 0.07) 100%)',
].join(','),
}}
/>
{/* Blush vignette frame — soft warm edges, clear center. A single cheap
backdrop-filter layer for a faint dreamy haze around the rim. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'saturate(1.05) brightness(1.01)',
WebkitBackdropFilter: 'saturate(1.05) brightness(1.01)',
backgroundImage:
'radial-gradient(135% 120% at 50% 46%, transparent 50%, oklch(0.85 0.1 355 / 0.06) 74%, oklch(0.62 0.16 12 / 0.14) 100%)',
animation: reduced ? 'none' : `${animBlushPulse} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Dreamy bokeh orbs — soft blurred blush lights that breathe. */}
{bokeh.map((b, i) => (
<div
key={`bokeh-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${b.left}%`,
top: `${b.top}%`,
width: `${b.size}px`,
height: `${b.size}px`,
marginLeft: `${-b.size / 2}px`,
marginTop: `${-b.size / 2}px`,
borderRadius: '50%',
background: `radial-gradient(circle at 42% 38%, ${b.color.replace(
')',
' / 0.5)',
)} 0%, ${b.color.replace(')', ' / 0.18)')} 45%, transparent 72%)`,
filter: 'blur(10px)',
mixBlendMode: 'screen',
opacity: reduced ? 0.7 : undefined,
animation: reduced
? 'none'
: `${animBokehBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
{/* Floating hearts (motion) — three parallax bands rising and bobbing.
The wrapper carries the lateral bob; the inner carries the rise so the
two combine into a wandering draft. */}
{!reduced &&
hearts.map((h, i) => (
<div
key={`heart-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
bottom: 0,
left: `${h.left}%`,
width: `${h.size}px`,
height: `${h.size}px`,
animation: `${animHeartBob} ${h.bobDuration}s ease-in-out ${h.delay}s infinite`,
willChange: 'transform',
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: h.image,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
filter: `drop-shadow(0 0 ${h.size * 0.2}px oklch(0.7 0.15 10 / 0.45))${
h.blur ? ` blur(${h.blur}px)` : ''
}`,
opacity: h.opacity,
animation: `${animHeartRise} ${h.duration}s ease-in-out ${h.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
</div>
))}
{/* Drifting rose petals (motion) — tumbling down through the scene. */}
{!reduced &&
petals.map((p, i) => (
<div
key={`petal-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${p.left}%`,
width: `${p.size}px`,
height: `${p.size * 1.33}px`,
backgroundImage: p.image,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: p.opacity,
animation: `${animPetalTumble} ${p.duration}s linear ${p.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
))}
{/* Faint sparkle glints (motion) — sparse romantic twinkle. */}
{!reduced &&
sparkles.map((s, i) => (
<div
key={`sparkle-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${s.left}%`,
top: `${s.top}%`,
width: `${s.size}px`,
height: `${s.size}px`,
background: `radial-gradient(circle, ${CREAM.replace(
')',
' / 0.9)',
)} 0%, oklch(0.9 0.06 350 / 0.5) 40%, transparent 70%)`,
borderRadius: '50%',
animation: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
))}
{/* Static reduced-motion / preview scene — settled hearts at rest, a
scatter of fallen petals, and still sparkle glints. Tender and still,
so the judged thumbnail stands on its own without any animation. */}
{reduced &&
hearts.map((h, i) => (
<div
key={`heart-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${h.left}%`,
top: `${h.restTop}%`,
width: `${h.size}px`,
height: `${h.size}px`,
backgroundImage: h.image,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
filter: `drop-shadow(0 0 ${h.size * 0.2}px oklch(0.7 0.15 10 / 0.4))${
h.blur ? ` blur(${h.blur}px)` : ''
}`,
opacity: h.opacity,
}}
/>
))}
{reduced &&
petals.map((p, i) => (
<div
key={`petal-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${p.left}%`,
top: `${p.restTop}%`,
width: `${p.size}px`,
height: `${p.size * 1.33}px`,
backgroundImage: p.image,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `rotate(${p.rotate}deg)`,
opacity: p.opacity,
}}
/>
))}
{reduced &&
sparkles.map((s, i) => (
<div
key={`sparkle-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${s.left}%`,
top: `${s.top}%`,
width: `${s.size}px`,
height: `${s.size}px`,
background: `radial-gradient(circle, ${CREAM.replace(
')',
' / 0.85)',
)} 0%, oklch(0.9 0.06 350 / 0.45) 40%, transparent 70%)`,
borderRadius: '50%',
opacity: 0.7,
}}
/>
))}
</>
);
}
+24
View File
@@ -0,0 +1,24 @@
// Shared seasonal types. Kept in a leaf module so the schedule, the overlay
// components (one per theme under ./themes/), and the settings UI can all import
// them without circular dependencies.
export type SeasonTheme =
| 'halloween'
| 'christmas'
| 'newyear'
| 'autumn'
| 'aprilfools'
| 'lunar'
| 'valentines'
| 'stpatricks'
| 'earthday'
| 'deepspace'
| 'arcade';
// Props every per-theme overlay component receives. `reduced` mirrors
// `prefers-reduced-motion`: when true the overlay must render a static (no
// animation) but still beautiful ambient version. The settings preview always
// passes reduced=true, so the static form has to stand on its own.
export type SeasonalOverlayProps = {
reduced: boolean;
};
@@ -0,0 +1,98 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
export type SettingsSelectOption<T extends string> = {
value: T;
label: string;
disabled?: boolean;
};
/**
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
* pattern — used instead of a raw `<select>`, which renders OS-styled and
* breaks under non-default themes.
*/
export function SettingsSelect<T extends string>({
value,
options,
onChange,
disabled,
'aria-label': ariaLabel,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
disabled?: boolean;
'aria-label'?: string;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={!!menuCords}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getOidcIssuer, AutoDiscoveryInfo } from './cs-api';
const info = (extra: Record<string, unknown>): AutoDiscoveryInfo =>
({ 'm.homeserver': { base_url: 'https://hs' }, ...extra }) as AutoDiscoveryInfo;
test('getOidcIssuer reads the stable m.authentication key', () => {
assert.deepEqual(
getOidcIssuer(info({ 'm.authentication': { issuer: 'https://i', account: 'https://a' } })),
{ issuer: 'https://i', account: 'https://a' },
);
});
test('getOidcIssuer falls back to the unstable msc2965 key', () => {
assert.deepEqual(
getOidcIssuer(info({ 'org.matrix.msc2965.authentication': { issuer: 'https://u' } })),
{ issuer: 'https://u', account: undefined },
);
});
test('getOidcIssuer prefers stable over unstable when both present', () => {
assert.equal(
getOidcIssuer(
info({
'm.authentication': { issuer: 'https://stable' },
'org.matrix.msc2965.authentication': { issuer: 'https://unstable' },
}),
).issuer,
'https://stable',
);
});
test('getOidcIssuer returns {} for non-OIDC servers', () => {
assert.deepEqual(getOidcIssuer(info({})), {});
assert.deepEqual(getOidcIssuer(info({ 'm.authentication': {} })), {}); // present but no issuer
});
+25
View File
@@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
'm.identity_server'?: {
base_url: string;
};
// v1.15 stable next-gen-auth (MSC2965) discovery key — emitted by servers that
// delegate to a Matrix Authentication Service (e.g. mozilla.org). The
// `org.matrix.msc2965.authentication` key below is the unstable predecessor.
'm.authentication'?: {
issuer?: string;
account?: string;
};
'org.matrix.msc2965.authentication'?: {
account?: string;
issuer?: string;
@@ -32,6 +39,24 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
];
};
/**
* Resolve the OIDC issuer (and account-management URL) advertised by a homeserver
* in its `.well-known/matrix/client`, preferring the v1.15 stable
* `m.authentication` key over the unstable `org.matrix.msc2965.authentication`.
* Returns `{}` when the server is not OIDC-native (e.g. matrix.lotusguild.org).
*/
export const getOidcIssuer = (info: AutoDiscoveryInfo): { issuer?: string; account?: string } => {
const stable = info['m.authentication'];
if (stable && typeof stable.issuer === 'string') {
return { issuer: stable.issuer, account: stable.account };
}
const unstable = info['org.matrix.msc2965.authentication'];
if (unstable && typeof unstable.issuer === 'string') {
return { issuer: unstable.issuer, account: unstable.account };
}
return {};
};
export const autoDiscovery = async (
request: typeof fetch,
server: string,
+37 -13
View File
@@ -37,6 +37,10 @@ import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
import { CallSoundboard } from './CallSoundboard';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomQualityContent } from '../../utils/callQuality';
type CallControlsProps = {
callEmbed: CallEmbed;
@@ -88,6 +92,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
// [P5-31] Hard room publish policy — hide controls the server will refuse so
// users don't click dead buttons. Absent/true = allowed.
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
const cameraAllowed = roomQuality?.allow_camera !== false;
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = cameraAllowed || video;
const showScreenshare = screenshareAllowed || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
@@ -339,24 +356,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
</Box>
{!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={handleVideoToggle} />
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
{!compact && showVideoGroup && <ControlDivider />}
{showVideoGroup && (
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
{/* Show a forbidden control while its track is still live so the
user can stop it; once stopped it hides and can't be restarted. */}
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
)}
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
)}
</Box>
{!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
<PopOut
anchor={cords}
position="Top"
+220
View File
@@ -0,0 +1,220 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import {
Box,
Chip,
Icon,
IconButton,
Icons,
Menu,
PopOut,
RectCords,
Spinner,
Text,
Tooltip,
TooltipProvider,
color,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { CallEmbed } from '../../plugins/call';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSoundboard } from '../../hooks/useSoundboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
import {
SOUNDBOARD_ACCEPT,
SOUNDBOARD_MAX_CLIPS,
playClipLocally,
resolveClipObjectUrl,
} from '../../utils/soundboardClips';
type CallSoundboardProps = {
callEmbed: CallEmbed;
};
/**
* [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each
* clip is published to peers as a separate track by the EC fork
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback.
* Clips are uploadable/managed here and synced across devices via the
* `io.lotus.soundboard` account data (like custom emoji/sticker packs).
*/
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
const mx = useMatrixClient();
const { clips, addClip, removeClip } = useSoundboard();
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
const [cords, setCords] = useState<RectCords>();
const [busyId, setBusyId] = useState<string>();
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string>();
const fileInputRef = useRef<HTMLInputElement>(null);
const volume = Math.max(0, Math.min(1, soundboardVolume / 100));
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
setError(undefined);
setCords(evt.currentTarget.getBoundingClientRect());
};
const handlePlay = useCallback(
async (id: string, mxc: string) => {
setBusyId(id);
setError(undefined);
try {
const objectUrl = await resolveClipObjectUrl(mx, mxc);
callEmbed.control.injectAudio(objectUrl, volume);
playClipLocally(objectUrl, volume);
} catch {
setError('Could not play that clip.');
} finally {
setBusyId(undefined);
}
},
[mx, callEmbed, volume],
);
const handleFile = useCallback(
async (file: File | undefined) => {
if (!file) return;
setUploading(true);
setError(undefined);
try {
await addClip(file);
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {
setUploading(false);
}
},
[addClip],
);
return (
<>
<input
ref={fileInputRef}
type="file"
accept={SOUNDBOARD_ACCEPT}
hidden
onChange={(e) => {
handleFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: '320px' }}>
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Soundboard</Text>
<Chip
variant="Secondary"
radii="Pill"
disabled={uploading || clips.length >= SOUNDBOARD_MAX_CLIPS}
onClick={() => fileInputRef.current?.click()}
before={
uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />
}
>
<Text size="B300">Upload</Text>
</Chip>
</Box>
{clips.length === 0 ? (
<Text size="T200" priority="300">
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
Clips sync across your devices.
</Text>
) : (
<Box wrap="Wrap" gap="200">
{clips.map((clip) => (
<Box
key={clip.id}
direction="Column"
gap="100"
style={{ position: 'relative' }}
>
<Chip
variant="SurfaceVariant"
radii="300"
disabled={busyId === clip.id}
onClick={() => handlePlay(clip.id, clip.url)}
before={
busyId === clip.id ? (
<Spinner size="100" />
) : (
<Icon size="100" src={Icons.Play} />
)
}
after={
<Icon
size="50"
src={Icons.Cross}
style={{ cursor: 'pointer' }}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
removeClip(clip.id);
}}
/>
}
>
<Text size="B300" truncate style={{ maxWidth: '120px' }}>
{clip.name}
</Text>
</Chip>
</Box>
))}
</Box>
)}
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
</Box>
</Menu>
</FocusTrap>
}
>
<TooltipProvider
tooltip={
<Tooltip>
<Text>Soundboard</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpen}
outlined
aria-label="Soundboard"
aria-expanded={!!cords}
aria-haspopup="menu"
>
<Icon size="400" src={Icons.BellRing} />
</IconButton>
)}
</TooltipProvider>
</PopOut>
</>
);
}
+3
View File
@@ -17,6 +17,7 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
import { useStateEvent } from '../../hooks/useStateEvent';
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { CallMemberRenderer } from './CallMemberCard';
@@ -199,6 +200,8 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
{/* [lotus #6] push avatar decorations to EC's in-call tiles (post-join) */}
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
</Box>
);
}
@@ -0,0 +1,198 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Switch, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import {
AUDIO_BITRATE_OPTIONS,
RoomQualityContent,
SCREENSHARE_BITRATE_OPTIONS,
SCREENSHARE_FRAMERATE_OPTIONS,
} from '../../../utils/callQuality';
// Only the numeric cap keys are edited via `update`; the boolean policy keys
// are handled by `setAllow`.
type CapKey = 'audio_max_kbps' | 'screenshare_max_kbps' | 'screenshare_max_fps';
// String <-> numeric bridge for SettingsSelect (which needs string values).
const toValue = (n?: number): string => (typeof n === 'number' ? String(n) : 'auto');
const CAP_KEYS: (keyof RoomQualityContent)[] = [
'audio_max_kbps',
'screenshare_max_kbps',
'screenshare_max_fps',
'allow_screenshare',
'allow_camera',
];
const capsEqual = (a: RoomQualityContent, b: RoomQualityContent): boolean =>
CAP_KEYS.every((k) => a[k] === b[k]);
type RoomQualityProps = {
permissions: RoomPermissionsAPI;
};
/**
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
*/
export function RoomQuality({ permissions }: RoomQualityProps) {
const mx = useMatrixClient();
const room = useRoom();
const canEdit = permissions.stateEvent(StateEvent.LotusRoomQuality, mx.getSafeUserId());
const event = useStateEvent(room, StateEvent.LotusRoomQuality);
const caps = useMemo<RoomQualityContent>(() => event?.getContent() ?? {}, [event]);
const [submitState, submit] = useAsyncCallback(
useCallback(
async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
},
[mx, room.roomId],
),
);
const submitting = submitState.status === AsyncStatus.Loading;
// Optimistic mirror: `useStateEvent` only refreshes when the write echoes
// back via /sync (not when sendStateEvent resolves), so consecutive edits
// must build on the pending write — otherwise a second edit spreads a stale
// `caps` and silently drops the first. `effective` is what the UI shows and
// what each edit merges into; it's reconciled below once the echo lands.
const [pending, setPending] = useState<RoomQualityContent | null>(null);
const effective = pending ?? caps;
useEffect(() => {
if (!pending) return;
// Revert the optimistic view if the write failed…
if (submitState.status === AsyncStatus.Error) {
setPending(null);
return;
}
// …or drop it once the synced state actually reflects it.
if (capsEqual(caps, pending)) setPending(null);
}, [caps, pending, submitState.status]);
const commit = (next: RoomQualityContent) => {
setPending(next);
submit(next);
};
const update = (key: CapKey, value: string) => {
const next: RoomQualityContent = { ...effective };
if (value === 'auto') delete next[key];
else next[key] = parseInt(value, 10);
commit(next);
};
const setAllow = (key: 'allow_screenshare' | 'allow_camera', allowed: boolean) => {
const next: RoomQualityContent = { ...effective };
// Absent = allowed, so only persist the key when forbidding.
if (allowed) delete next[key];
else next[key] = false;
commit(next);
};
// Absent/true = allowed.
const screenshareAllowed = effective.allow_screenshare !== false;
const cameraAllowed = effective.allow_camera !== false;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Call Permissions"
description={
<Text size="T200" priority="300">
Control what participants may share in this room. These are enforced on the server for
every Matrix client (Element, FluffyChat, Lotus Chat, ).
</Text>
}
/>
<Box direction="Column" gap="300">
<SettingTile
title="Allow Screen Sharing"
description="When off, no one can share their screen in this room."
after={
<Switch
variant="Primary"
value={screenshareAllowed}
onChange={(v) => setAllow('allow_screenshare', v)}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Allow Camera"
description="When off, this is an audio-only room — no one can turn on their camera. Microphones are always allowed."
after={
<Switch
variant="Primary"
value={cameraAllowed}
onChange={(v) => setAllow('allow_camera', v)}
disabled={!canEdit || submitting}
/>
}
/>
</Box>
<SettingTile
title="Call Quality Caps"
description={
<Text size="T200" priority="300">
Set a maximum microphone bitrate, screenshare bitrate, and screenshare framerate for
this room. Lotus Chat clamps each participant to these ceilings (best-effort applies
to Lotus Chat clients). Auto = no cap.
</Text>
}
/>
<Box direction="Column" gap="300">
<SettingTile
title="Max Microphone Bitrate"
after={
<SettingsSelect
value={toValue(effective.audio_max_kbps)}
onChange={(v) => update('audio_max_kbps', v)}
options={AUDIO_BITRATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Max Screenshare Bitrate"
after={
<SettingsSelect
value={toValue(effective.screenshare_max_kbps)}
onChange={(v) => update('screenshare_max_kbps', v)}
options={SCREENSHARE_BITRATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Max Screenshare Framerate"
after={
<SettingsSelect
value={toValue(effective.screenshare_max_fps)}
onChange={(v) => update('screenshare_max_fps', v)}
options={SCREENSHARE_FRAMERATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
</Box>
</SequenceCard>
);
}
@@ -4,6 +4,7 @@ export * from './RoomHistoryVisibility';
export * from './RoomJoinRules';
export * from './RoomProfile';
export * from './RoomPublish';
export * from './RoomQuality';
export * from './RoomShareInvite';
export * from './RoomUpgrade';
export * from './RoomVoiceLimit';
@@ -0,0 +1,97 @@
import React, { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react';
import { type CallEmbed } from '../../plugins/call';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
import { decorationUrl } from './avatarDecorations';
/**
* [lotus #6] Pushes each call participant's avatar-decoration image URL to the
* forked Element Call (`io.lotus.decorations`), which renders it on the in-call
* video-tile avatars. Mounted only while joined, so the EC-side handler exists.
*
* The decoration roster is per-user slugs resolved via `useAvatarDecoration`;
* we render one invisible probe per member to reuse that hook + its cache, then
* debounce-send the aggregated `{ userId: url }` map whenever it changes.
*/
function DecorationProbe({
userId,
onResolve,
}: {
userId: string;
onResolve: (userId: string, url: string | null) => void;
}): null {
const slug = useAvatarDecoration(userId);
useEffect(() => {
onResolve(userId, slug ? decorationUrl(slug) : null);
}, [userId, slug, onResolve]);
return null;
}
export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }): ReactElement {
const session = useCallSession(callEmbed.room);
const members = useCallMembers(session);
const map = useRef<Map<string, string>>(new Map());
const pushTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const userIds = useMemo(
() => Array.from(new Set(members.map((m) => m.userId).filter((u): u is string => !!u))),
[members],
);
const push = useCallback(() => {
const decorations: Record<string, string> = {};
map.current.forEach((url, userId) => {
decorations[userId] = url;
});
callEmbed.call.transport.send('io.lotus.decorations', { decorations }).catch(() => undefined);
}, [callEmbed]);
const schedulePush = useCallback(() => {
if (pushTimer.current) clearTimeout(pushTimer.current);
pushTimer.current = setTimeout(push, 300);
}, [push]);
const onResolve = useCallback(
(userId: string, url: string | null) => {
const prev = map.current.get(userId);
if (url) {
if (prev !== url) {
map.current.set(userId, url);
schedulePush();
}
} else if (prev !== undefined) {
map.current.delete(userId);
schedulePush();
}
},
[schedulePush],
);
// Drop decorations for participants who left the call.
useEffect(() => {
const present = new Set(userIds);
let changed = false;
map.current.forEach((_url, userId) => {
if (!present.has(userId)) {
map.current.delete(userId);
changed = true;
}
});
if (changed) schedulePush();
}, [userIds, schedulePush]);
useEffect(
() => () => {
if (pushTimer.current) clearTimeout(pushTimer.current);
},
[],
);
return (
<>
{userIds.map((userId) => (
<DecorationProbe key={userId} userId={userId} onResolve={onResolve} />
))}
</>
);
}
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DECORATION_CDN,
DECORATION_CATEGORIES,
ALL_DECORATIONS,
decorationUrl,
} from './avatarDecorations';
test('decorationUrl builds a CDN png url from the slug', () => {
assert.equal(decorationUrl('joystick'), `${DECORATION_CDN}/joystick.png`);
assert.equal(decorationUrl('lotus_flower'), `${DECORATION_CDN}/lotus_flower.png`);
// slug is used verbatim, no encoding/normalisation
assert.equal(decorationUrl(''), `${DECORATION_CDN}/.png`);
});
test('DECORATION_CDN is an https url with no trailing slash', () => {
assert.match(DECORATION_CDN, /^https:\/\//);
assert.equal(DECORATION_CDN.endsWith('/'), false);
});
test('ALL_DECORATIONS is the flattened set of every category decoration', () => {
const expectedCount = DECORATION_CATEGORIES.reduce((n, c) => n + c.decorations.length, 0);
assert.equal(ALL_DECORATIONS.length, expectedCount);
// every decoration in a category is present (by reference) in ALL_DECORATIONS
DECORATION_CATEGORIES.forEach((category) => {
category.decorations.forEach((decoration) => {
assert.ok(ALL_DECORATIONS.includes(decoration));
});
});
});
test('every category has a non-empty id, label and decorations list', () => {
DECORATION_CATEGORIES.forEach((category) => {
assert.equal(typeof category.id, 'string');
assert.ok(category.id.length > 0);
assert.equal(typeof category.label, 'string');
assert.ok(category.label.length > 0);
assert.ok(Array.isArray(category.decorations));
assert.ok(category.decorations.length > 0);
});
});
test('category ids are unique', () => {
const ids = DECORATION_CATEGORIES.map((c) => c.id);
assert.equal(new Set(ids).size, ids.length);
});
test('every decoration slug is unique across all categories', () => {
const slugs = ALL_DECORATIONS.map((d) => d.slug);
assert.equal(new Set(slugs).size, slugs.length);
});
test('every decoration has a non-empty slug and name', () => {
ALL_DECORATIONS.forEach((decoration) => {
assert.equal(typeof decoration.slug, 'string');
assert.ok(decoration.slug.length > 0);
assert.equal(typeof decoration.name, 'string');
assert.ok(decoration.name.length > 0);
});
});
test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
ALL_DECORATIONS.forEach((decoration) => {
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
});
});
+10 -1
View File
@@ -6,6 +6,15 @@
export const DECORATION_CDN =
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
// Runtime base. A deployment can repoint the decorations at a different host
// without editing the catalog literal above (which scripts/syncDecorations.mjs
// and the build read) by setting `VITE_DECORATION_CDN`; otherwise it falls back
// to DECORATION_CDN. `import.meta.env` is undefined under the tsx test runner,
// hence the guard. Trailing slashes are trimmed so `decorationUrl` stays clean.
const envDecorationCdn = (import.meta as unknown as { env?: Record<string, string | undefined> })
.env?.VITE_DECORATION_CDN;
const RESOLVED_DECORATION_CDN = (envDecorationCdn || DECORATION_CDN).replace(/\/+$/, '');
export type AvatarDecoration = {
slug: string;
name: string;
@@ -180,5 +189,5 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
);
export function decorationUrl(slug: string): string {
return `${DECORATION_CDN}/${slug}.png`;
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
}
@@ -0,0 +1,49 @@
import { keyframes } from '@vanilla-extract/css';
// Aurora Flow — a SLOW, gentle pan of layered soft aurora ribbons.
//
// The living-aurora illusion is a pure `background-position` drift: each
// comma-separated gradient layer is authored larger than the viewport
// (backgroundSize 200%300%, see animAurora.ts) so there is slack to slide it
// around. Panning several broad blurred bands by DIFFERENT
// amounts and along DIFFERENT paths makes the ribbons appear to curl and cross
// like real northern lights — no single layer ever moves in lockstep.
//
// LAYER ORDER (must match animAurora.ts exactly — one position value per layer):
// 1. green ribbon (drifts a wide, lazy horizontal arc)
// 2. teal ribbon (drifts on a slower, offset diagonal)
// 3. violet ribbon (drifts vertically, the "curtain" fold)
// 4. sky/aqua highlight (small counter-drift for shimmer)
// 5. calm reading core (STATIC — kept at 50% 50% so the center never moves)
// 6. vignette (STATIC — kept at 50% 50% so edges never move)
//
// SEAMLESS LOOP: every animated layer starts and ends on the SAME position
// ('0%'/'100%' being identical sample points of the repeating gradient tile),
// so one period returns each band to its origin with no visible jump. The two
// static layers list their fixed position at every stop so they never pan.
//
// SLOW & GENTLE: paired with a long duration + ease-in-out in animAurora.ts, the
// motion reads as a barely-perceptible breathing drift, keeping the reading
// center calm and text crisp.
//
// getChatBg adds `willChange: 'background-position'` here and STRIPS the whole
// `animation` for prefers-reduced-motion / pause-animations, at which point the
// static `backgroundPosition` authored in animAurora.ts is what shows — already
// a finished, gorgeous aurora.
export const auroraFlow = keyframes({
'0%': {
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
},
'25%': {
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
},
'50%': {
backgroundPosition: '65% 60%, 40% 40%, 45% 70%, 70% 35%, 50% 50%, 50% 50%',
},
'75%': {
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
},
'100%': {
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
},
});
@@ -0,0 +1,81 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
import { auroraFlow } from './animAurora.css';
// Aurora Flow — a premium ANIMATED aurora: soft ribbons of northern-lights color
// slowly drifting and curling over a deep, calm base.
//
// CONCEPT
// Broad, heavily-feathered gradient bands stacked over a deep midnight base, with
// a gentle vignette that darkens the edges and keeps the reading center calm.
// The distinct STATIC 'aurora' is a favorite still; this one earns its own slot
// by MOVING — see animAurora.css.ts, which slowly pans each ribbon along its own
// path via `background-position` so the curtains appear to fold and cross.
//
// LAYER ORDER (must stay in lockstep with auroraFlow's per-layer position list):
// 1. green ribbon 2. teal ribbon 3. violet ribbon 4. sky highlight
// 5. calm reading core (static) 6. vignette (static)
//
// READABILITY
// Every ribbon is a wide ellipse fading fully to transparent well before its
// edge, at low alpha (~0.050.13), so no band ever concentrates enough contrast
// under the message column to threaten WCAG-AA. Layer 5 lifts a soft, even wash
// through the vertical center — the reading zone — so text always sits on a calm,
// low-variance field. oklch() keeps every hue perceptually smooth and low-chroma.
//
// MOTION / SEAMLESS LOOP
// backgroundSize is >100% per animated layer, giving room to drift; the keyframe
// returns every band to its start over one long, ease-in-out period, so the loop
// is seamless and the motion barely-perceptible. willChange/animation are added
// (and stripped for reduced-motion) by getChatBg; the static positions below are
// the finished still that shows when motion is off.
const dark: CSSProperties = {
// Deep midnight blue — the polar night sky the aurora glows over.
backgroundColor: 'oklch(0.17 0.045 255)',
backgroundImage: [
// 1. Green ribbon — the signature aurora band.
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.7 0.14 160 / 0.13) 0%, oklch(0.7 0.14 160 / 0.05) 45%, transparent 72%)',
// 2. Teal ribbon — cool counterpart, offset.
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.65 0.12 200 / 0.12) 0%, oklch(0.65 0.12 200 / 0.04) 48%, transparent 74%)',
// 3. Violet ribbon — the high curtain fold.
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.55 0.13 300 / 0.11) 0%, oklch(0.55 0.13 300 / 0.04) 46%, transparent 70%)',
// 4. Sky/aqua highlight — subtle shimmer that counter-drifts.
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.72 0.1 220 / 0.09) 0%, transparent 65%)',
// 5. Calm reading core (static) — a soft even wash down the center column so
// message text always rests on a low-variance field.
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.2 0.04 255 / 0.5) 0%, transparent 70%)',
// 6. Vignette (static) — gently darkens the edges for luminous depth.
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.12 0.04 260 / 0.55) 100%)',
].join(','),
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
};
const light: CSSProperties = {
// Pale cool base — a soft pre-dawn sky the pastel aurora dreams over.
backgroundColor: 'oklch(0.97 0.012 240)',
backgroundImage: [
// 1. Mint ribbon.
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.85 0.08 160 / 0.5) 0%, oklch(0.85 0.08 160 / 0.16) 45%, transparent 72%)',
// 2. Sky ribbon.
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.83 0.07 220 / 0.48) 0%, oklch(0.83 0.07 220 / 0.14) 48%, transparent 74%)',
// 3. Lilac ribbon — the high curtain fold.
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.82 0.07 300 / 0.42) 0%, oklch(0.82 0.07 300 / 0.12) 46%, transparent 70%)',
// 4. Aqua highlight — subtle shimmer that counter-drifts.
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.88 0.06 200 / 0.34) 0%, transparent 65%)',
// 5. Calm reading core (static) — a bright even wash down the center column
// so dark message text always rests on a light, low-variance field.
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.99 0.005 240 / 0.6) 0%, transparent 70%)',
// 6. Vignette (static) — a whisper of cool shade at the edges for depth.
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.9 0.02 250 / 0.45) 100%)',
].join(','),
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
};
export const animAurora: ChatBgVariants = { dark, light };
@@ -0,0 +1,45 @@
import { keyframes } from '@vanilla-extract/css';
// Fireflies — a slow, gentle PAN of sparse glowing motes across a warm summer
// dusk. The scene in animFireflies.ts stacks these background layers:
// 1. large bright motes — tile 227x227, brightest core+halo, drifts FASTEST
// 2. medium motes — tile 293x293, dimmer, medium drift
// 3. tiny far sparks — tile 179x179, faintest, drifts SLOWEST (small step)
// 4. center vignette (100% 100%) — STATIC
// 5. warm dusk wash A (100% 100%) — STATIC
// 6. warm dusk wash B (100% 100%) — STATIC
//
// Seamless drift: the single `animation` shorthand shares ONE duration across all
// layers, so the differing apparent speeds come purely from how FAR each layer
// travels. For a jump-free loop every mote layer must translate by an EXACT
// integer multiple of its own tile period in BOTH axes, so the mote re-entering
// at the wrap is identical to the one that left. Each layer moves exactly one
// full tile:
// large : -227 / -227 (1 x 227)
// medium: -293 / -293 (1 x 293) — bigger tile, same 1-tile move => SLOWER look
// far : -179 / -179 (1 x 179) — smallest tile, damped by low opacity so it
// reads as the calm distant layer
// Because tile sizes differ, one shared 1-tile translation yields three distinct
// apparent speeds — the wandering-firefly parallax — while every layer lands back
// on an identical phase at 100% for a perfectly seamless repeat.
//
// The diagonal component (both x and y shift) makes motes feel like they wander
// through the meadow rather than slide flatly. The three static layers (vignette
// and the two dusk washes) are pinned at '0 0' every frame so the warm ambient
// glow and the calm reading center never move under the text.
//
// The '0%' frame MUST match the static backgroundPosition authored in
// animFireflies.ts, so when getChatBg STRIPS this animation for
// prefers-reduced-motion the finished scene of glowing motes shows without a jump.
export const firefliesDrift = keyframes({
'0%': {
// large, medium, far, vignette, wash A, wash B
backgroundPosition: '0 0, 83px 47px, 131px 101px, 0 0, 0 0, 0 0',
},
'100%': {
// large: 0-227 / 0-227
// medium: 83-293 / 47-293
// far: 131-179 / 101-179
backgroundPosition: '-227px -227px, -210px -246px, -48px -78px, 0 0, 0 0, 0 0',
},
});
@@ -0,0 +1,98 @@
import { ChatBgVariants } from './types';
import { firefliesDrift } from './animFireflies.css';
// Fireflies — a warm summer-dusk meadow. A few soft golden-green motes drift over
// a deep base, each mote a bright core melting into a warm halo. Sparse by design
// so the reading column stays clear; the motion is a slow, gentle background-
// position PAN (see animFireflies.css.ts) that reads as fireflies wandering.
//
// Layer stacking order (topmost first — CSS paints image #1 on top):
// 1. large bright motes — crisp warm core -> warm halo, sparse, largest step
// 2. medium motes — dimmer, smaller, more of them
// 3. tiny far sparks — faintest, smallest tile, calm distant layer
// 4. center vignette — keeps the reading center the calmest area
// 5. warm dusk wash A — ambient glow, upper
// 6. warm dusk wash B — ambient glow, lower
// Mote tiles use coprime-ish sizes (227/293/179) so their repeats never line up
// and the field reads as scattered, not gridded.
//
// getChatBg STRIPS the `animation` for prefers-reduced-motion / pause, so the
// authored backgroundPosition already composes a finished, gorgeous still scene
// of glowing motes on its own — the animation only sets them gently adrift.
export const animFireflies: ChatBgVariants = {
// Dark: warm gold-green glows on a deep forest-navy base with a soft vignette.
// Cores sit near oklch(0.85 0.13 110); halos fall to a warm amber-green. All
// opacities are kept low so message text stays crisp (WCAG-AA) over the field.
dark: {
backgroundColor: 'oklch(0.17 0.035 175)',
backgroundImage: [
// 1. large bright motes — golden-green core fading through a warm halo
'radial-gradient(circle at center, oklch(0.85 0.13 110 / 0.55) 1.4px, oklch(0.72 0.14 95 / 0.16) 3px, transparent 6px)',
// 2. medium motes — a touch cooler-green, dimmer, more numerous
'radial-gradient(circle at center, oklch(0.82 0.13 128 / 0.40) 1.1px, oklch(0.70 0.12 110 / 0.12) 2.4px, transparent 5px)',
// 3. tiny far sparks — faint warm pinpoints, the calm distant layer
'radial-gradient(circle at center, oklch(0.88 0.11 100 / 0.28) 0.8px, transparent 2.4px)',
// 4. center vignette — darkens the edges, keeps reading center calmest
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 40%, oklch(0.10 0.03 175 / 0.55) 100%)',
// 5. warm dusk wash A — a low amber-green glow drifting in from upper-right
'radial-gradient(ellipse 140% 120% at 80% 10%, oklch(0.30 0.07 120 / 0.45) 0%, transparent 58%)',
// 6. warm dusk wash B — deep teal-navy pooling into the lower-left
'radial-gradient(ellipse 135% 115% at 16% 94%, oklch(0.22 0.05 190 / 0.50) 0%, transparent 60%)',
].join(','),
backgroundSize: [
'227px 227px', // large motes
'293px 293px', // medium motes
'179px 179px', // far sparks
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
backgroundPosition: [
'0 0', // large (matches firefliesDrift 0%)
'83px 47px', // medium (offset breaks alignment)
'131px 101px', // far (offset again)
'0 0', // vignette (static)
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
},
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
// gentle halos float on a warm blush->honey gradient. Contrast stays low so the
// reading area is comfortable and text remains crisp (WCAG-AA).
light: {
backgroundColor: 'oklch(0.955 0.02 85)',
backgroundImage: [
// 1. large amber motes — warm honey core into a soft amber halo
'radial-gradient(circle at center, oklch(0.80 0.11 80 / 0.30) 1.4px, oklch(0.85 0.09 70 / 0.12) 3px, transparent 6px)',
// 2. medium motes — slightly greener-gold, softer
'radial-gradient(circle at center, oklch(0.78 0.10 95 / 0.22) 1.1px, oklch(0.86 0.08 85 / 0.10) 2.4px, transparent 5px)',
// 3. tiny far sparks — faint warm pinpoints for texture, never noise
'radial-gradient(circle at center, oklch(0.75 0.10 75 / 0.16) 0.8px, transparent 2.4px)',
// 4. center vignette — brightens the calm reading center a touch
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.40) 30%, transparent 100%)',
// 5. warm dusk wash A — honey glow from the upper-right
'radial-gradient(ellipse 140% 120% at 80% 8%, oklch(0.92 0.06 85 / 0.55) 0%, transparent 60%)',
// 6. warm dusk wash B — soft rose blush pooling lower-left
'radial-gradient(ellipse 135% 115% at 15% 95%, oklch(0.93 0.05 40 / 0.45) 0%, transparent 62%)',
].join(','),
backgroundSize: [
'227px 227px', // large motes
'293px 293px', // medium motes
'179px 179px', // far sparks
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
backgroundPosition: [
'0 0', // large (matches firefliesDrift 0%)
'83px 47px', // medium
'131px 101px', // far
'0 0', // vignette (static)
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
},
};
@@ -0,0 +1,39 @@
import { keyframes } from '@vanilla-extract/css';
// Grid Pulse — a slow "energy" glow that sweeps across a static tech grid.
//
// The motif is a crisp thin grid that pulses. Rather than scaling the grid
// (which shifts every line and reads as a jitter behind text), we keep the grid
// perfectly still and PAN a single soft radial "bloom" layer diagonally across
// it. As the bloom drifts, the grid lines it passes over appear to brighten and
// then settle — a calm travelling pulse, never a flash.
//
// Layer mapping (see animPulse.ts — one background-position value per layer):
// 0. grid core lines (vertical) — STATIC ('0 0')
// 1. grid core lines (horizontal) — STATIC ('0 0')
// 2. grid fine sub-lines (V) — STATIC ('0 0')
// 3. grid fine sub-lines (H) — STATIC ('0 0')
// 4. TRAVELLING BLOOM — panned here (the only moving layer)
// 5. base wash / centre glow — STATIC ('0 0')
// 6. vignette — STATIC ('0 0')
//
// Seamless loop: the bloom layer is authored to tile (its backgroundSize in
// animPulse.ts is 480px — an exact 4x multiple of the 120px grid module, and
// 8x of the 60px sub-grid). Panning it by EXACTLY one bloom-tile (480px on both
// axes) returns every pixel to an identical neighbouring tile, so the wrap at
// 100% is invisible. Diagonal travel (both axes move together) makes the sweep
// feel organic while still landing on a whole-tile offset.
//
// getChatBg adds `willChange: 'background-position'` for the animated case, so a
// background-position pulse is exactly what the compositor is hinted for. It
// STRIPS this whole `animation` for prefers-reduced-motion / pause-animations,
// at which point the static bloom position authored in animPulse.ts is what
// shows — a finished, gently glowing grid.
export const gridPulse = keyframes({
'0%': {
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 0px 0px, 0 0, 0 0',
},
'100%': {
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 480px 480px, 0 0, 0 0',
},
});
@@ -0,0 +1,121 @@
import { ChatBgVariants } from './types';
import { gridPulse } from './animPulse.css';
// Grid Pulse (anim-pulse) — a refined sci-fi grid with a slow energy pulse.
//
// Concept: a crisp thin tech grid over which a single soft radial glow drifts
// diagonally, so the lines it crosses seem to charge and settle — a hypnotic
// travelling pulse rather than a strobing brightness flash. Three ingredients,
// exactly per the quality bar:
// 1. a crisp thin grid — two hairline linear layers (V + H) at a 120px module
// plus a fainter 60px sub-grid, so the mesh reads as fine machined lattice;
// 2. a soft bloom layer — one wide, very-low-opacity radial that TRAVELS across
// the grid (the pulse), authored to tile so the loop is seamless;
// 3. a radial vignette — keeps the reading centre calm (dark theme darkens it,
// light theme brightens it) so text always sits on the quietest region.
//
// Animation approach & why it's subtle: only ONE layer moves — the bloom — and
// it moves by pure background-position (the property getChatBg hints via
// willChange). No line ever shifts, no global brightness flicker, so text never
// wobbles. The glow itself is barely-there (opacity well under the neon bloom),
// so the "pulse" is felt as a slow wash of light passing behind the words. 22s
// per cycle makes it meditative, not busy.
//
// Seamless loop: the bloom's backgroundSize is 480px — an exact 4x multiple of
// the 120px grid module (and 8x of the 60px sub-grid). The keyframe pans it by
// exactly one 480px tile on both axes, so it wraps onto an identical tile with
// no visible seam (see animPulse.css.ts).
//
// Reduced-motion fallback: getChatBg strips `animation`, leaving the bloom at
// its authored static position — parked slightly above-centre so the finished
// frame reads as a deliberately-lit, gently glowing grid rather than a frozen
// mid-sweep. The grid, wash and vignette are all static regardless, so the
// still image is already a complete, premium background.
//
// Dark vs light: dark is a cool cyan lattice glowing on deep blue-black with a
// dim bloom and a centre-darkening vignette. Light is a soft slate-blue lattice
// on pale cool-white with a whisper-faint bloom and a centre-BRIGHTENING
// vignette, so the reading column lifts toward white. Both keep line + glow
// opacity low for WCAG-AA legibility in either app theme.
export const animPulse: ChatBgVariants = {
// Dark: cyan grid on deep blue-black, a dim energy bloom sweeping through.
dark: {
backgroundColor: 'oklch(0.16 0.03 240)',
backgroundImage: [
// 0. grid core — vertical hairlines (cool cyan)
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
// 1. grid core — horizontal hairlines
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
// 2. fine sub-grid — vertical (fainter, half module)
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
// 3. fine sub-grid — horizontal
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
// 4. TRAVELLING BLOOM — the pulse: a wide soft cyan glow that drifts
'radial-gradient(circle at 50% 50%, oklch(0.8 0.12 200 / 0.16) 0%, oklch(0.75 0.11 205 / 0.06) 26%, transparent 55%)',
// 5. base wash — a faint steady centre glow so the grid never looks flat
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.42 0.07 235 / 0.28) 0%, transparent 62%)',
// 6. vignette — darken the edges, keep the reading centre calm & dark
'radial-gradient(ellipse 130% 100% at 50% 46%, transparent 34%, oklch(0.11 0.02 245 / 0.72) 100%)',
].join(','),
backgroundSize: [
'120px 120px', // grid core V
'120px 120px', // grid core H
'60px 60px', // sub-grid V (exact 1/2 divisor — re-registers)
'60px 60px', // sub-grid H
'480px 480px', // bloom (4x module — pans one whole tile, seamless)
'100% 100%', // base wash
'100% 100%', // vignette
].join(','),
backgroundPosition: [
'0 0', // grid core V
'0 0', // grid core H
'0 0', // sub-grid V
'0 0', // sub-grid H
'120px 40px', // bloom static (reduced-motion) — parked above-centre
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
},
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
light: {
backgroundColor: 'oklch(0.975 0.006 235)',
backgroundImage: [
// 0. grid core — vertical hairlines (soft slate-blue)
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
// 1. grid core — horizontal hairlines
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
// 2. fine sub-grid — vertical (fainter, half module)
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
// 3. fine sub-grid — horizontal
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
// 4. TRAVELLING BLOOM — a whisper of slate-blue light drifting through
'radial-gradient(circle at 50% 50%, oklch(0.6 0.09 240 / 0.09) 0%, oklch(0.62 0.08 245 / 0.035) 26%, transparent 55%)',
// 5. base wash — the faintest cool tint so the grid sits on soft light
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.86 0.03 235 / 0.30) 0%, transparent 62%)',
// 6. vignette — brighten the calm reading centre toward white for legibility
'radial-gradient(ellipse 130% 100% at 50% 46%, oklch(1 0 0 / 0.5) 30%, transparent 100%)',
].join(','),
backgroundSize: [
'120px 120px', // grid core V
'120px 120px', // grid core H
'60px 60px', // sub-grid V
'60px 60px', // sub-grid H
'480px 480px', // bloom (4x module — seamless one-tile pan)
'100% 100%', // base wash
'100% 100%', // vignette
].join(','),
backgroundPosition: [
'0 0', // grid core V
'0 0', // grid core H
'0 0', // sub-grid V
'0 0', // sub-grid H
'120px 40px', // bloom static (reduced-motion) — parked above-centre
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
},
};
@@ -0,0 +1,22 @@
import { keyframes } from '@vanilla-extract/css';
// Digital Rain — a slow vertical PAN of the streak columns.
//
// The streak SVG tile is authored 200px tall (see animRain.ts, backgroundSize
// height = 200px). The falling illusion is a pure background-position translate
// downward by EXACTLY one tile height (200px) over the cycle, so the loop is
// perfectly seamless — the pixel at y re-enters where the pixel at y-200 was,
// which is identical because the tile repeats.
//
// Only the first background layer (the streak SVG) is panned; every subsequent
// comma-separated layer is kept at its authored position ('0 0') so the base
// gradients / vignette stay put while the rain falls over them. Listing a value
// per layer is required — a single value would pan ALL layers.
//
// getChatBg adds `willChange: 'background-position'` for the animated case, and
// STRIPS this whole `animation` for reduced-motion, at which point the static
// backgroundPosition authored in animRain.ts is what shows.
export const rainFall = keyframes({
'0%': { backgroundPosition: '0 0, 0 0, 0 0, 0 0' },
'100%': { backgroundPosition: '0 200px, 0 0, 0 0, 0 0' },
});
@@ -0,0 +1,123 @@
import { ChatBgVariants } from './types';
import { rainFall } from './animRain.css';
// anim-rain — "Digital Rain" — a premium take on the Matrix code-rain motif.
//
// Concept: sparse vertical columns of falling glyph-streaks. Each streak is a
// soft vertical gradient that fades from a brighter LEADING glyph (the drop's
// head) up into a dim trailing tail, punctuated by a scatter of faint monospace
// glyph marks so it reads as CODE rather than plain stripes. It floats over a
// near-black base carrying a subtle green phosphor cast and a gentle vignette.
// Columns are deliberately sparse (only a handful across the 260px-wide tile)
// so the reading area breathes and text always wins the contrast fight.
//
// SEAMLESS TILING + PAN — the streak SVG tile is 260×200. Its content is
// authored to wrap top↔bottom: each streak's gradient and glyphs are placed so
// the tile is vertically continuous, and the animation (see animRain.css.ts)
// pans this first layer down by EXACTLY one tile height (200px) per cycle, so
// the "fall" loops with no seam. The base / vignette layers are 100% 100% and
// stay fixed (the keyframe holds them at '0 0').
//
// ANIMATION-STRIP SAFETY — getChatBg removes `animation` for reduced-motion /
// pause-animations users, so the non-animation properties below already read as
// a finished, gorgeous STATIC rain: a frozen frame of streaks over the base.
//
// CSP / Tauri-safe: inline SVG via encodeURIComponent (NOT base64). oklch used
// throughout; alphas kept low so both themes stay WCAG-AA-friendly for text.
// One vertical streak-column, colour-parameterised. Placed at x within a
// 260-wide tile. `head` is the bright leading-glyph colour, `tail` the dim
// trailing colour, `glyph` the colour of the riding monospace glyph ticks.
const streak = (
x: number,
headY: number, // y of the leading glyph (drop head)
len: number, // trailing tail length upward
head: string,
tail: string,
glyph: string,
): string => {
const topY = headY - len;
const id = `g${x}_${headY}`; // unique even when two columns share an x
// Vertical fade: transparent at the tail top → tail colour → bright head.
const grad = `
<linearGradient id='${id}' x1='0' y1='${topY}' x2='0' y2='${headY}' gradientUnits='userSpaceOnUse'>
<stop offset='0' stop-color='${tail}' stop-opacity='0'/>
<stop offset='0.55' stop-color='${tail}'/>
<stop offset='1' stop-color='${head}'/>
</linearGradient>`;
// The streak body is a soft, slightly-blurred vertical bar.
const bar = `<rect x='${x - 3}' y='${topY}' width='6' height='${len}' rx='3' fill='url(#${id})'/>`;
// A few monospace glyph ticks riding the column (short horizontal dashes).
const ticks = [0.22, 0.45, 0.68, 0.86]
.map((f, i) => {
const gy = Math.round(topY + len * f);
const gw = i % 2 === 0 ? 5 : 3;
const op = i === 3 ? '0.9' : '0.5';
return `<rect x='${x - gw / 2}' y='${gy}' width='${gw}' height='1.4' rx='0.7' fill='${glyph}' fill-opacity='${op}'/>`;
})
.join('');
// The leading glyph: a brighter small square cap at the head.
const cap = `<rect x='${x - 2.5}' y='${headY - 3}' width='5' height='5' rx='1' fill='${head}'/>`;
return grad + bar + ticks + cap;
};
// Full 260×200 tile. Columns are wrapped vertically: a column whose head sits
// low in the tile has its tail running off the top, and a companion column
// re-enters that space, so panning by one tile height reads as continuous fall.
const tile = (head: string, tail: string, glyph: string): string => {
const cols = [
streak(24, 150, 140, head, tail, glyph),
streak(78, 60, 120, head, tail, glyph),
streak(122, 196, 160, head, tail, glyph), // head near bottom → tail wraps up
streak(122, 40, 160, head, tail, glyph), // partner near top completes the wrap
streak(178, 110, 100, head, tail, glyph),
streak(232, 176, 130, head, tail, glyph),
].join('');
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='260' height='200' viewBox='0 0 260 200'><defs></defs>${cols}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
};
export const animRain: ChatBgVariants = {
// Dark: phosphor-green streaks on deep near-black with a faint green cast.
dark: {
backgroundColor: 'oklch(0.16 0.02 150)',
backgroundImage: [
// 1) the falling streak columns (this is the panned layer)
tile(
'oklch(0.75 0.14 150 / 0.5)', // head — bright phosphor glyph
'oklch(0.68 0.12 150 / 0.28)', // tail — dim phosphor
'oklch(0.82 0.1 150 / 0.5)', // glyph ticks — brightest
),
// 2) soft top-down phosphor haze so the rain has atmosphere
'linear-gradient(180deg, oklch(0.24 0.04 150 / 0.55) 0%, transparent 40%)',
// 3) subtle green cast pooling toward the bottom
'radial-gradient(120% 90% at 50% 100%, oklch(0.28 0.05 150 / 0.45) 0%, transparent 60%)',
// 4) vignette — quiet the corners so the reading column stays clean
'radial-gradient(140% 140% at 50% 45%, transparent 60%, oklch(0.1 0.02 150 / 0.6) 100%)',
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
},
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
light: {
backgroundColor: 'oklch(0.97 0.008 165)',
backgroundImage: [
tile(
'oklch(0.55 0.07 165 / 0.4)', // head — soft teal-grey drop
'oklch(0.62 0.05 165 / 0.22)', // tail — faint teal-grey
'oklch(0.5 0.06 165 / 0.42)', // glyph ticks
),
// gentle cool wash from the top
'linear-gradient(180deg, oklch(0.94 0.015 175 / 0.6) 0%, transparent 42%)',
// faint teal pooling at the bottom edge
'radial-gradient(120% 90% at 50% 100%, oklch(0.9 0.02 170 / 0.5) 0%, transparent 60%)',
// soft vignette in cool grey
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.88 0.02 165 / 0.5) 100%)',
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
},
};
@@ -0,0 +1,39 @@
import { keyframes } from '@vanilla-extract/css';
// Star Drift — a slow, serene PAN of a deep-space starfield with real parallax.
//
// The starfield in animStars.ts stacks six background layers:
// 1. near stars — tile 137x137, brighter, drifts FASTEST
// 2. mid stars — tile 191x191, medium
// 3. far dust — tile 233x233, dimmest, drifts SLOWEST
// 4. center vignette (100% 100%) — STATIC
// 5. nebula wash A (100% 100%) — STATIC
// 6. nebula wash B (100% 100%) — STATIC
//
// Seamless parallax: the single `animation` shorthand shares ONE duration across
// all layers, so speed differences are produced purely by how FAR each layer
// travels in the keyframe. For a perfectly seamless loop each star layer must
// translate by an EXACT integer multiple of its own tile period, so the pixel
// re-entering at the wrap is identical to the one that left. We move:
// near : -274px = 2 x 137 (two tiles -> fastest apparent drift)
// mid : -191px = 1 x 191 (one tile -> medium)
// far : -233px = 1 x 233 (one tile, but larger tile => slowest apparent)
// so near/mid/far read as three depths sliding past each other, yet every layer
// lands back on an identical phase at 100% for a jump-free repeat.
//
// A diagonal component (both x and y shift) makes the drift feel like gentle
// motion through space rather than a flat slide. The static layers are pinned at
// '0 0' every frame so the vignette and nebula never move under the text.
//
// The start frame ('0%') MUST match the static backgroundPosition authored in
// animStars.ts, so that when getChatBg STRIPS this animation for
// prefers-reduced-motion the finished starfield shows without a jump.
export const starDrift = keyframes({
'0%': {
backgroundPosition: '0 0, 61px 43px, 113px 97px, 0 0, 0 0, 0 0',
},
'100%': {
// near: -274/-274 (2 tiles), mid: 61-191/43-191, far: 113-233/97-233
backgroundPosition: '-274px -274px, -130px -148px, -120px -136px, 0 0, 0 0, 0 0',
},
});
@@ -0,0 +1,117 @@
import { ChatBgVariants } from './types';
import { starDrift } from './animStars.css';
// animStars ("Star Drift") — a serene deep-space field slowly drifting, with
// genuine parallax between a near (brighter, faster) and a far (dim, slower)
// star layer, floated on a faint nebula wash and calmed by a center vignette.
//
// Concept: three tiling star layers at coprime-ish tile sizes (137/191/233 dark,
// 149/199/251 light) so their combined repeat is astronomically large and no
// seam is ever perceivable. The near layer is crisp and sparse; the far "dust"
// layer is dim and dense — the layer that gives depth. Beneath the stars sit a
// deep-blue -> violet nebula (two soft ellipses) and a center vignette that keeps
// the reading column the calmest, lowest-contrast area of the whole canvas.
//
// Layer stacking order (CSS paints image #1 on TOP):
// 1. near stars — brighter, largest visible drift (tile 137 / 149)
// 2. mid stars — softer, medium (tile 191 / 199)
// 3. far dust — dimmest, slowest, most-repeated (tile 233 / 251)
// 4. center vignette (100% 100%, static)
// 5. nebula wash A (100% 100%, static)
// 6. nebula wash B (100% 100%, static)
//
// Animation: `starDrift` (see animStars.css.ts) is a SLOW background-position PAN
// that translates each star layer by an exact integer number of its own tiles,
// so the loop is seamless AND the three layers drift at different apparent
// speeds (parallax). getChatBg adds willChange/contain for the animated case and
// STRIPS the `animation` for prefers-reduced-motion — at which point the static
// backgroundPosition below (identical to the keyframe's 0% frame) shows as a
// fully finished starfield on its own.
//
// Density is kept modest toward the center by the vignette + conservative dot
// sizes, and every star opacity stays low so text over the field always clears
// WCAG-AA in both themes.
export const animStars: ChatBgVariants = {
// Dark: cool white + faint blue stars on a near-black cosmos, lifted onto a
// deep-blue -> violet nebula with a soft vignette darkening the calm center.
dark: {
backgroundColor: 'oklch(0.15 0.03 275)',
backgroundImage: [
// 1. near stars — crisp cool-white, sparse, the "fast" parallax layer
'radial-gradient(circle at center, oklch(0.98 0.012 255 / 0.85) 0.6px, transparent 1.5px)',
// 2. mid stars — softer, a touch blue, more of them
'radial-gradient(circle at center, oklch(0.90 0.03 260 / 0.52) 0.6px, transparent 1.3px)',
// 3. far dust — faint blue haze, the slow depth layer (most repeats)
'radial-gradient(circle at center, oklch(0.78 0.06 255 / 0.28) 0.5px, transparent 1.1px)',
// 4. center vignette — keeps the reading column calmest / lowest-contrast
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 40%, oklch(0.09 0.03 270 / 0.58) 100%)',
// 5. nebula wash A — deep violet high-right
'radial-gradient(ellipse 140% 120% at 78% 10%, oklch(0.26 0.09 285 / 0.55) 0%, transparent 55%)',
// 6. nebula wash B — deep blue low-left
'radial-gradient(ellipse 130% 110% at 16% 94%, oklch(0.21 0.07 250 / 0.50) 0%, transparent 58%)',
].join(','),
backgroundSize: [
'137px 137px', // near stars
'191px 191px', // mid stars
'233px 233px', // far dust
'100% 100%', // vignette
'100% 100%', // nebula A
'100% 100%', // nebula B
].join(','),
// Must equal starDrift's 0% frame so reduced-motion shows this exact field.
backgroundPosition: [
'0 0', // near
'61px 43px', // mid (offset breaks tile alignment)
'113px 97px', // far (offset again)
'0 0', // vignette
'0 0', // nebula A
'0 0', // nebula B
].join(','),
animation: `${starDrift} 90s linear infinite`,
},
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
// soft pale sparkles plus the merest cool speckles, floated on a gentle cool
// gradient. Reads as elegant atmosphere, never as noise over text.
light: {
backgroundColor: 'oklch(0.965 0.008 255)',
backgroundImage: [
// 1. near sparkles — a hair brighter/warmer than the sky
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.50) 0.6px, transparent 1.5px)',
// 2. mid cool speckles — faintest hint of darkness for texture/contrast
'radial-gradient(circle at center, oklch(0.60 0.05 260 / 0.15) 0.5px, transparent 1.2px)',
// 3. far dust — very soft cool haze, the slow depth layer
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.11) 0.5px, transparent 1.1px)',
// 4. center vignette — subtly brightens the calm reading center
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
// 5. pre-dawn wash A — cool blue high-right
'radial-gradient(ellipse 150% 120% at 80% 6%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
// 6. pre-dawn wash B — warm blush low-left
'radial-gradient(ellipse 140% 120% at 14% 96%, oklch(0.93 0.04 40 / 0.42) 0%, transparent 62%)',
].join(','),
// Same tile sizes as dark (137/191/233). The shared starDrift keyframe pans
// each layer by an exact integer multiple of ITS tile (near 2x137, mid 1x191,
// far 1x233); reusing these tiles here guarantees the loop wraps seamlessly in
// light mode too, since one keyframe drives both themes. Coprime-ish sizes keep
// the combined repeat astronomically large so no seam is ever perceivable.
backgroundSize: [
'137px 137px', // near sparkles
'191px 191px', // mid speckles
'233px 233px', // far dust
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
// Positions mirror the keyframe 0% frame (== reduced-motion static field).
backgroundPosition: [
'0 0', // near
'61px 43px', // mid
'113px 97px', // far
'0 0', // vignette
'0 0', // wash A
'0 0', // wash B
].join(','),
animation: `${starDrift} 100s linear infinite`,
},
};
@@ -0,0 +1,85 @@
import { ChatBgVariants } from './types';
// blueprint — an engineering / architectural drafting sheet.
//
// Layers (painted top-to-bottom):
// 1. SVG draftsman tick-marks + a centred crosshair accent (96px tile — lands
// exactly on the major grid; corner quarter-arms tile into a full "+" on
// every major intersection).
// 2. Major grid lines (heavier) — 96px.
// 3. Minor grid lines (fine, fainter) — 16px (96 = 6 × 16, so it nests
// seamlessly inside the major grid with no beat/moiré).
// 4. A soft radial vignette + a gentle sheet-glow so the surface reads like a
// real drafting sheet with subtle dimension rather than a flat tile.
//
// Everything is kept at low alpha (~0.030.16) so the motif is felt, not read:
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
const DARK_TICKS =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.32%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.18%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const LIGHT_TICKS =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.38%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.22%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
export const blueprint: ChatBgVariants = {
// Cyan-blue lines on a deep navy sheet.
dark: {
backgroundColor: 'oklch(0.22 0.05 250)',
backgroundImage: [
// 1. draftsman ticks + centre crosshair
DARK_TICKS,
// 4a. sheet-glow: a faint cooler highlight drifting off the top-left,
// giving the flat navy some dimension.
'radial-gradient(120% 120% at 18% 8%, oklch(0.30 0.06 245 / 0.55) 0%, transparent 55%)',
// 4b. vignette: gently darkens the corners like a drafting sheet edge.
'radial-gradient(140% 140% at 50% 42%, transparent 58%, oklch(0.14 0.04 255 / 0.5) 100%)',
// 2. major grid (heavier)
'linear-gradient(oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
// 3. minor grid (fine, fainter)
'linear-gradient(oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'96px 96px', // ticks
'100% 100%', // sheet-glow
'100% 100%', // vignette
'96px 96px', // major V
'96px 96px', // major H
'16px 16px', // minor V
'16px 16px', // minor H
].join(','),
// All layers share the default top-left (0 0) origin so the tick tile, the
// 96px major grid and the 16px minor grid stay phase-locked (96 = 6 × 16) —
// no drift, no visible seams. (A per-layer `center` would let the differently
// sized tiles center independently and fall out of alignment.)
},
// Blue lines on a cool paper-white sheet.
light: {
backgroundColor: 'oklch(0.97 0.01 240)',
backgroundImage: [
LIGHT_TICKS,
// sheet-glow: a hint of brighter paper toward the top-left.
'radial-gradient(120% 120% at 18% 8%, oklch(0.99 0.008 240 / 0.7) 0%, transparent 55%)',
// vignette: soft cool shading into the corners.
'radial-gradient(140% 140% at 50% 42%, transparent 60%, oklch(0.90 0.02 245 / 0.55) 100%)',
// major grid (heavier)
'linear-gradient(oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
// minor grid (fine, fainter)
'linear-gradient(oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'96px 96px',
'100% 100%',
'100% 100%',
'96px 96px',
'96px 96px',
'16px 16px',
'16px 16px',
].join(','),
// Shared top-left origin keeps the tick tile and both grids phase-locked.
},
};
@@ -0,0 +1,76 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// chevron — refined woven-upholstery zigzag.
//
// The motif is a continuous, crisp chevron built to read as *textured fabric*
// rather than flat stripes. The zigzag threads themselves are drawn with a
// tiny inline-SVG tile (guaranteed geometrically seamless — the "V" path exits
// each tile edge exactly where the next tile's path enters, both horizontally
// and vertically). Over that, layered CSS gradients add the premium feel:
// • a soft light→shade sweep across the weave gives each band an embossed,
// woven cross-section (catches light on one diagonal face, shade on the
// other);
// • a faint two-tone wash alternates the tint of successive chevron rows for
// an interlocked-yarn look;
// • a gentle centre lift + corner vignette settle the field so text always
// sits over the calmer middle.
//
// SEAMLESS TILING
// The SVG is a WxH tile whose path is one full zigzag wave: it starts at the
// left edge, dips to the vertex, rises to the right edge at the SAME y it
// started — so horizontally each tile's end meets the next tile's start with no
// step. Two stacked strokes (offset by H) fill the vertical repeat, and the
// tile height equals the row pitch, so vertical stacking is seamless too. The
// gradient overlays are non-repeating (100% 100%) or share the SVG's tile
// width, so none of them introduce a seam.
//
// Everything sits at low alpha (~0.030.11) so the pattern is felt, not read:
// crisp message text stays comfortably WCAG-AA in both themes.
// One zigzag wave, 40px wide × 20px tall. Path enters at (0,4), dips to the
// vertex at (20,16), climbs back to (40,4) — identical entry/exit y => seamless
// horizontal repeat. A second copy shifted +10 in y keeps a soft double thread.
const svg = (stroke: string, faint: string) =>
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20' +
'width%3D%2240%22%20height%3D%2220%22%3E' +
`%3Cpath%20d%3D%22M0%204%20L20%2016%20L40%204%22%20fill%3D%22none%22%20stroke%3D%22${stroke}%22%20stroke-width%3D%223%22%2F%3E` +
`%3Cpath%20d%3D%22M0%2014%20L20%2026%20L40%2014%20M0%20-6%20L20%206%20L40%20-6%22%20fill%3D%22none%22%20stroke%3D%22${faint}%22%20stroke-width%3D%222%22%2F%3E` +
'%3C%2Fsvg%3E")';
const dark: CSSProperties = {
backgroundColor: 'oklch(0.20 0.022 260)',
backgroundImage: [
// 1. The zigzag threads — muted indigo/slate, main + fainter under-thread.
svg('oklch(0.55 0.05 265 %2F 0.16)', 'oklch(0.50 0.045 262 %2F 0.07)'),
// 2. Woven emboss — a soft diagonal light→shade sweep across the weave so
// the bands catch light on one face and fall to shade on the other.
'linear-gradient(135deg, oklch(0.62 0.05 265 / 0.05) 0%, transparent 45%, transparent 55%, oklch(0.14 0.02 260 / 0.06) 100%)',
// 3. Two-tone weft — a whisper shade on alternate chevron rows.
'repeating-linear-gradient(0deg, oklch(0.50 0.04 258 / 0.035) 0px, oklch(0.50 0.04 258 / 0.035) 20px, transparent 20px, transparent 40px)',
// 4. Tonal wash — cool centre lift for gentle depth.
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.26 0.03 262 / 0.40) 0%, transparent 60%)',
// 5. Vignette — feather corners into deeper charcoal-blue.
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.15 0.02 260 / 0.55) 100%)',
].join(','),
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
};
const light: CSSProperties = {
backgroundColor: 'oklch(0.965 0.006 85)',
backgroundImage: [
// 1. The zigzag threads — soft dusty-blue, main + fainter under-thread.
svg('oklch(0.55 0.05 255 %2F 0.14)', 'oklch(0.52 0.045 255 %2F 0.06)'),
// 2. Woven emboss — diagonal light→shade sweep for a knit-fabric surface.
'linear-gradient(135deg, oklch(0.99 0.008 85 / 0.06) 0%, transparent 45%, transparent 55%, oklch(0.55 0.05 255 / 0.05) 100%)',
// 3. Two-tone weft — faint alternating-row shade.
'repeating-linear-gradient(0deg, oklch(0.52 0.04 255 / 0.03) 0px, oklch(0.52 0.04 255 / 0.03) 20px, transparent 20px, transparent 40px)',
// 4. Tonal wash — warm paper highlight through the reading centre.
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 60%)',
// 5. Vignette — settle corners into a slightly deeper dusty tone.
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.91 0.012 250 / 0.40) 100%)',
].join(','),
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
};
export const chevron: ChatBgVariants = { dark, light };
@@ -0,0 +1,116 @@
import { ChatBgVariants } from './types';
// circuit — an elegant printed-circuit board.
//
// Concept: thin right-angle copper traces route between small pads / vias and
// the occasional solder-junction dot, over a deep board base. It reads as an
// authentic PCB rather than a plain grid: the routing turns corners, dead-ends
// at through-hole pads, and picks up faint via-glows — but stays sparse, with
// generous negative space so message text always wins the contrast fight.
//
// The trace network is a single inline SVG data-URI (encodeURIComponent, NOT
// base64 — CSP / Tauri-safe) so the geometry can be real right-angle routing
// instead of gradient fakery. It is layered over a subtle board-base gradient.
//
// SEAMLESS TILING — the 120×120 tile is authored so every trace that leaves an
// edge re-enters at the identical coordinate on the OPPOSITE edge, so the copper
// runs continuously across tile boundaries with no visible seam:
// • horizontal runs cross the left/right edges at y = 30 and y = 90
// • vertical runs cross the top/bottom edges at x = 40 and x = 88
// backgroundSize is set to the tile size (120px) so those crossings line up
// exactly on repeat.
//
// Two hand-tuned SVGs (dark / light) differ only in stroke/fill colour + alpha.
// Alphas stay low (≈0.050.5 on the accents, traces ~0.10.16) so the pattern is
// felt, not read — crisp text sits comfortably above it in both themes.
// Shared geometry, colour-parameterised so the two themes stay pixel-identical
// in layout and only diverge in palette.
const tile = (
trace: string, // trace stroke colour
traceW: string, // trace stroke-width
pad: string, // pad ring colour
padFill: string, // pad centre / board-coloured hole
via: string, // via glow colour
junction: string, // filled junction-dot colour
): string => {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'>
<g fill='none' stroke='${trace}' stroke-width='${traceW}' stroke-linecap='round' stroke-linejoin='round'>
<path d='M0 30 H26 V58 H60'/>
<path d='M60 58 V90 H120'/>
<path d='M0 90 H40 V120'/>
<path d='M40 0 V22 H88'/>
<path d='M88 0 V44 H104'/>
<path d='M104 44 V90 H120'/>
<path d='M88 120 V90'/>
<path d='M60 30 H120'/>
<path d='M60 30 V58'/>
<path d='M26 58 V90'/>
</g>
<g fill='none' stroke='${pad}' stroke-width='${traceW}'>
<circle cx='26' cy='58' r='3.4'/>
<circle cx='40' cy='90' r='3.4'/>
<circle cx='88' cy='22' r='3.4'/>
<circle cx='104' cy='44' r='3.4'/>
</g>
<g fill='${padFill}'>
<circle cx='26' cy='58' r='1.3'/>
<circle cx='40' cy='90' r='1.3'/>
<circle cx='88' cy='22' r='1.3'/>
<circle cx='104' cy='44' r='1.3'/>
</g>
<g fill='${junction}'>
<circle cx='60' cy='58' r='2'/>
<circle cx='60' cy='30' r='2'/>
<circle cx='104' cy='90' r='2'/>
</g>
<g fill='${via}'>
<circle cx='26' cy='58' r='7'/>
<circle cx='88' cy='22' r='7'/>
<circle cx='104' cy='44' r='7'/>
</g>
</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
};
export const circuit: ChatBgVariants = {
// Faint teal/green copper with dim cyan via-glows on a near-black board.
dark: {
backgroundColor: 'oklch(0.17 0.02 165)',
backgroundImage: [
tile(
'oklch(0.7 0.1 165 / 0.16)', // traces — faint teal-green copper
'1',
'oklch(0.72 0.11 175 / 0.32)', // pad rings — slightly brighter
'oklch(0.17 0.02 165)', // pad holes — board colour (drilled look)
'oklch(0.78 0.13 200 / 0.14)', // via glow — dim cyan halo
'oklch(0.74 0.12 170 / 0.4)', // junction dots — solid copper
),
// board-base: a gentle diagonal sheen so the flat near-black gains depth.
'radial-gradient(130% 130% at 20% 12%, oklch(0.22 0.03 170 / 0.6) 0%, transparent 58%)',
// vignette: barely darkens the corners like a laminated board edge.
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.12 0.02 165 / 0.55) 100%)',
].join(','),
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
},
// Soft green-grey traces on a pale board.
light: {
backgroundColor: 'oklch(0.96 0.012 160)',
backgroundImage: [
tile(
'oklch(0.55 0.07 165 / 0.24)', // traces — soft green-grey copper
'1',
'oklch(0.5 0.08 170 / 0.4)', // pad rings
'oklch(0.96 0.012 160)', // pad holes — board colour
'oklch(0.6 0.09 200 / 0.1)', // via glow — faint cool halo
'oklch(0.5 0.08 165 / 0.42)', // junction dots
),
// board-base: a hint of brighter laminate toward the top-left.
'radial-gradient(130% 130% at 20% 12%, oklch(0.99 0.008 160 / 0.7) 0%, transparent 58%)',
// vignette: soft green-grey shading into the corners.
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.9 0.02 160 / 0.5) 100%)',
].join(','),
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
},
};
@@ -0,0 +1,56 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// crosshatch — fine pen-and-ink engraving, like a banknote guilloché.
// Three hatch directions (right-leaning, left-leaning, near-horizontal cross)
// are layered at low opacity so the eye reads a woven ink texture rather than
// discrete stripes. Each direction uses a slightly different pitch so the
// combined pattern never lines up into a coarse moire, and a barely-there
// diagonal tonal gradient lends etched depth.
//
// Seamless tiling: each hatch is a `repeating-linear-gradient`, which repeats
// infinitely by definition, so the layers are left at `backgroundSize: auto`
// and tile with no visible seam at any element size (constraining a diagonal
// repeat to a small square would clip it mid-period and create a seam). The
// tonal wash is a single non-repeating gradient stretched to `cover`.
//
// Opacities are kept in the 0.020.05 range so the texture is felt, not read —
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
const dark: CSSProperties = {
// near-black base with a whisper of cool blue so silver ink reads as engraving
backgroundColor: 'oklch(0.16 0.01 255)',
backgroundImage: [
// faint tonal gradient — top-left slightly lifted for etched depth
'linear-gradient(135deg, oklch(0.20 0.012 255 / 0.5) 0%, oklch(0.15 0.01 255 / 0) 55%, oklch(0.14 0.008 260 / 0.45) 100%)',
// primary hatch, right-leaning fine lines (cool silver ink), ~9px pitch
'repeating-linear-gradient(45deg, oklch(0.75 0.02 250 / 0.05) 0, oklch(0.75 0.02 250 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
// secondary hatch, left-leaning — the cross of the crosshatch
'repeating-linear-gradient(135deg, oklch(0.75 0.02 250 / 0.045) 0, oklch(0.75 0.02 250 / 0.045) 0.75px, transparent 0.75px, transparent 9px)',
// tertiary hatch, right-leaning at a denser pitch for engraved richness
'repeating-linear-gradient(45deg, oklch(0.78 0.018 250 / 0.02) 0, oklch(0.78 0.018 250 / 0.02) 0.5px, transparent 0.5px, transparent 4.5px)',
// quaternary near-horizontal fill line, very faint, weaves the mesh together
'repeating-linear-gradient(20deg, oklch(0.72 0.015 255 / 0.018) 0, oklch(0.72 0.015 255 / 0.018) 0.5px, transparent 0.5px, transparent 13px)',
].join(','),
backgroundSize: 'cover, auto, auto, auto, auto',
};
const light: CSSProperties = {
// warm paper base — graphite ink on cream stock
backgroundColor: 'oklch(0.975 0.006 85)',
backgroundImage: [
// faint tonal wash — soft warm depth for aged-paper feel
'linear-gradient(135deg, oklch(0.94 0.008 85 / 0.55) 0%, oklch(0.98 0.005 85 / 0) 55%, oklch(0.93 0.01 80 / 0.5) 100%)',
// primary hatch, right-leaning graphite lines, ~9px pitch
'repeating-linear-gradient(45deg, oklch(0.42 0.01 265 / 0.055) 0, oklch(0.42 0.01 265 / 0.055) 0.75px, transparent 0.75px, transparent 9px)',
// secondary hatch, left-leaning — the cross
'repeating-linear-gradient(135deg, oklch(0.42 0.01 265 / 0.05) 0, oklch(0.42 0.01 265 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
// tertiary denser right-leaning hatch for engraved fineness
'repeating-linear-gradient(45deg, oklch(0.40 0.012 265 / 0.025) 0, oklch(0.40 0.012 265 / 0.025) 0.5px, transparent 0.5px, transparent 4.5px)',
// quaternary near-horizontal weave line, barely-there
'repeating-linear-gradient(20deg, oklch(0.45 0.01 260 / 0.022) 0, oklch(0.45 0.01 260 / 0.022) 0.5px, transparent 0.5px, transparent 13px)',
].join(','),
backgroundSize: 'cover, auto, auto, auto, auto',
};
export const crosshatch: ChatBgVariants = { dark, light };
@@ -0,0 +1,52 @@
import { ChatBgVariants } from './types';
// Herringbone — a refined, tactile broken-zigzag weave (the classic parquet / tweed
// motif) rather than a flat hairline grid. Each plank is drawn twice in a compact SVG
// data-URI tile: a lit "thread" and a 0.6px-offset shadow companion, so every plank
// reads as a beveled, three-dimensional strand of fabric instead of a line.
//
// SEAMLESS TILING: planks live on a 12px lattice and their orientation follows the true
// herringbone rule orient(cx, cy) = '/' when (cx - cy) mod 4 in {0, 1}, else '\\'.
// That rule is exactly periodic every 4 cells in BOTH axes, so the 48x48px tile repeats
// with no seam at any scroll offset; segment endpoints all land on lattice corners, so
// the broken V's interlock perfectly across tile edges.
//
// DEPTH: beneath the weave sit two very low-contrast oklch layers — a diagonal two-tone
// wash that gives the fabric a faint lit/shadowed side, plus a soft vignette that lets
// the centre (where text lives) stay calmest. Everything is kept in the "felt, not read"
// opacity band so WCAG-AA body text sits comfortably on top in both themes.
const WEAVE_DARK =
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%2810%2C8%2C6%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.085%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28210%2C199%2C180%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.111%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
const WEAVE_LIGHT =
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%28126%2C116%2C98%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.075%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28255%2C253%2C247%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.098%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
export const herringbone: ChatBgVariants = {
// Warm taupe threads (~oklch(0.79 0.02 75)) over a charcoal base. The two-tone wash
// runs cool-charcoal -> slightly warmer charcoal across the diagonal so the weave has
// a gentle light side; the vignette darkens the far corners a touch for depth.
dark: {
backgroundColor: '#14120f',
backgroundImage: [
`url("${WEAVE_DARK}")`,
'linear-gradient(135deg, oklch(0.26 0.012 70 / 0.5) 0%, oklch(0.2 0.008 60 / 0.5) 100%)',
'radial-gradient(120% 120% at 50% 40%, oklch(0.24 0.01 65 / 0) 55%, oklch(0.12 0.006 55 / 0.45) 100%)',
].join(','),
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
backgroundRepeat: 'repeat, no-repeat, no-repeat',
},
// Greige threads (shadow ~oklch(0.6 0.015 75)) with a warm-white highlight over a warm
// off-white base. The wash tilts warm-white -> faint greige across the diagonal for the
// lit/shadow side; a whisper-soft vignette keeps corners from going flat.
light: {
backgroundColor: '#f6f3ec',
backgroundImage: [
`url("${WEAVE_LIGHT}")`,
'linear-gradient(135deg, oklch(0.99 0.006 85 / 0.6) 0%, oklch(0.93 0.01 80 / 0.6) 100%)',
'radial-gradient(120% 120% at 50% 40%, oklch(0.98 0.006 85 / 0) 58%, oklch(0.87 0.012 78 / 0.4) 100%)',
].join(','),
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
backgroundRepeat: 'repeat, no-repeat, no-repeat',
},
};
@@ -0,0 +1,66 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// hexgrid — a refined sci-fi HUD honeycomb lattice.
//
// The motif is a crisp pointy-top hexagon honeycomb, drawn as thin interlocking
// outlines like the readout of a sci-fi interface. It is layered over a soft
// depth sheen: a faint central glow lifts the middle of the field and a gentle
// vignette settles the corners, so the lattice reads as a lit HUD surface with
// dimension rather than a flat repeating tile. Everything is kept at low alpha
// (hex lines ~0.140.16, washes well under legibility thresholds) so the motif
// is *felt, not read* — crisp message text stays comfortably WCAG-AA in both
// themes.
//
// SEAMLESS TILING
// The hex outlines live in a single inline-SVG data-URI tile of exactly
// √3·s × 3·s = 34.641 × 60 (side length s = 20). That is the natural repeat cell
// of a pointy-top honeycomb: one full central hexagon plus the six neighbours
// whose bodies straddle the tile edges. Because each straddling hexagon is drawn
// in full, the half that spills past one edge is completed pixel-for-pixel by the
// matching half re-entering from the opposite edge on the next repeat — the six
// vertical side edges land exactly on x = 0 and x = 34.641, the slanted edges
// meet across y = 0 / y = 60, so the lattice interlocks with no seam and no
// moiré. `backgroundSize: 34.641px 60px` locks the tile to that period; the glow
// and vignette are single non-repeating layers sized to 100%.
//
// DARK vs LIGHT
// Dark: cool cyan hex lines (oklch 0.72 0.1 200) on a deep blue-black base, with
// a soft cyan-tinted central glow — the classic "cold HUD" look.
// Light: soft slate-blue hexes (oklch 0.55 0.07 250) on a pale cool-white sheet,
// with a bright paper highlight at centre. Each alpha/lightness is tuned
// independently so both feel equally quiet against their own base.
// One seamless honeycomb tile (√3·20 × 3·20). Colour is injected per-theme.
const hexTile = (stroke: string): string =>
`url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2234.641%22%20height%3D%2260%22%3E%3Cpath%20d%3D%22M17.32%2010L0%2020L0%2040L17.32%2050L34.64%2040L34.64%2020Z%20M0%20-20L-17.32%20-10L-17.32%2010L0%2020L17.32%2010L17.32%20-10Z%20M34.64%20-20L17.32%20-10L17.32%2010L34.64%2020L51.96%2010L51.96%20-10Z%20M0%2040L-17.32%2050L-17.32%2070L0%2080L17.32%2070L17.32%2050Z%20M34.64%2040L17.32%2050L17.32%2070L34.64%2080L51.96%2070L51.96%2050Z%20M17.32%20-50L0%20-40L0%20-20L17.32%20-10L34.64%20-20L34.64%20-40Z%20M17.32%2070L0%2080L0%20100L17.32%20110L34.64%20100L34.64%2080Z%22%20fill%3D%22none%22%20stroke%3D%22${encodeURIComponent(
stroke,
)}%22%20stroke-width%3D%220.9%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E")`;
const dark: CSSProperties = {
backgroundColor: 'oklch(0.19 0.03 245)',
backgroundImage: [
// 1. the honeycomb lattice — cool cyan hex outlines.
hexTile('oklch(0.72 0.1 200 / 0.14)'),
// 2. central glow — a soft cyan lift so the field looks lit from within.
'radial-gradient(120% 90% at 50% 42%, oklch(0.30 0.05 210 / 0.55) 0%, transparent 60%)',
// 3. vignette — settles the corners into the deep base for depth.
'radial-gradient(130% 130% at 50% 45%, transparent 55%, oklch(0.13 0.02 240 / 0.55) 100%)',
].join(','),
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
};
const light: CSSProperties = {
backgroundColor: 'oklch(0.965 0.008 240)',
backgroundImage: [
// 1. the honeycomb lattice — soft slate-blue hex outlines.
hexTile('oklch(0.55 0.07 250 / 0.16)'),
// 2. central highlight — a hint of brighter paper toward the middle.
'radial-gradient(120% 90% at 50% 40%, oklch(0.99 0.005 240 / 0.7) 0%, transparent 60%)',
// 3. vignette — feather the edges into a slightly cooler paper.
'radial-gradient(130% 130% at 50% 45%, transparent 58%, oklch(0.90 0.015 245 / 0.5) 100%)',
].join(','),
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
};
export const hexgrid: ChatBgVariants = { dark, light };
+121
View File
@@ -0,0 +1,121 @@
import { ChatBgVariants } from './types';
// neon — a synthwave neon grid with real bloom, kept restrained for readability.
//
// Concept: a retro-futuristic magenta/cyan grid that *glows* rather than shouts.
// The glow is built the way a real neon tube reads: a crisp hairline of light
// sitting inside a much wider, softer halo of the same hue. We achieve this per
// axis by stacking TWO linear-gradient layers that share the identical tile size
// (so their lines land on exactly the same pixel column/row across every repeat):
// - a wide "bloom" line: a fat, very-low-opacity band with a soft gradient
// falloff on both sides (transparent -> colour -> transparent), reading as
// out-of-focus glow;
// - a crisp "core" line: a 1px bright hairline centred in that bloom.
// A dark radial vignette then pulls the whole grid back toward the edges and
// keeps the reading column — the calm centre — darkest and highest-contrast, so
// text stays crisp. Pure CSS: only linear + radial gradients, no assets.
//
// Seamless tiling: every grid layer uses the SAME backgroundSize per axis
// (magenta and cyan share one 88px module in dark; the fine cyan sub-grid is an
// exact 1/2 divisor at 44px so it re-registers). Because the bloom and core for
// an axis share a size and a 0/0 position, their lines are always co-registered
// and no seam is possible. Vignette/wash layers are 100% 100% and never tile.
export const neon: ChatBgVariants = {
// Dark: magenta + cyan tubes glowing over near-black, bloom kept low so the
// lines are felt, not read. Vignette darkens the centre for legibility.
dark: {
backgroundColor: 'oklch(0.135 0.02 285)',
backgroundImage: [
// 1. magenta core hairlines — crisp, bright, thin (vertical + horizontal)
'linear-gradient(90deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
// 2. magenta bloom — a wide soft halo hugging the same lines
'linear-gradient(90deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
'linear-gradient(0deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
// 3. cyan core hairlines on the offset half-grid — the cross accent
'linear-gradient(90deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
// 4. cyan bloom — soft cool halo on the same half-grid lines
'linear-gradient(90deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
'linear-gradient(0deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
// 5. vignette — recede the grid, keep the reading centre calm & dark
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 34%, oklch(0.10 0.02 285 / 0.72) 100%)',
// 6. horizon wash — a faint magenta->cyan synthwave glow low on the canvas
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.4 0.14 340 / 0.30) 0%, transparent 60%)',
].join(','),
backgroundSize: [
'88px 88px', // magenta core V
'88px 88px', // magenta core H
'88px 88px', // magenta bloom V
'88px 88px', // magenta bloom H
'44px 44px', // cyan core V (exact 1/2 divisor — re-registers)
'44px 44px', // cyan core H
'44px 44px', // cyan bloom V
'44px 44px', // cyan bloom H
'100% 100%', // vignette
'100% 100%', // horizon wash
].join(','),
backgroundPosition: [
'0 0', // magenta core V
'0 0', // magenta core H
'-3px 0', // magenta bloom V — centre the 7px halo on the 1px core
'0 -3px', // magenta bloom H
'22px 22px', // cyan core V — sit the fine grid between magenta lines
'22px 22px', // cyan core H
'20px 22px', // cyan bloom V — centre the 5px halo on the cyan core
'22px 20px', // cyan bloom H
'0 0', // vignette
'0 0', // horizon wash
].join(','),
},
// Light: "neon" reinterpreted as a soft luminous violet/teal grid on a pale
// cool-white base — no glow-on-black, just gentle coloured light. Bloom is even
// lighter here; a subtle centre-brightening vignette lifts the reading column.
light: {
backgroundColor: 'oklch(0.972 0.006 275)',
backgroundImage: [
// 1. violet core hairlines — soft but defined
'linear-gradient(90deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
// 2. violet bloom — the merest wide halo for luminosity
'linear-gradient(90deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
'linear-gradient(0deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
// 3. teal core hairlines on the offset half-grid — cool accent
'linear-gradient(90deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
'linear-gradient(0deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
// 4. teal bloom — faint cool halo on the same half-grid lines
'linear-gradient(90deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
'linear-gradient(0deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
// 5. vignette — brighten the calm reading centre for max legibility
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.50) 30%, transparent 100%)',
// 6. horizon wash — a whisper of violet->teal light low on the canvas
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.8 0.09 320 / 0.28) 0%, transparent 60%)',
].join(','),
backgroundSize: [
'88px 88px', // violet core V
'88px 88px', // violet core H
'88px 88px', // violet bloom V
'88px 88px', // violet bloom H
'44px 44px', // teal core V
'44px 44px', // teal core H
'44px 44px', // teal bloom V
'44px 44px', // teal bloom H
'100% 100%', // vignette
'100% 100%', // horizon wash
].join(','),
backgroundPosition: [
'0 0', // violet core V
'0 0', // violet core H
'-3px 0', // violet bloom V
'0 -3px', // violet bloom H
'22px 22px', // teal core V
'22px 22px', // teal core H
'20px 22px', // teal bloom V
'22px 20px', // teal bloom H
'0 0', // vignette
'0 0', // horizon wash
].join(','),
},
};
+135
View File
@@ -0,0 +1,135 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// plaid — an authentic woven tartan, muted to a heather-wool hush.
//
// Real tartan is not a grid of lines: it is a *sett* — a repeating sequence of
// coloured bands of different widths — thrown in BOTH warp (vertical) and weft
// (horizontal) directions with the SAME sequence. Where a warp band crosses a
// weft band of the same colour the yarn density doubles and the colour visibly
// deepens; that reinforced overlap at every crossing is exactly what makes cloth
// read as woven rather than printed. We reproduce that physically with
// semi-transparent bands: a vertical band at alpha a and a horizontal band at
// alpha a stack to ~2a where they cross (over transparent to 1x elsewhere), so
// the crossings darken on their own with no extra layer.
//
// THE SETT (band widths across one tile)
// We use a few closely-related widths for a wool-flannel rhythm rather than a
// clean check: a wide ground band, a medium companion, and a thin accent
// over-stripe of a warmer hue (the classic single guard line). The identical
// sequence in warp and weft yields the tartan lattice. A faint diagonal twill
// hatch sits on top at very low alpha to suggest the 2/2 twill thread angle of
// woven wool. A soft central wash lifts the reading zone and a gentle vignette
// settles the corners.
//
// SEAMLESS TILING
// Every band layer is a `repeating-linear-gradient` whose stop sequence is
// expressed in px and whose period divides the tile exactly (dark tile 96px:
// wide=48, medium=24, accent=96; light tile 88px similarly). Warp layers repeat
// at 0deg-across (90deg gradient) and weft at 0deg, sharing one square
// `backgroundSize`, so the sett closes on itself with no seam in either axis.
// The twill hatch is a repeating-linear-gradient on a small square tile that
// divides the main tile. Wash and vignette are single non-repeating gradients
// at 100% 100%, so they never seam.
const dark: CSSProperties = {
// Deep muted forest-charcoal ground.
backgroundColor: 'oklch(0.19 0.018 155)',
backgroundImage: [
// Twill hatch — whisper-faint diagonal thread angle of the weave itself.
'repeating-linear-gradient(45deg,' +
' oklch(0.55 0.03 155 / 0.03) 0px, oklch(0.55 0.03 155 / 0.03) 1px,' +
' transparent 1px, transparent 4px)',
// WEFT (horizontal bands) --------------------------------------------
// Wide muted-forest ground band.
'repeating-linear-gradient(0deg,' +
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
' transparent 22px, transparent 48px)',
// Medium companion band (cooler, offset into the ground gap).
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 60px,' +
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
' transparent 72px, transparent 96px)',
// Thin warm amber guard line — the single accent over-stripe.
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 36px,' +
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
' transparent 38px, transparent 96px)',
// WARP (vertical bands, identical sett) -------------------------------
'repeating-linear-gradient(90deg,' +
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
' transparent 22px, transparent 48px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 60px,' +
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
' transparent 72px, transparent 96px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 36px,' +
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
' transparent 38px, transparent 96px)',
// Tonal wash — soft warm-green lift through the reading centre.
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.27 0.03 150 / 0.38) 0%, transparent 62%)',
// Vignette — feather the corners into deeper forest-charcoal.
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.14 0.016 155 / 0.55) 100%)',
].join(','),
backgroundSize:
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
'96px 96px, 96px 96px, 96px 96px,' + // weft: wide, medium, accent
'96px 96px, 96px 96px, 96px 96px,' + // warp: wide, medium, accent
'100% 100%, 100% 100%', // wash, vignette
};
const light: CSSProperties = {
// Warm off-white paper ground.
backgroundColor: 'oklch(0.965 0.007 85)',
backgroundImage: [
// Twill hatch — faint diagonal weave angle on paper.
'repeating-linear-gradient(45deg,' +
' oklch(0.45 0.03 250 / 0.025) 0px, oklch(0.45 0.03 250 / 0.025) 1px,' +
' transparent 1px, transparent 4px)',
// WEFT (horizontal bands) --------------------------------------------
// Wide dusty-blue ground band.
'repeating-linear-gradient(0deg,' +
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
' transparent 20px, transparent 44px)',
// Medium greige companion band.
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 55px,' +
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
' transparent 66px, transparent 88px)',
// Thin warm sand guard line — the single accent over-stripe.
'repeating-linear-gradient(0deg,' +
' transparent 0px, transparent 33px,' +
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
' transparent 35px, transparent 88px)',
// WARP (vertical bands, identical sett) -------------------------------
'repeating-linear-gradient(90deg,' +
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
' transparent 20px, transparent 44px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 55px,' +
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
' transparent 66px, transparent 88px)',
'repeating-linear-gradient(90deg,' +
' transparent 0px, transparent 33px,' +
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
' transparent 35px, transparent 88px)',
// Tonal wash — warm paper highlight through the reading centre.
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 62%)',
// Vignette — settle the corners into a slightly deeper dusty tone.
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.90 0.014 245 / 0.40) 100%)',
].join(','),
backgroundSize:
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
'88px 88px, 88px 88px, 88px 88px,' + // weft: wide, medium, accent
'88px 88px, 88px 88px, 88px 88px,' + // warp: wide, medium, accent
'100% 100%, 100% 100%', // wash, vignette
};
export const plaid: ChatBgVariants = { dark, light };
@@ -0,0 +1,59 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// polka — a grown-up polka dot: embossed leather / fine letterpress stationery,
// not childish spots. Each dot is not a flat circle but a soft radial "bump":
// an off-centre highlight fading into a faint recessed shadow, so it reads as a
// gently raised (or debossed) node catching a single top-left light. Two subtly
// different dot sizes are staggered on a half-tile offset for a refined,
// hand-set rhythm, and a large single vignette gradient adds quiet depth toward
// the edges.
//
// SEAMLESS TILING: both dot layers repeat on the SAME 44px cell (backgroundSize
// 44px 44px). The larger "primary" dots sit at 0 0; the smaller "secondary"
// dots are shifted by exactly half a tile (22px 22px) so they fall in the gaps
// of the primary lattice — a true staggered brick layout that wraps with no
// seam. Each radial gradient's highlight/shadow rings are fully enclosed well
// inside its cell, so nothing is clipped at a tile boundary. The vignette is a
// single non-repeating gradient covering the whole element ('cover').
//
// SUBTLETY: dot opacities live in the 0.030.10 range and every dot fades to
// transparent over a soft edge (no hard rim), so the surface is felt as tactile
// grain rather than read as dots. Crisp message text sits comfortably above it
// in both themes (WCAG-AA safe).
const dark: CSSProperties = {
// deep espresso base — warm, near-black brown
backgroundColor: 'oklch(0.19 0.018 65)',
backgroundImage: [
// vignette — corners settle darker so the field feels like supple leather
'radial-gradient(120% 120% at 50% 40%, oklch(0.22 0.02 65 / 0.5) 0%, oklch(0.19 0.018 65 / 0) 55%, oklch(0.15 0.015 60 / 0.55) 100%)',
// PRIMARY dot — larger raised pearl. Top-left warm highlight, then the body,
// then a whisper of shadow at the lower-right rim for embossed dimension.
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.10) 0%, oklch(0.80 0.02 80 / 0.075) 22%, oklch(0.55 0.02 70 / 0.045) 44%, oklch(0.12 0.01 60 / 0.05) 62%, transparent 72%)',
// SECONDARY dot — smaller, staggered into the gaps, fainter for depth layering
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.075) 0%, oklch(0.78 0.02 80 / 0.05) 26%, oklch(0.50 0.02 70 / 0.03) 52%, oklch(0.12 0.01 60 / 0.04) 70%, transparent 82%)',
].join(','),
// primary dots ~9px, secondary ~6px, both on the same 44px lattice
backgroundSize: 'cover, 44px 44px, 44px 44px',
// secondary offset by half a tile => staggered brick lattice
backgroundPosition: 'center, 0 0, 22px 22px',
};
const light: CSSProperties = {
// cream stationery base — warm off-white paper stock
backgroundColor: 'oklch(0.975 0.008 85)',
backgroundImage: [
// vignette — a gentle warm settling toward the edges, like heavy cotton paper
'radial-gradient(120% 120% at 50% 40%, oklch(0.99 0.006 85 / 0.5) 0%, oklch(0.975 0.008 85 / 0) 55%, oklch(0.945 0.012 80 / 0.55) 100%)',
// PRIMARY dot — soft taupe deboss. Faint paper highlight at top-left, taupe
// body, then a soft shadow lower-right so each dot reads pressed into the sheet.
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.35) 0%, oklch(0.72 0.02 70 / 0.075) 30%, oklch(0.60 0.025 65 / 0.09) 50%, oklch(0.55 0.025 60 / 0.05) 66%, transparent 76%)',
// SECONDARY dot — smaller, staggered, lighter for a two-tier hand-set rhythm
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.28) 0%, oklch(0.74 0.02 70 / 0.05) 34%, oklch(0.62 0.025 65 / 0.06) 56%, oklch(0.56 0.025 60 / 0.035) 72%, transparent 84%)',
].join(','),
backgroundSize: 'cover, 44px 44px, 44px 44px',
backgroundPosition: 'center, 0 0, 22px 22px',
};
export const polka: ChatBgVariants = { dark, light };
@@ -0,0 +1,91 @@
import { ChatBgVariants } from './types';
// stars — a deep-space starfield with subtle depth.
//
// Concept: three parallax layers of stars at different tile sizes and offsets
// (so the repeat never lines up and reads as a genuine random field), lifted
// onto a faint deep-blue->violet nebula wash for depth, and finished with a
// gentle center vignette that keeps the reading column the calmest area of the
// canvas. Every layer is a stacked radial-gradient — pure CSS, no assets.
//
// Layer stacking order (topmost first, as CSS paints image #1 on top):
// 1. bright near stars (crisp, sparse, largest tile)
// 2. mid stars (dimmer, medium tile)
// 3. faint blue far stars (haze, smallest tile — most repeats, least visible)
// 4. calming center vignette
// 5. nebula wash (deep blue -> violet)
// The three star tiles use coprime-ish sizes (137/191/233 dark) so their least
// common repeat is enormous and no seam is perceivable.
export const stars: ChatBgVariants = {
// Dark: bright/dim white + faint blue stars on a near-black cosmos, with a
// deep-blue->violet nebula and a soft vignette that darkens the calm center.
dark: {
backgroundColor: 'oklch(0.16 0.03 275)',
backgroundImage: [
// 1. bright near stars — crisp cool-white, sparse
'radial-gradient(circle at center, oklch(0.98 0.01 260 / 0.85) 0.6px, transparent 1.4px)',
// 2. mid stars — softer, more of them
'radial-gradient(circle at center, oklch(0.92 0.02 265 / 0.55) 0.6px, transparent 1.3px)',
// 3. faint blue far dust — the parallax haze
'radial-gradient(circle at center, oklch(0.80 0.06 255 / 0.30) 0.5px, transparent 1.1px)',
// 4. center vignette — keeps the reading column calmest
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 42%, oklch(0.10 0.03 270 / 0.55) 100%)',
// 5. nebula wash — deep blue -> violet drift
'radial-gradient(ellipse 140% 120% at 78% 12%, oklch(0.25 0.08 280 / 0.55) 0%, transparent 55%)',
'radial-gradient(ellipse 130% 110% at 18% 92%, oklch(0.20 0.06 250 / 0.50) 0%, transparent 58%)',
].join(','),
backgroundSize: [
'137px 137px', // near stars
'191px 191px', // mid stars
'233px 233px', // far dust
'100% 100%', // vignette
'100% 100%', // nebula A
'100% 100%', // nebula B
].join(','),
backgroundPosition: [
'0 0', // near
'61px 43px', // mid (offset breaks alignment)
'113px 97px', // far (offset again)
'0 0', // vignette
'0 0', // nebula A
'0 0', // nebula B
].join(','),
},
// Light: an airy pre-dawn sky. No literal white stars on white — instead very
// soft pale sparkles paired with the faintest cool-grey speckles, floated on a
// gentle cool gradient. Reads as elegant atmosphere, never as noise over text.
light: {
backgroundColor: 'oklch(0.965 0.008 255)',
backgroundImage: [
// 1. pale warm pre-dawn sparkles — a hair brighter than the sky
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.55) 0.6px, transparent 1.4px)',
// 2. tiny cool speckles — the merest hint of darkness for texture/contrast
'radial-gradient(circle at center, oklch(0.62 0.05 260 / 0.16) 0.5px, transparent 1.2px)',
// 3. faint far dust — very soft, most-repeated layer
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.12) 0.5px, transparent 1.1px)',
// 4. center vignette — brightens the calm reading center slightly
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
// 5. pre-dawn wash — cool blue high, warm blush low
'radial-gradient(ellipse 150% 120% at 80% 8%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
'radial-gradient(ellipse 140% 120% at 15% 95%, oklch(0.93 0.04 40 / 0.45) 0%, transparent 62%)',
].join(','),
backgroundSize: [
'149px 149px', // sparkles
'199px 199px', // speckles
'251px 251px', // far dust
'100% 100%', // vignette
'100% 100%', // wash A
'100% 100%', // wash B
].join(','),
backgroundPosition: [
'0 0', // sparkles
'71px 53px', // speckles
'127px 109px', // far dust
'0 0', // vignette
'0 0', // wash A
'0 0', // wash B
].join(','),
},
};
@@ -0,0 +1,93 @@
import { ChatBgVariants } from './types';
// tactical — a military tactical display / recon coordinate grid (MGRS-style).
//
// The motif is a fine grid nested inside bold sector squares, with a reticle
// crosshair (arms + ring) at every sector intersection, small stencil corner
// brackets inside each sector, and coordinate tick-marks along the sector edges
// — a convincing mil-spec map overlay rather than a plain dot grid.
//
// Layers (painted top-to-bottom):
// 1. SVG reticle/stencil tile (128px). Corner arms + quarter-ring arcs radiate
// from each of the four tile corners, so four neighbouring tiles combine
// into ONE full crosshair "+" with a full ring at every sector intersection.
// The tile also carries L-shaped stencil brackets, edge coordinate ticks and
// a micro centre reticle. Because every mark is anchored to the 128px tile
// lattice, it stays phase-locked to the grids below — no seams, no drift.
// 2. Sector lines (heavier) — 128px.
// 3. Fine recon grid (fainter) — 16px (128 = 8 × 16, so it nests
// exactly inside every sector with no beat/moiré).
// 4. A soft scan vignette that keeps the CENTRE calm and clear for text while
// letting the grid fall away slightly toward the edges — dimension without
// contrast.
//
// All strokes sit at low alpha (~0.030.30 on 1px marks) so the display is felt,
// not read: crisp message text stays comfortably WCAG-AA legible in both themes.
// A single shared top-left (0 0) origin keeps the reticle tile, the 128px sector
// grid and the 16px fine grid all in phase.
const DARK_RETICLE =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const LIGHT_RETICLE =
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
export const tactical: ChatBgVariants = {
// Phosphor amber/olive lines glowing on a near-black recon display.
dark: {
backgroundColor: 'oklch(0.17 0.012 95)',
backgroundImage: [
// 1. reticles + stencil brackets + coordinate ticks
DARK_RETICLE,
// 4. scan vignette: keeps the centre calm, eases grid contrast at edges.
'radial-gradient(135% 120% at 50% 46%, transparent 52%, oklch(0.11 0.01 100 / 0.55) 100%)',
// a faint phosphor bloom drifting off the top so the black isn't dead flat.
'radial-gradient(120% 90% at 50% 0%, oklch(0.24 0.03 90 / 0.45) 0%, transparent 60%)',
// 2. sector lines (heavier)
'linear-gradient(oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
// 3. fine recon grid (fainter)
'linear-gradient(oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'128px 128px', // reticle tile
'100% 100%', // vignette
'100% 100%', // phosphor bloom
'128px 128px', // sector V
'128px 128px', // sector H
'16px 16px', // fine V
'16px 16px', // fine H
].join(','),
// Shared top-left origin: reticle tile + 128px sector grid + 16px fine grid
// (128 = 8 × 16) stay phase-locked, so corner arms land on sector crossings.
},
// Olive-graphite recon grid printed on cool tactical paper.
light: {
backgroundColor: 'oklch(0.95 0.008 120)',
backgroundImage: [
LIGHT_RETICLE,
// scan vignette: gentle cool shading into the corners, calm centre.
'radial-gradient(135% 120% at 50% 46%, transparent 56%, oklch(0.86 0.02 125 / 0.5) 100%)',
// paper sheen toward the top so the surface reads like a printed sheet.
'radial-gradient(120% 90% at 50% 0%, oklch(0.98 0.006 120 / 0.7) 0%, transparent 60%)',
// sector lines (heavier)
'linear-gradient(oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
// fine recon grid (fainter)
'linear-gradient(oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
'linear-gradient(90deg, oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
].join(','),
backgroundSize: [
'128px 128px',
'100% 100%',
'100% 100%',
'128px 128px',
'128px 128px',
'16px 16px',
'16px 16px',
].join(','),
// Shared top-left origin keeps the reticle tile and both grids phase-locked.
},
};
@@ -0,0 +1,73 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// topographic — an elegant contour / elevation map.
//
// The motif is a delicate cartographic contour survey: nested rings suggest two
// gentle "peaks" and a shallow "valley", drawn with occasional heavier "index
// contour" lines for authenticity, all floating over a soft tonal wash. It is
// tuned to be *felt, not read* — line opacities sit well under legibility
// thresholds so crisp message text stays comfortably WCAG-AA in both themes.
//
// SEAMLESS TILING
// Each contour system is a `repeating-radial-gradient` whose ring period P is a
// clean divisor of its `backgroundSize` tile. A repeating-radial-gradient tiles
// seamlessly only when the tile edge falls on a whole number of ring periods, so
// every layer below uses tile = N * P. Peak A's fine (32px) and index (128px)
// layers share one 256px tile (256 = 8*32 = 2*128) AND one center, so the heavy
// index lines land exactly on every 4th fine ring — a true index contour, never
// drifting out of register. Peak B tiles 288 = 12*24; the valley tiles 384 =
// 8*48. The tonal washes/vignette are single non-repeating gradients sized to
// the same tiles, so nothing shows a visible seam.
const dark: CSSProperties = {
backgroundColor: 'oklch(0.205 0.018 235)',
backgroundImage: [
// Peak A — fine contour lines (soft teal), 32px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
' oklch(0.62 0.055 190 / 0.09) 27px, oklch(0.62 0.055 190 / 0.09) 28px, transparent 29px, transparent 32px)',
// Peak A — index (heavier) contour every 4th ring, 128px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
' oklch(0.66 0.06 190 / 0.10) 123px, oklch(0.66 0.06 190 / 0.10) 125px, transparent 126px, transparent 128px)',
// Peak B — fine contour lines (cooler sage-teal), 24px period.
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
' oklch(0.60 0.05 200 / 0.07) 20px, oklch(0.60 0.05 200 / 0.07) 21px, transparent 22px, transparent 24px)',
// Valley — broad shallow rings (very faint), 48px period.
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
' oklch(0.58 0.045 195 / 0.05) 43px, oklch(0.58 0.045 195 / 0.05) 44px, transparent 45px, transparent 48px)',
// Tonal wash — lifts the "peaks", sinks the corners for depth.
'radial-gradient(circle at 27% 34%, oklch(0.26 0.03 200 / 0.55) 0%, transparent 46%)',
'radial-gradient(circle at 78% 72%, oklch(0.24 0.028 205 / 0.45) 0%, transparent 44%)',
// Vignette — soft edge darkening keeps the field calm behind text.
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.15 0.02 235 / 0.55) 100%)',
].join(','),
backgroundSize:
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
};
const light: CSSProperties = {
backgroundColor: 'oklch(0.965 0.008 85)',
backgroundImage: [
// Peak A — fine contour lines (warm graphite/sand), 32px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
' oklch(0.45 0.03 70 / 0.08) 27px, oklch(0.45 0.03 70 / 0.08) 28px, transparent 29px, transparent 32px)',
// Peak A — index (heavier) contour every 4th ring, 128px period.
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
' oklch(0.40 0.035 68 / 0.10) 123px, oklch(0.40 0.035 68 / 0.10) 125px, transparent 126px, transparent 128px)',
// Peak B — fine contour lines (soft sage-graphite), 24px period.
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
' oklch(0.47 0.028 120 / 0.06) 20px, oklch(0.47 0.028 120 / 0.06) 21px, transparent 22px, transparent 24px)',
// Valley — broad shallow rings (very faint), 48px period.
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
' oklch(0.46 0.025 75 / 0.045) 43px, oklch(0.46 0.025 75 / 0.045) 44px, transparent 45px, transparent 48px)',
// Tonal wash — warm paper highlights over the "peaks".
'radial-gradient(circle at 27% 34%, oklch(0.985 0.012 85 / 0.60) 0%, transparent 46%)',
'radial-gradient(circle at 78% 72%, oklch(0.945 0.014 95 / 0.45) 0%, transparent 44%)',
// Vignette — feather the edges to a slightly deeper sand for depth.
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.90 0.016 80 / 0.45) 100%)',
].join(','),
backgroundSize:
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
};
export const topographic: ChatBgVariants = { dark, light };
@@ -0,0 +1,103 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// triangles — elegant low-poly / faceted-crystal mesh.
//
// The motif stays true to its name — a triangular tessellation — but is rebuilt
// to read as a *faceted crystalline surface* rather than the old flat isometric
// lines. Neighbouring triangular facets carry barely-there tonal shifts (one
// face catches a whisper of light, the adjacent one falls into a whisper of
// shade) so the plane looks gently faceted and dimensional, like brushed slate
// or cut glass seen at a shallow angle. A hairline "mesh glint" traces the facet
// edges so the crystalline structure is felt, never read. A soft tonal wash and
// a feathered vignette give the whole field quiet architectural depth.
//
// FACET SHADING
// An isometric triangle grid is three families of parallel lines at 0deg, 60deg
// and 120deg. Each `linear-gradient` below is a *hard-edged* two-band ramp along
// one of those axes: a faint tonal band followed by transparent, repeating
// across the tile. Overlapping the three axes partitions the plane into small
// triangular cells; because each axis contributes its shade to a different set
// of cells, up-pointing and down-pointing facets end up carrying subtly
// different summed tones — the alternating light/shadow facet look. A separate
// hairline layer per axis draws the thin edge glint at the facet borders.
//
// SEAMLESS TILING
// Equilateral geometry needs the tile height to be the width times sqrt(3). We
// use a 48x83px tile (48 * 1.732 = 83.1, rounded to 83) so the 60deg/120deg
// ramps close exactly on the tile box, and the horizontal edge family repeats on
// half-height (48x41.5 -> the 0deg hairline is sized to the full tile so its
// bands land on tile edges). Every facet-shade and edge layer shares this tile
// (or an exact multiple), and the 60/120 layers meet at the tile's mid columns,
// so triangles interlock across every seam with no drift. Wash and vignette are
// single non-repeating gradients at 100% 100%, so they never seam.
const dark: CSSProperties = {
// Deep navy base — the crystal sits on cool night stone.
backgroundColor: 'oklch(0.19 0.028 258)',
backgroundImage: [
// --- Facet shading: three cool-slate tonal ramps, one per triangle axis.
// Ascending-diagonal facets — a soft light band on one face family.
'linear-gradient(60deg,' +
' oklch(0.46 0.03 250 / 0.07) 0%, oklch(0.46 0.03 250 / 0.07) 50%,' +
' transparent 50%, transparent 100%)',
// Descending-diagonal facets — the shade family, closing the triangles.
'linear-gradient(120deg,' +
' oklch(0.34 0.03 255 / 0.06) 0%, oklch(0.34 0.03 255 / 0.06) 50%,' +
' transparent 50%, transparent 100%)',
// Horizontal facets — a third, fainter slate band so cells read three-sided.
'linear-gradient(0deg,' +
' oklch(0.42 0.028 248 / 0.045) 0%, oklch(0.42 0.028 248 / 0.045) 50%,' +
' transparent 50%, transparent 100%)',
// --- Mesh glint: hairline edges tracing the crystalline facet borders.
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
' transparent 50%)',
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
' transparent 50%)',
// --- Tonal wash — a gentle cool lift through the reading centre for depth.
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.28 0.03 255 / 0.45) 0%, transparent 62%)',
// --- Vignette — feather the corners into deeper navy.
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.13 0.022 258 / 0.55) 100%)',
].join(','),
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
// Offset the 120deg (shade) and its glint by half a tile so up/down facets
// interlock — this is what alternates the light/shadow triangles.
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
};
const light: CSSProperties = {
// Pale ice-white base — cut glass on frosted paper.
backgroundColor: 'oklch(0.975 0.004 250)',
backgroundImage: [
// --- Facet shading: soft cool-grey tonal ramps, one per triangle axis.
// Ascending-diagonal facets — a barely-there shade on one face family.
'linear-gradient(60deg,' +
' oklch(0.66 0.022 252 / 0.09) 0%, oklch(0.66 0.022 252 / 0.09) 50%,' +
' transparent 50%, transparent 100%)',
// Descending-diagonal facets — a hair darker, closing the triangles.
'linear-gradient(120deg,' +
' oklch(0.58 0.024 255 / 0.08) 0%, oklch(0.58 0.024 255 / 0.08) 50%,' +
' transparent 50%, transparent 100%)',
// Horizontal facets — the third, faintest cool-grey band.
'linear-gradient(0deg,' +
' oklch(0.62 0.02 250 / 0.055) 0%, oklch(0.62 0.02 250 / 0.055) 50%,' +
' transparent 50%, transparent 100%)',
// --- Mesh glint: crisp hairline facet edges in cool slate.
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
' transparent 50%)',
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
' transparent 50%)',
// --- Tonal wash — a clean white highlight through the reading centre.
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.995 0.003 250 / 0.60) 0%, transparent 62%)',
// --- Vignette — settle the corners into a faint cool grey.
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.90 0.012 252 / 0.42) 100%)',
].join(','),
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
};
export const triangles: ChatBgVariants = { dark, light };
@@ -0,0 +1,13 @@
import { CSSProperties } from 'react';
// A chat background provides an independently-tuned CSSProperties per app theme:
// the `dark` variant is a subtle light-ish pattern on a dark base, the `light`
// variant a subtle dark-ish pattern on a light base. Each sits DIRECTLY behind
// the chat message list, so both must stay subtle enough to preserve WCAG-AA
// text legibility. Animated backgrounds include an `animation`; getChatBg strips
// it for prefers-reduced-motion / pause-animations, so the remaining properties
// must already read as a finished static background on their own.
export type ChatBgVariants = {
dark: CSSProperties;
light: CSSProperties;
};
@@ -0,0 +1,68 @@
import { CSSProperties } from 'react';
import { ChatBgVariants } from './types';
// waves — a serene, rhythmic ocean swell / sound-wave contour.
//
// The motif is three stacked sine contours — layered swell at slightly varied
// amplitude, weight and opacity — floating over a soft vertical depth wash so
// the field reads like gentle water or sculpted sand. It is tuned to be *felt,
// not read*: every stroke sits well under legibility thresholds so crisp
// message text stays comfortably WCAG-AA in both themes.
//
// TRUE SINE CURVES VIA INLINE SVG
// Gradients can't draw a real sine, so each wave is a polyline sampling of
// y = yc - amp*sin(2*pi*N*x/W), rendered as an inline SVG data-URI (fully
// URL-encoded, so it is CSP/Tauri-safe and needs no external asset). oklch()
// stroke colors give perceptually even, low-chroma lines.
//
// SEAMLESS TILING
// The SVG tile is 240x120 with EXACTLY N=2 whole periods across its 240px
// width, so the first and last sample of every wave share the same y — the
// horizontal repeat has no seam. All three contours live within y = 24..106,
// clear of the 0/120 tile edges, so the vertical repeat is seam-free too. To
// avoid a rigid stacked look, the same tile is layered a second time shifted by
// half a tile (120px x, 60px y) at lower opacity, weaving the rows into a
// continuous drifting swell. backgroundSize = 240px 120px keeps the SVG at its
// authored scale; the depth wash is a single 100% gradient sized to match.
const waveTileDark =
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.65%200.08%20200%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.68%200.07%20195%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.62%200.075%20205%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const waveTileLight =
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.62%200.045%20235%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.66%200.04%20240%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.60%200.05%20230%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
const dark: CSSProperties = {
// Deep ink-blue base — the "water" the swell floats on.
backgroundColor: 'oklch(0.19 0.03 245)',
backgroundImage: [
// Primary swell — teal/aqua sine contours.
waveTileDark,
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
waveTileDark,
// Depth wash — subtle lift toward the top, sink toward the bottom.
'linear-gradient(180deg, oklch(0.24 0.04 240 / 0.5) 0%, oklch(0.16 0.025 250 / 0.5) 100%)',
].join(','),
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
backgroundPosition: '0 0, 120px 60px, 0 0',
// Dim the offset echo layer relative to the primary swell.
backgroundBlendMode: 'normal, soft-light, normal',
};
const light: CSSProperties = {
// Soft warm white base — like sunlit paper or pale sand.
backgroundColor: 'oklch(0.975 0.006 240)',
backgroundImage: [
// Primary swell — pale blue-grey sine contours.
waveTileLight,
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
waveTileLight,
// Depth wash — faint cool tint feathering toward the bottom for calm depth.
'linear-gradient(180deg, oklch(0.99 0.004 240 / 0.5) 0%, oklch(0.95 0.01 245 / 0.5) 100%)',
].join(','),
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
backgroundPosition: '0 0, 120px 60px, 0 0',
// Dim the offset echo layer relative to the primary swell.
backgroundBlendMode: 'normal, multiply, normal',
};
export const waves: ChatBgVariants = { dark, light };
+62 -409
View File
@@ -1,12 +1,24 @@
import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
import {
animRainKeyframe,
animStarsDriftKeyframe,
animGridPulseKeyframe,
animAuroraKeyframe,
animFirefliesKeyframe,
} from '../../styles/Animations.css';
import { blueprint } from './backgrounds/blueprint';
import { stars } from './backgrounds/stars';
import { topographic } from './backgrounds/topographic';
import { herringbone } from './backgrounds/herringbone';
import { crosshatch } from './backgrounds/crosshatch';
import { chevron } from './backgrounds/chevron';
import { polka } from './backgrounds/polka';
import { triangles } from './backgrounds/triangles';
import { plaid } from './backgrounds/plaid';
import { tactical } from './backgrounds/tactical';
import { circuit } from './backgrounds/circuit';
import { hexgrid } from './backgrounds/hexgrid';
import { waves } from './backgrounds/waves';
import { neon } from './backgrounds/neon';
import { animRain } from './backgrounds/animRain';
import { animStars } from './backgrounds/animStars';
import { animPulse } from './backgrounds/animPulse';
import { animAurora } from './backgrounds/animAurora';
import { animFireflies } from './backgrounds/animFireflies';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -33,20 +45,14 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'anim-fireflies', label: 'Fireflies' },
];
// `none`, `carbon` and `aurora` stay inline: carbon + aurora are the kept user
// favorites, none is the empty layer. Every other background is a premium
// per-pattern module under ./backgrounds/ (each exposes a `dark` + `light`
// variant). Keeping the whole record here lets getChatBg stay the single entry
// point and preserves the Record<ChatBackground, ...> exhaustiveness check.
const DARK: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#0a1628',
backgroundImage: [
'linear-gradient(rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(rgba(100,149,237,0.05) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#0e0e0e',
backgroundImage: [
@@ -55,138 +61,6 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '8px 8px',
},
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#0f0f17',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(152,0,0,0.07) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(100,100,200,0.06) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(152,0,0,0.04) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#111118',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#0f0f0f',
backgroundImage: [
'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.022) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
// Interlocking zigzag stripes
chevron: {
backgroundColor: '#0f0f17',
backgroundImage: [
'linear-gradient(135deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
// Even dot grid
polka: {
backgroundColor: '#0e0e14',
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.2) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
// Isometric triangle grid
triangles: {
backgroundColor: '#111118',
backgroundImage: [
'linear-gradient(60deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
'linear-gradient(120deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
// Tartan-inspired crossing lines with accent colour
plaid: {
backgroundColor: '#0a1020',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
].join(','),
},
// LotusGuild TDS exact dot-grid
tactical: {
backgroundColor: '#030508',
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
// Circuit board — green grid with node dots
circuit: {
backgroundColor: '#040a04',
backgroundImage: [
'linear-gradient(rgba(0,255,136,0.045) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,255,136,0.045) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,255,136,0.20) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
// True pointy-top hexagonal grid via SVG data URI
hexgrid: {
backgroundColor: '#060c14',
backgroundImage:
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
// Flowing sine-wave lines
waves: {
backgroundColor: '#080c18',
backgroundImage: [
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(80,130,255,0.07) 19px, transparent 20px)',
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(80,130,255,0.05) 29px, transparent 30px)',
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(100,60,200,0.06) 23px, transparent 24px)',
].join(','),
},
// Neon cyberpunk grid — orange/cyan TDS colors
neon: {
backgroundColor: '#020408',
backgroundImage: [
'linear-gradient(rgba(255,107,0,0.10) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,107,0,0.10) 1px, transparent 1px)',
'linear-gradient(rgba(0,212,255,0.05) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,212,255,0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
// Aurora borealis — flowing gradient bands
aurora: {
backgroundColor: '#030810',
backgroundImage: [
@@ -197,86 +71,30 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
},
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker
'anim-rain': {
backgroundColor: '#010804',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,255,136,0.16) 0px, rgba(0,255,136,0.16) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
},
// Animated: drifting star field — three seamlessly-tiling layers at different speeds
'anim-stars': {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,220,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(180,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
},
// Animated: neon grid pulse — size breathe + independent brightness oscillation
'anim-pulse': {
backgroundColor: '#030508',
backgroundImage: [
'linear-gradient(rgba(255,107,0,0.12) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,107,0,0.12) 1px, transparent 1px)',
'linear-gradient(rgba(0,212,255,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
},
// Animated: aurora borealis — four bands each travel an independent path
'anim-aurora': {
backgroundColor: '#020a10',
backgroundImage: [
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,255,136,0.12) 0%, transparent 70%)',
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,100,255,0.12) 0%, transparent 70%)',
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(191,95,255,0.09) 0%, transparent 65%)',
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,212,255,0.09) 0%, transparent 65%)',
].join(','),
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
},
// Animated: fireflies — drift + brightness glow + opacity blink at prime periods
'anim-fireflies': {
backgroundColor: '#030508',
backgroundImage: [
'radial-gradient(circle, rgba(255,220,50,0.7) 1.5px, rgba(255,160,0,0.18) 3px, transparent 4px)',
'radial-gradient(circle, rgba(255,200,30,0.55) 1px, rgba(255,140,0,0.14) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(255,240,100,0.4) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 30s linear infinite`,
},
blueprint: blueprint.dark,
stars: stars.dark,
topographic: topographic.dark,
herringbone: herringbone.dark,
crosshatch: crosshatch.dark,
chevron: chevron.dark,
polka: polka.dark,
triangles: triangles.dark,
plaid: plaid.dark,
tactical: tactical.dark,
circuit: circuit.dark,
hexgrid: hexgrid.dark,
waves: waves.dark,
neon: neon.dark,
'anim-rain': animRain.dark,
'anim-stars': animStars.dark,
'anim-pulse': animPulse.dark,
'anim-aurora': animAurora.dark,
'anim-fireflies': animFireflies.dark,
};
const LIGHT: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#eef3ff',
backgroundImage: [
'linear-gradient(rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(rgba(50,100,220,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#efefef',
backgroundImage: [
@@ -285,129 +103,6 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '8px 8px',
},
// Stars is intentionally always dark — it's a night-sky theme
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#faf8f5',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(100,60,60,0.09) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(60,60,130,0.07) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(100,60,60,0.05) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#f9f9f9',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#ffffff',
backgroundImage: [
'linear-gradient(rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.025) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
chevron: {
backgroundColor: '#f9f8ff',
backgroundImage: [
'linear-gradient(135deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
polka: {
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.18) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
triangles: {
backgroundColor: '#f4f7ff',
backgroundImage: [
'linear-gradient(60deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
'linear-gradient(120deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
plaid: {
backgroundColor: '#f5f0ff',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
].join(','),
},
tactical: {
backgroundColor: '#f0f4fa',
backgroundImage: 'radial-gradient(circle, rgba(0,100,200,0.08) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
circuit: {
backgroundColor: '#f0f8f0',
backgroundImage: [
'linear-gradient(rgba(0,160,80,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,160,80,0.06) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,160,80,0.22) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
hexgrid: {
backgroundColor: '#f4f8ff',
backgroundImage:
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
waves: {
backgroundColor: '#eef3ff',
backgroundImage: [
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(50,100,220,0.09) 19px, transparent 20px)',
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(50,100,220,0.07) 29px, transparent 30px)',
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(80,40,180,0.07) 23px, transparent 24px)',
].join(','),
},
neon: {
backgroundColor: '#fafafa',
backgroundImage: [
'linear-gradient(rgba(196,78,0,0.12) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(196,78,0,0.12) 1px, transparent 1px)',
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
aurora: {
backgroundColor: '#f4faf8',
backgroundImage: [
@@ -418,67 +113,25 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','),
},
// Animated light variants
'anim-rain': {
backgroundColor: '#f0fff4',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,160,80,0.16) 0px, rgba(0,160,80,0.16) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
},
'anim-stars': {
backgroundColor: '#f5f5ff',
backgroundImage: [
'radial-gradient(circle, rgba(60,60,160,0.50) 1px, transparent 1px)',
'radial-gradient(circle, rgba(80,80,180,0.35) 1px, transparent 1px)',
'radial-gradient(circle, rgba(100,100,200,0.20) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
},
'anim-pulse': {
backgroundColor: '#ffffff',
backgroundImage: [
'linear-gradient(rgba(0,98,184,0.14) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.14) 1px, transparent 1px)',
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
},
'anim-aurora': {
backgroundColor: '#f0f8f4',
backgroundImage: [
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,160,80,0.13) 0%, transparent 70%)',
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,80,200,0.13) 0%, transparent 70%)',
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(140,60,220,0.10) 0%, transparent 65%)',
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,160,200,0.10) 0%, transparent 65%)',
].join(','),
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
},
'anim-fireflies': {
backgroundColor: '#fffdf0',
backgroundImage: [
'radial-gradient(circle, rgba(180,120,0,0.70) 1.5px, rgba(160,90,0,0.18) 3px, transparent 4px)',
'radial-gradient(circle, rgba(160,100,0,0.55) 1px, rgba(140,80,0,0.14) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(200,140,0,0.40) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 30s linear infinite`,
},
blueprint: blueprint.light,
stars: stars.light,
topographic: topographic.light,
herringbone: herringbone.light,
crosshatch: crosshatch.light,
chevron: chevron.light,
polka: polka.light,
triangles: triangles.light,
plaid: plaid.light,
tactical: tactical.light,
circuit: circuit.light,
hexgrid: hexgrid.light,
waves: waves.light,
neon: neon.light,
'anim-rain': animRain.light,
'anim-stars': animStars.light,
'anim-pulse': animPulse.light,
'anim-aurora': animAurora.light,
'anim-fireflies': animFireflies.light,
};
export const getChatBg = (
@@ -133,7 +133,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
<Text size="T300" truncate>
{room.name}
</Text>
<Text size="T200" style={{ opacity: 0.55 }}>
<Text size="T200" priority="300">
{msgEvents.length > 0
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
: 'No messages cached yet'}
@@ -153,7 +153,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
</Button>
)}
{!canLoadMore && events.length > 0 && (
<Text size="T200" style={{ opacity: 0.5, flexShrink: 0 }}>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
Fully cached
</Text>
)}
@@ -656,7 +656,7 @@ export function MessageSearch({
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
{!senderOnlyMode && (
<Text size="T200" style={{ opacity: 0.55 }}>
<Text size="T200" priority="300">
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
</Text>
)}
@@ -280,7 +280,8 @@ export function SearchInput({
<Text
size="T200"
truncate
style={{ opacity: 0.6, fontFamily: 'monospace', fontSize: '0.75em' }}
priority="300"
style={{ fontFamily: 'monospace', fontSize: '0.75em' }}
>
{user.userId}
</Text>
@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
// (event.content.msgtype, event.event_id, group.roomId).
const item = (msgtype: string | undefined, eventId: string) => ({
rank: 1,
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
context: {},
});
const mkGroups = (
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
): ResultGroup[] => groups as unknown as ResultGroup[];
test('filterGroupsByMsgType: empty filter returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1'), item('m.image', '$2')] });
assert.equal(filterGroupsByMsgType(groups, []), groups);
});
test('filterGroupsByMsgType: keeps only matching msgtypes (union)', () => {
const groups = mkGroups({
roomId: '!r1',
items: [item('m.text', '$1'), item('m.image', '$2'), item('m.file', '$3')],
});
const out = filterGroupsByMsgType(groups, ['m.image', 'm.file']);
assert.equal(out.length, 1);
assert.deepEqual(
out[0].items.map((i) => i.event.event_id),
['$2', '$3'],
);
});
test('filterGroupsByMsgType: drops groups left empty', () => {
const groups = mkGroups(
{ roomId: '!r1', items: [item('m.text', '$1')] },
{ roomId: '!r2', items: [item('m.image', '$2')] },
);
const out = filterGroupsByMsgType(groups, ['m.image']);
assert.equal(out.length, 1);
assert.equal(out[0].roomId, '!r2');
});
test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
const groups = mkGroups({ roomId: '!r1', items: [item(undefined, '$1'), item('m.video', '$2')] });
const out = filterGroupsByMsgType(groups, ['m.video']);
assert.equal(out[0].items.length, 1);
assert.equal(out[0].items[0].event.event_id, '$2');
});
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
assert.equal(
filterGroupsByPinned(groups, false, () => false),
groups,
);
});
test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
const groups = mkGroups(
{ roomId: '!r1', items: [item('m.text', '$1'), item('m.text', '$2')] },
{ roomId: '!r2', items: [item('m.text', '$3')] },
);
const pinned = new Set(['!r1/$2']);
const out = filterGroupsByPinned(groups, true, (roomId, eventId) =>
pinned.has(`${roomId}/${eventId}`),
);
assert.equal(out.length, 1);
assert.equal(out[0].roomId, '!r1');
assert.deepEqual(
out[0].items.map((i) => i.event.event_id),
['$2'],
);
});
@@ -11,6 +11,7 @@ import {
RoomLocalAddresses,
RoomPublishedAddresses,
RoomPublish,
RoomQuality,
RoomShareInvite,
RoomUpgrade,
RoomVoiceLimit,
@@ -58,6 +59,7 @@ export function General({ requestClose }: GeneralProps) {
<Box direction="Column" gap="100">
<Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} />
<RoomQuality permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
+104 -91
View File
@@ -6,6 +6,8 @@ import {
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
Scroll,
Spinner,
Text,
@@ -15,6 +17,7 @@ import {
config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
@@ -250,102 +253,112 @@ function Lightbox({
});
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
role="dialog"
aria-modal
aria-label="Media viewer"
onKeyDown={handleKeyDown}
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header bar */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
</Text>
</Box>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
role="dialog"
aria-modal
aria-label="Media viewer"
onKeyDown={handleKeyDown}
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
flexDirection: 'column',
}}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Header bar */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
}}
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
</Text>
</Box>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Media area with nav arrows */}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
{/* Media area with nav arrows */}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', height: '100%' }}
>
<LightboxMedia
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', height: '100%' }}
>
<LightboxMedia
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
</FocusTrap>
</Overlay>
);
}
@@ -241,7 +241,7 @@ export function ScheduleMessageModal({
<Text size="L400">Send at</Text>
<Box gap="200">
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="schedule-date" size="T200" style={{ opacity: 0.7 }}>
<Text as="label" htmlFor="schedule-date" size="T200" priority="400">
Date
</Text>
<input
@@ -253,7 +253,7 @@ export function ScheduleMessageModal({
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="schedule-time" size="T200" style={{ opacity: 0.7 }}>
<Text as="label" htmlFor="schedule-time" size="T200" priority="400">
Time
</Text>
<input
@@ -140,17 +140,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
>
<Text
size="T200"
priority="400"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.8,
}}
>
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
</Text>
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
+76
View File
@@ -0,0 +1,76 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MsgType } from 'matrix-js-sdk';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { getAudioMsgContent, getFileMsgContent } from './msgContent';
import { TUploadItem } from '../../state/room/roomInputDrafts';
// Pure builders getAudioMsgContent / getFileMsgContent: msgtype/body/filename/info
// shape plus the encInfo branch (content.file w/ url vs plain content.url). The
// image/video builders need a DOM + MatrixClient and are not covered here.
const MXC = 'mxc://example.org/abc';
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem => {
const file = { name: 'sound.ogg', type: 'audio/ogg', size: 4096 };
return {
file,
originalFile: file,
metadata: { markedAsSpoiler: false },
encInfo,
} as unknown as TUploadItem;
};
const fakeEncInfo = (): EncryptedAttachmentInfo =>
({
v: 'v2',
key: { alg: 'A256CTR' },
iv: 'iv',
hashes: { sha256: 'h' },
}) as unknown as EncryptedAttachmentInfo;
test('getAudioMsgContent builds an unencrypted audio message', () => {
const content = getAudioMsgContent(makeItem(), MXC);
assert.equal(content.msgtype, MsgType.Audio);
assert.equal(content.body, 'sound.ogg');
assert.equal(content.filename, 'sound.ogg');
assert.deepEqual(content.info, { mimetype: 'audio/ogg', size: 4096 });
assert.equal(content.url, MXC);
assert.equal(content.file, undefined);
});
test('getAudioMsgContent uses content.file with url when encInfo is present', () => {
const enc = fakeEncInfo();
const content = getAudioMsgContent(makeItem(enc), MXC);
assert.equal(content.url, undefined);
assert.deepEqual(content.file, { ...enc, url: MXC });
});
test('getFileMsgContent builds an unencrypted file message', () => {
const content = getFileMsgContent(makeItem(), MXC);
assert.equal(content.msgtype, MsgType.File);
assert.equal(content.body, 'sound.ogg');
assert.equal(content.filename, 'sound.ogg');
assert.deepEqual(content.info, { mimetype: 'audio/ogg', size: 4096 });
assert.equal(content.url, MXC);
assert.equal(content.file, undefined);
});
test('getFileMsgContent uses content.file with url when encInfo is present', () => {
const enc = fakeEncInfo();
const content = getFileMsgContent(makeItem(enc), MXC);
assert.equal(content.url, undefined);
assert.deepEqual(content.file, { ...enc, url: MXC });
});
test('info mirrors the file mimetype and size', () => {
const item = {
file: { name: 'doc.pdf', type: 'application/pdf', size: 12 },
originalFile: { name: 'doc.pdf', type: 'application/pdf', size: 12 },
metadata: { markedAsSpoiler: false },
encInfo: undefined,
} as unknown as TUploadItem;
const content = getFileMsgContent(item, MXC);
assert.deepEqual(content.info, { mimetype: 'application/pdf', size: 12 });
assert.equal(content.body, 'doc.pdf');
});
@@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList';
import { OidcManageAccount } from './OidcManageAccount';
type AccountProps = {
requestClose: () => void;
@@ -32,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
<Box direction="Column" gap="700">
<Profile />
<MatrixId />
<OidcManageAccount />
<ContactInformation />
<IgnoredUserList />
</Box>
@@ -0,0 +1,49 @@
import React, { useCallback } from 'react';
import { Box, Chip, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { withSearchParam } from '../../../pages/pathUtils';
/**
* On OIDC/next-gen-auth servers, profile/password/sessions are managed by the
* authentication service surface a deep-link to its account page (MSC2965
* account-management URL). Renders nothing on password/legacy-SSO servers, where
* `useAuthMetadata()` is undefined.
*/
export function OidcManageAccount() {
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const open = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(withSearchParam(authUrl, { action: accountManagementActions.profile }), '_blank');
}, [authMetadata, accountManagementActions]);
if (!authMetadata) return null;
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Management</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Manage account"
description="Your profile, password, and sessions are managed by your single sign-on provider."
after={
<Chip variant="Secondary" radii="Pill" onClick={open}>
<Text size="T200">Open</Text>
</Chip>
}
/>
</SequenceCard>
</Box>
);
}
+14 -63
View File
@@ -30,6 +30,7 @@ import {
} from 'folds';
import { Method } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
@@ -541,38 +542,15 @@ function ProfileStatus() {
</Text>
)}
<Box alignItems="Center" gap="200">
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
Auto-clear after:
</Text>
<select
<SettingsSelect
value={clearAfter}
onChange={(e) => setClearAfter(e.target.value)}
options={CLEAR_AFTER_OPTIONS}
onChange={setClearAfter}
aria-label="Auto-clear status after"
style={{
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
cursor: 'pointer',
outline: 'none',
}}
>
{CLEAR_AFTER_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{opt.label}
</option>
))}
</select>
/>
</Box>
{(presence?.status || statusMsg) && (
<Button
@@ -781,10 +759,6 @@ function ProfileTimezone() {
);
const saving = saveState.status === AsyncStatus.Loading;
const handleSelectChange = (evt: React.ChangeEvent<HTMLSelectElement>) => {
setTimezone(evt.currentTarget.value);
};
const handleReset = () => {
setTimezone(savedTimezone);
};
@@ -813,39 +787,16 @@ function ProfileTimezone() {
<Box direction="Column" grow="Yes" gap="100">
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box grow="Yes" direction="Column">
<select
name="timezoneInput"
aria-label="Timezone"
<SettingsSelect
value={timezone}
onChange={handleSelectChange}
options={[
{ value: '', label: '— select timezone —' },
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
]}
onChange={setTimezone}
disabled={saving}
style={{
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.875rem',
padding: `${config.space.S200} ${config.space.S300}`,
width: '100%',
cursor: 'pointer',
outline: 'none',
}}
>
<option value=""> select timezone </option>
{COMMON_TIMEZONES.map((tz) => (
<option
key={tz}
value={tz}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{tz}
</option>
))}
</select>
aria-label="Timezone"
/>
</Box>
{hasChanges && !saving && (
<IconButton
@@ -218,7 +218,7 @@ export function ProfileDecoration() {
>
{DECORATION_CATEGORIES.map((category) => (
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Text size="L400" style={{ opacity: 0.7 }}>
<Text size="L400" priority="400">
{category.label}
</Text>
<div
+104 -78
View File
@@ -45,6 +45,7 @@ import {
} from '../../../utils/lotusDenoiseUtils';
import { useSetting } from '../../../state/hooks/settings';
import {
CallAudioBitrate,
ChatBackground,
ComposerToolbarSettings,
DateFormat,
@@ -53,10 +54,18 @@ import {
MessageSpacing,
NoiseSuppressionMode,
RingtoneId,
ScreenshareBitrate,
ScreenshareFramerate,
Settings,
settingsAtom,
} from '../../../state/settings';
import {
AUDIO_BITRATE_OPTIONS,
SCREENSHARE_BITRATE_OPTIONS,
SCREENSHARE_FRAMERATE_OPTIONS,
} from '../../../utils/callQuality';
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -81,6 +90,7 @@ import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@@ -169,83 +179,6 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
);
}
type SettingsSelectOption<T extends string> = { value: T; label: string; disabled?: boolean };
function SettingsSelect<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
@@ -514,7 +447,7 @@ function Appearance() {
>
<SettingTile
title="Seasonal Theme"
description="Decorative overlays for holidays and events. Preview below — click to select."
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
<SeasonalBgGrid
@@ -1296,6 +1229,18 @@ function Calls() {
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
const [callAudioBitrate, setCallAudioBitrate] = useSetting(settingsAtom, 'callAudioBitrate');
const [screenshareBitrate, setScreenshareBitrate] = useSetting(
settingsAtom,
'screenshareBitrate',
);
const [screenshareFramerate, setScreenshareFramerate] = useSetting(
settingsAtom,
'screenshareFramerate',
);
const [soundboardEnabled, setSoundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
const [soundboardVolume, setSoundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
setCallJoinLeaveSound(value);
if (value !== 'off') playCallJoinSound(value);
@@ -1691,6 +1636,80 @@ function Calls() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Microphone Bitrate"
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
after={
<SettingsSelect<CallAudioBitrate>
value={callAudioBitrate}
onChange={setCallAudioBitrate}
options={AUDIO_BITRATE_OPTIONS}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screenshare Bitrate"
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
after={
<SettingsSelect<ScreenshareBitrate>
value={screenshareBitrate}
onChange={setScreenshareBitrate}
options={SCREENSHARE_BITRATE_OPTIONS}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screenshare Framerate"
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
after={
<SettingsSelect<ScreenshareFramerate>
value={screenshareFramerate}
onChange={setScreenshareFramerate}
options={SCREENSHARE_FRAMERATE_OPTIONS}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Soundboard"
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
after={
<Switch variant="Primary" value={soundboardEnabled} onChange={setSoundboardEnabled} />
}
/>
{soundboardEnabled && (
<SettingTile
title="Soundboard Volume"
after={
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
<input
type="range"
min={0}
max={100}
step={5}
value={soundboardVolume}
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
style={{ flexGrow: 1 }}
aria-label="Soundboard volume"
/>
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
{soundboardVolume}%
</Text>
</Box>
}
/>
)}
</SequenceCard>
</Box>
);
}
@@ -1775,6 +1794,13 @@ function SeasonalBgGrid({
<Text size="T200" style={selected ? { color: color.Primary.Main } : undefined}>
{opt.label}
</Text>
{(opt.value === 'auto' || !isSpecial) && (
<Text size="T200" style={{ opacity: 0.6, textAlign: 'center' }}>
{opt.value === 'auto'
? 'By calendar'
: SEASON_DATE_RANGES[opt.value as SeasonTheme]}
</Text>
)}
</Box>
);
})}
@@ -1,6 +1,7 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
@@ -193,10 +194,6 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
setRuleId(evt.currentTarget.value);
};
const handleModeChange: ChangeEventHandler<HTMLSelectElement> = (evt) => {
setMode(evt.target.value as NotificationMode);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
<Text size="T200" priority="300">
@@ -217,24 +214,12 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
/>
</Box>
<Box shrink="No">
<select
<SettingsSelect
value={mode}
onChange={handleModeChange}
style={{
background: 'transparent',
border: '1px solid currentColor',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: 'inherit',
fontSize: 'inherit',
}}
>
{ADD_MODES.map((m) => (
<option key={m} value={m}>
{MODE_LABELS[m]}
</option>
))}
</select>
options={ADD_MODES.map((m) => ({ value: m, label: MODE_LABELS[m] }))}
onChange={setMode}
aria-label="Notification mode"
/>
</Box>
<Button
size="400"
@@ -4,6 +4,7 @@ import { color, config, Icon, IconButton, Icons } from 'folds';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { zIndices } from '../../styles/zIndex';
// Inject the keyframe animation once
const STYLE_ID = 'lotus-toast-keyframes';
@@ -214,7 +215,7 @@ export function LotusToastContainer() {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 10001,
zIndex: zIndices.toast,
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { settingsAtom } from '../state/settings';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { buildQualityPayload, RoomQualityContent } from '../utils/callQuality';
/**
* [P5-31] Apply the user's call quality settings (clamped by any room-level
* cap) to the Element Call fork via the `io.lotus.set_quality` widget action.
*
* The fork stores the settings and re-applies them on every (re)publish and
* reconnect, so we only need to (re)send when the payload changes or the widget
* becomes ready no need to poll the track lifecycle here.
*/
export function useCallQuality(embed: CallEmbed): void {
const { callAudioBitrate, screenshareBitrate, screenshareFramerate } = useAtomValue(settingsAtom);
const roomQualityEvent = useStateEvent(embed.room, StateEvent.LotusRoomQuality);
const roomCaps = roomQualityEvent?.getContent<RoomQualityContent>();
// Depend on the primitive cap values (not the event object) so re-renders
// don't resend needlessly.
const audioCap = roomCaps?.audio_max_kbps;
const ssCap = roomCaps?.screenshare_max_kbps;
const fpsCap = roomCaps?.screenshare_max_fps;
useEffect(() => {
const payload = buildQualityPayload(
{ callAudioBitrate, screenshareBitrate, screenshareFramerate },
{ audio_max_kbps: audioCap, screenshare_max_kbps: ssCap, screenshare_max_fps: fpsCap },
);
const send = (): void => embed.control.setQuality(payload);
// Send now (settings are sticky fork-side even if tracks aren't up yet) and
// again once the widget signals ready, in case the transport wasn't up.
send();
const off = embed.onReady(send);
return off;
}, [embed, callAudioBitrate, screenshareBitrate, screenshareFramerate, audioCap, ssCap, fpsCap]);
}
+34 -3
View File
@@ -35,6 +35,19 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
const syncState = (): void => {
// [lotus #2] Prefer the fork's io.lotus.call_state events over scraping
// EC's rendered DOM. Falls back to the DOM path below when the fork hasn't
// sent yet (null) OR sent a spurious empty list (you're always present in
// your own joined call, so [] means "no usable data", not "nobody").
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const ls = new Set<string>();
lotus.forEach((p) => {
if (p.speaking && isUserId(p.userId)) ls.add(p.userId);
});
setSpeakers(ls);
return;
}
const doc = getDoc();
if (!doc) {
setSpeakers(new Set<string>());
@@ -91,6 +104,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
};
attachObserver();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined;
@@ -109,6 +124,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
return () => {
tileObserver?.disconnect();
bodyWatcher?.disconnect();
unsubLotus();
};
}, [callEmbed, callMembers, joined]);
@@ -137,6 +153,14 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
const localUserId = callEmbed.room.client?.getUserId() ?? '';
const syncState = (): void => {
// [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping;
// ignore a spurious empty list (fall back to DOM).
const lotus = callEmbed.getLotusParticipants();
if (lotus !== null && lotus.length > 0) {
const remote = lotus.filter((p) => p.userId !== localUserId);
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
return;
}
const doc = getDoc();
if (!doc) {
setMuted(false);
@@ -145,13 +169,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
// Each participant's mute icon has data-muted="true"|"false" and
// aria-label set to their Matrix user ID.
const muteIcons = doc.querySelectorAll<HTMLElement>('[data-muted]');
let anyRemoteMuted = false;
let remoteCount = 0;
let remoteMutedCount = 0;
muteIcons.forEach((el) => {
const userId = el.getAttribute('aria-label') ?? '';
if (userId === localUserId) return;
if (el.getAttribute('data-muted') === 'true') anyRemoteMuted = true;
remoteCount += 1;
if (el.getAttribute('data-muted') === 'true') remoteMutedCount += 1;
});
setMuted(anyRemoteMuted);
// "All muted" badge: true only when there is at least one remote
// participant and every one of them is muted (not merely any single one).
setMuted(remoteCount > 0 && remoteMutedCount === remoteCount);
};
let tileObserver: MutationObserver | undefined;
@@ -186,6 +214,8 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
};
attachObserver();
// [lotus #2] Re-derive whenever the fork pushes new call-state.
const unsubLotus = callEmbed.onLotusCallState(syncState);
// If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined;
@@ -204,6 +234,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
return () => {
tileObserver?.disconnect();
bodyWatcher?.disconnect();
unsubLotus();
};
}, [callEmbed]);
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
import {
getOidcCompatibilityFlag,
getSSOFlow,
getPasswordFlow,
getTokenFlow,
} from './useParsedLoginFlows';
const flows = (...f: Record<string, unknown>[]): LoginFlow[] => f as unknown as LoginFlow[];
test('flow getters pick the right flow', () => {
const f = flows({ type: 'm.login.password' }, { type: 'm.login.sso' }, { type: 'm.login.token' });
assert.equal(getPasswordFlow(f)?.type, 'm.login.password');
assert.equal(getSSOFlow(f)?.type, 'm.login.sso');
assert.equal(getTokenFlow(f)?.type, 'm.login.token');
assert.equal(getSSOFlow(flows({ type: 'm.login.cas' }))?.type, 'm.login.cas');
});
test('getOidcCompatibilityFlag detects the stable flag name', () => {
assert.equal(
getOidcCompatibilityFlag(flows({ type: 'm.login.sso', oauth_aware_preferred: true })),
true,
);
});
test('getOidcCompatibilityFlag detects the msc3824 alt name', () => {
assert.equal(
getOidcCompatibilityFlag(
flows({ type: 'm.login.sso', 'org.matrix.msc3824.delegated_oidc_compatibility': true }),
),
true,
);
});
test('getOidcCompatibilityFlag is false without sso or without the flag', () => {
assert.equal(getOidcCompatibilityFlag(flows({ type: 'm.login.password' })), false);
assert.equal(getOidcCompatibilityFlag(flows({ type: 'm.login.sso' })), false);
// non-true values do not count
assert.equal(
getOidcCompatibilityFlag(flows({ type: 'm.login.sso', oauth_aware_preferred: 'yes' })),
false,
);
});
+23 -1
View File
@@ -1,5 +1,11 @@
import { useMemo } from 'react';
import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
import {
ILoginFlow,
IPasswordFlow,
ISSOFlow,
LoginFlow,
OAUTH_AWARE_PREFERRED_FLOW_FIELD,
} from 'matrix-js-sdk/lib/@types/auth';
export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined =>
loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as
@@ -13,6 +19,22 @@ export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined =>
type: 'm.login.token';
};
/**
* MSC3824: a server may advertise `m.login.sso` while signalling (via the
* `oauth_aware_preferred` / `org.matrix.msc3824.delegated_oidc_compatibility`
* flow field) that it actually prefers native OIDC. This is a *secondary* hint
* the authoritative signal for next-gen auth is an issuer in `.well-known`
* discovery (see `getOidcIssuer` in cs-api.ts).
*/
export const getOidcCompatibilityFlag = (loginFlows: LoginFlow[]): boolean => {
const sso = getSSOFlow(loginFlows) as (ISSOFlow & Record<string, unknown>) | undefined;
if (!sso) return false;
return (
sso[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name] === true ||
sso[OAUTH_AWARE_PREFERRED_FLOW_FIELD.altName] === true
);
};
export type ParsedLoginFlows = {
password?: LoginFlow;
token?: LoginFlow;
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
SoundboardClip,
SoundboardContent,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
SOUNDBOARD_NAME_MAX,
readSoundboardClips,
} from '../utils/soundboardClips';
const KEY = AccountDataEvent.LotusSoundboard;
/**
* [P5-15] Read/write the user's personal soundboard, stored in the
* `io.lotus.soundboard` account data event (synced across devices like custom
* emoji/sticker packs). Uploading writes the audio to the media repo and
* appends an mxc reference.
*/
export function useSoundboard(): {
clips: SoundboardClip[];
addClip: (file: File, name?: string) => Promise<void>;
removeClip: (id: string) => Promise<void>;
renameClip: (id: string, name: string) => Promise<void>;
} {
const mx = useMatrixClient();
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
useAccountDataCallback(
mx,
useCallback((evt) => {
if (evt.getType() === KEY) {
const content = evt.getContent<SoundboardContent>();
setClips(Array.isArray(content?.clips) ? content.clips : []);
}
}, []),
);
useEffect(() => {
setClips(readSoundboardClips(mx));
}, [mx]);
const persist = useCallback(
async (next: SoundboardClip[]) => {
const content: SoundboardContent = { clips: next };
await (
mx as unknown as { setAccountData: (t: string, c: unknown) => Promise<void> }
).setAccountData(KEY, content);
},
[mx],
);
const addClip = useCallback(
async (file: File, name?: string) => {
const current = readSoundboardClips(mx);
if (current.length >= SOUNDBOARD_MAX_CLIPS) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error('Clip is too large (max 1 MB).');
}
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
const label = (name ?? file.name.replace(/\.[^/.]+$/, ''))
.trim()
.slice(0, SOUNDBOARD_NAME_MAX);
const clip: SoundboardClip = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: label || 'Clip',
url: mxc,
mimetype: file.type || undefined,
size: file.size,
};
await persist([...current, clip]);
},
[mx, persist],
);
const removeClip = useCallback(
async (id: string) => {
const next = readSoundboardClips(mx).filter((c) => c.id !== id);
await persist(next);
},
[mx, persist],
);
const renameClip = useCallback(
async (id: string, name: string) => {
const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX);
if (!trimmed) return;
const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c));
await persist(next);
},
[mx, persist],
);
return { clips, addClip, removeClip, renameClip };
}
+12 -1
View File
@@ -27,6 +27,9 @@ import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
@@ -95,7 +98,7 @@ function NightLightOverlay() {
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9998,
zIndex: zIndices.nightLight,
backgroundColor: `rgba(255, 140, 0, ${(settings.nightLightOpacity ?? 30) / 100})`,
}}
/>
@@ -110,6 +113,14 @@ function App() {
const portalContainer = document.getElementById('portalContainer') ?? undefined;
// OIDC/next-gen-auth callback is a real (non-hash) path: OAuth redirect_uris
// can't contain a fragment, so it must be handled OUTSIDE the router. Render
// the standalone callback page before the RouterProvider mounts. It needs no
// app providers (it only touches the SDK + localStorage).
if (window.location.pathname.endsWith(OIDC_CALLBACK_PATH)) {
return <OidcCallback />;
}
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (

Some files were not shown because too many files have changed in this diff Show More