Compare commits

..

20 Commits

Author SHA1 Message Date
jared c1efa7b94e feat(accent): custom accent themes links, text selection, and focus rings
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 8s
The accent previously only overrode the folds Primary.* family; links kept the
hardcoded --tc-link blue, ::selection was browser-default, and focus rings were
neutral grey (Other.FocusRing). Now all three derive from the chosen base color:
- --tc-link → accent hex (messages, topics, URL previews)
- ::selection via an injected <style id="lotus-accent-style"> (accent bg +
  WCAG-contrasting text)
- Other.FocusRing → rgba(accent, 0.5)

Deliberately NOT recolored: Secondary.* (doubles as the neutral text/button/
badge palette), Success.* + mention pills (semantic mention/notification green),
scrollbar thumbs (folds styles them per-component; a global rule would only
half-apply). removeCustomAccent() clears everything — no residue when switching
off or to the TDS theme. +2 unit tests (561 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:44:26 -04:00
jared e31b84c08e fix(chrome): TitleBar drag via explicit window_start_drag (official recipe)
data-tauri-drag-region only fires when the exact element is the event target
and was never runtime-verified; replace it with the official Tauri custom-
titlebar recipe — primary-button mousedown starts an OS drag, detail===2
toggles maximize. Works across the whole region (brand text included, which
already passes pointer events through).

Pairs with cinny-desktop set_custom_chrome Mica fix (clear backdrop before
undecorating; window-state no longer restores the decorated flag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:42:56 -04:00
jared 258e3ec620 fix(desktop): address code-review findings on the desktop wave
CI / Build & Quality Checks (push) Successful in 10m40s
CI / Trigger Desktop Build (push) Successful in 8s
- fileEntries: a single unreadable file/dir in a dropped folder no longer aborts
  the whole traversal (try/catch per entry, skip failures) — was discarding ALL
  dropped files (incl. the flat-file path) + an unhandled rejection; also add
  .catch in both useFileDrop consumers.
- RoomInput: mirror a localStorage-restored draft into the draft atom so the
  P5-57 indicator reflects a persisted draft after a page reload, not only on
  same-session room re-entry.
- useTauriThumbbar: swallow toggleMicrophone()/hangup() rejections (parity with
  SMTC) — avoids an unhandled rejection when clicked mid-teardown.
- App/DesktopChrome: keep wrapper element types stable across the chrome toggle
  (display:contents when off) so flipping it no longer remounts RouterProvider.
- settings: normalizeComposerToolbarOrder also appends missing keys from the
  canonical key set (safety net if a new button is absent from the default order).

Gates: tsc/eslint/prettier clean, build OK, 559 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:40:31 -04:00
jared 3336abb66f docs: P5-42 done + final Tier C dispositions (P5-51/52/53)
- P5-42 → [~] IMPLEMENTED (pragmatic WebView2 keep-alive) + LOTUS_FEATURES entry.
- P5-51 → [DEFERRED] with a concrete future-work spec (single-session storage map:
  sessions.ts localStorage keys + initMatrix IndexedDB stores; the 6 things true
  per-context isolation needs; multi-account as the smaller intermediate step).
- P5-52 → [DROPPED] (matrix-js-sdk can't do true per-room sync filtering; only
  cosmetic client-side hiding).
- P5-53 → [DEFERRED] with the lighter automation-rules alternative recorded.

Every desktop P5 item is now dispositioned: implemented, won't-fix, or
deferred-with-spec/dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:27:23 -04:00
jared a184ee0221 docs: document desktop features (Tier A + B) across catalog/README/TODO
- LOTUS_FEATURES.md: new "Desktop App Features" section (+ TOC) covering all
  desktop capabilities — no-sleep, jump list, thumbbar, SMTC, network awareness,
  rich notifications, Focus Assist, window chrome, update toast, toolbar reorder,
  draft indicator, recursive folder DnD.
- README.md: "Desktop-Specific Features" bullets under the Desktop App section.
- LOTUS_TODO.md: P5-35/41/56 → [~] IMPLEMENTED (Tier B); P5-48 → [~] (recursive
  folder upload; .lnk/Send-To scoped-out with rationale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:04:03 -04:00
jared 4509a2b6d3 feat(desktop): Tier B web side — toast actions, Focus Assist gate, folder DnD
- P5-41/35 useTauriToastActions: native rich-toast click → navigate(path) (opens
  the room), quick reply → mx.sendMessage(roomId, m.text). The desktop bridge
  routes message notifications (tag=roomId) to show_rich_toast.
- P5-56 useTauriFocusAssist + focusAssistActiveAtom: a native focus-assist-changed
  event drives the atom, OR'd into the existing quiet-hours gate in
  ClientNonUIFeatures so notifications+sounds suppress during Windows Focus Assist.
- P5-48 recursive folder drag-drop: fileEntries.ts (sync webkitGetAsEntry capture
  → async batched readEntries traversal, path-prefixed names, 500-file cap) wired
  into useFileDrop, reusing the existing upload pipeline. +3 unit tests.

Hooks no-op in the browser. Gates: tsc/eslint/prettier clean, build OK, 559 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:01:10 -04:00
jared 7e38baa7b6 docs(todo): mark desktop Tier A wave; P5-40 done, P5-50 won't-fix
- P5-36/43/44/46/47/49/55/57 → [~] IMPLEMENTED (web verified; native
  CI-compile-pending, runtime-verify on Windows).
- P5-40 → [x] DONE (TauriUpdateFeature already ships the update toast).
- P5-50 → [WON'T FIX] (can't inject Media Foundation into WebView2's WebRTC
  pipeline; Chromium already HW-decodes).
- P5-35 → note the "can't compile-test without Windows" premise is outdated
  (CI compiles Windows now); remains Tier B (rides with P5-41).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:10:04 -04:00
jared aab7e5ae20 feat(desktop): Tier A desktop features — web side (P5-46/36/44/43/49/47/55/57)
Web half of the desktop feature wave. A shared bridge (`hooks/useTauri.ts`:
invokeTauri/isTauri/useTauriEvent) backs per-feature hooks that no-op in the
browser and drive the native Tauri commands (compiled in cinny-desktop):

- P5-46 useTauriCallPower — hold system awake while a call is active.
- P5-36 useTauriJumpList — Windows jump list of recent rooms → matrix: deep links.
- P5-44 useTauriThumbbar — taskbar Mute/Deafen/End; events toggle mic/sound/hangup.
- P5-43 useTauriSmtc — SMTC call state + button events.
- P5-49 useTauriNetwork — react to native network-change → mx.retryImmediately().
- P5-47 window chrome — opt-in `customWindowChromeAtom` + TDS `TitleBar`; DesktopChrome
  wrapper in App.tsx (zero layout impact when off) + a desktop-only settings toggle.
- P5-55 composer toolbar drag-reorder (settings order[] + pragmatic-drag-and-drop).
- P5-57 DraftIndicator — subtle "draft saved" cue in the composer.

Client-scoped hooks mount via TauriDesktopFeatures in ClientNonUIFeatures; window
chrome mounts at App level. Gates: tsc/eslint/prettier clean, build OK, 556 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:07:03 -04:00
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
105 changed files with 10586 additions and 1677 deletions
+20 -9
View File
@@ -548,8 +548,19 @@ roster capped. Only `https`/`blob` URLs accepted for inject/decoration assets.
### 12.1 cinny host integration checklist (REQUIRED to light these up) ### 12.1 cinny host integration checklist (REQUIRED to light these up)
The EC side is additive and dormant until cinny opts in. Host work needed (in > ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
`src/app/plugins/call/CallEmbed.ts` unless noted): > 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** > ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call > actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
@@ -559,16 +570,16 @@ The EC side is additive and dormant until cinny opts in. Host work needed (in
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and > leaves the host's `transport.send` pending until the **10s timeout**. Queue and
> flush on join, or no-op before join. > flush on join, or no-op before join.
> >
> Also: **F3** — the fork implements only `rnnoise`/`speex`; cinny's `dtln`/ > Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
> `deepfilternet` selections silently fall back to rnnoise (now logged). Restrict > `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
> the embedded-call model picker to rnnoise/speex, or implement the others in > the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
> `lotusDenoiseProcessor.ts`. **F4** — cinny sends `lotusNativeNS`, which the > in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
> fork ignores; drop it or wire it in. **F7** — no widget _capability_ changes > real calls — open item). **F7** — no widget _capability_ changes needed;
> needed; custom actions bypass capability checks. > custom actions bypass capability checks.
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in 1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`, `CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
`lotusAudioInject=1` as desired. (Denoise already sets `lotusDenoise=ml` etc.) `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', …)` — 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 without a reply the fork's sends time out every 250ms. Feed the payload into
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape. `useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
+76 -31
View File
@@ -39,38 +39,31 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
## 🧩 Element Call source-level items — now actionable via the fork ## 🧩 Element Call source-level items — now actionable via the fork
> 🔱 **[EC-FORK]** **UPDATE 2026-06-29: the fork is live.** We now own and > 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
> self-build Element Call (`LotusGuild/element-call` → > self-build Element Call (`LotusGuild/element-call` →
> `@lotusguild/element-call-embedded`, Phase 1 done & cinny wired). A5/A6/A7 > `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
> below are **no longer "won't fix"** — they are ordinary source changes. See > below are **fixed in the fork** — they are now ⚠️ awaiting **live
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10 + the Phase > verification** (`LOTUS_TESTING.md` §D2), not open work. See
> 2 work list. (The iframe is **same-origin** / self-hosted; the old blocker was > [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
> that we didn't own EC's compiled source — which we now do.) > row once verified live.
The in-call participant grid is rendered **inside EC's app**. Previously a The in-call participant grid is rendered **inside EC's app** — now editable source
pre-built npm bundle we could only style/place around; now editable source. (previously a prebuilt npm bundle we could only style around). Status of the items
Items from testing, with their fork-level fix path: from testing:
- **A5 — "Focus camera":** EC supports native tile-pinning. Our bottom-bar "Focus - **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
camera" is a programmatic wrapper that **`.click()`s the tile** today sends an `io.lotus.focus_participant` widget action that pins a participant in
(`CallControl.ts` `focusCameraParticipant`), and during a screenshare EC EC's layout (coexisting with / overriding the screenshare spotlight); the old
spotlights the shared screen so a camera pin may not override it. **Fork fix:** `.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
add an `io.lotus.focus_participant` widget action that pins a participant in - **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
EC's layout (coexisting with / overriding the screenshare spotlight); cinny cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
sends it via the widget API and the DOM-click hack is deleted. _Status: Open — them on EC's participant video-tile avatars — not just our pre-join lobby roster.
Actionable (Phase 2)._ - **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
- **A6 — avatar decorations in-call:** decorations render on **our** pre-join (D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
lobby roster (`CallMemberCard`) but not on EC's in-call video tiles. **Fork LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
fix:** render the decoration APNG inside EC's participant-tile component, fed (re)publish, so reconnects keep denoise alive natively. The build-time
decoration slugs via widget member data. _Status: Open — Actionable (Phase 2)._ `getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
- **A7 — mic dead after EC's "Reconnect":** the mid-call "Connection lost / blast radius — everyone's mic; verify D2-1 carefully.**
Reconnect" screen is **EC's own** (our load watchdog only covers an initial
hung load). After EC reconnects, the mic isn't re-published through our denoise
`getUserMedia` shim until a clean End+rejoin. **Fork fix:** move denoise into
EC's mic-capture/publish pipeline as a first-class audio stage — EC re-runs it
on every (re)publish, so reconnects keep denoise alive natively, and the
build-time `index.html` injection is removed. _Status: Open — Actionable
(Phase 2); root cause is the `getUserMedia` monkeypatch, not EC itself._
--- ---
@@ -78,7 +71,59 @@ Items from testing, with their fork-level fix path:
### Calls / Audio ### 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. _Note: this **dissolves entirely** once denoise moves in-source in the fork (A7 fix) — there is then no build-time injection to be missing in dev._ - ~~**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 ### Security & Privacy
@@ -99,7 +144,7 @@ Items from testing, with their fork-level fix path:
### Code Hygiene / DevEx ### Code Hygiene / DevEx
- **Automated test suite — 231 tests across ~26 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), state (settings, sessions, recentSearches, upload, typingMembers), plugins (matrix-to, call/utils, markdown/utils), lotus/avatarDecorations, search filters. Prevention work has caught + fixed **2 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface). - **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. - **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk. - **`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). - **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).
+189 -17
View File
@@ -25,7 +25,8 @@ Last updated: June 2026.
16. [Notifications](#notifications) 16. [Notifications](#notifications)
17. [Server Integration](#server-integration) 17. [Server Integration](#server-integration)
18. [Infrastructure](#infrastructure) 18. [Infrastructure](#infrastructure)
19. [Key Custom Files](#key-custom-files) 19. [Desktop App Features](#desktop-app-features)
20. [Key Custom Files](#key-custom-files)
--- ---
@@ -322,14 +323,104 @@ Users can set a custom background color for `@mention` chips that highlight thei
## Voice / Video Call Improvements ## Voice / Video Call Improvements
> 🔱 **[EC-FORK]** Element Call is embedded as a **pre-built npm bundle** today. > 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
> The plan to fork & self-build it from source for true ownership — and which of > (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
> the items below would move into our EC source — is in > `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). > [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
### Element Call Upgrade ### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**. 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 ### Camera Default Off
@@ -422,7 +513,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
**Advanced Features & Test Options:** **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. - **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. - **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. - **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
@@ -431,20 +522,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. - **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. - **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:** **Open-Source Models (all now in-source in the EC fork):**
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | | Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| **RNNoise** | Poor | Moderate | < 5% | | **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
| **DTLN** | Good | High | 10-20% | | **DTLN** | Good | High | 10-20% | 16 kHz |
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ | | **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 ### Files
- `build/lotus-denoise.js` — multi-model getUserMedia shim - **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 (copies assets for RNNoise, Speex, and NoiseGate) - `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 → widget URL params - `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/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter - `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
@@ -1047,6 +1162,63 @@ The `encUrlPreview` setting defaults to `true` rather than `false`. A security a
--- ---
## Desktop App Features
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
### Call Continuity — No-Sleep (P5-46)
Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower``native/power.rs`.
### Windows Jump List (P5-36)
Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList``native/jumplist.rs` (`ICustomDestinationList`).
### Taskbar Thumbnail Toolbar (P5-44)
Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar``native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).
### System Media Transport Controls — SMTC (P5-43)
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc``native/smtc.rs`. _Experimental — may require an active audio session to surface._
### Network Awareness (P5-49)
Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork``native/network.rs`.
### Instant Background Sync (P5-42)
Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
### Native Rich Notifications (P5-41 / P5-35)
Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions``native/toast.rs`; the desktop notification bridge routes room notifications to it.
### Focus Assist Sync (P5-56)
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom``native/focus_assist.rs` (`SHQueryUserNotificationState`).
### Custom Window Chrome (P5-47)
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome``native/chrome.rs`.
### Proactive Update Toast (P5-40)
Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.
### Cross-platform composer niceties
- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.
### Files
- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).
---
## Key Custom Files ## Key Custom Files
| File | Purpose | | File | Purpose |
+119
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 # 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. > 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.
+104 -72
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 | | Desktop — proactive update notifications (Tauri) | J1 |
| Remind Me Later | K1 | | Remind Me Later | K1 |
| Mobile Bookmarks access | E5 | | 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 ### Confirmed facts
| Finding | Impact | | 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 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 | | **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 | | **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 | | **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 | | **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 ✅ | | **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 | | `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` | | 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` | | `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 | | 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.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 | | `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 | | 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 (`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 | | 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 | | 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` | | `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 | | `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 | | `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 | | ~~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 | | 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 | | 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 | | `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 | | MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
--- ---
@@ -266,12 +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. **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**.
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature. **🔱 [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.
**🔱 [EC-FORK]** Owning the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) would unblock real audio-injection — a proper soundboard mixed into the call — which is impossible against the prebuilt bundle today. **Shipped (cinny):**
**Complexity:** High.
- 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.
--- ---
@@ -287,39 +295,55 @@ Features:
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style) ### [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. **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)". **Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
**🔱 [EC-FORK]** Once we own the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)), denoise should become a first-class audio stage **inside** EC instead of an `index.html` getUserMedia monkeypatch — more robust, survives reconnects (fixes the A7 mic-after-reconnect bug), and removes the build-time injection hack. **🔱 [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. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps. **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.
**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".
**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. - [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**.
- [ ] **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. - [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`.
- [ ] **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] **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). - **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). **What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps. **🔱 [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.
**[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. **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.
--- ---
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED) ### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window. **What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
**Status:** Deferred `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment. **Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream. **Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required). **Complexity:** High (platform-specific native code required).
--- ---
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED) ### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation. **What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical. **Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
@@ -328,78 +352,86 @@ Features:
--- ---
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications ### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
**What:** Replace emulated notifications with native WinRT Toast notifications. **What:** Replace emulated notifications with native WinRT Toast notifications.
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality. **Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
### [ ] P5-42 · Desktop — Persistent Background Sync ### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
**What:** Maintain light connection to homeserver when WebView2 is suspended. **What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery. **Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC) ### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
**What:** Integrate with Windows SMTC for volume flyout call/media control. **What:** Integrate with Windows SMTC for volume flyout call/media control.
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay. **Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar ### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Add persistent call controls to the taskbar preview. **What:** Add persistent call controls to the taskbar preview.
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons. **Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
### [ ] P5-46 · Desktop — System Power Management (Call Continuity) ### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
**What:** Prevent system sleep/hibernate during active calls. **What:** Prevent system sleep/hibernate during active calls.
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active. **Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome ### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
**What:** Replace system titlebar with custom Lotus TDS chrome. **What:** Replace system titlebar with custom Lotus TDS chrome.
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI. **Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements ### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
**What:** Enhance drag-and-drop support for Windows. **What:** Enhance drag-and-drop support for Windows.
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions. **Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration) ### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
**What:** Proactively detect Windows network connectivity changes. **What:** Proactively detect Windows network connectivity changes.
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery. **Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline ### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
**What:** Replace standard browser decoding with native Windows Media Foundation. **What:** Replace standard browser decoding with native Windows Media Foundation.
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls. **Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager) ### [DEFERRED] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts." **What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context. **Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
**Future-work spec (why it's big):** the app is currently **single-session**.
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
**Priority:** Extreme Low (Multi-sprint/Architectural). **Priority:** Extreme Low (Multi-sprint/Architectural).
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING] ### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
**What:** Granular sync tuning for individual rooms. **What:** Granular per-room sync tuning (frequency, event-type filtering).
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue. **Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like) ### [DEFERRED] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
**What:** A sandboxed environment for local execution of user scripts on Matrix events. **What:** A sandboxed environment for local execution of user scripts on Matrix events.
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine. **Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering ### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
**What:** Allow users to reorder toolbar icons via drag-and-drop. **What:** Allow users to reorder toolbar icons via drag-and-drop.
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order. **Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync ### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
**What:** Automatically toggle notification state based on Windows Focus Assist. **What:** Automatically toggle notification state based on Windows Focus Assist.
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled. **Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator ### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
--- ---
@@ -540,7 +572,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
> ⚠️ **[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). > ⚠️ **[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 — partial correction]** The "cross-origin" claim above is **outdated**: EC is now **same-origin** / self-hosted (`iframe.sandbox` has `allow-same-origin`; we read `contentDocument`). The _practical_ blocker still holds — LiveKit's `LocalAudioTrack` lives in EC's **module scope** (not on `window`), so it's unreachable from cinny even same-origin. **Owning the EC source** (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) is the path to a real call-audio-inject API, which would unblock a true in-call soundboard. > 🔱 **[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.
--- ---
@@ -611,7 +643,7 @@ See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced
--- ---
### P5-40 · Desktop — Proactive Update Notifications (Tauri) ### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`. **Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
+34 -14
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 - 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 - 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 - 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 ### Customization & Appearance
@@ -136,6 +139,20 @@ When you first run the installer on Windows, you may see a popup that says **"Wi
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates. After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
### Desktop-Specific Features
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
- **Network awareness** — reconnects promptly when Windows connectivity changes.
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
- **Automatic background updates** with a one-click update toast.
--- ---
## For Developers ## For Developers
@@ -144,22 +161,25 @@ 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. See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### 🔱 Planned: Element Call fork ("Lotus Call") ### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**. Today it's a **pre-built npm bundle** Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@element-hq/element-call-embedded` 0.20.1) copied to `public/element-call/` and (`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
served same-origin; we steer it via the `matrix-widget-api` plus fragile DOM `LotusGuild/element-call`), published to our private Gitea npm registry and served
hacks. Because we don't own its compiled source, several in-call issues (avatar same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
decorations on tiles, camera focus/fullscreen during screenshare, mic recovery behavior is editable source instead of fragile DOM/widget hacks.
after reconnect, native theming, real call-audio injection) are unfixable from
outside.
**The plan is to fork `element-hq/element-call` into a new `LotusGuild/element-call` **Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
repo, build it from source, and host our own build** for true ownership. The full reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
self-contained plan and integration map — written for a fresh session with no avatar decorations on EC video tiles, and a native transparent background.
prior context — is in **[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**. **Built but dormant (need cinny UI):** real call-audio injection
Infra/hosting notes also live in the `LotusGuild/matrix` repo README. Search the (`io.lotus.inject_audio` → in-call soundboard) and quality controls
docs for the **`[EC-FORK]`** tag to find every related note. (`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 ### Build
+2
View File
@@ -45,6 +45,7 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone } from '../utils/ringtones'; import { previewRingtone, startRingtone } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallQuality } from '../hooks/useCallQuality';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers'; import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList'; import { mDirectAtom } from '../state/mDirectList';
@@ -584,6 +585,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallMemberSoundSync(embed); useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed); useCallJoinLeaveSounds(embed);
useCallThemeSync(embed); useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent( useCallHangupEvent(
embed, embed,
useCallback(() => { useCallback(() => {
@@ -0,0 +1,25 @@
import { useTauriCallPower } from '../hooks/useTauriCallPower';
import { useTauriJumpList } from '../hooks/useTauriJumpList';
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
import { useTauriSmtc } from '../hooks/useTauriSmtc';
import { useTauriNetwork } from '../hooks/useTauriNetwork';
import { useTauriToastActions } from '../hooks/useTauriToastActions';
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
/**
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
* desktop features (window chrome) live in `App.tsx` instead, so they work
* before login.
*/
export function TauriDesktopFeatures(): null {
useTauriCallPower(); // P5-46 no-sleep during calls
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
useTauriSmtc(); // P5-43 system media transport controls
useTauriNetwork(); // P5-49 network-change awareness → sync retry
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
return null;
}
-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' },
});
+17 -712
View File
@@ -2,719 +2,24 @@ import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { zIndices } from '../../styles/zIndex'; import { zIndices } from '../../styles/zIndex';
import { import { SeasonTheme } from './types';
animSeasonFall, import { getActiveSeason } from './seasonSchedule';
animLeafFall, import { HalloweenOverlay } from './themes/Halloween';
animFloatUp, import { ChristmasOverlay } from './themes/Christmas';
animBob, import { NewYearOverlay } from './themes/NewYear';
animTasselSway, import { AutumnOverlay } from './themes/Autumn';
animGoldShimmer, import { AprilFoolsOverlay } from './themes/AprilFools';
animCloverDrift, import { LunarNewYearOverlay } from './themes/LunarNewYear';
animEarthLeafDrift, import { ValentinesOverlay } from './themes/Valentines';
animWarp, import { StPatricksOverlay } from './themes/StPatricks';
animScanline, import { EarthDayOverlay } from './themes/EarthDay';
animPixelBlink, import { DeepSpaceOverlay } from './themes/DeepSpace';
} from './Seasonal.css'; import { ArcadeOverlay } from './themes/Arcade';
export type SeasonTheme = // SeasonTheme + the date-window logic now live in leaf modules (single source
| 'halloween' // of truth, shared with the settings UI). Re-exported here for existing
| 'christmas' // importers that still reach for it from this file.
| 'newyear' export type { SeasonTheme };
| '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%)',
}}
/>
</>
);
}
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ── // ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
@@ -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;
};
+37 -13
View File
@@ -37,6 +37,10 @@ import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed'; import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute'; 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 = { type CallControlsProps = {
callEmbed: CallEmbed; callEmbed: CallEmbed;
@@ -88,6 +92,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey'); 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); const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) // 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()} onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/> />
</Box> </Box>
{!compact && <ControlDivider />} {!compact && showVideoGroup && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> {showVideoGroup && (
<VideoButton enabled={video} onToggle={handleVideoToggle} /> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ScreenShareButton {/* Show a forbidden control while its track is still live so the
enabled={screenshare} user can stop it; once stopped it hides and can't be restarted. */}
onToggle={() => {showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) {showScreenshare && (
} <ScreenShareButton
/> enabled={screenshare}
{!!document.fullscreenEnabled && ( onToggle={() =>
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} /> screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
)} }
</Box> />
)}
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
)}
</Box> </Box>
{!compact && <ControlDivider />} {!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> <Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton /> <ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
<PopOut <PopOut
anchor={cords} anchor={cords}
position="Top" 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>
</>
);
}
@@ -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 './RoomJoinRules';
export * from './RoomProfile'; export * from './RoomProfile';
export * from './RoomPublish'; export * from './RoomPublish';
export * from './RoomQuality';
export * from './RoomShareInvite'; export * from './RoomShareInvite';
export * from './RoomUpgrade'; export * from './RoomUpgrade';
export * from './RoomVoiceLimit'; export * from './RoomVoiceLimit';
+77
View File
@@ -0,0 +1,77 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
const BAR_HEIGHT = toRem(32);
const CONTROL_WIDTH = toRem(46);
export const TitleBar = style([
DefaultReset,
{
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
height: BAR_HEIGHT,
width: '100%',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
// Sit above app content but never intercept scroll etc. below the bar.
userSelect: 'none',
},
]);
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
// the free space so most of the bar is grabbable.
export const DragRegion = style({
display: 'flex',
alignItems: 'center',
flexGrow: 1,
minWidth: 0,
gap: config.space.S200,
paddingInline: config.space.S300,
});
export const Brand = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
// Children shouldn't swallow the drag; the region itself owns the attribute.
pointerEvents: 'none',
});
export const Controls = style({
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
});
export const ControlButton = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: CONTROL_WIDTH,
height: '100%',
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: 'transparent',
color: 'inherit',
transition: 'background-color 100ms ease',
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.ContainerLine,
},
},
},
]);
export const ControlButtonClose = style({
selectors: {
'&:hover': {
backgroundColor: color.Critical.Main,
color: color.Critical.OnMain,
},
},
});
+144
View File
@@ -0,0 +1,144 @@
import React, { MouseEvent, ReactNode } from 'react';
import { useAtomValue } from 'jotai';
import { Text } from 'folds';
import { customWindowChromeAtom } from '../../state/customWindowChrome';
import { invokeTauri, isTauri } from '../../hooks/useTauri';
import * as css from './TitleBar.css';
/**
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
* a coarse "is this a Mac" signal to decide which side the window controls sit
* on, so the UA/platform sniff is sufficient and stays cross-platform.
*/
const isMacOS = (): boolean => {
const platform =
(
navigator as unknown as {
userAgentData?: { platform?: string };
}
).userAgentData?.platform ??
navigator.platform ??
navigator.userAgent;
return /mac/i.test(platform);
};
const MIN_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
</svg>
);
const MAX_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
</svg>
);
const CLOSE_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
</svg>
);
type ControlButtonProps = {
label: string;
glyph: ReactNode;
onClick: () => void;
close?: boolean;
};
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
>
{glyph}
</button>
);
}
/**
* P5-47 — TDS Custom Window Chrome titlebar.
*
* Renders `null` unless we're inside Tauri **and** the user opted into custom
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
* draggable region (explicit `window_start_drag` on mousedown, double-press to
* maximize) with the app brand, plus minimize / maximize / close controls that
* call the native window commands.
*
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
* the left (the native traffic-light position) since decorations — and thus the
* real traffic lights — are stripped while custom chrome is on.
*/
export function TitleBar() {
const enabled = useAtomValue(customWindowChromeAtom);
if (!isTauri() || !enabled) return null;
const mac = isMacOS();
// Official Tauri custom-titlebar recipe: primary-button mousedown starts an
// OS window drag; a double press (detail === 2) toggles maximize instead. An
// explicit `window_start_drag` invoke is used rather than
// `data-tauri-drag-region` because the attribute only fires when the exact
// element is the event target (children like the brand text wouldn't drag).
const handleDragMouseDown = (evt: MouseEvent<HTMLDivElement>): void => {
if (evt.button !== 0) return;
if (evt.detail === 2) {
invokeTauri('window_toggle_maximize');
} else {
invokeTauri('window_start_drag');
}
};
const controls = (
<div className={css.Controls}>
<ControlButton
label="Minimize"
glyph={MIN_GLYPH}
onClick={() => invokeTauri('window_minimize')}
/>
<ControlButton
label="Maximize"
glyph={MAX_GLYPH}
onClick={() => invokeTauri('window_toggle_maximize')}
/>
<ControlButton
label="Close"
glyph={CLOSE_GLYPH}
onClick={() => invokeTauri('window_close')}
close
/>
</div>
);
const dragRegion = (
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
<span className={css.Brand}>
<Text as="span" size="T200" truncate>
Lotus Chat
</Text>
</span>
</div>
);
return (
<header className={css.TitleBar}>
{mac ? (
<>
{controls}
{dragRegion}
</>
) : (
<>
{dragRegion}
{controls}
</>
)}
</header>
);
}
+10 -1
View File
@@ -6,6 +6,15 @@
export const DECORATION_CDN = export const DECORATION_CDN =
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations'; '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 = { export type AvatarDecoration = {
slug: string; slug: string;
name: string; name: string;
@@ -180,5 +189,5 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
); );
export function decorationUrl(slug: string): string { 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 { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings'; import { ChatBackground } from '../../state/settings';
import { import { blueprint } from './backgrounds/blueprint';
animRainKeyframe, import { stars } from './backgrounds/stars';
animStarsDriftKeyframe, import { topographic } from './backgrounds/topographic';
animGridPulseKeyframe, import { herringbone } from './backgrounds/herringbone';
animAuroraKeyframe, import { crosshatch } from './backgrounds/crosshatch';
animFirefliesKeyframe, import { chevron } from './backgrounds/chevron';
} from '../../styles/Animations.css'; 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 }[] = [ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
@@ -33,20 +45,14 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'anim-fireflies', label: 'Fireflies' }, { 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> = { const DARK: Record<ChatBackground, CSSProperties> = {
none: {}, 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: { carbon: {
backgroundColor: '#0e0e0e', backgroundColor: '#0e0e0e',
backgroundImage: [ backgroundImage: [
@@ -55,138 +61,6 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','), ].join(','),
backgroundSize: '8px 8px', 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: { aurora: {
backgroundColor: '#030810', backgroundColor: '#030810',
backgroundImage: [ backgroundImage: [
@@ -197,86 +71,30 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','), ].join(','),
}, },
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker blueprint: blueprint.dark,
'anim-rain': { stars: stars.dark,
backgroundColor: '#010804', topographic: topographic.dark,
backgroundImage: [ herringbone: herringbone.dark,
'repeating-linear-gradient(180deg, rgba(0,255,136,0.16) 0px, rgba(0,255,136,0.16) 1px, transparent 1px, transparent 20px)', crosshatch: crosshatch.dark,
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)', chevron: chevron.dark,
].join(','), polka: polka.dark,
backgroundSize: '40px 200px, 12px 200px', triangles: triangles.dark,
backgroundPosition: '0 0, 0 0', plaid: plaid.dark,
animation: `${animRainKeyframe} 8s linear infinite`, tactical: tactical.dark,
}, circuit: circuit.dark,
hexgrid: hexgrid.dark,
// Animated: drifting star field — three seamlessly-tiling layers at different speeds waves: waves.dark,
'anim-stars': { neon: neon.dark,
backgroundColor: '#050510', 'anim-rain': animRain.dark,
backgroundImage: [ 'anim-stars': animStars.dark,
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)', 'anim-pulse': animPulse.dark,
'radial-gradient(circle, rgba(200,220,255,0.55) 1px, transparent 1px)', 'anim-aurora': animAurora.dark,
'radial-gradient(circle, rgba(180,200,255,0.3) 1px, transparent 1px)', 'anim-fireflies': animFireflies.dark,
].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`,
},
}; };
const LIGHT: Record<ChatBackground, CSSProperties> = { const LIGHT: Record<ChatBackground, CSSProperties> = {
none: {}, 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: { carbon: {
backgroundColor: '#efefef', backgroundColor: '#efefef',
backgroundImage: [ backgroundImage: [
@@ -285,129 +103,6 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','), ].join(','),
backgroundSize: '8px 8px', 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: { aurora: {
backgroundColor: '#f4faf8', backgroundColor: '#f4faf8',
backgroundImage: [ backgroundImage: [
@@ -418,67 +113,25 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','), ].join(','),
}, },
// Animated light variants blueprint: blueprint.light,
stars: stars.light,
'anim-rain': { topographic: topographic.light,
backgroundColor: '#f0fff4', herringbone: herringbone.light,
backgroundImage: [ crosshatch: crosshatch.light,
'repeating-linear-gradient(180deg, rgba(0,160,80,0.16) 0px, rgba(0,160,80,0.16) 1px, transparent 1px, transparent 20px)', chevron: chevron.light,
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)', polka: polka.light,
].join(','), triangles: triangles.light,
backgroundSize: '40px 200px, 12px 200px', plaid: plaid.light,
backgroundPosition: '0 0, 0 0', tactical: tactical.light,
animation: `${animRainKeyframe} 8s linear infinite`, circuit: circuit.light,
}, hexgrid: hexgrid.light,
waves: waves.light,
'anim-stars': { neon: neon.light,
backgroundColor: '#f5f5ff', 'anim-rain': animRain.light,
backgroundImage: [ 'anim-stars': animStars.light,
'radial-gradient(circle, rgba(60,60,160,0.50) 1px, transparent 1px)', 'anim-pulse': animPulse.light,
'radial-gradient(circle, rgba(80,80,180,0.35) 1px, transparent 1px)', 'anim-aurora': animAurora.light,
'radial-gradient(circle, rgba(100,100,200,0.20) 1px, transparent 1px)', 'anim-fireflies': animFireflies.light,
].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`,
},
}; };
export const getChatBg = ( export const getChatBg = (
@@ -11,6 +11,7 @@ import {
RoomLocalAddresses, RoomLocalAddresses,
RoomPublishedAddresses, RoomPublishedAddresses,
RoomPublish, RoomPublish,
RoomQuality,
RoomShareInvite, RoomShareInvite,
RoomUpgrade, RoomUpgrade,
RoomVoiceLimit, RoomVoiceLimit,
@@ -58,6 +59,7 @@ export function General({ requestClose }: GeneralProps) {
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Voice</Text> <Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} /> <RoomVoiceLimit permissions={permissions} />
<RoomQuality permissions={permissions} />
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Addresses</Text> <Text size="L400">Addresses</Text>
@@ -0,0 +1,31 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
// A brief, gentle acknowledgement when a draft first becomes persisted.
// Guarded by `prefers-reduced-motion` so it only plays for users who opt in.
const savedPulse = keyframes({
'0%': { opacity: 0.4, transform: 'scale(0.7)' },
'45%': { opacity: 1, transform: 'scale(1.15)' },
'100%': { opacity: 1, transform: 'scale(1)' },
});
export const DraftIndicatorBase = style({
userSelect: 'none',
whiteSpace: 'nowrap',
});
export const DraftDot = style({
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Success.Main,
flexShrink: 0,
});
export const DraftDotPulse = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${savedPulse} 600ms ease-out`,
},
},
});
+64
View File
@@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Text, config } from 'folds';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { toPlainText } from '../../components/editor';
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
const PULSE_DURATION = 600;
type DraftIndicatorProps = {
roomId: string;
};
/**
* Subtle, non-distracting status shown near the composer when the current room
* has a persisted (unsent) message draft. It reacts to the shared draft atom
* (`roomIdToMsgDraftAtomFamily`) — the same source that backs the
* `draft-msg-${roomId}` localStorage persistence — so it never introduces a
* parallel persistence path.
*
* A short "Saved" pulse plays the moment a draft becomes persisted, then the
* indicator settles into a quiet, muted resting state. The pulse is gated behind
* `prefers-reduced-motion` in CSS, so motion-averse users only ever see the
* static label.
*/
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
// Real content, not just an empty paragraph.
const hasDraft = toPlainText(draft, false).trim().length > 0;
const [pulse, setPulse] = useState(false);
const hadDraft = useRef(false);
useEffect(() => {
if (hasDraft && !hadDraft.current) {
hadDraft.current = true;
setPulse(true);
const timeout = setTimeout(() => setPulse(false), PULSE_DURATION);
return () => clearTimeout(timeout);
}
hadDraft.current = hasDraft;
return undefined;
}, [hasDraft]);
if (!hasDraft) return null;
return (
<Box
className={DraftIndicatorBase}
as="span"
shrink="No"
alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S100}` }}
aria-hidden
>
<span className={`${DraftDot}${pulse ? ` ${DraftDotPulse}` : ''}`} />
<Text as="span" size="T200" priority="300">
Draft saved
</Text>
</Box>
);
}
+260 -181
View File
@@ -1,9 +1,11 @@
import React, { import React, {
KeyboardEventHandler, KeyboardEventHandler,
ReactNode,
RefObject, RefObject,
forwardRef, forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common'; import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { settingsAtom } from '../../state/settings'; import {
ComposerToolbarButtonKey,
normalizeComposerToolbarOrder,
settingsAtom,
} from '../../state/settings';
import { import {
getAudioMsgContent, getAudioMsgContent,
getFileMsgContent, getFileMsgContent,
@@ -128,6 +134,7 @@ import { PollCreator } from './PollCreator';
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus'; import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
import { ScheduleMessageModal } from './ScheduleMessageModal'; import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray'; import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages'; import { scheduledMessagesAtom } from '../../state/scheduledMessages';
const GifPicker = React.lazy(() => const GifPicker = React.lazy(() =>
@@ -219,6 +226,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showPoll = composerToolbarButtons?.showPoll ?? true; const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true; const showVoice = composerToolbarButtons?.showVoice ?? true;
const showSchedule = composerToolbarButtons?.showSchedule ?? true; const showSchedule = composerToolbarButtons?.showSchedule ?? true;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null); const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => { const handleShareLocation = useCallback(() => {
@@ -358,13 +369,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const nodes = JSON.parse(stored); const nodes = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) { if (Array.isArray(nodes) && nodes.length > 0) {
Transforms.insertFragment(editor, nodes); Transforms.insertFragment(editor, nodes);
// Mirror the restored draft into the atom so the draft indicator
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
// after a page reload — not only on same-session room re-entry.
setMsgDraft(nodes);
} }
} }
} catch { } catch {
// Ignore malformed stored draft // Ignore malformed stored draft
} }
} }
}, [editor, msgDraft, roomId]); }, [editor, msgDraft, roomId, setMsgDraft]);
useEffect( useEffect(
() => () => { () => () => {
@@ -954,59 +969,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon src={Icons.PlusCircle} /> <Icon src={Icons.PlusCircle} />
</IconButton> </IconButton>
} }
after={ after={(() => {
<> const formatButton = showFormat ? (
{showFormat && ( <IconButton
<IconButton key="showFormat"
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
style={touchTarget} style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'} aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar} aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)} onClick={() => setToolbar(!toolbar)}
> >
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} /> <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton> </IconButton>
)} ) : null;
{(showEmoji || showSticker) && (
<UseStateProvider initial={undefined}> // Emoji and Sticker share a single EmojiBoard PopOut anchored to the
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( // emoji button, so they are rendered together as one unit. Their
<PopOut // relative order still follows the saved order.
offset={16} const emojiStickerBlock =
alignOffset={-44} showEmoji || showSticker ? (
position="Top" <UseStateProvider key="showEmojiSticker" initial={undefined}>
align="End" {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
anchor={ const stickerBtn =
emojiBoardTab === undefined showSticker && !hideStickerBtn ? (
? undefined
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
}
content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{showSticker && !hideStickerBtn && (
<IconButton <IconButton
key="showSticker"
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker} aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
aria-label="Insert sticker" aria-label="Insert sticker"
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)} onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
@@ -1020,36 +1009,76 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
filled={emojiBoardTab === EmojiBoardTab.Sticker} filled={emojiBoardTab === EmojiBoardTab.Sticker}
/> />
</IconButton> </IconButton>
)} ) : null;
{showEmoji && ( const emojiBtn = showEmoji ? (
<IconButton <IconButton
ref={emojiBtnRef} key="showEmoji"
aria-label="Insert emoji" ref={emojiBtnRef}
aria-pressed={ aria-label="Insert emoji"
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
filled={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
} }
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)} />
variant="SurfaceVariant" </IconButton>
size="300" ) : null;
radii="300" const emojiFirst =
style={touchTarget} composerButtonOrder.indexOf('showEmoji') <
> composerButtonOrder.indexOf('showSticker');
<Icon return (
src={Icons.Smile} <PopOut
filled={ offset={16}
hideStickerBtn alignOffset={-44}
? !!emojiBoardTab position="Top"
: emojiBoardTab === EmojiBoardTab.Emoji align="End"
} anchor={
/> emojiBoardTab === undefined
</IconButton> ? undefined
)} : (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
</PopOut> }
)} content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
</PopOut>
);
}}
</UseStateProvider> </UseStateProvider>
)} ) : null;
{!!gifApiKey && showGif && (
<UseStateProvider initial={false}> const gifButton =
!!gifApiKey && showGif ? (
<UseStateProvider key="showGif" initial={false}>
{(gifOpen: boolean, setGifOpen) => ( {(gifOpen: boolean, setGifOpen) => (
<PopOut <PopOut
offset={16} offset={16}
@@ -1101,113 +1130,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut> </PopOut>
)} )}
</UseStateProvider> </UseStateProvider>
)} ) : null;
{gifError && (
<Text const locationButton = showLocation ? (
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
{showLocation && (
<IconButton
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
)}
{showPoll && (
<IconButton
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
)}
{showVoice && (
<VoiceMessageRecorder
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
)}
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
{showSchedule && (
<IconButton
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Clock} size="100" />
</IconButton>
)}
<IconButton <IconButton
onClick={submit} key="showLocation"
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
) : null;
const pollButton = showPoll ? (
<IconButton
key="showPoll"
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
) : null;
const voiceButton = showVoice ? (
<VoiceMessageRecorder
key="showVoice"
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
) : null;
const scheduleButton = showSchedule ? (
<IconButton
key="showSchedule"
onClick={handleScheduleClick}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
style={touchTarget} style={touchTarget}
aria-label="Send message" aria-label="Schedule message"
title="Schedule message"
> >
<Icon src={Icons.Send} /> <Icon src={Icons.Clock} size="100" />
</IconButton> </IconButton>
</> ) : null;
}
const orderedButtons: ReactNode[] = [];
let emojiStickerRendered = false;
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
switch (key) {
case 'showFormat':
if (formatButton) orderedButtons.push(formatButton);
break;
case 'showEmoji':
case 'showSticker':
// Rendered once as a combined unit at whichever of the two
// keys comes first in the order.
if (!emojiStickerRendered) {
emojiStickerRendered = true;
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
}
break;
case 'showGif':
if (gifButton) orderedButtons.push(gifButton);
break;
case 'showLocation':
if (locationButton) orderedButtons.push(locationButton);
break;
case 'showPoll':
if (pollButton) orderedButtons.push(pollButton);
break;
case 'showVoice':
if (voiceButton) orderedButtons.push(voiceButton);
break;
case 'showSchedule':
if (scheduleButton) orderedButtons.push(scheduleButton);
break;
default:
break;
}
});
return (
<>
{orderedButtons}
{gifError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
<DraftIndicator roomId={roomId} />
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
<IconButton
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
>
<Icon src={Icons.Send} />
</IconButton>
</>
);
})()}
bottom={ bottom={
toolbar && ( toolbar && (
<div> <div>
+333 -35
View File
@@ -5,6 +5,7 @@ import React, {
MouseEventHandler, MouseEventHandler,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@@ -34,6 +35,19 @@ import {
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
import {
attachClosestEdge,
extractClosestEdge,
Edge,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { useAtom } from 'jotai';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut'; import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css'; import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -45,18 +59,29 @@ import {
} from '../../../utils/lotusDenoiseUtils'; } from '../../../utils/lotusDenoiseUtils';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { import {
CallAudioBitrate,
ChatBackground, ChatBackground,
ComposerToolbarButtonKey,
ComposerToolbarSettings, ComposerToolbarSettings,
DateFormat, DateFormat,
DenoiseModelId, DenoiseModelId,
MessageLayout, MessageLayout,
MessageSpacing, MessageSpacing,
NoiseSuppressionMode, NoiseSuppressionMode,
normalizeComposerToolbarOrder,
RingtoneId, RingtoneId,
ScreenshareBitrate,
ScreenshareFramerate,
Settings, Settings,
settingsAtom, settingsAtom,
} from '../../../state/settings'; } from '../../../state/settings';
import {
AUDIO_BITRATE_OPTIONS,
SCREENSHARE_BITRATE_OPTIONS,
SCREENSHARE_FRAMERATE_OPTIONS,
} from '../../../utils/callQuality';
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect'; import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol'; import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent'; import { isMacOS } from '../../../utils/user-agent';
@@ -77,12 +102,33 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing'; import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater'; import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds'; import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones'; import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester'; import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect'; import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
/**
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
* (mounted in App.tsx) applies `set_decorations` when this flips.
*/
function DesktopChromeSetting() {
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
if (!isTauriEnv()) return null;
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Window Chrome (Beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly."
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
/>
</SequenceCard>
);
}
type ThemeSelectorProps = { type ThemeSelectorProps = {
themeNames: Record<string, string>; themeNames: Record<string, string>;
themes: Theme[]; themes: Theme[];
@@ -396,6 +442,8 @@ function Appearance() {
/> />
</SequenceCard> </SequenceCard>
<DesktopChromeSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Twitter Emoji" title="Twitter Emoji"
@@ -438,7 +486,7 @@ function Appearance() {
> >
<SettingTile <SettingTile
title="Seasonal Theme" 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}` }}> <Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
<SeasonalBgGrid <SeasonalBgGrid
@@ -1016,6 +1064,165 @@ function DateAndTime() {
); );
} }
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
showFormat: 'Format',
showEmoji: 'Emoji',
showSticker: 'Sticker',
showGif: 'GIF',
showLocation: 'Location',
showPoll: 'Poll',
showVoice: 'Voice',
showSchedule: 'Schedule',
};
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
type ComposerToolbarButtonRowProps = {
buttonKey: ComposerToolbarButtonKey;
index: number;
active: boolean;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarButtonRow({
buttonKey,
index,
active,
onToggle,
}: ComposerToolbarButtonRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
const [dragging, setDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
useEffect(() => {
const element = rowRef.current;
const dragHandle = handleRef.current;
if (!element || !dragHandle) return undefined;
return combine(
draggable({
element,
dragHandle,
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
onDragStart: () => setDragging(true),
onDrop: () => setDragging(false),
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
getData: ({ input }) =>
attachClosestEdge(
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
{ element, input, allowedEdges: ['top', 'bottom'] },
),
getIsSticky: () => true,
onDrag: ({ self, source }) => {
if (source.data.buttonKey === buttonKey) {
setClosestEdge(null);
return;
}
setClosestEdge(extractClosestEdge(self.data));
},
onDragLeave: () => setClosestEdge(null),
onDrop: () => setClosestEdge(null),
}),
);
}, [buttonKey, index]);
let boxShadow: string | undefined;
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
return (
<Box
ref={rowRef}
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S400}`,
opacity: dragging ? 0.5 : undefined,
boxShadow,
}}
>
<IconButton
ref={handleRef}
size="300"
radii="300"
variant="SurfaceVariant"
style={{ cursor: 'grab' }}
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<Text style={{ flexGrow: 1 }} size="T300">
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
</Text>
<Chip
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => onToggle(buttonKey)}
aria-pressed={active}
>
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
</Chip>
</Box>
);
}
type ComposerToolbarReorderProps = {
order: ComposerToolbarButtonKey[];
buttons: ComposerToolbarSettings;
onReorder: (startIndex: number, finishIndex: number) => void;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarReorder({
order,
buttons,
onReorder,
onToggle,
}: ComposerToolbarReorderProps) {
useEffect(
() =>
monitorForElements({
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const startIndex = source.data.index;
const indexOfTarget = target.data.index;
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
const closestEdgeOfTarget = extractClosestEdge(target.data);
// Insert relative to the target row, then compensate for the source
// row being removed from its original position.
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
if (startIndex < finishIndex) finishIndex -= 1;
if (finishIndex === startIndex) return;
onReorder(startIndex, finishIndex);
},
}),
[onReorder],
);
return (
<Box direction="Column">
{order.map((key, index) => (
<ComposerToolbarButtonRow
key={key}
buttonKey={key}
index={index}
active={buttons[key]}
onToggle={onToggle}
/>
))}
</Box>
);
}
function Editor() { function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -1025,20 +1232,31 @@ function Editor() {
'composerToolbarButtons', 'composerToolbarButtons',
); );
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => { const composerToolbarOrder = useMemo(
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] }); () => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
}; [composerToolbarButtons?.order],
);
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [ const toggleToolbarButton = useCallback(
{ key: 'showFormat', label: 'Format' }, (key: ComposerToolbarButtonKey) => {
{ key: 'showEmoji', label: 'Emoji' }, setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
{ key: 'showSticker', label: 'Sticker' }, },
{ key: 'showGif', label: 'GIF' }, [setComposerToolbarButtons],
{ key: 'showLocation', label: 'Location' }, );
{ key: 'showPoll', label: 'Poll' },
{ key: 'showVoice', label: 'Voice' }, const reorderToolbarButtons = useCallback(
{ key: 'showSchedule', label: 'Schedule' }, (startIndex: number, finishIndex: number) => {
]; setComposerToolbarButtons((current) => ({
...current,
order: reorder({
list: normalizeComposerToolbarOrder(current.order),
startIndex,
finishIndex,
}),
}));
},
[setComposerToolbarButtons],
);
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
@@ -1073,28 +1291,15 @@ function Editor() {
> >
<SettingTile <SettingTile
title="Composer Toolbar" title="Composer Toolbar"
description="Tap a button to show or hide it in the message composer." description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
/> />
<Box <Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
wrap="Wrap" <ComposerToolbarReorder
gap="200" order={composerToolbarOrder}
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }} buttons={composerToolbarButtons}
> onReorder={reorderToolbarButtons}
{TOOLBAR_CHIPS.map(({ key, label }) => { onToggle={toggleToolbarButton}
const active = composerToolbarButtons?.[key] ?? true; />
return (
<Chip
key={key}
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => toggleToolbarButton(key)}
aria-pressed={active}
>
<Text size="T300">{label}</Text>
</Chip>
);
})}
</Box> </Box>
</SequenceCard> </SequenceCard>
</Box> </Box>
@@ -1220,6 +1425,18 @@ function Calls() {
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume'); const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId'); 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') => { const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
setCallJoinLeaveSound(value); setCallJoinLeaveSound(value);
if (value !== 'off') playCallJoinSound(value); if (value !== 'off') playCallJoinSound(value);
@@ -1615,6 +1832,80 @@ function Calls() {
} }
/> />
</SequenceCard> </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> </Box>
); );
} }
@@ -1699,6 +1990,13 @@ function SeasonalBgGrid({
<Text size="T200" style={selected ? { color: color.Primary.Main } : undefined}> <Text size="T200" style={selected ? { color: color.Primary.Main } : undefined}>
{opt.label} {opt.label}
</Text> </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> </Box>
); );
})} })}
+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]);
}
+16 -5
View File
@@ -1,11 +1,16 @@
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react'; import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
import { getDataTransferFiles } from '../utils/dom'; import { collectDroppedFiles } from '../utils/fileEntries';
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler => export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
useCallback( useCallback(
(evt) => { (evt) => {
const files = getDataTransferFiles(evt.dataTransfer); // `collectDroppedFiles` synchronously captures the entry list from the
if (files) onDrop(files); // DataTransfer before traversing folders asynchronously.
collectDroppedFiles(evt.dataTransfer)
.then((files) => {
if (files) onDrop(files);
})
.catch(() => undefined);
}, },
[onDrop], [onDrop],
); );
@@ -24,8 +29,14 @@ export const useFileDropZone = (
dragCounterRef.current = 0; dragCounterRef.current = 0;
setActive(false); setActive(false);
if (!evt.dataTransfer) return; if (!evt.dataTransfer) return;
const files = getDataTransferFiles(evt.dataTransfer); // Capture entries synchronously (inside the event) then traverse any
if (files) onDrop(files); // dropped folders asynchronously — the DataTransferItemList is emptied
// once this handler returns.
collectDroppedFiles(evt.dataTransfer)
.then((files) => {
if (files) onDrop(files);
})
.catch(() => undefined);
}; };
target?.addEventListener('drop', handleDrop); target?.addEventListener('drop', handleDrop);
+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 };
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
// Tauri v2 injects `__TAURI_INTERNALS__` into the webview at runtime; we use it
// directly so cinny doesn't need `@tauri-apps/api` as a dependency. Native Rust
// modules push data back to the web by dispatching DOM CustomEvents (see
// `emit_to_web` in cinny-desktop's `native` module), which `useTauriEvent`
// subscribes to. This module is the single source for the desktop bridge that
// every `useTauri*` feature hook builds on.
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
export const tauriInvoke = (): Invoke | undefined =>
(window as unknown as { __TAURI_INTERNALS__?: { invoke: Invoke } }).__TAURI_INTERNALS__?.invoke;
export const isTauri = (): boolean => tauriInvoke() !== undefined;
/** Fire-and-forget invoke that no-ops (and never throws) outside Tauri. */
export const invokeTauri = (cmd: string, args?: Record<string, unknown>): void => {
tauriInvoke()?.(cmd, args).catch(() => undefined);
};
/**
* Subscribe to a CustomEvent dispatched from the Rust side via `emit_to_web`.
* The handler is kept in a ref so callers don't need to memoize it to avoid
* re-subscribing. No-op outside Tauri.
*/
export function useTauriEvent<T = unknown>(name: string, handler: (detail: T) => void): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
if (!isTauri()) return undefined;
const listener = (e: Event): void => handlerRef.current((e as CustomEvent<T>).detail);
window.addEventListener(name, listener);
return () => window.removeEventListener(name, listener);
}, [name]);
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { invokeTauri } from './useTauri';
/**
* P5-46 — keep the system awake during calls (call continuity). Mirrors the
* call-embed atom (undefined = no active call) onto the native `set_call_active`
* command, which holds a `SetThreadExecutionState` request on Windows while a
* voice/video call is active and releases it when the call ends. No-op in the
* browser.
*/
export function useTauriCallPower(): void {
const callEmbed = useAtomValue(callEmbedAtom);
useEffect(() => {
invokeTauri('set_call_active', { active: callEmbed !== undefined });
}, [callEmbed]);
}
+24
View File
@@ -0,0 +1,24 @@
import { useSetAtom } from 'jotai';
import { focusAssistActiveAtom } from '../state/focusAssist';
import { useTauriEvent } from './useTauri';
/** Detail shape of the `focus-assist-changed` event emitted by the native side. */
type FocusAssistChangedDetail = {
active: boolean;
};
/**
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (desktop). Subscribes to
* the native `focus-assist-changed` event (Windows `SHQueryUserNotificationState`
* poll, `{ active }`) and mirrors it into `focusAssistActiveAtom`, which the
* notification gate reads to suppress notifications while the shell is in Focus
* Assist / Quiet Hours, presenting, gaming full-screen, or busy. Inert in the
* browser, since `useTauriEvent` only listens under Tauri.
*/
export function useTauriFocusAssist(): void {
const setFocusAssist = useSetAtom(focusAssistActiveAtom);
useTauriEvent<FocusAssistChangedDetail>('focus-assist-changed', ({ active }) =>
setFocusAssist(active),
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { allRoomsAtom } from '../state/room-list/roomList';
import { useMatrixClient } from './useMatrixClient';
import { isTauri, invokeTauri } from './useTauri';
/** Cap the Jump List to a small, glanceable set of rooms. */
const MAX_ITEMS = 8;
/** Wait for room activity to settle before re-publishing the (native) list. */
const DEBOUNCE_MS = 1500;
type JumpItem = { title: string; uri: string };
/**
* Build the `matrix:` deep link the desktop deep-link handler understands (see
* `useDeepLinkNavigate`): `matrix:r/<alias>` for a canonical alias, otherwise
* `matrix:roomid/<id>`. The sigil is dropped and the remainder is percent-encoded
* because the handler decodes each segment with `decodeURIComponent`.
*/
const roomToUri = (room: Room): string => {
const alias = room.getCanonicalAlias();
if (alias && alias.startsWith('#')) {
return `matrix:r/${encodeURIComponent(alias.slice(1))}`;
}
return `matrix:roomid/${encodeURIComponent(room.roomId.slice(1))}`;
};
/**
* P5-36 — publish a Windows taskbar Jump List of the most recently-active rooms.
* Rooms come from `allRoomsAtom` (the joined-room list), sorted by
* `getLastActiveTimestamp` (mirroring the sort used elsewhere, e.g. the forward
* dialog), with spaces excluded. The list is pushed to the native
* `set_jump_list` command, debounced so bursts of activity don't thrash the
* shell. No-op outside Tauri.
*/
export function useTauriJumpList(): void {
const mx = useMatrixClient();
const allRooms = useAtomValue(allRoomsAtom);
useEffect(() => {
if (!isTauri()) return undefined;
const timeout = setTimeout(() => {
const items: JumpItem[] = allRooms
.map((roomId) => mx.getRoom(roomId))
.filter((room): room is Room => room !== null && !room.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0))
.slice(0, MAX_ITEMS)
.map((room) => ({ title: room.name || room.roomId, uri: roomToUri(room) }));
invokeTauri('set_jump_list', { items });
}, DEBOUNCE_MS);
return () => clearTimeout(timeout);
}, [mx, allRooms]);
}
+38
View File
@@ -0,0 +1,38 @@
import { useRef, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useTauriEvent } from './useTauri';
/** Detail shape of the `network-changed` event emitted by the native side. */
type NetworkChangedDetail = {
online: boolean;
};
/**
* P5-49 — Network awareness (desktop). Subscribes to the native
* `network-changed` event (Windows Network List Manager poll, `{ online }`) and,
* on a transition back to online, calls `mx.retryImmediately()` so the sync loop
* retries its backed-off `/sync` at once instead of waiting out the backoff
* timer. Returns the last known connectivity (`undefined` until the first
* event). Inert in the browser, since `useTauriEvent` only listens under Tauri.
*/
export function useTauriNetwork(): boolean | undefined {
const mx = useMatrixClient();
const [online, setOnline] = useState<boolean | undefined>(undefined);
// Track the previous value in a ref so we can detect an offline -> online
// transition without adding it to a dependency list.
const onlineRef = useRef<boolean | undefined>(undefined);
useTauriEvent<NetworkChangedDetail>('network-changed', ({ online: next }) => {
const previous = onlineRef.current;
onlineRef.current = next;
setOnline(next);
// Only nudge the client when connectivity is (re)gained. The initial event
// (previous === undefined) also triggers a retry, which is safe: it's a
// no-op if nothing is backed off.
if (next && previous !== true) {
mx.retryImmediately();
}
});
return online;
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { useCallControlState } from '../plugins/call';
import { invokeTauri, useTauriEvent } from './useTauri';
/**
* P5-43 — expose the active call to the Windows System Media Transport Controls
* (the volume-flyout / media overlay). Mirrors the call-embed atom (undefined =
* no active call) and the current mic state onto the native
* `set_smtc_call_state` command, and translates SMTC button presses back into
* call actions:
* - Play/Pause (`smtc-action` → `mute`) toggles the microphone.
* - Stop (`smtc-action` → `end`) hangs up the call.
* No-op in the browser (the native command and events only fire under Tauri).
*/
type SmtcAction = { action: 'mute' | 'end' };
export function useTauriSmtc(): void {
const callEmbed = useAtomValue(callEmbedAtom);
// `microphone` reflects mic-enabled; muted is its inverse while in a call.
const { microphone } = useCallControlState(callEmbed?.control);
const active = callEmbed !== undefined;
const muted = active && !microphone;
useEffect(() => {
invokeTauri('set_smtc_call_state', { active, muted });
}, [active, muted]);
useTauriEvent<SmtcAction>('smtc-action', ({ action }) => {
if (!callEmbed) return;
if (action === 'mute') {
callEmbed.control.toggleMicrophone().catch(() => undefined);
} else if (action === 'end') {
callEmbed.hangup().catch(() => undefined);
}
});
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { callEmbedAtom } from '../state/callEmbed';
import { useCallControlState } from '../plugins/call';
import { invokeTauri, useTauriEvent } from './useTauri';
type ThumbbarAction = { action: 'mute' | 'deafen' | 'end' };
/**
* P5-44 — Taskbar thumbnail toolbar (call controls). While a call is active,
* mirrors the mic/sound state onto the native `set_thumbbar` command (three
* Mute / Deafen / End-Call buttons on the Windows taskbar thumbnail toolbar) and
* hides them when the call ends. Thumb-button clicks come back as the
* `thumbbar-action` event and drive the real call controls. No-op in the browser.
*/
export function useTauriThumbbar(): void {
const callEmbed = useAtomValue(callEmbedAtom);
const { microphone, sound } = useCallControlState(callEmbed?.control);
const active = callEmbed !== undefined;
// Muted / deafened only make sense while a call is active; report false
// otherwise so the buttons render in a sane (hidden) state.
const muted = active && !microphone;
const deafened = active && !sound;
useEffect(() => {
invokeTauri('set_thumbbar', { active, muted, deafened });
}, [active, muted, deafened]);
useTauriEvent<ThumbbarAction>('thumbbar-action', ({ action }) => {
if (!callEmbed) return;
if (action === 'mute') {
// toggleMicrophone flips the mic; `microphone === false` means muted.
// Async transport send — swallow rejection (widget mid-teardown), as SMTC does.
callEmbed.control.toggleMicrophone().catch(() => undefined);
} else if (action === 'deafen') {
// toggleSound flips local audio; `sound === false` means deafened. It also
// mutes the mic while deafened, matching the in-app Deafen control.
callEmbed.control.toggleSound();
} else if (action === 'end') {
callEmbed.hangup().catch(() => undefined);
}
});
}
+39
View File
@@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom';
import { MsgType } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useTauriEvent } from './useTauri';
/** Payload of the `lotus-notification-activate` event (a plain body click). */
interface ActivateDetail {
path?: string;
}
/** Payload of the `lotus-notification-reply` event (the inline reply box). */
interface ReplyDetail {
roomId?: string;
text?: string;
}
/**
* P5-41 / P5-35 — wire the native WinRT toast's click + quick-reply back into the
* client. The Rust side (`show_rich_toast`) dispatches DOM CustomEvents via
* `emit_to_web`:
* - `lotus-notification-activate` → route to the room the toast was for, reusing
* the same `useNavigate(path)` mechanism the web `notificationclick` path uses
* (see ClientNonUIFeatures).
* - `lotus-notification-reply` → send the typed reply straight to the room.
* No-op outside Tauri (the events never fire).
*/
export function useTauriToastActions(): void {
const navigate = useNavigate();
const mx = useMatrixClient();
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
if (path) navigate(path);
});
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
if (!roomId || !text) return;
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
});
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { invokeTauri, isTauri } from './useTauri';
/**
* P5-47 — drive the native window frame from the `customWindowChromeAtom`.
*
* On mount and whenever the atom changes, pushes the value onto the native
* `set_custom_chrome` command: `enabled = true` strips the OS decorations so the
* web `<TitleBar/>` can take over, `enabled = false` restores the native frame.
* No-op in the browser (`isTauri()` guard), so it's safe to call unconditionally
* from the app shell.
*/
export function useTauriWindowChrome(): void {
const enabled = useAtomValue(customWindowChromeAtom);
useEffect(() => {
if (!isTauri()) return;
invokeTauri('set_custom_chrome', { enabled });
}, [enabled]);
}
+38 -2
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { ReactNode, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai'; import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import { import {
@@ -25,6 +25,10 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { LotusToastContainer } from '../features/toast/LotusToastContainer'; import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge'; import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
import { isTauri } from '../hooks/useTauri';
import { TitleBar } from '../features/desktop/TitleBar';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect'; import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor'; import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex'; import { zIndices } from '../styles/zIndex';
@@ -88,6 +92,36 @@ function TauriEffects() {
return null; return null;
} }
// P5-47 — opt-in TDS window chrome. `useTauriWindowChrome` keeps the native OS
// window decorations in sync with the setting; when a desktop user enables
// custom chrome we replace the OS titlebar with <TitleBar/>. When off (the
// default, and always in the browser) this returns children unchanged, so there
// is zero layout impact for everyone else.
function DesktopChrome({ children }: { children: ReactNode }) {
const customChrome = useAtomValue(customWindowChromeAtom);
useTauriWindowChrome();
const useChrome = isTauri() && customChrome;
// Keep the wrapper element structure STABLE across the toggle so flipping the
// setting never changes the element type in `children`'s ancestry — otherwise
// React would unmount/remount the whole RouterProvider subtree (losing scroll,
// menus, unsaved composer state). When off, both wrappers use `display:contents`
// so they generate no box → zero layout impact (also the browser default path).
return (
<div
style={
useChrome
? { display: 'flex', flexDirection: 'column', height: '100vh' }
: { display: 'contents' }
}
>
{useChrome && <TitleBar />}
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
{children}
</div>
</div>
);
}
function NightLightOverlay() { function NightLightOverlay() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
if (!settings.nightLightEnabled) return null; if (!settings.nightLightEnabled) return null;
@@ -160,7 +194,9 @@ function App() {
<JotaiProvider> <JotaiProvider>
<AppearanceEffects /> <AppearanceEffects />
<TauriEffects /> <TauriEffects />
<RouterProvider router={createRouter(clientConfig, screenSize)} /> <DesktopChrome>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</DesktopChrome>
<SeasonalEffect /> <SeasonalEffect />
<NightLightOverlay /> <NightLightOverlay />
<LotusToastContainer /> <LotusToastContainer />
+11 -2
View File
@@ -2,6 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/lotus.png'; import LogoSVG from '../../../../public/res/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png'; import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
@@ -33,6 +34,7 @@ import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast'; import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders'; import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater'; import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
function isInQuietHours(start: string, end: string): boolean { function isInQuietHours(start: string, end: string): boolean {
const now = new Date(); const now = new Date();
@@ -109,6 +111,7 @@ function InviteNotifications() {
const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId'); const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
@@ -167,7 +170,8 @@ function InviteNotifications() {
useEffect(() => { useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') { if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd); const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) { if (!quietActive) {
if (showNotifications && notificationPermission('granted')) { if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen); notify(invites.length - perviousInviteLen);
@@ -189,6 +193,7 @@ function InviteNotifications() {
quietHoursEnabled, quietHoursEnabled,
quietHoursStart, quietHoursStart,
quietHoursEnd, quietHoursEnd,
focusAssistActive,
inviteSoundId, inviteSoundId,
]); ]);
@@ -212,6 +217,7 @@ function MessageNotifications() {
const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId'); const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
@@ -355,7 +361,8 @@ function MessageNotifications() {
return; return;
} }
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd); const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) { if (!quietActive) {
if (showNotifications && notificationPermission('granted')) { if (showNotifications && notificationPermission('granted')) {
const avatarMxc = const avatarMxc =
@@ -394,6 +401,7 @@ function MessageNotifications() {
quietHoursEnabled, quietHoursEnabled,
quietHoursStart, quietHoursStart,
quietHoursEnd, quietHoursEnd,
focusAssistActive,
messageSoundId, messageSoundId,
]); ]);
@@ -555,6 +563,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<MessageNotifications /> <MessageNotifications />
<ReminderMonitor /> <ReminderMonitor />
<TauriUpdateFeature /> <TauriUpdateFeature />
<TauriDesktopFeatures />
<LotusDenoiseFeature /> <LotusDenoiseFeature />
<DeepLinkNavigator /> <DeepLinkNavigator />
{children} {children}
+38
View File
@@ -7,6 +7,17 @@ export enum CallControlEvent {
StateUpdate = 'state_update', StateUpdate = 'state_update',
} }
/**
* [lotus #7 / P5-31] Payload for the fork's `io.lotus.set_quality` action.
* All fields optional; `null` clears that cap. Bits/sec for bitrates, fps for
* framerate.
*/
export type LotusQualityPayload = {
audioMaxBitrate?: number | null;
screenshareMaxBitrate?: number | null;
screenshareMaxFramerate?: number | null;
};
export class CallControl extends EventEmitter implements CallControlState { export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState; private state: CallControlState;
@@ -358,6 +369,33 @@ export class CallControl extends EventEmitter implements CallControlState {
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined); this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
} }
/**
* [lotus #3 / P5-15] Inject a soundboard clip into the call so other
* participants hear it. The fork publishes it as a separate LiveKit audio
* track (`io.lotus.inject_audio`) rather than splicing the mic. `url` must be
* an https/blob URL the widget can fetch WITHOUT credentials — the host
* resolves an mxc clip to a `blob:` object URL first (authenticated media
* can't be fetched cross-realm by the widget). `volume` is 01.
*
* The local user does not hear their own published track, so callers should
* also play the clip locally for feedback.
*/
public injectAudio(url: string, volume = 1): void {
this.call.transport.send('io.lotus.inject_audio', { url, volume }).catch(() => undefined);
}
/**
* [lotus #7 / P5-31] Apply audio/screenshare encoding limits to the local
* published tracks (the fork's `io.lotus.set_quality` action, via
* `RTCRtpSender.setParameters` — no republish). Bitrates are bits/sec,
* framerate is fps. A field set to `null` clears that cap. Settings are
* sticky fork-side (re-applied on every re-publish / reconnect). Values are
* clamped fork-side, so out-of-range input can't brick the encoder.
*/
public setQuality(settings: LotusQualityPayload): void {
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
}
public dispose() { public dispose() {
this.bodyMutationObserver.disconnect(); this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect(); this.controlMutationObserver.disconnect();
+20 -7
View File
@@ -138,9 +138,9 @@ export class CallEmbed {
themeKind: ElementCallThemeKind, themeKind: ElementCallThemeKind,
denoiseMode: NoiseSuppressionMode = 'browser', denoiseMode: NoiseSuppressionMode = 'browser',
denoiseModel: string = 'rnnoise', denoiseModel: string = 'rnnoise',
// [lotus] no longer used by the in-source denoise path; kept positionally // [lotus] "Series suppression": also run EC's built-in WebRTC NS before the
// for callers. Prefixed with _ to satisfy no-unused-vars. // in-source ML model (opt-in test aid for stacking browser NS + ML).
_denoiseNativeNS: boolean = true, denoiseNativeNS: boolean = false,
denoiseGate: boolean = false, denoiseGate: boolean = false,
denoiseGateThreshold: number = -45, denoiseGateThreshold: number = -45,
initialAudio = true, initialAudio = true,
@@ -166,10 +166,18 @@ export class CallEmbed {
perParticipantE2EE: room.hasEncryptionStateEvent().toString(), perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
lang: 'en-EN', lang: 'en-EN',
theme: themeKind, theme: themeKind,
// EC's built-in WebRTC suppressor: on only for 'browser' tier. For 'ml' // EC's built-in WebRTC suppressor: on for the 'browser' tier, and for the
// we disable it so EC captures a raw mic and the fork's in-source denoise // 'ml' tier only when "series suppression" is opted into (stack browser NS
// TrackProcessor (lotusDenoiseSource) handles the pipeline. // before the fork's in-source ML model). Plain 'ml' keeps it OFF so the
noiseSuppression: (denoiseMode === 'browser').toString(), // fork's TrackProcessor (lotusDenoiseSource) gets a raw mic.
noiseSuppression: (
denoiseMode === 'browser' ||
(denoiseMode === 'ml' && denoiseNativeNS)
).toString(),
// Turn the browser's auto gain control OFF for the ML tier only: its
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
// tiers keep the browser's normal capture pipeline (AGC on).
autoGainControl: (denoiseMode !== 'ml').toString(),
audio: initialAudio.toString(), audio: initialAudio.toString(),
video: initialVideo.toString(), video: initialVideo.toString(),
header: 'none', header: 'none',
@@ -179,6 +187,11 @@ export class CallEmbed {
// - transparent background so the room wallpaper shows through natively // - transparent background so the room wallpaper shows through natively
lotusCallState: 'true', lotusCallState: 'true',
lotusTransparent: 'true', lotusTransparent: 'true',
// [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call
// soundboard can publish clips into the call. Dormant until the host
// sends io.lotus.inject_audio (only on an explicit user click), so
// arming it for every call is safe.
lotusAudioInject: 'true',
}); });
if (denoiseMode === 'ml') { if (denoiseMode === 'ml') {
+22
View File
@@ -0,0 +1,22 @@
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
const CUSTOM_WINDOW_CHROME = 'customWindowChrome';
/**
* P5-47 — TDS Custom Window Chrome opt-in flag (default `false`).
*
* Standalone, `localStorage`-backed boolean atom kept separate from
* `state/settings.ts` on purpose. When `true` (and running inside Tauri) the app
* strips the native window frame and renders its own `<TitleBar/>`; when `false`
* the native OS frame is used. The feature is runtime-reversible, so flipping
* this atom is all it takes to switch back and forth.
*/
export const customWindowChromeAtom = atomWithLocalStorage<boolean>(
CUSTOM_WINDOW_CHROME,
(key) => getLocalStorageItem<boolean>(key, false),
(key, value) => setLocalStorageItem(key, value),
);
+14
View File
@@ -0,0 +1,14 @@
import { atom } from 'jotai';
/**
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (live OS state).
*
* Standalone, non-persisted boolean atom reflecting whether the shell is
* currently suppressing notifications (Focus Assist / Quiet Hours, presentation
* mode, full-screen D3D, or "busy"). It is driven at runtime by
* `useTauriFocusAssist` from the native `focus-assist-changed` event and read by
* the notification gate. Because it mirrors transient OS state — not a user
* preference — it is a plain in-memory atom that defaults to `false` and is
* intentionally NOT written to `localStorage`.
*/
export const focusAssistActiveAtom = atom(false);
+103 -2
View File
@@ -24,6 +24,13 @@ export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts); // 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
// 'none' is silent (visual-only incoming-call UI). // 'none' is silent (visual-only incoming-call UI).
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none'; export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
// [P5-31] Granular call quality caps. 'auto' = don't cap (the EC fork keeps its
// default encoding). Numbers are kbps (audio/screenshare bitrate) or fps
// (screenshare framerate); converted to the fork's bits/sec + fps payload in
// utils/callQuality.ts and applied via the io.lotus.set_quality widget action.
export type CallAudioBitrate = 'auto' | '32' | '64' | '96' | '128' | '256';
export type ScreenshareBitrate = 'auto' | '500' | '1500' | '3000' | '8000';
export type ScreenshareFramerate = 'auto' | '15' | '30' | '60';
export type ChatBackground = export type ChatBackground =
| 'none' | 'none'
| 'blueprint' | 'blueprint'
@@ -53,6 +60,39 @@ export enum MessageLayout {
Bubble = 2, Bubble = 2,
} }
/**
* Keys of the toggleable composer toolbar buttons. Also used as the identity
* of each button when persisting/restoring a custom drag-and-drop order.
*/
export const COMPOSER_TOOLBAR_BUTTON_KEYS = [
'showFormat',
'showEmoji',
'showSticker',
'showGif',
'showLocation',
'showPoll',
'showVoice',
'showSchedule',
] as const;
export type ComposerToolbarButtonKey = (typeof COMPOSER_TOOLBAR_BUTTON_KEYS)[number];
/**
* The fixed order the composer toolbar rendered before reordering existed.
* Used as the fallback for users without a saved order, and to append any
* new/unknown button keys, so existing users see no change.
*/
export const DEFAULT_COMPOSER_TOOLBAR_ORDER: ComposerToolbarButtonKey[] = [
'showFormat',
'showSticker',
'showEmoji',
'showGif',
'showLocation',
'showPoll',
'showVoice',
'showSchedule',
];
export interface ComposerToolbarSettings { export interface ComposerToolbarSettings {
showFormat: boolean; showFormat: boolean;
showEmoji: boolean; showEmoji: boolean;
@@ -62,6 +102,7 @@ export interface ComposerToolbarSettings {
showPoll: boolean; showPoll: boolean;
showVoice: boolean; showVoice: boolean;
showSchedule: boolean; showSchedule: boolean;
order: ComposerToolbarButtonKey[];
} }
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = { export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
@@ -73,6 +114,47 @@ export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
showPoll: true, showPoll: true,
showVoice: true, showVoice: true,
showSchedule: true, showSchedule: true,
order: DEFAULT_COMPOSER_TOOLBAR_ORDER,
};
/**
* Returns a complete, de-duplicated composer toolbar order:
* - drops unknown/duplicate keys from the saved order
* - appends any missing keys (new buttons or existing users with no saved
* order) at the end in their canonical default position
* so a button can never disappear from the toolbar.
*/
export const normalizeComposerToolbarOrder = (
order: ComposerToolbarButtonKey[] | undefined,
): ComposerToolbarButtonKey[] => {
const known = new Set<ComposerToolbarButtonKey>(COMPOSER_TOOLBAR_BUTTON_KEYS);
const seen = new Set<ComposerToolbarButtonKey>();
const result: ComposerToolbarButtonKey[] = [];
(order ?? []).forEach((key) => {
if (known.has(key) && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
// Append missing keys in their canonical default position…
DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
});
// …then any known key not covered by the default order (safety net so a new
// button added to COMPOSER_TOOLBAR_BUTTON_KEYS but forgotten in the default
// order can still render/reorder rather than being permanently dropped).
COMPOSER_TOOLBAR_BUTTON_KEYS.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}; };
export interface Settings { export interface Settings {
@@ -156,6 +238,14 @@ export interface Settings {
ringtoneId: RingtoneId; ringtoneId: RingtoneId;
ringtoneVolume: number; // 0100 ringtoneVolume: number; // 0100
// [P5-31] Call quality controls
callAudioBitrate: CallAudioBitrate;
screenshareBitrate: ScreenshareBitrate;
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
soundboardVolume: number; // 0100
seasonalThemeOverride: seasonalThemeOverride:
| 'auto' | 'auto'
| 'off' | 'off'
@@ -221,9 +311,13 @@ const defaultSettings: Settings = {
perMessageProfiles: false, perMessageProfiles: false,
cameraOnJoin: false, cameraOnJoin: false,
// Tier default stays browser-native (known-good; best-perceived in testing so
// far). If a user opts into the ML tier, default to the highest-quality model.
callNoiseSuppression: 'browser', callNoiseSuppression: 'browser',
callDenoiseModel: 'rnnoise', callDenoiseModel: 'deepfilternet',
callDenoiseNativeNS: true, // "Series suppression" (stack the browser's native NS before the ML model) is
// off by default — best practice is a single NS stage; it's an opt-in test aid.
callDenoiseNativeNS: false,
callDenoiseGate: false, callDenoiseGate: false,
callDenoiseGateThreshold: -45, callDenoiseGateThreshold: -45,
pttMode: false, pttMode: false,
@@ -253,6 +347,12 @@ const defaultSettings: Settings = {
ringtoneId: 'classic', ringtoneId: 'classic',
ringtoneVolume: 70, ringtoneVolume: 70,
callAudioBitrate: 'auto',
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
soundboardEnabled: true,
soundboardVolume: 80,
seasonalThemeOverride: 'auto', seasonalThemeOverride: 'auto',
}; };
@@ -293,6 +393,7 @@ export const getSettings = (): Settings => {
composerToolbarButtons: { composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR, ...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}), ...(saved.composerToolbarButtons ?? {}),
order: normalizeComposerToolbarOrder(saved.composerToolbarButtons?.order),
}, },
}; };
} catch { } catch {
+21
View File
@@ -9,6 +9,8 @@ import {
contrastingText, contrastingText,
varNameFromToken, varNameFromToken,
derivePrimaryPalette, derivePrimaryPalette,
deriveAccentExtras,
buildAccentCss,
} from './accentColor'; } from './accentColor';
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => { test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
@@ -66,3 +68,22 @@ test('derivePrimaryPalette produces the full Primary token set', () => {
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/); assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/); assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
}); });
test('deriveAccentExtras derives focus ring, link and selection from one base', () => {
const base = { r: 255, g: 136, b: 0 };
const extras = deriveAccentExtras(base);
// focus ring keeps the translucent character in the accent hue
assert.equal(extras.focusRing, 'rgba(255, 136, 0, 0.5)');
// link + selection background are the solid base hex
assert.equal(extras.link, '#ff8800');
assert.equal(extras.selectionBg, '#ff8800');
// selection text is WCAG-aware contrasting text over the base
assert.equal(extras.selectionText, contrastingText(base));
});
test('buildAccentCss emits selection rules using the derived palette', () => {
const base = { r: 0, g: 0, b: 0 };
const css = buildAccentCss(base);
assert.match(css, /::selection\{background:#000000;color:#fff;\}/);
assert.match(css, /::-moz-selection\{background:#000000;color:#fff;\}/);
});
+64 -1
View File
@@ -74,6 +74,45 @@ const PRIMARY_TOKENS: Record<string, string> = {
OnContainer: color.Primary.OnContainer, OnContainer: color.Primary.OnContainer,
}; };
// The neutral focus-ring token folds uses for the outline on inputs, buttons,
// switches, checkboxes and radios. Its default is a semi-transparent grey/black,
// so tinting it in the accent hue themes every focus ring without touching the
// neutral Secondary family (see below). We keep the same translucent character
// so it reads as a ring rather than a fill.
const FOCUS_RING_TOKEN = color.Other.FocusRing;
// `--tc-link` is the global anchor color (index.css `a { color: var(--tc-link) }`);
// overriding it themes plain links inside messages, room topics and URL previews.
const LINK_VAR = '--tc-link';
// Injected stylesheet id — carries rules that cannot be expressed as a single
// CSS variable (currently text ::selection).
const ACCENT_STYLE_ID = 'lotus-accent-style';
export type AccentExtras = {
focusRing: string;
link: string;
selectionBg: string;
selectionText: string;
};
// Derive the extra (non-Primary) accent values from the single base color, using
// the same helpers as the Primary palette so everything stays in one hue.
export const deriveAccentExtras = (base: Rgb): AccentExtras => ({
focusRing: rgba(base, 0.5),
link: rgbToHex(base),
selectionBg: rgbToHex(base),
selectionText: contrastingText(base),
});
// Build the injected stylesheet body. Selection uses a solid accent fill with
// WCAG-aware contrasting text so highlighted text stays readable.
export const buildAccentCss = (base: Rgb): string => {
const { selectionBg, selectionText } = deriveAccentExtras(base);
const selection = `background:${selectionBg};color:${selectionText};`;
return `::selection{${selection}}::-moz-selection{${selection}}`;
};
// Derive the 10 Primary sub-token values from a single chosen base color. // Derive the 10 Primary sub-token values from a single chosen base color.
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => { export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
const baseHex = rgbToHex(base); const baseHex = rgbToHex(base);
@@ -96,22 +135,46 @@ export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
}; };
// Apply a custom accent color by overriding the folds Primary CSS variables on // Apply a custom accent color by overriding the folds Primary CSS variables on
// `document.body`. Returns true when applied, false when the input is invalid. // `document.body`, tinting the focus-ring and link vars, and injecting a small
// stylesheet for text selection. Returns true when applied, false when the input
// is invalid.
export const applyCustomAccent = (hex: string): boolean => { export const applyCustomAccent = (hex: string): boolean => {
const base = hexToRgb(hex); const base = hexToRgb(hex);
if (!base) return false; if (!base) return false;
const palette = derivePrimaryPalette(base); const palette = derivePrimaryPalette(base);
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => { Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
const varName = varNameFromToken(token); const varName = varNameFromToken(token);
if (varName) document.body.style.setProperty(varName, palette[key]); if (varName) document.body.style.setProperty(varName, palette[key]);
}); });
const extras = deriveAccentExtras(base);
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
if (focusRingVar) document.body.style.setProperty(focusRingVar, extras.focusRing);
document.body.style.setProperty(LINK_VAR, extras.link);
let styleEl = document.getElementById(ACCENT_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = ACCENT_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = buildAccentCss(base);
return true; return true;
}; };
// Remove all custom accent overrides, reverting to the active theme's defaults. // Remove all custom accent overrides, reverting to the active theme's defaults.
// Idempotent — safe to call even when nothing was applied.
export const removeCustomAccent = (): void => { export const removeCustomAccent = (): void => {
Object.values(PRIMARY_TOKENS).forEach((token) => { Object.values(PRIMARY_TOKENS).forEach((token) => {
const varName = varNameFromToken(token); const varName = varNameFromToken(token);
if (varName) document.body.style.removeProperty(varName); if (varName) document.body.style.removeProperty(varName);
}); });
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
if (focusRingVar) document.body.style.removeProperty(focusRingVar);
document.body.style.removeProperty(LINK_VAR);
document.getElementById(ACCENT_STYLE_ID)?.remove();
}; };
+60
View File
@@ -0,0 +1,60 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildQualityPayload } from './callQuality';
describe('buildQualityPayload', () => {
it("sends null for every 'auto' field so a prior cap is reset", () => {
const payload = buildQualityPayload({
callAudioBitrate: 'auto',
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
});
assert.deepEqual(payload, {
audioMaxBitrate: null,
screenshareMaxBitrate: null,
screenshareMaxFramerate: null,
});
});
it('converts kbps user settings to bits/sec and passes fps through', () => {
const payload = buildQualityPayload({
callAudioBitrate: '64',
screenshareBitrate: '1500',
screenshareFramerate: '30',
});
assert.equal(payload.audioMaxBitrate, 64_000);
assert.equal(payload.screenshareMaxBitrate, 1_500_000);
assert.equal(payload.screenshareMaxFramerate, 30);
});
it('clamps the user setting down to the room cap (lower wins)', () => {
const payload = buildQualityPayload(
{ callAudioBitrate: '256', screenshareBitrate: '8000', screenshareFramerate: '60' },
{ audio_max_kbps: 64, screenshare_max_kbps: 1500, screenshare_max_fps: 30 },
);
assert.equal(payload.audioMaxBitrate, 64_000);
assert.equal(payload.screenshareMaxBitrate, 1_500_000);
assert.equal(payload.screenshareMaxFramerate, 30);
});
it('does not raise a user setting that is already below the room cap', () => {
const payload = buildQualityPayload(
{ callAudioBitrate: '32', screenshareBitrate: 'auto', screenshareFramerate: '15' },
{ audio_max_kbps: 128, screenshare_max_kbps: 3000, screenshare_max_fps: 60 },
);
assert.equal(payload.audioMaxBitrate, 32_000);
// user 'auto' but room caps screenshare bitrate -> room cap applies
assert.equal(payload.screenshareMaxBitrate, 3_000_000);
assert.equal(payload.screenshareMaxFramerate, 15);
});
it('applies a room cap even when the user left the field on auto', () => {
const payload = buildQualityPayload(
{ callAudioBitrate: 'auto', screenshareBitrate: 'auto', screenshareFramerate: 'auto' },
{ audio_max_kbps: 96 },
);
assert.equal(payload.audioMaxBitrate, 96_000);
assert.equal(payload.screenshareMaxBitrate, null);
assert.equal(payload.screenshareMaxFramerate, null);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { LotusQualityPayload } from '../plugins/call/CallControl';
import { CallAudioBitrate, ScreenshareBitrate, ScreenshareFramerate } from '../state/settings';
/**
* [P5-31] Room-level quality caps, stored in the `io.lotus.room_quality` state
* event. Admins set a ceiling every client must stay under. Values mirror the
* user-setting units (kbps / fps); `undefined`/absent = no cap.
*
* NOTE: the client applies these as a best-effort UX cap. Hard enforcement for
* ALL Matrix clients is a server-side follow-up (a `voice-limit-guard`-style
* sidecar on LXC 151 that reads this event) see LOTUS_TODO.md P5-31.
*/
export type RoomQualityContent = {
// Numeric caps: client-cooperative only (our fork honors them; the SFU cannot
// enforce publisher bitrate/fps — LiveKit forwards, never transcodes).
audio_max_kbps?: number;
screenshare_max_kbps?: number;
screenshare_max_fps?: number;
// Publish-source policy: HARD-enforced server-side for ALL clients by the
// voice-limit-guard (it re-signs the LiveKit JWT's canPublishSources).
// Absent/true = allowed; only an explicit false forbids.
allow_screenshare?: boolean;
allow_camera?: boolean;
};
// Selectable options (kbps / fps), shared by the settings UI and the room-admin
// UI so they stay in sync. Values are strings so they satisfy SettingsSelect's
// `T extends string` constraint; parsed to numbers in buildQualityPayload.
export const AUDIO_BITRATE_OPTIONS: { value: CallAudioBitrate; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: '32', label: '32 kbps' },
{ value: '64', label: '64 kbps' },
{ value: '96', label: '96 kbps' },
{ value: '128', label: '128 kbps' },
{ value: '256', label: '256 kbps' },
];
export const SCREENSHARE_BITRATE_OPTIONS: { value: ScreenshareBitrate; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: '500', label: '0.5 Mbps' },
{ value: '1500', label: '1.5 Mbps' },
{ value: '3000', label: '3 Mbps' },
{ value: '8000', label: '8 Mbps' },
];
export const SCREENSHARE_FRAMERATE_OPTIONS: { value: ScreenshareFramerate; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: '15', label: '15 fps' },
{ value: '30', label: '30 fps' },
{ value: '60', label: '60 fps' },
];
/** Lower of two caps, treating `undefined` as "no cap on that side". */
const minCap = (a: number | undefined, b: number | undefined): number | undefined => {
if (a === undefined) return b;
if (b === undefined) return a;
return Math.min(a, b);
};
/** Parse a setting value ('auto' | numeric string) to a number or undefined. */
const num = (v: string): number | undefined => {
if (v === 'auto') return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
};
type QualitySettings = {
callAudioBitrate: CallAudioBitrate;
screenshareBitrate: ScreenshareBitrate;
screenshareFramerate: ScreenshareFramerate;
};
/**
* Build the `io.lotus.set_quality` payload from the user's settings, clamped by
* any room-level cap. Every field is always present so clearing a setting back
* to 'auto' sends an explicit `null` that resets the fork-side cap (otherwise a
* previously-applied cap would stick for the rest of the call).
*/
export const buildQualityPayload = (
settings: QualitySettings,
roomCaps?: RoomQualityContent,
): LotusQualityPayload => {
const userAudio = num(settings.callAudioBitrate);
const userSsBitrate = num(settings.screenshareBitrate);
const userSsFps = num(settings.screenshareFramerate);
const audioKbps = minCap(userAudio, roomCaps?.audio_max_kbps);
const ssKbps = minCap(userSsBitrate, roomCaps?.screenshare_max_kbps);
const ssFps = minCap(userSsFps, roomCaps?.screenshare_max_fps);
return {
audioMaxBitrate: audioKbps === undefined ? null : audioKbps * 1000,
screenshareMaxBitrate: ssKbps === undefined ? null : ssKbps * 1000,
screenshareMaxFramerate: ssFps === undefined ? null : ssFps,
};
};
+229
View File
@@ -0,0 +1,229 @@
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { playCallJoinSound, playCallLeaveSound, unlockCallSounds } from './callSounds';
// ── Minimal Web Audio mock ──────────────────────────────────────────────────
// callSounds.ts reaches for the global `AudioContext` lazily (inside its own
// getAudioContext), so we can swap a fake in before each test and inspect the
// oscillators/gains it schedules.
type RampCall = { value: number; time: number };
class FakeParam {
value = 0;
setValueAtTimeCalls: RampCall[] = [];
linearRampCalls: RampCall[] = [];
expRampCalls: RampCall[] = [];
setValueAtTime(value: number, time: number): this {
this.setValueAtTimeCalls.push({ value, time });
return this;
}
linearRampToValueAtTime(value: number, time: number): this {
this.linearRampCalls.push({ value, time });
return this;
}
exponentialRampToValueAtTime(value: number, time: number): this {
this.expRampCalls.push({ value, time });
return this;
}
}
class FakeOscillator {
type = 'sine';
frequency = { value: 0 };
startCalls: number[] = [];
stopCalls: number[] = [];
connectedTo: unknown = null;
connect(node: unknown): unknown {
this.connectedTo = node;
return node;
}
start(t: number): void {
this.startCalls.push(t);
}
stop(t: number): void {
this.stopCalls.push(t);
}
}
class FakeGain {
gain = new FakeParam();
connectedTo: unknown = null;
connect(node: unknown): unknown {
this.connectedTo = node;
return node;
}
}
class FakeAudioContext {
static instances: FakeAudioContext[] = [];
static throwOnConstruct = false;
state: 'running' | 'suspended' | 'closed' = 'running';
// currentTime 0 keeps `start = now + at` exactly equal to the table offsets
// (no float drift), so scheduling assertions stay precise.
currentTime = 0;
destination = { id: 'destination' };
oscillators: FakeOscillator[] = [];
gains: FakeGain[] = [];
resumeCalls = 0;
constructor() {
if (FakeAudioContext.throwOnConstruct) throw new Error('AudioContext unavailable');
FakeAudioContext.instances.push(this);
}
createOscillator(): FakeOscillator {
const osc = new FakeOscillator();
this.oscillators.push(osc);
return osc;
}
createGain(): FakeGain {
const gain = new FakeGain();
this.gains.push(gain);
return gain;
}
resume(): Promise<void> {
this.resumeCalls += 1;
this.state = 'running';
return Promise.resolve();
}
close(): Promise<void> {
this.state = 'closed';
return Promise.resolve();
}
}
const current = (): FakeAudioContext => {
const ctx = FakeAudioContext.instances.at(-1);
assert.ok(ctx, 'expected an AudioContext to have been created');
return ctx;
};
const freqs = (ctx: FakeAudioContext): number[] => ctx.oscillators.map((o) => o.frequency.value);
beforeEach(() => {
// The module keeps a single shared AudioContext. Close any instance it may
// still hold so the next call forces a fresh one, then reset our registry.
FakeAudioContext.instances.forEach((i) => {
i.state = 'closed';
});
FakeAudioContext.instances = [];
FakeAudioContext.throwOnConstruct = false;
(globalThis as unknown as { AudioContext: unknown }).AudioContext = FakeAudioContext;
});
// ── Sound design (regression-pins the melodies) ─────────────────────────────
test('chime join plays D5 then A5 as sine notes, staggered', () => {
playCallJoinSound('chime');
const ctx = current();
assert.equal(ctx.oscillators.length, 2);
assert.deepEqual(
ctx.oscillators.map((o) => o.type),
['sine', 'sine'],
);
assert.deepEqual(freqs(ctx), [587.33, 880]);
assert.deepEqual(
ctx.oscillators.map((o) => o.startCalls[0]),
[0, 0.1],
);
});
test('chime leave reverses the join melody', () => {
playCallLeaveSound('chime');
assert.deepEqual(freqs(current()), [880, 587.33]);
});
test('soft join is a single triangle note at the right gain', () => {
playCallJoinSound('soft');
const ctx = current();
assert.equal(ctx.oscillators.length, 1);
assert.equal(ctx.oscillators[0].type, 'triangle');
assert.equal(ctx.oscillators[0].frequency.value, 523.25);
assert.equal(ctx.gains[0].gain.linearRampCalls[0].value, 0.18);
});
test('soft leave drops to the lower triangle note', () => {
playCallLeaveSound('soft');
assert.deepEqual(freqs(current()), [392]);
});
test('retro join is three ascending square notes', () => {
playCallJoinSound('retro');
const ctx = current();
assert.deepEqual(
ctx.oscillators.map((o) => o.type),
['square', 'square', 'square'],
);
assert.deepEqual(freqs(ctx), [440, 554.37, 659.25]);
assert.deepEqual(
ctx.oscillators.map((o) => o.startCalls[0]),
[0, 0.07, 0.14],
);
});
test('retro leave is three descending square notes', () => {
playCallLeaveSound('retro');
assert.deepEqual(freqs(current()), [659.25, 554.37, 440]);
});
// ── Envelope (click-avoidance shape) ────────────────────────────────────────
test('each note ramps 0 -> peak -> ~0 to avoid clicks, and wires osc->gain->out', () => {
playCallJoinSound('soft');
const ctx = current();
const { gain } = ctx.gains[0];
assert.equal(gain.setValueAtTimeCalls[0].value, 0); // starts silent
assert.equal(gain.linearRampCalls[0].value, 0.18); // attack to peak
assert.equal(gain.expRampCalls[0].value, 0.0001); // exp decay can't hit 0
// osc -> gain -> destination
assert.equal(ctx.oscillators[0].connectedTo, ctx.gains[0]);
assert.equal(ctx.gains[0].connectedTo, ctx.destination);
assert.equal(ctx.oscillators[0].startCalls.length, 1);
assert.equal(ctx.oscillators[0].stopCalls.length, 1);
});
// ── Dispatch / defensive contracts ──────────────────────────────────────────
test('an unknown style is a no-op and never even creates a context', () => {
assert.doesNotThrow(() => playCallJoinSound('bogus' as never));
assert.doesNotThrow(() => playCallLeaveSound('bogus' as never));
assert.equal(FakeAudioContext.instances.length, 0);
});
test('does not throw when AudioContext construction fails (unsupported env)', () => {
FakeAudioContext.throwOnConstruct = true;
assert.doesNotThrow(() => playCallJoinSound('chime'));
assert.equal(FakeAudioContext.instances.length, 0);
});
// ── Shared-context lifecycle ────────────────────────────────────────────────
test('unlockCallSounds primes a single running context', () => {
unlockCallSounds();
assert.equal(FakeAudioContext.instances.length, 1);
assert.equal(current().state, 'running');
});
test('reuses one running context across multiple sounds', () => {
playCallJoinSound('chime');
playCallLeaveSound('chime');
playCallJoinSound('soft');
assert.equal(FakeAudioContext.instances.length, 1);
});
test('recreates the context after it has been closed', () => {
playCallJoinSound('soft');
current().state = 'closed';
playCallJoinSound('soft');
assert.equal(FakeAudioContext.instances.length, 2);
});
test('resumes (without recreating) a suspended context', () => {
playCallJoinSound('soft');
const ctx = current();
ctx.state = 'suspended';
playCallJoinSound('soft');
assert.equal(FakeAudioContext.instances.length, 1);
assert.equal(ctx.resumeCalls, 1);
});
+70
View File
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { filesFromEntries } from './fileEntries';
const fileEntry = (name: string): FileSystemFileEntry =>
({
isFile: true,
isDirectory: false,
name,
file: (success: (file: File) => void) => {
success(new File(['x'], name, { type: 'text/plain' }));
},
}) as unknown as FileSystemFileEntry;
/**
* A directory whose reader yields its children in several batches (mirroring
* Chromium's `readEntries`, which caps each call) and finally an empty batch.
*/
const dirEntry = (name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry => {
const batches = [children.slice(0, 1), children.slice(1), [] as FileSystemEntry[]];
return {
isFile: false,
isDirectory: true,
name,
createReader: () => {
let call = 0;
return {
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
const batch = batches[call] ?? [];
call += 1;
success(batch);
},
} as unknown as FileSystemDirectoryReader;
},
} as unknown as FileSystemDirectoryEntry;
};
test('filesFromEntries flattens nested folders and prefixes relative paths', async () => {
const entries: FileSystemEntry[] = [
fileEntry('top.txt'),
dirEntry('photos', [
fileEntry('a.jpg'),
dirEntry('2024', [fileEntry('b.jpg'), fileEntry('c.jpg')]),
]),
];
const files = await filesFromEntries(entries);
const names = files.map((f) => f.name).sort();
assert.deepEqual(names, ['photos/2024/b.jpg', 'photos/2024/c.jpg', 'photos/a.jpg', 'top.txt']);
});
test('filesFromEntries reads directory entries in batches until empty', async () => {
const entries: FileSystemEntry[] = [
dirEntry('docs', [fileEntry('one.txt'), fileEntry('two.txt')]),
];
const files = await filesFromEntries(entries);
assert.equal(files.length, 2);
});
test('filesFromEntries respects the maxFiles cap', async () => {
const entries: FileSystemEntry[] = [
dirEntry('many', [fileEntry('a.txt'), fileEntry('b.txt')]),
fileEntry('c.txt'),
];
const files = await filesFromEntries(entries, 2);
assert.equal(files.length, 2);
});
+134
View File
@@ -0,0 +1,134 @@
import { getDataTransferFiles, renameFile } from './dom';
// Guard against pathological drops (deeply nested / huge trees) that could
// otherwise queue thousands of uploads and freeze the composer.
export const MAX_DROPPED_FILES = 500;
/**
* Synchronously collect the `FileSystemEntry` objects for every item in a
* drop's `DataTransfer`.
*
* This MUST be called synchronously inside the drop event handler: the
* `DataTransferItemList` is emptied once the handler returns, so calling
* `webkitGetAsEntry()` after an `await` yields `null`. Capture the entries
* first, then traverse them asynchronously with {@link filesFromEntries}.
*
* Returns an empty array when `webkitGetAsEntry` is unavailable (non-Chromium
* browsers), signalling the caller to fall back to the flat file list.
*/
export const entriesFromDataTransfer = (dataTransfer: DataTransfer): FileSystemEntry[] => {
const entries: FileSystemEntry[] = [];
const { items } = dataTransfer;
if (!items) return entries;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item && item.kind === 'file' && typeof item.webkitGetAsEntry === 'function') {
const entry = item.webkitGetAsEntry();
if (entry) entries.push(entry);
}
}
return entries;
};
const fileFromFileEntry = (entry: FileSystemFileEntry): Promise<File> =>
new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
/**
* Read every entry from a directory reader.
*
* `readEntries` returns results in BATCHES (Chromium yields at most ~100 per
* call), so it must be called repeatedly until it resolves with an empty array.
*/
const readAllDirectoryEntries = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> =>
new Promise((resolve, reject) => {
const all: FileSystemEntry[] = [];
const readBatch = () => {
reader.readEntries((batch) => {
if (batch.length === 0) {
resolve(all);
return;
}
all.push(...batch);
readBatch();
}, reject);
};
readBatch();
});
/**
* Recursively walk `FileSystemEntry` objects (as produced by
* {@link entriesFromDataTransfer}) and resolve them into a flat `File[]`,
* descending into every nested directory.
*
* Nested files keep their relative folder path as a name prefix (e.g.
* `photos/2024/pic.jpg`) so uploads remain distinguishable. Traversal stops
* once `maxFiles` files have been collected.
*/
export const filesFromEntries = async (
entries: FileSystemEntry[],
maxFiles: number = MAX_DROPPED_FILES,
): Promise<File[]> => {
const files: File[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (files.length >= maxFiles) return;
// A single unreadable file/directory (moved between drop and read, a
// permissions/lock error, an OS special file) must NOT abort the whole
// traversal — skip it and keep collecting the rest.
if (entry.isFile) {
try {
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
if (files.length >= maxFiles) return;
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
} catch {
/* skip unreadable file */
}
return;
}
if (entry.isDirectory) {
let childEntries: FileSystemEntry[] = [];
try {
const reader = (entry as FileSystemDirectoryEntry).createReader();
childEntries = await readAllDirectoryEntries(reader);
} catch {
return; /* skip unreadable directory */
}
const childPrefix = `${prefix}${entry.name}/`;
for (const child of childEntries) {
if (files.length >= maxFiles) break;
// eslint-disable-next-line no-await-in-loop
await walk(child, childPrefix);
}
}
};
for (const entry of entries) {
if (files.length >= maxFiles) break;
// eslint-disable-next-line no-await-in-loop
await walk(entry, '');
}
return files;
};
/**
* Extract dropped files, descending into any dropped folders.
*
* Captures the `FileSystemEntry` list synchronously (required see
* {@link entriesFromDataTransfer}) then traverses it asynchronously. Falls back
* to the flat `dataTransfer.files` list when the directory API is unavailable
* (non-Chromium) or when no entries are exposed.
*/
export const collectDroppedFiles = (dataTransfer: DataTransfer): Promise<File[] | undefined> => {
const entries = entriesFromDataTransfer(dataTransfer);
if (entries.length === 0) {
return Promise.resolve(getDataTransferFiles(dataTransfer));
}
return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined));
};
+104
View File
@@ -0,0 +1,104 @@
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { DENOISE_MODELS, ML_DENOISE_REQUIREMENTS, isMLDenoiseSupported } from './lotusDenoiseUtils';
// ── Model catalog (data integrity) ──────────────────────────────────────────
test('DENOISE_MODELS lists the four models ordered best-quality (highest CPU) first', () => {
assert.deepEqual(
DENOISE_MODELS.map((m) => m.id),
['deepfilternet', 'dtln', 'rnnoise', 'speex'],
);
});
test('DENOISE_MODELS ids are unique', () => {
const ids = DENOISE_MODELS.map((m) => m.id);
assert.equal(new Set(ids).size, ids.length);
});
test('every model has non-empty display fields and valid rating enums', () => {
const transients = new Set(['Poor', 'Good', 'Excellent']);
const voice = new Set(['Moderate', 'High', 'Very High']);
for (const m of DENOISE_MODELS) {
for (const field of ['name', 'description', 'cpuUsage', 'binarySize'] as const) {
assert.ok(typeof m[field] === 'string' && m[field].length > 0, `${m.id}.${field} empty`);
}
assert.ok(transients.has(m.transients), `${m.id} bad transients: ${m.transients}`);
assert.ok(voice.has(m.voiceQuality), `${m.id} bad voiceQuality: ${m.voiceQuality}`);
}
});
test('ML_DENOISE_REQUIREMENTS is a non-empty list of strings', () => {
assert.ok(Array.isArray(ML_DENOISE_REQUIREMENTS) && ML_DENOISE_REQUIREMENTS.length > 0);
assert.ok(ML_DENOISE_REQUIREMENTS.every((r) => typeof r === 'string' && r.length > 0));
});
// ── isMLDenoiseSupported (feature detection) ────────────────────────────────
const g = globalThis as Record<string, unknown>;
const NAMES = ['window', 'navigator', 'AudioWorkletNode'] as const;
let saved: Record<string, PropertyDescriptor | undefined>;
const setGlobal = (name: string, value: unknown): void => {
Object.defineProperty(g, name, { value, configurable: true, writable: true });
};
const removeGlobal = (name: string): void => {
// Make a bare reference to `name` throw ReferenceError (simulate an absent
// global binding), as it would in a browser lacking the API entirely.
if (Object.getOwnPropertyDescriptor(g, name)) delete g[name];
};
const withMediaDevices = { mediaDevices: { getUserMedia: () => Promise.resolve() } };
beforeEach(() => {
saved = {};
for (const n of NAMES) saved[n] = Object.getOwnPropertyDescriptor(g, n);
});
afterEach(() => {
for (const n of NAMES) {
const d = saved[n];
if (d) Object.defineProperty(g, n, d);
else if (Object.getOwnPropertyDescriptor(g, n)) delete g[n];
}
});
test('returns false when there is no window (non-browser)', () => {
setGlobal('window', undefined);
assert.equal(isMLDenoiseSupported(), false);
});
test('returns true when AudioContext, AudioWorklet and getUserMedia are present', () => {
setGlobal('window', { AudioContext: function AudioContext() {} });
setGlobal('AudioWorkletNode', function AudioWorkletNode() {});
setGlobal('navigator', withMediaDevices);
assert.equal(isMLDenoiseSupported(), true);
});
test('accepts the legacy webkitAudioContext prefix', () => {
setGlobal('window', { webkitAudioContext: function webkitAudioContext() {} });
setGlobal('AudioWorkletNode', function AudioWorkletNode() {});
setGlobal('navigator', withMediaDevices);
assert.equal(isMLDenoiseSupported(), true);
});
test('returns false when getUserMedia is unavailable', () => {
setGlobal('window', { AudioContext: function AudioContext() {} });
setGlobal('AudioWorkletNode', function AudioWorkletNode() {});
setGlobal('navigator', {}); // no mediaDevices
assert.equal(isMLDenoiseSupported(), false);
});
test('returns false (does NOT throw) when the AudioWorkletNode binding is absent', () => {
// Regression: a bare `!!AudioWorkletNode` threw ReferenceError on browsers
// with AudioContext but no AudioWorkletNode; a detection helper must report
// false, not throw.
setGlobal('window', { AudioContext: function AudioContext() {} });
setGlobal('navigator', withMediaDevices);
removeGlobal('AudioWorkletNode');
let result: boolean | undefined;
assert.doesNotThrow(() => {
result = isMLDenoiseSupported();
});
assert.equal(result, false);
});

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