Compare commits
75 Commits
5204766276
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 258e3ec620 | |||
| 3336abb66f | |||
| a184ee0221 | |||
| 4509a2b6d3 | |||
| 7e38baa7b6 | |||
| aab7e5ae20 | |||
| a0fcdf74da | |||
| ebc782b16c | |||
| 7939dc92d4 | |||
| 7c06b27c73 | |||
| 02b2ce8109 | |||
| 26f998d243 | |||
| f816049fdf | |||
| eafa353364 | |||
| 353bb59393 | |||
| 1daa8aa9b1 | |||
| 5af024f7e7 | |||
| 84ce9843ff | |||
| efcee88f05 | |||
| 0b307037e0 | |||
| 67bd05fc96 | |||
| dd6b0bccb3 | |||
| a50d3e7ca7 | |||
| d3d2f9a448 | |||
| 98ad5674a8 | |||
| 30d0331174 | |||
| 24662fa994 | |||
| 230ef8ed7c | |||
| 160c09e525 | |||
| 589d45e0a0 | |||
| acd355bb5a | |||
| 6e59395fb8 | |||
| 9f4516c6a8 | |||
| 0bd2273bee | |||
| d37fa1584c | |||
| e17cb09269 | |||
| 4d55e45962 | |||
| e3532064b5 | |||
| 1e37b20c6a | |||
| 4f03775e04 | |||
| 9678b02aba | |||
| a926487f5e | |||
| ae1d30bc5a | |||
| a7d145aa70 | |||
| 472d4ba008 | |||
| 2a0478cad8 | |||
| cee0c591e2 | |||
| 68b6ffffd7 | |||
| 9bc8c4b47f | |||
| e80ebd35cb | |||
| 36343baecc | |||
| 89cf171efc | |||
| 149ec8e4e4 | |||
| d1cd963e4b | |||
| 5ef0a1fd3e | |||
| 6ace96f2cf | |||
| 2d71f2ce30 | |||
| 2c3dba55e6 | |||
| c7a04dcc70 | |||
| 4b14c15518 | |||
| c68ef346bf | |||
| c5d7fcc303 | |||
| 9bf56d5748 | |||
| d5ce56930b | |||
| 349194e7e5 | |||
| 24d6460e4c | |||
| 127e783f66 | |||
| 198fd12bb2 | |||
| 34d5209165 | |||
| 9684ab75bb | |||
| 0a6b035a67 | |||
| cbfd3e5632 | |||
| 3faf0866a0 | |||
| bab3a160c2 | |||
| 1778cd0009 |
@@ -1,2 +1 @@
|
|||||||
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
|
|
||||||
VITE_APP_VERSION=lotus
|
VITE_APP_VERSION=lotus
|
||||||
|
|||||||
@@ -45,9 +45,14 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||||
|
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||||
|
# A failure blocks the deploy.
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||||
- name: TypeScript
|
- name: TypeScript
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
save-exact=true
|
save-exact=true
|
||||||
|
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
# HANDOFF — Forking & Self-Building Element Call ("Lotus Call")
|
||||||
|
|
||||||
|
> **Audience:** a fresh Claude/engineer session with **no prior context** on this
|
||||||
|
> project. Read this top-to-bottom before touching anything. This document is the
|
||||||
|
> single source of truth for the Element Call (EC) fork initiative.
|
||||||
|
>
|
||||||
|
> **Status:** **PHASE 0–2 IMPLEMENTED (build-verified, not yet live-tested)**
|
||||||
|
> (2026-06-30). The fork exists, builds, is published, and cinny consumes it
|
||||||
|
> (Phase 0/1). **All 7 Phase-2 EC features are implemented on the fork's `lotus`
|
||||||
|
> branch**, each additive + flag-gated, build+typecheck-clean, per-feature
|
||||||
|
> reviewed (+ a holistic multi-agent review), and pushed. **None are live-tested
|
||||||
|
> yet** — every one needs the `LOTUS_TESTING.md` §D sweep, and the **cinny host
|
||||||
|
> side must be wired** (set flags / send actions / handle call_state) — see §12.
|
||||||
|
> See **§9** Phase 0/1 results, **§10** cutover, **§11** Phase-2 seams, **§12**
|
||||||
|
> Phase-2 status + cinny integration checklist. Created 2026-06 from `LotusGuild/cinny`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phase 0 Results (verified 2026-06-29)
|
||||||
|
|
||||||
|
**Decisions taken with the user:** scope = Phase 0 recon; consumption model =
|
||||||
|
**private npm package** (§5 option 1). Recommended registry = **Gitea's built-in
|
||||||
|
npm registry** (`code.lotusguild.org`) — zero new infra.
|
||||||
|
|
||||||
|
### 9.1 Version → tag → commit mapping (LOCKED)
|
||||||
|
|
||||||
|
| Source | Value |
|
||||||
|
| :--------------------------------------------------- | :----------------------------------------- |
|
||||||
|
| cinny `package.json` pin | `@element-hq/element-call-embedded@0.20.1` |
|
||||||
|
| Bundle self-report (`VITE_APP_VERSION`/`appVersion`) | `embedded-v0.20.1` |
|
||||||
|
| npm registry `gitHead` for 0.20.1 | `2d74c48151d9edc01c65a22a91478aac81bf24d0` |
|
||||||
|
| GitHub tag `v0.20.1` → commit | `2d74c48…` ✅ **same commit** |
|
||||||
|
|
||||||
|
→ **Fork from upstream tag `v0.20.1` (commit `2d74c48`).** The embedded package
|
||||||
|
version equals the element-call release tag; repo `package.json` version is
|
||||||
|
`0.0.0` and the real version is stamped at publish time from the tag.
|
||||||
|
|
||||||
|
### 9.2 The shipped npm dist is a CLEAN upstream build
|
||||||
|
|
||||||
|
No `lotus`/`denoise`/`rnnoise` strings anywhere in
|
||||||
|
`node_modules/@element-hq/element-call-embedded/dist`. **All Lotus customization
|
||||||
|
(denoise shim) is injected at cinny build time, not baked into the package** — so
|
||||||
|
swapping the source does not disturb cinny's denoise injection layer. The
|
||||||
|
ringtone/reaction assets (`baduntss`, `cat`, `clap`, `call_declined`, …) are
|
||||||
|
upstream EC's own, not ours.
|
||||||
|
|
||||||
|
### 9.3 Build toolchain & mechanism
|
||||||
|
|
||||||
|
- **Node `24`** (`.node-version`), **pnpm `10.33.0`** (`packageManager` field,
|
||||||
|
via corepack).
|
||||||
|
- Build: **`pnpm run build:embedded`** = `vite build --config
|
||||||
|
vite-embedded.config.ts` with `NODE_OPTIONS=--max-old-space-size=16384`.
|
||||||
|
- Output dir is **repo-root `dist/`**; CI stages it into **`embedded/web/dist`**
|
||||||
|
(the `embedded/web/` dir holds the publish template: `package.json`, README,
|
||||||
|
both LICENSE files).
|
||||||
|
- Publish workflow upstream = `.github/workflows/publish-embedded-packages.yaml`:
|
||||||
|
builds → `npm version <tag> --no-git-tag-version` → `npm publish --provenance
|
||||||
|
--access public` to npmjs as `@element-hq/element-call-embedded`. (Also
|
||||||
|
Android/Maven + iOS/SwiftPM — irrelevant; we are web-only.)
|
||||||
|
|
||||||
|
### 9.4 Build reproduction — PARITY CONFIRMED
|
||||||
|
|
||||||
|
Cloned `element-call@v0.20.1` to `/root/code/element-call` (shallow), built with
|
||||||
|
isolated Node 24 / pnpm 10.33.0 (system Node 20 / cinny untouched). Result vs the
|
||||||
|
shipped npm dist:
|
||||||
|
|
||||||
|
- **137 of 147 files byte-identical** (same Vite content-hash): all CSS, fonts,
|
||||||
|
wasm, audio, JSON locale files, and `IndexedDBWorker`.
|
||||||
|
- **Only 5 JS chunks differ** (`index`, `pako.esm`, `polyfill-force`,
|
||||||
|
`rust-crypto`, `spa`) — **cause isolated to the version define**: our local
|
||||||
|
build baked `appVersion:\`dev\``(because`VITE_APP_VERSION`was unset) vs the
|
||||||
|
npm build's`appVersion:\`embedded-v0.20.1\``. `index.html` is identical modulo
|
||||||
|
the hashed asset filenames. **Benign** — our CI sets the version from the git
|
||||||
|
tag, so a tagged CI build will match.
|
||||||
|
|
||||||
|
### 9.5 Fork CI (drafted)
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` is staged in the clone (models cinny's
|
||||||
|
`.gitea/workflows/ci.yml` + upstream's publish flow). Linux-only (`ubuntu-latest`)
|
||||||
|
— the Windows worker is for cinny-desktop/Tauri, not the EC web bundle. Build job
|
||||||
|
on PR/push to `lotus`; publish job on `v*` tag → `@lotusguild/element-call-embedded`
|
||||||
|
to the Gitea npm registry (needs `secrets.GITEA_NPM_TOKEN`).
|
||||||
|
|
||||||
|
### 9.6 Phase 1 — DONE (2026-06-29)
|
||||||
|
|
||||||
|
1. ✅ **Fork repo live:** `code.lotusguild.org/LotusGuild/element-call` (public,
|
||||||
|
AGPL), default branch `lotus`, full history (7018 commits) + tag `v0.20.1`.
|
||||||
|
Branch `lotus` = `v0.20.1` + 2-file diff (CI workflow + embedded package
|
||||||
|
rename).
|
||||||
|
2. ✅ **Package published:** `@lotusguild/element-call-embedded@0.20.1` on the
|
||||||
|
Gitea npm registry (published manually from the version-faithful build while
|
||||||
|
the admin token was available). **Publicly readable** (unauth `npm install`
|
||||||
|
works → devs/CI need no token to consume; only publishing needs one).
|
||||||
|
3. ✅ **cinny wired & built clean** (Node 24): `.npmrc` scope line +
|
||||||
|
`package.json` dep + `vite.config.js` `viteStaticCopy` src. `npm install`
|
||||||
|
swapped the package (resolved from Gitea), `npm run build` succeeded,
|
||||||
|
`dist/public/element-call/` populated, bundle reports `appVersion:
|
||||||
|
embedded-v0.20.1`, **denoise shim injected + all denoise assets copied**
|
||||||
|
(injection layer unchanged). **These cinny edits are staged in the working
|
||||||
|
tree, NOT committed/pushed** — pushing triggers CI → desktop → deploy, so it's
|
||||||
|
gated on the §D live test (see §10).
|
||||||
|
|
||||||
|
### 9.8 Reproducibility note (important)
|
||||||
|
|
||||||
|
A from-source rebuild is **NOT byte-identical** to upstream's npm tarball.
|
||||||
|
137/147 files match exactly (CSS, fonts, wasm, audio, worker); the 5 JS chunks
|
||||||
|
(`index`, `pako.esm`, `polyfill-force`, `rust-crypto`, `spa`) differ because the
|
||||||
|
rolldown/oxc **minifier mangles export names differently** across build
|
||||||
|
environments (and the version-define is one input). This is normal and benign —
|
||||||
|
the code is functionally equivalent. **Do not chase byte-parity; the §D live call
|
||||||
|
test is the real parity gate.**
|
||||||
|
|
||||||
|
### 9.9 Remaining follow-ups (not blocking the cutover)
|
||||||
|
|
||||||
|
- **CI publishing:** `.gitea/workflows/ci.yml` publishes on a `v*` tag but needs
|
||||||
|
(a) a Gitea Actions runner for `LotusGuild/element-call`, and (b) a **durable**
|
||||||
|
`GITEA_NPM_TOKEN` repo secret with package read/write (the admin token used for
|
||||||
|
the manual publish is being deleted, so it was deliberately NOT baked in). Until
|
||||||
|
then, publishing is manual (`npm version <tag>` in `embedded/web` →
|
||||||
|
`npm publish`).
|
||||||
|
- Decide rebase cadence vs upstream (0.20.2 / 0.20.3 already out — see §9.1).
|
||||||
|
|
||||||
|
### 9.7 Ready-to-apply artifacts (staged 2026-06-29)
|
||||||
|
|
||||||
|
**Fork side — already committed** on branch `lotus` in `/root/code/element-call`
|
||||||
|
(remote `lotus` = `code.lotusguild.org/LotusGuild/element-call.git`, push deferred
|
||||||
|
until the repo exists). Minimal 2-file diff vs tag `v0.20.1`:
|
||||||
|
`.gitea/workflows/ci.yml` (new) + `embedded/web/package.json` (rename to
|
||||||
|
`@lotusguild/element-call-embedded`). Push with:
|
||||||
|
`git push -u lotus lotus && git push lotus v0.20.1` (and tag `v0.20.1` on our side
|
||||||
|
to trigger the first publish, or push our own `v0.20.1` tag).
|
||||||
|
|
||||||
|
**cinny side — NOT yet applied** (applying before the package is published breaks
|
||||||
|
`npm ci`). Exactly 3 edits + a lockfile regen:
|
||||||
|
|
||||||
|
1. `.npmrc` — append the scoped-registry line:
|
||||||
|
```
|
||||||
|
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
|
||||||
|
```
|
||||||
|
(CI/auth: `//code.lotusguild.org/api/packages/LotusGuild/npm/:_authToken=${GITEA_NPM_TOKEN}`
|
||||||
|
— inject via env in CI, do not commit a plaintext token.)
|
||||||
|
2. `package.json:104` —
|
||||||
|
`"@element-hq/element-call-embedded": "0.20.1"` →
|
||||||
|
`"@lotusguild/element-call-embedded": "0.20.1"`.
|
||||||
|
3. `vite.config.js:25` — `viteStaticCopy` src:
|
||||||
|
`node_modules/@element-hq/element-call-embedded/dist` →
|
||||||
|
`node_modules/@lotusguild/element-call-embedded/dist`.
|
||||||
|
**`stripBase: 4` stays unchanged** — `node_modules/@lotusguild/element-call-embedded/dist`
|
||||||
|
is still exactly 4 leading segments. (Update the comment's path reference too.)
|
||||||
|
4. `package-lock.json` — regenerated by `npm install`, not hand-edited (drops the
|
||||||
|
`registry.npmjs.org/@element-hq/...` resolved URL for the Gitea one).
|
||||||
|
|
||||||
|
The denoise injection (`lotusDenoise()` in `vite.config.js`) is **unchanged** — it
|
||||||
|
keys off `dist/public/element-call/index.html`, which our fork's bundle still
|
||||||
|
produces identically (verified: `index.html` byte-identical modulo asset hashes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR / The Goal
|
||||||
|
|
||||||
|
We embed **Element Call** (the Matrix group-VoIP/video app) inside Lotus Chat to
|
||||||
|
power voice/video channels. Today we consume Element's **pre-compiled npm
|
||||||
|
bundle** and can only steer it from the outside (a limited widget API + fragile
|
||||||
|
same-origin DOM hacks). Several in-call problems are **unfixable from outside**
|
||||||
|
because they live in EC's compiled JS.
|
||||||
|
|
||||||
|
**We want true ownership: fork `element-hq/element-call`, build it from source
|
||||||
|
ourselves, host our build, and replace the npm bundle with our fork.** Then
|
||||||
|
every in-call behavior becomes editable code.
|
||||||
|
|
||||||
|
**This requires standing up a brand-new repo and build pipeline for our EC fork.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why fork? (What we cannot fix today)
|
||||||
|
|
||||||
|
These came out of live testing and are documented in `LOTUS_BUGS.md` →
|
||||||
|
"Known Element Call iframe limitations":
|
||||||
|
|
||||||
|
| Issue | What's wrong | Why outside-fixes fail |
|
||||||
|
| :----------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **A6** — avatar decorations in-call | Our profile-decoration overlays don't appear on in-call video tiles | The video grid is rendered by EC's React app inside the iframe. We can only inject overlay DOM (fragile) — we can't make it a first-class part of the tile. |
|
||||||
|
| **A5** — focus camera / fullscreen during screenshare | Can't reliably spotlight a participant's camera while someone screenshares | EC's **layout logic** (screenshare priority, spotlight) is compiled JS we don't control. We currently DOM-click tiles as a hack. |
|
||||||
|
| **A7** — mic dead after EC's "Reconnect" | After EC's own mid-call reconnect, the local mic isn't re-published | EC's reconnect/track-republish path is internal. (Partly entangled with our denoise shim — see §6.) |
|
||||||
|
| Native theming | EC's UI doesn't match Lotus design; we inject CSS hacks | Real theming needs source-level component/token changes. |
|
||||||
|
| Decorations, custom controls, custom layouts, branding | all blocked | all require source access |
|
||||||
|
|
||||||
|
**Bottom line:** the iframe is **same-origin** (we self-host it), so we can read
|
||||||
|
and even write its DOM — but we **do not own its source**, so we can't change its
|
||||||
|
**behavior/logic**, only poke at its rendered output. Forking removes that wall.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How EC is integrated TODAY (the current architecture)
|
||||||
|
|
||||||
|
Understand this fully before changing it — the fork must slot into the same
|
||||||
|
integration seams.
|
||||||
|
|
||||||
|
### 2.1 Where the EC bundle comes from
|
||||||
|
|
||||||
|
- npm package: **`@element-hq/element-call-embedded`**, pinned to **`0.20.1`** in
|
||||||
|
`cinny/package.json` (line ~104).
|
||||||
|
- It ships a **pre-built `dist/`**. At cinny build time,
|
||||||
|
`vite-plugin-static-copy` copies that `dist/` flat into
|
||||||
|
**`public/element-call/`** (see `cinny/vite.config.js`, the `copyFiles`
|
||||||
|
target with `rename: { stripBase: 4 }` — note the stripBase gotcha documented
|
||||||
|
there; getting this wrong 404s the widget).
|
||||||
|
- It is **NOT committed** to git (`git ls-files public/element-call` → 0). It's a
|
||||||
|
build artifact materialized from `node_modules`.
|
||||||
|
|
||||||
|
### 2.2 How EC is loaded & controlled
|
||||||
|
|
||||||
|
- The widget iframe `src` is **same-origin**:
|
||||||
|
`${BASE_URL}/public/element-call/index.html?<params>` (see
|
||||||
|
`cinny/src/app/plugins/call/CallEmbed.ts`, `getWidget()` /
|
||||||
|
`getIframe()`). Sandbox: `allow-forms allow-scripts allow-same-origin
|
||||||
|
allow-popups allow-modals allow-downloads`; `allow="microphone; camera;
|
||||||
|
display-capture; autoplay; clipboard-write;"`.
|
||||||
|
- **Control surface #1 — the official widget API** (`matrix-widget-api`):
|
||||||
|
`ClientWidgetApi` + a custom `CallWidgetDriver`. This is the robust,
|
||||||
|
version-stable channel (theme change, hangup, capabilities, timeline events).
|
||||||
|
Files: `plugins/call/CallEmbed.ts`, `plugins/call/CallWidgetDriver.ts`,
|
||||||
|
`plugins/call/utils.ts` (capabilities), `plugins/call/CallControl.ts`.
|
||||||
|
- **Control surface #2 — same-origin DOM poking** (fragile, version-coupled):
|
||||||
|
reading `iframe.contentDocument` to detect speakers/mute state and
|
||||||
|
`.click()`-ing tiles to focus a camera. Files:
|
||||||
|
`hooks/useCallSpeakers.ts` (reads `[data-muted]`, `[data-video-fit]`),
|
||||||
|
`plugins/call/CallControl.ts` (`focusCameraParticipant` — tile selectors).
|
||||||
|
**These selectors break on every EC version bump.** A fork lets us replace
|
||||||
|
these hacks with real APIs/props.
|
||||||
|
- **Control surface #3 — URL params + build-time injection** for our denoise
|
||||||
|
shim (see §6).
|
||||||
|
|
||||||
|
### 2.3 Full file inventory (everything that touches EC in cinny)
|
||||||
|
|
||||||
|
Plugin / core:
|
||||||
|
|
||||||
|
- `src/app/plugins/call/CallEmbed.ts` — iframe creation, widget API wiring, theme sync, hangup, load watchdog/self-heal, denoise URL params.
|
||||||
|
- `src/app/plugins/call/CallControl.ts` — control state + **DOM-poking** (`focusCameraParticipant`, spotlight).
|
||||||
|
- `src/app/plugins/call/CallControl.tsx` _(call-status variant)_ and `features/call-status/CallControl.tsx`.
|
||||||
|
- `src/app/plugins/call/CallWidgetDriver.ts` — widget driver (capabilities, event relay).
|
||||||
|
- `src/app/plugins/call/utils.ts` — widget capabilities set.
|
||||||
|
- `src/app/plugins/call/hooks.ts`, `index.ts` — plugin exports/hooks.
|
||||||
|
- `src/app/state/callEmbed.ts` — jotai atoms for the active embed.
|
||||||
|
|
||||||
|
React / UI:
|
||||||
|
|
||||||
|
- `src/app/components/CallEmbedProvider.tsx` — the big one: incoming-call ring/banner, RTCNotification + **RTCDecline** listeners, PiP, mute badges, fullscreen, ringtones.
|
||||||
|
- `src/app/features/call/CallView.tsx` — prescreen lobby vs joined (the iframe placement target), load-error recovery UI.
|
||||||
|
- `src/app/features/call/CallControls.tsx` — in-call control bar (mic/cam/deafen/screenshare/fullscreen/more/PiP).
|
||||||
|
- `src/app/features/call/CallMemberCard.tsx` — **lobby** participant roster (this is where `AvatarDecoration` works today; in-call grid is EC's).
|
||||||
|
- `src/app/features/call/PrescreenControls.tsx` — join controls.
|
||||||
|
- `src/app/features/call-status/*` — `CallStatus.tsx`, `MemberGlance.tsx` (the "Focus camera" menu lives here), `LiveChip.tsx`.
|
||||||
|
- `src/app/features/room-nav/RoomNavItem.tsx`, `features/room/Room.tsx`, `features/room/RoomViewHeader.tsx`, `pages/client/space/Space.tsx`, `pages/CallStatusRenderer.tsx`, `pages/Router.tsx` — call entry points / status surfacing.
|
||||||
|
|
||||||
|
Hooks:
|
||||||
|
|
||||||
|
- `src/app/hooks/useCallEmbed.ts`, `useCall.ts`, `useCallSpeakers.ts` (DOM-poking), `useCallJoinLeaveSounds.ts`, `useAfkAutoMute.ts`.
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
- `cinny/vite.config.js` — `copyFiles` (EC dist copy) + `lotusDenoise()` plugin (denoise asset copy + index.html shim injection, in `closeBundle`).
|
||||||
|
|
||||||
|
Utils:
|
||||||
|
|
||||||
|
- `src/app/utils/ringtones.ts`, `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Hosting / infra context (the OTHER repo)
|
||||||
|
|
||||||
|
There are **two repos**:
|
||||||
|
|
||||||
|
1. **`LotusGuild/cinny`** (`/root/code/cinny`) — this Lotus Chat fork. Consumes EC.
|
||||||
|
2. **`LotusGuild/matrix`** (`/root/code/matrix`) — the **infra/homeserver** repo.
|
||||||
|
Subdirs: `livekit/` (the SFU EC talks to), `deploy/`, `draupnir/`,
|
||||||
|
`hookshot/`, `landing/`, `matrixbot/`, `systemd/`. Gitea remote
|
||||||
|
`code.lotusguild.org/LotusGuild/matrix`, branch `main`.
|
||||||
|
|
||||||
|
EC needs a **LiveKit SFU** + the **livekit-jwt-service**; those live in
|
||||||
|
`matrix/livekit/`. A self-hosted EC build must be configured to point at our
|
||||||
|
homeserver (`matrix.lotusguild.org` / synapse) and our LiveKit. EC's runtime
|
||||||
|
`config.json` (homeserver, livekit URL, feature flags) is part of what we'll own
|
||||||
|
once we build it ourselves.
|
||||||
|
|
||||||
|
Deployment today: `chat.lotusguild.org` (the cinny web build, which embeds EC at
|
||||||
|
`/public/element-call/`). cinny-desktop (`LotusGuild/cinny-desktop`, a Tauri
|
||||||
|
wrapper, bumped by cinny CI) embeds the same.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The plan (proposed — confirm with the user before executing)
|
||||||
|
|
||||||
|
### Decision: **YES, create a new repo.** `LotusGuild/element-call`
|
||||||
|
|
||||||
|
Rationale: EC is a large standalone app (React + LiveKit client SDK + matrixRTC +
|
||||||
|
its own Vite build + heavy deps). Keep it out of cinny so cinny's build stays
|
||||||
|
clean — cinny keeps consuming a **built EC `dist/`**, exactly as today, just
|
||||||
|
sourced from **our fork** instead of npm.
|
||||||
|
|
||||||
|
### Phase 0 — Recon (no code)
|
||||||
|
|
||||||
|
- Fork `github.com/element-hq/element-call` → `LotusGuild/element-call` on Gitea.
|
||||||
|
- Pin to the upstream tag matching **0.20.1** (`element-call-embedded` 0.20.1's
|
||||||
|
corresponding `element-call` release) so behavior matches what's shipping now.
|
||||||
|
Verify the embedded-package version ↔ element-call repo tag mapping.
|
||||||
|
- Read EC's own build docs: it builds the "embedded" widget bundle (the thing
|
||||||
|
currently published as `@element-hq/element-call-embedded`). Reproduce that
|
||||||
|
build locally and confirm the output matches `public/element-call/` today.
|
||||||
|
- **License:** element-call is **AGPL-3.0**, same as Lotus Chat — compatible.
|
||||||
|
Our fork must remain AGPL and publish source.
|
||||||
|
|
||||||
|
### Phase 1 — Reproduce current behavior from our fork (parity, no features)
|
||||||
|
|
||||||
|
- Build our fork's embedded bundle; wire cinny to consume it instead of the npm
|
||||||
|
package (see §5 for the consumption options). Smoke-test: a call works exactly
|
||||||
|
as today (web + desktop), denoise shim still injects, widget API + theme still
|
||||||
|
work. **No behavior change yet** — this de-risks the swap.
|
||||||
|
|
||||||
|
### Phase 2 — Replace the outside hacks with source-level features
|
||||||
|
|
||||||
|
Tackle the §1 issues in EC's source:
|
||||||
|
|
||||||
|
- **A6:** render avatar decorations as part of the video-tile component
|
||||||
|
(read decoration data we pass in via widget data / URL param / a small bridge).
|
||||||
|
- **A5:** fix focus/spotlight + screenshare-coexistence in EC's layout code;
|
||||||
|
expose a clean widget action so cinny can trigger it (kill the DOM `.click()`).
|
||||||
|
- **A7:** fix mic re-publish on reconnect; reconcile with our denoise shim (§6) —
|
||||||
|
ideally move denoise INTO the fork as a real audio-processing step instead of a
|
||||||
|
`getUserMedia` monkeypatch.
|
||||||
|
- Native Lotus theming/branding at the source (kill the injected-CSS hacks).
|
||||||
|
- Then retire the DOM-poking in `useCallSpeakers.ts` / `CallControl.ts` in favor
|
||||||
|
of real widget messages.
|
||||||
|
|
||||||
|
### Phase 3 — Maintenance posture
|
||||||
|
|
||||||
|
- Decide rebase cadence vs. upstream element-call releases. Keep customizations
|
||||||
|
isolated (feature flags / minimal-diff patches) to ease rebasing.
|
||||||
|
- CI in the new repo builds + publishes the embedded dist as a versioned
|
||||||
|
artifact; cinny CI consumes a pinned version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How cinny should consume the fork (pick one — decide with user)
|
||||||
|
|
||||||
|
1. **Private npm package** (mirror the current model): our fork's CI publishes
|
||||||
|
`@lotusguild/element-call-embedded` to a registry; cinny depends on it and
|
||||||
|
`viteStaticCopy` keeps working almost unchanged. _Cleanest swap; needs a
|
||||||
|
registry._
|
||||||
|
2. **Git submodule + build in cinny CI:** add the fork as a submodule, build it
|
||||||
|
during cinny's build, copy its `dist/` to `public/element-call/`. _No
|
||||||
|
registry; heavier cinny CI._
|
||||||
|
3. **CI artifact copy:** fork CI uploads a `dist` tarball; cinny CI downloads a
|
||||||
|
pinned version at build. _Decoupled; needs artifact plumbing._
|
||||||
|
|
||||||
|
**Recommendation: Option 1** — it changes the least in cinny (just swap the
|
||||||
|
package name in `package.json` + the `viteStaticCopy` src path) and preserves the
|
||||||
|
clean cinny/EC separation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The denoise shim — critical interaction (don't break this)
|
||||||
|
|
||||||
|
Lotus ships ML noise suppression by **injecting a same-origin pre-init shim into
|
||||||
|
EC's `index.html` at build time** (cinny `vite.config.js` → `lotusDenoise()`,
|
||||||
|
`closeBundle`). The shim monkeypatches `getUserMedia` **before EC captures the
|
||||||
|
mic** and routes audio through RNNoise/Speex/DTLN AudioWorklets, then EC/LiveKit
|
||||||
|
publishes the processed track. It's activated via URL params
|
||||||
|
(`lotusDenoise=ml&lotusModel=…&lotusGate=…`) set in `CallEmbed.ts`.
|
||||||
|
|
||||||
|
- Assets copied to `public/element-call/denoise/` at build (sapphi RNNoise/Speex/
|
||||||
|
gate worklets + `@workadventure/noise-suppression` DTLN tree).
|
||||||
|
- Related: `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`,
|
||||||
|
`settings/general/DenoiseTester.tsx`, `VoiceMessageRecorder.tsx`.
|
||||||
|
- **Known issues:** denoise quality is still poor (tracked separately); and the
|
||||||
|
mic-after-reconnect bug (A7) is suspected to involve the shim's getUserMedia
|
||||||
|
patch handing back a stale processed stream when EC re-acquires the mic.
|
||||||
|
|
||||||
|
**Once we own the fork, the right move is to make denoise a first-class
|
||||||
|
audio-processing stage inside EC** (not an index.html monkeypatch) — more robust,
|
||||||
|
survives reconnects, and removes the build-time injection hack. Until then, the
|
||||||
|
fork's `index.html` must remain injectable the same way, or the shim must be
|
||||||
|
re-homed into the fork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Doc-accuracy notes / corrections for the new session
|
||||||
|
|
||||||
|
- `LOTUS_TODO.md` (~line 533) calls EC a **"cross-origin iframe"** — **outdated.**
|
||||||
|
EC is **same-origin** today (self-hosted under our domain;
|
||||||
|
`iframe.sandbox` includes `allow-same-origin`; we read `contentDocument`), and
|
||||||
|
**as of 2026-06-29 we own the fork's source** (`@lotusguild/element-call-embedded`).
|
||||||
|
The _practical_ point it made still holds _until we ship the audio-inject API_:
|
||||||
|
**LiveKit's `LocalAudioTrack` lives in EC's module scope**, not on `window`, so
|
||||||
|
cinny can't reach it even same-origin — which is why the in-call soundboard had
|
||||||
|
to be local-playback-only. **The fork removes this wall:** EC can expose a real
|
||||||
|
`io.lotus.inject_audio` widget action (Phase 2) that mixes into the published
|
||||||
|
track from inside its own module scope.
|
||||||
|
- `LOTUS_FEATURES.md` documents the EC upgrade history (0.16.3 → 0.19.4 →
|
||||||
|
0.20.1), the dark-mode CSS injection, and AFK auto-mute — all relevant prior
|
||||||
|
art for what the fork must preserve.
|
||||||
|
- `LOTUS_TESTING.md` §D is the **EC regression sweep** to re-run after the fork
|
||||||
|
swap (Phase 1 parity check).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. First actions for the new session
|
||||||
|
|
||||||
|
1. Read this file, then skim §2.3's files in `cinny` to internalize the seams.
|
||||||
|
2. Confirm with the user: new repo name, consumption model (§5), rebase cadence.
|
||||||
|
3. Phase 0: fork element-call, map 0.20.1 ↔ element-call tag, reproduce the
|
||||||
|
embedded build locally, diff against `public/element-call/`.
|
||||||
|
4. Phase 1: wire cinny to the fork, run `LOTUS_TESTING.md` §D parity sweep.
|
||||||
|
5. Only then start Phase 2 features (A5/A6/A7, theming, denoise-in-source).
|
||||||
|
|
||||||
|
**Cross-references:** `LOTUS_BUGS.md` (EC limitations + verify queue),
|
||||||
|
`LOTUS_TODO.md` (denoise/soundboard constraints), `LOTUS_FEATURES.md` (EC history),
|
||||||
|
`LOTUS_TESTING.md` §D (regression sweep). Infra: `/root/code/matrix` (`livekit/`,
|
||||||
|
`deploy/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Live cutover — the remaining steps (Phase 1 finish)
|
||||||
|
|
||||||
|
The fork is published and cinny builds against it locally (§9.6). What's left to
|
||||||
|
go live:
|
||||||
|
|
||||||
|
1. **Run `LOTUS_TESTING.md` §D** against a local cinny build (`npm run build` is
|
||||||
|
already proven; serve `dist/` or `npm run dev`). Verify a real call: join,
|
||||||
|
mic/cam, screenshare, theme sync, denoise on, widget hangup — web first.
|
||||||
|
2. **Commit the cinny edits** (currently staged, uncommitted in the working tree):
|
||||||
|
`.npmrc`, `package.json`, `package-lock.json`, `vite.config.js`. Suggested
|
||||||
|
message: `chore(call): consume self-built @lotusguild/element-call-embedded`.
|
||||||
|
3. **Push to `lotus`** → cinny CI builds, then `trigger-desktop` bumps
|
||||||
|
cinny-desktop → Tauri release. Re-run §D on **cinny-desktop** (the path where
|
||||||
|
the old `stripBase` bug bit — verify the widget loads, not a 404).
|
||||||
|
4. Only then start **Phase 2** (A5/A6/A7, theming, denoise-in-source).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 2 — implementation seams (mapped 2026-06-29)
|
||||||
|
|
||||||
|
The exact integration points for each Phase 2 item, found by reading the EC fork
|
||||||
|
|
||||||
|
- cinny source. **All of these are media-path / in-call features that cannot be
|
||||||
|
functionally verified without a live Matrix + LiveKit call** — implement each as
|
||||||
|
a minimal, **feature-flagged, additive** diff (no behavior change unless cinny
|
||||||
|
opts in), build-verify the fork (`pnpm build:embedded`, ~15s) AND cinny
|
||||||
|
(`npm run build`), then gate shipping on `LOTUS_TESTING.md` §D.
|
||||||
|
|
||||||
|
**Shared widget channel (the backbone for #2/#3/#4/#7):**
|
||||||
|
|
||||||
|
- EC→cinny: `widget.api.transport.send("io.lotus.<x>", data)` (see
|
||||||
|
`element-call/src/widget.ts`).
|
||||||
|
- cinny→EC actions: add the action name to the `lazyActions` allow-list in
|
||||||
|
`widget.ts` (the array at ~L101) and handle it in EC; cinny sends via
|
||||||
|
`this.call.transport.send(...)`.
|
||||||
|
- cinny receives EC→cinny actions via the existing `listenAction(type, cb)`
|
||||||
|
helper in `plugins/call/CallEmbed.ts:626` (auto-replies `{}` so the transport
|
||||||
|
doesn't time out — same pattern as `io.element.device_mute`).
|
||||||
|
|
||||||
|
**#2 mute/speaker events** — Source: subscribe to `vm.userMedia$`
|
||||||
|
(`CallViewModel`), per member `speaking$` + `audioEnabled$`
|
||||||
|
(`state/media/UserMediaViewModel.ts:47-48`); aggregate and
|
||||||
|
`transport.send("io.lotus.call_state", {participants:[{id,speaking,audioEnabled}]})`.
|
||||||
|
Mount in `room/InCallView.tsx` via `useEffect` guarded by `widget !== null`.
|
||||||
|
cinny: `listenAction("io.lotus.call_state")` in `CallEmbed.ts`, feed
|
||||||
|
`hooks/useCallSpeakers.ts` → delete its `contentDocument` `[data-muted]` /
|
||||||
|
`[data-video-fit]` scrape. _Additive, low risk._
|
||||||
|
|
||||||
|
**#4 spotlight/focus** — EC: add `io.lotus.focus_participant` to the `lazyActions`
|
||||||
|
list (`widget.ts`), drive `vm`'s spotlight (`spotlightSpeaker$` /
|
||||||
|
`spotlight$` in `CallViewModel.ts:898/1001`) to pin a given identity, coexisting
|
||||||
|
with `hasRemoteScreenShares$` (L1008). cinny: replace
|
||||||
|
`CallControl.ts` `focusCameraParticipant` `.click()` walk with
|
||||||
|
`transport.send("io.lotus.focus_participant", {userId})`. _Additive, low risk._
|
||||||
|
|
||||||
|
**#3 audio-inject** — EC: add `io.lotus.inject_audio` action; mix an
|
||||||
|
`AudioBufferSourceNode` into the published mic track. The local publish path is
|
||||||
|
`state/CallViewModel/localMember/Publisher.ts` + `LocalMember.ts` (LiveKit
|
||||||
|
`localParticipant`); create a `MediaStreamAudioDestinationNode`, mix mic + clip,
|
||||||
|
`replaceTrack`. cinny soundboard calls the action instead of local-only playback.
|
||||||
|
_Medium; touches publish path → live-test carefully._
|
||||||
|
|
||||||
|
**#1 denoise-in-source** — replace the cinny `lotusDenoise()` `getUserMedia`
|
||||||
|
monkeypatch with a real processing stage in EC's mic capture
|
||||||
|
(`Publisher.ts`/`LocalMember.ts`; note EC has a `TrackProcessorContext` +
|
||||||
|
`BlurBackgroundTransformer` precedent in `livekit/`). EC re-runs it on every
|
||||||
|
(re)publish → fixes A7. Remove `vite.config.js` `lotusDenoise()` + URL params in
|
||||||
|
`CallEmbed.ts`; move `denoise/` assets into the fork. _Highest value, highest
|
||||||
|
risk — most live testing._
|
||||||
|
|
||||||
|
**#5 theming** — add a Lotus/TDS theme in EC's theme system (`src/useTheme.ts` +
|
||||||
|
EC theme tokens / CSS); driven by the existing `setTheme()` channel cinny already
|
||||||
|
calls (`CallEmbed.ts:277`). Bake transparent background. Delete cinny's
|
||||||
|
`applyStyles()` injection + `background:none !important`. _Medium._
|
||||||
|
|
||||||
|
**#6 in-call decorations** — render the decoration APNG in EC's tile component
|
||||||
|
(`tile/GridTile.tsx`); pass slugs via widget member data. cinny already has the
|
||||||
|
decoration data + `AvatarDecoration` (lobby `CallMemberCard.tsx`). _Medium-Large._
|
||||||
|
|
||||||
|
**#7 quality controls** — set audio `maxBitrate` via
|
||||||
|
`RTCRtpSender.setParameters` and screenshare `getDisplayMedia` constraints in
|
||||||
|
EC's publish path (`Publisher.ts`); configurable via `config.json` / a widget
|
||||||
|
message. Keep the server `voice-limit-guard` as enforcement. _Medium._
|
||||||
|
|
||||||
|
**Rollback:** revert the 4 cinny files (restores `@element-hq/...@0.20.1` from
|
||||||
|
npmjs). The fork repo/package can stay; nothing else depends on it until pushed.
|
||||||
|
|
||||||
|
### Local repro/build environment (this session, 2026-06-29)
|
||||||
|
|
||||||
|
- Upstream cloned + our `lotus` branch at `/root/code/element-call` (remote
|
||||||
|
`lotus` → Gitea; origin → github upstream, now un-shallowed/full history).
|
||||||
|
- Isolated **Node 24.18.0** lives in the session scratchpad (system Node is 20);
|
||||||
|
cinny's `.node-version` is `24.13.1`, so use Node 24 to build cinny too.
|
||||||
|
- Build the embedded bundle: in `/root/code/element-call`, with Node 24 + pnpm
|
||||||
|
10.33.0 on PATH, `VITE_APP_VERSION=embedded-v0.20.1 pnpm run build:embedded`
|
||||||
|
→ output in `dist/`; stage to `embedded/web/dist` before publishing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Phase 2 — IMPLEMENTED on the fork (2026-06-30)
|
||||||
|
|
||||||
|
All 7 EC features are on the `lotus` branch of `LotusGuild/element-call`, each
|
||||||
|
**additive + feature-flagged** (a vanilla call with no `lotus*` params / no Lotus
|
||||||
|
actions behaves exactly like upstream), build + `tsc` clean, per-feature reviewed
|
||||||
|
(fixes applied) and holistically reviewed. **Not yet live-tested** — all need the
|
||||||
|
`LOTUS_TESTING.md` §D sweep.
|
||||||
|
|
||||||
|
Fork modules live under `element-call/src/lotus/*`; mounts are `useEffect`s in
|
||||||
|
`src/room/InCallView.tsx`. Custom widget actions are in `src/lotus/lotusActions.ts`
|
||||||
|
(toWidget ones allow-listed in `src/widget.ts`).
|
||||||
|
|
||||||
|
| # | Feature | Enable via | EC module |
|
||||||
|
| :-- | :------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) |
|
||||||
|
| 4 | Focus/spotlight a participant (works during screenshare) | action `io.lotus.focus_participant {userId | null}` | `lotusFocus.ts` + `CallViewModel` spotlight override |
|
||||||
|
| 3 | Soundboard audio-inject (heard by peers) | URL `lotusAudioInject=1` + action `io.lotus.inject_audio {url,volume?}` | `lotusAudioInject.ts` |
|
||||||
|
| 7 | Audio/screenshare quality caps | action `io.lotus.set_quality {audioMaxBitrate?,screenshareMaxBitrate?,screenshareMaxFramerate?}` | `lotusQuality.ts` |
|
||||||
|
| 5 | Transparent bg + Lotus theme | URL `lotusTransparent=1` / `lotusTheme=1` | `useTheme.ts` + `index.css` |
|
||||||
|
| 6 | In-call avatar decorations | action `io.lotus.decorations {decorations:{userId:url}}` | `lotusDecorations.ts` + `MediaView.tsx` |
|
||||||
|
| 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
|
||||||
|
|
||||||
|
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
|
||||||
|
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
|
||||||
|
via a crafted link); audio-inject gated behind `lotusAudioInject=1`; decoration
|
||||||
|
roster capped. Only `https`/`blob` URLs accepted for inject/decoration assets.
|
||||||
|
|
||||||
|
### 12.1 cinny host integration checklist (REQUIRED to light these up)
|
||||||
|
|
||||||
|
> ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
|
||||||
|
> focus_participant, decorations, and transparent background are active; the
|
||||||
|
> in-source denoise cutover is done (flag `lotusDenoiseSource=1`, **all four**
|
||||||
|
> models in-source); and the two formerly-dormant capabilities now have cinny
|
||||||
|
> UI — **soundboard** (`io.lotus.inject_audio`, P5-15) and **quality controls +
|
||||||
|
> room permissions** (`io.lotus.set_quality` + `io.lotus.room_quality`, P5-31,
|
||||||
|
> with server-side enforcement in `LotusGuild/matrix`). See `LOTUS_FEATURES.md`
|
||||||
|
> → "Element Call — Self-Built Fork". The checklist is kept below as the record
|
||||||
|
> of what was wired. (One open denoise item tracked separately: the "Series
|
||||||
|
> Suppression" native-NS toggle is not wired to the real call path.)
|
||||||
|
|
||||||
|
The EC side is additive and dormant until cinny opts in. Host work (in
|
||||||
|
`src/app/plugins/call/CallEmbed.ts` unless noted) — **done**:
|
||||||
|
|
||||||
|
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
|
||||||
|
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
|
||||||
|
> is joined (`CallEmbed.onCallJoined` / `this.joined`). Those actions are
|
||||||
|
> allow-listed at EC app-init (so `preventDefault` suppresses the auto-error)
|
||||||
|
> but their handlers only mount with `InCallView` (post-join). Sending earlier
|
||||||
|
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and
|
||||||
|
> flush on join, or no-op before join.
|
||||||
|
>
|
||||||
|
> Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
|
||||||
|
> `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
|
||||||
|
> the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
|
||||||
|
> in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
|
||||||
|
> real calls — open item). **F7** — no widget _capability_ changes needed;
|
||||||
|
> custom actions bypass capability checks.
|
||||||
|
|
||||||
|
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
|
||||||
|
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
|
||||||
|
`lotusAudioInject=1` as desired. (Denoise sets `lotusDenoiseSource=1` + `lotusModel`/`lotusGate`/`lotusGateThreshold` in the `ml` tier.)
|
||||||
|
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
|
||||||
|
without a reply the fork's sends time out every 250ms. Feed the payload into
|
||||||
|
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
|
||||||
|
3. **Send actions** via `this.call.transport.send(...)`:
|
||||||
|
`io.lotus.focus_participant` (replace `CallControl.focusCameraParticipant`’s
|
||||||
|
`.click()`), `io.lotus.inject_audio` (from the soundboard), `io.lotus.set_quality`
|
||||||
|
(from quality settings), `io.lotus.decorations` (push the MSC4133 decoration
|
||||||
|
map; resolve mxc→https first).
|
||||||
|
4. **#1 denoise cutover**: once verified, STOP injecting the `lotusDenoise()`
|
||||||
|
shim in `cinny/vite.config.js` and remove the `index.html` injection — the
|
||||||
|
fork now does denoise in-source. Keep shipping the `denoise/` assets (the
|
||||||
|
fork loads `./denoise/…` at runtime) until those move into the fork build.
|
||||||
|
5. Re-run `LOTUS_TESTING.md` §D for each feature; only then ship.
|
||||||
|
|
||||||
|
### 12.2 Holistic multi-agent review — outstanding follow-ups (non-blocking)
|
||||||
|
|
||||||
|
Four aspect-agents reviewed the whole fork. Criticals were fixed in-branch (the
|
||||||
|
denoise restart-silence/A7 bug; the `lotusDenoiseBase` code-load vector;
|
||||||
|
audio-inject opt-in gate; #6 rendering in the wrong component; #7 simulcast cap).
|
||||||
|
Remaining, deliberately deferred:
|
||||||
|
|
||||||
|
- **Denoise H2 (double-processing):** if cinny is set to `lotusDenoise=ml` while
|
||||||
|
ALSO still injecting its build-time `getUserMedia` shim, audio is denoised
|
||||||
|
twice. The #1 cutover MUST remove the cinny-side injection (it currently has
|
||||||
|
none injected into the iframe — keep it that way). Hard requirement, not code.
|
||||||
|
- **Denoise M1 (perf):** in-source uses non-SIMD `rnnoise.wasm`; the reference
|
||||||
|
preferred SIMD with detection. Perf-only; add SIMD detection later.
|
||||||
|
- **dtln/deepfilternet (F3): RESOLVED** — all four models
|
||||||
|
(rnnoise/speex/dtln/deepfilternet) are now implemented in
|
||||||
|
`lotusDenoiseProcessor.ts` (faithful port of cinny's `build/lotus-denoise.js`
|
||||||
|
pipeline). This also fixed a real bug (the gate worklet name was `noiseGate`;
|
||||||
|
correct is the hyphenated `noise-gate`) and added per-model sample rates
|
||||||
|
(DTLN 16 kHz, others 48 kHz), context `resume()`, and SIMD wasm selection.
|
||||||
|
Still needs live §D testing per model, and depends on cinny shipping the
|
||||||
|
DTLN (`denoise/workadventure/`) + DeepFilterNet (`denoise/deepfilternet/`)
|
||||||
|
asset trees (it already does).
|
||||||
|
- **Rebase-fragility (build agent MED):** the `CallViewModel` spotlight override
|
||||||
|
edits hot upstream lines (renamed `spotlightSpeaker$`→`autoSpotlightSpeaker$`).
|
||||||
|
For cheaper future rebases, refactor it into a `src/lotus/lotusSpotlight.ts`
|
||||||
|
wrapper that takes the upstream stream and returns the overridden one, leaving
|
||||||
|
upstream's definition byte-identical (a single import + two token swaps).
|
||||||
|
- **Denoise asset coupling (build agent HIGH):** the fork loads `./denoise/*`
|
||||||
|
shipped by cinny, not by the fork build (documented in the processor). Add an
|
||||||
|
integration smoke-check that `GET …/element-call/denoise/rnnoise.wasm` == 200,
|
||||||
|
and pin the `@sapphi-red/web-noise-suppressor` version both repos expect.
|
||||||
|
- **Unconditional effect registration (build agent LOW):** focus/audio-inject/
|
||||||
|
quality/decorations register widget handlers on every embedded call (true
|
||||||
|
no-ops for a non-Lotus host). Intentional; gate behind a coarse `lotus=1` flag
|
||||||
|
if strict zero-footprint is desired.
|
||||||
|
- **Privacy (security agent):** decoration/inject URLs accept any `https`; ideally
|
||||||
|
restrict to the homeserver media origin host-side. Call-state exposes
|
||||||
|
userId/deviceId/speaking to the (trusted, same-origin) host — documented.
|
||||||
|
|
||||||
|
**Nothing here blocks the §D live test — but every feature still needs it.**
|
||||||
|
|
||||||
|
### 12.3 Safe rollout when prod is the only test environment
|
||||||
|
|
||||||
|
Every Phase-2 feature is now **dormant by default** — with the flags cinny sets
|
||||||
|
today, the fork behaves identically to the parity build (`#1` was decoupled onto
|
||||||
|
`lotusDenoiseSource=1` so it no longer collides with the host's `lotusDenoise=ml`
|
||||||
|
shim). This enables a low-risk incremental rollout even without a staging env:
|
||||||
|
|
||||||
|
1. **Ship dormant first.** Publish the `lotus` branch (e.g. `0.20.1-lotus.1`),
|
||||||
|
bump cinny's pin, deploy. With no Lotus flags set / no Lotus actions sent,
|
||||||
|
this is upstream-equivalent (only inert, holistically-reviewed code runs).
|
||||||
|
"Testing" here = confirm a normal call still works.
|
||||||
|
2. **Enable ONE feature at a time**, each independently revertable:
|
||||||
|
- URL-flag features (#2 `lotusCallState`, #5 `lotusTransparent`/`lotusTheme`,
|
||||||
|
#1 `lotusDenoiseSource`): add the flag in `CallEmbed.getWidget`, deploy,
|
||||||
|
test that one feature, roll back just that flag if needed.
|
||||||
|
- Action features (#3,#4,#6,#7): wire the host send + (for #2) the
|
||||||
|
`listenAction` ack, gated on join (§12.1 F1).
|
||||||
|
3. **#1 denoise cutover is a coordinated 2-step** (do together): set
|
||||||
|
`lotusDenoiseSource=1` AND remove the `lotusDenoise()` shim injection +
|
||||||
|
`lotusDenoise=ml` param in cinny — otherwise audio is denoised twice.
|
||||||
|
Roll back = revert both.
|
||||||
|
4. Baseline is always upstream-equivalent, so any single feature can be disabled
|
||||||
|
by flipping its flag/send off without touching the rest.
|
||||||
|
|
||||||
|
**Blocker to step 1:** publishing the `lotus` branch needs a Gitea npm token
|
||||||
|
(the admin token used for the `0.20.1` parity publish was deleted). Either
|
||||||
|
provide a token for a manual `npm publish`, or stand up the Gitea Actions runner
|
||||||
|
|
||||||
|
- `GITEA_NPM_TOKEN` secret so a `v0.20.1-lotus.1` tag auto-publishes.
|
||||||
+105
-23
@@ -15,22 +15,55 @@ step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
|
|||||||
|
|
||||||
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
| ID | Item | File / area | Test |
|
| ID | Item | File / area | Test |
|
||||||
| :--- | :------------------------------------------------------- | :--------------------------------------------------- | :---- |
|
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||||
| #1 | Camera focus during screenshare ("Focus camera" menu) | `CallControl.ts`, `MemberGlance.tsx` | A5 |
|
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||||
| #3 | Avatar decorations on call tiles | `call/CallMemberCard.tsx` | A6 |
|
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||||
| #4 | DM/group ringtone selection + in-call banner | `CallEmbedProvider.tsx`, `ringtones.ts` | A1–A4 |
|
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||||
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||||
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||||
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
||||||
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
||||||
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
||||||
| #12 | PiP mute badge attribution (you vs. all-muted) | `CallEmbedProvider.tsx` | G1 |
|
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
||||||
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
||||||
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
||||||
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
||||||
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
|
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||||
|
|
||||||
|
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Element Call source-level items — now actionable via the fork
|
||||||
|
|
||||||
|
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
|
||||||
|
> self-build Element Call (`LotusGuild/element-call` →
|
||||||
|
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
|
||||||
|
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
|
||||||
|
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
|
||||||
|
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
|
||||||
|
> row once verified live.
|
||||||
|
|
||||||
|
The in-call participant grid is rendered **inside EC's app** — now editable source
|
||||||
|
(previously a prebuilt npm bundle we could only style around). Status of the items
|
||||||
|
from testing:
|
||||||
|
|
||||||
|
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
|
||||||
|
sends an `io.lotus.focus_participant` widget action that pins a participant in
|
||||||
|
EC's layout (coexisting with / overriding the screenshare spotlight); the old
|
||||||
|
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
|
||||||
|
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
|
||||||
|
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
|
||||||
|
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
|
||||||
|
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
|
||||||
|
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
|
||||||
|
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
|
||||||
|
(re)publish, so reconnects keep denoise alive natively. The build-time
|
||||||
|
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
|
||||||
|
blast radius — everyone's mic; verify D2-1 carefully.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,8 +71,59 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
|||||||
|
|
||||||
### Calls / Audio
|
### Calls / Audio
|
||||||
|
|
||||||
- **N95 — AFK auto-mute keeps the hardware mic active while muted.** `useAfkAutoMute.ts` holds its own `getUserMedia` stream independent of EC's; muting in the UI doesn't stop those tracks, so the OS recording indicator stays lit. Fix: stop the `MediaStream` tracks on mute, re-request on unmute. (Repro: `LOTUS_TESTING.md` L1.)
|
- ~~**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.
|
||||||
- **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.
|
|
||||||
|
### 🧨 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
|
||||||
|
|
||||||
@@ -49,11 +133,9 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
|||||||
|
|
||||||
### PWA / Offline / Notifications
|
### PWA / Offline / Notifications
|
||||||
|
|
||||||
- **N105 — Service worker has no `notificationclick` handler** — notification clicks are broken when the tab is closed. Needs `showNotification()` via the SW + a `notificationclick` listener.
|
|
||||||
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
|
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
|
||||||
- **N108 — No maskable PWA icon** — Android adaptive icons render incorrectly. Needs a maskable icon asset + `purpose: "maskable"` manifest entry.
|
|
||||||
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
|
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
|
||||||
- **`manifest: false`** in `vite.config.js` — may block correct PWA install if not handled externally.
|
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
|
||||||
|
|
||||||
### Dependencies & Build
|
### Dependencies & Build
|
||||||
|
|
||||||
@@ -62,13 +144,13 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
|||||||
|
|
||||||
### Code Hygiene / DevEx
|
### Code Hygiene / DevEx
|
||||||
|
|
||||||
- **No automated test suite** (`src/`) — no unit/integration tests configured.
|
- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
|
||||||
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
- **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).
|
||||||
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
|
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
|
||||||
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
|
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
|
||||||
- **README / CONTRIBUTING:** stale upstream bug-tracker/donations/CLA links; README↔CONTRIBUTING misalignment.
|
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
|
||||||
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
|
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
|
||||||
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
|
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
|
||||||
|
|
||||||
|
|||||||
+191
-14
@@ -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,9 +323,104 @@ Users can set a custom background color for `@mention` chips that highlight thei
|
|||||||
|
|
||||||
## Voice / Video Call Improvements
|
## Voice / Video Call Improvements
|
||||||
|
|
||||||
### Element Call Upgrade
|
> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
|
||||||
|
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
|
||||||
|
> `LotusGuild/element-call`), served same-origin — no longer the upstream
|
||||||
|
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
||||||
|
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
||||||
|
> work list are in
|
||||||
|
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
|
||||||
|
|
||||||
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
|
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
||||||
|
|
||||||
|
The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
|
||||||
|
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
|
||||||
|
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
|
||||||
|
`@element-hq/element-call-embedded`. The iframe prints
|
||||||
|
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
|
||||||
|
upstream) — the quickest way to confirm a deploy landed the fork.
|
||||||
|
|
||||||
|
All custom behavior lives in the fork's `src/lotus/` modules and is **additive
|
||||||
|
and dormant by default**, gated by URL flags / widget actions the host opts into,
|
||||||
|
so a stock EC config is byte-for-byte upstream behavior.
|
||||||
|
|
||||||
|
**Active (cinny drives them today):**
|
||||||
|
|
||||||
|
| # | Feature | Mechanism | Replaces (old hack) |
|
||||||
|
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||||
|
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||||
|
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||||
|
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||||
|
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||||
|
|
||||||
|
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
|
||||||
|
|
||||||
|
| # | Capability | Widget action | cinny surface |
|
||||||
|
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||||
|
| P5-15 | **Audio inject** | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below |
|
||||||
|
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
||||||
|
|
||||||
|
> Both were dormant capabilities; cinny now drives them (armed via
|
||||||
|
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
|
||||||
|
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
||||||
|
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
||||||
|
> client cap is best-effort.
|
||||||
|
|
||||||
|
### In-Call Soundboard (P5-15)
|
||||||
|
|
||||||
|
A soundboard button (🔔) in the call controls bar opens a popout of the user's
|
||||||
|
clips. Clicking one **injects it into the call as a real published LiveKit
|
||||||
|
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
|
||||||
|
plays it locally for the presser (LiveKit doesn't loop your own track back).
|
||||||
|
|
||||||
|
- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
|
||||||
|
`io.lotus.soundboard` account data event, so they **sync across all your
|
||||||
|
devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
|
||||||
|
inline.
|
||||||
|
- Authenticated media can't be fetched from the widget's realm, so the host
|
||||||
|
resolves each mxc clip → an authenticated download → a same-session `blob:`
|
||||||
|
object URL and hands that to the widget.
|
||||||
|
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
|
||||||
|
slider. The button is hidden when disabled.
|
||||||
|
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
|
||||||
|
`features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||||
|
|
||||||
|
### Call Quality Controls (P5-31)
|
||||||
|
|
||||||
|
Discord-style encoding controls applied to the local tracks via the fork's
|
||||||
|
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
|
||||||
|
encodings, re-applied on every re-publish/reconnect).
|
||||||
|
|
||||||
|
- **User settings** (Settings → General → Calls): Microphone Bitrate,
|
||||||
|
Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
|
||||||
|
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
|
||||||
|
(`io.lotus.room_quality` state event); every Lotus client clamps its per-user
|
||||||
|
quality to `min(user setting, room cap)`.
|
||||||
|
- Applied by the `useCallQuality` hook on join and whenever settings/caps
|
||||||
|
change; `utils/callQuality.ts` builds the payload (unit-tested).
|
||||||
|
|
||||||
|
**Server-enforced call permissions (hard, ALL clients).** The same
|
||||||
|
`io.lotus.room_quality` event carries a **publish-source policy**
|
||||||
|
(`allow_screenshare`, `allow_camera`) enforced server-side by
|
||||||
|
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
|
||||||
|
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
|
||||||
|
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
|
||||||
|
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
|
||||||
|
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
|
||||||
|
new joins, and a background reconcile loop revokes an **in-progress**
|
||||||
|
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
|
||||||
|
flipping the policy — so it kills active shares mid-call, not just future ones.
|
||||||
|
|
||||||
|
- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
|
||||||
|
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
|
||||||
|
grant, room config, server `limit:`, or admin API; stock Element Call ignores
|
||||||
|
room metadata for publish quality. Numeric caps are therefore inherently
|
||||||
|
**cooperative** — our fork honors them, which is the design above. The
|
||||||
|
publish-source policy is the one genuine hard, cross-client lever, and it's
|
||||||
|
implemented.
|
||||||
|
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
|
||||||
|
the fork).
|
||||||
|
|
||||||
### Camera Default Off
|
### Camera Default Off
|
||||||
|
|
||||||
@@ -417,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.
|
||||||
@@ -426,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
|
||||||
|
|
||||||
@@ -1042,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 |
|
||||||
|
|||||||
+175
-9
@@ -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 ~3–5 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 A–D 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 A–D are; do them as you have the right device handy.
|
> Sections A–D 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 A–D are; do them as you have the right device handy.
|
||||||
@@ -342,22 +461,25 @@ Trigger a desktop/browser notification for a new message.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## L. Open bugs flagged by audit — reproduction needed before fix
|
## L. Fixed — verify
|
||||||
|
|
||||||
### L1. AFK auto-mute keeps the OS microphone indicator lit (N95) — 👥 live call
|
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
|
||||||
|
|
||||||
**Context:** `useAfkAutoMute.ts` calls `getUserMedia({ audio: true })` independently of Element Call's managed stream. When you mute in the Lotus UI, the LiveKit mic inside EC's iframe is muted via the widget API — but the separate `MediaStream` held by the AFK hook keeps its tracks running. The OS-level recording indicator (green dot on macOS, mic icon on Windows/Linux) therefore stays lit while your mic is muted.
|
**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
|
||||||
|
|
||||||
**To reproduce:**
|
**To verify:**
|
||||||
|
|
||||||
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
||||||
2. Manually **mute your mic** using the call controls.
|
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
|
||||||
3. Check the **OS recording indicator** (macOS: green dot top-right of menu bar; Windows: mic icon in taskbar).
|
3. **Unmute** → the indicator should re-appear (capture re-acquired).
|
||||||
|
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
|
||||||
|
|
||||||
**Expected (current broken behavior):** the OS recording indicator stays on even though your Lotus mic shows muted.
|
### L2. Maskable PWA icon (N108) — Android install
|
||||||
**Expected after fix:** the indicator should clear when you mute and re-appear when you unmute.
|
|
||||||
|
|
||||||
> **Note:** This is an **open bug** — no fix has been applied yet. Reproduce and confirm the symptom first. The fix involves stopping `MediaStream` tracks on mute and re-requesting `getUserMedia` on unmute (see LOTUS_BUGS.md N95 for full details). Once fixed, re-run this check to verify the indicator clears.
|
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
|
||||||
|
2. Look at the **home-screen icon**.
|
||||||
|
|
||||||
|
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -407,6 +529,50 @@ Settings → Appearance → theme picker → try each of the 5 new themes.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
|
||||||
|
|
||||||
|
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
|
||||||
|
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
|
||||||
|
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
|
||||||
|
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
|
||||||
|
delta + a `config.json` override) OR against **mozilla.org** with a real account.
|
||||||
|
|
||||||
|
### N1. OIDC login flow (the core test) — needs a MAS homeserver
|
||||||
|
|
||||||
|
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
|
||||||
|
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
|
||||||
|
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
|
||||||
|
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
|
||||||
|
|
||||||
|
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
|
||||||
|
|
||||||
|
### N2. Session persists across reload (token storage)
|
||||||
|
|
||||||
|
After N1, hard-refresh the page.
|
||||||
|
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
|
||||||
|
|
||||||
|
### N3. Token refresh (long-lived session)
|
||||||
|
|
||||||
|
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
|
||||||
|
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
|
||||||
|
|
||||||
|
### N4. Logout revokes at the issuer
|
||||||
|
|
||||||
|
Log out from Settings.
|
||||||
|
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
|
||||||
|
|
||||||
|
### N5. Account-management deep-link
|
||||||
|
|
||||||
|
Settings → Account.
|
||||||
|
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
|
||||||
|
|
||||||
|
### N6. Non-OIDC regression — password login unchanged
|
||||||
|
|
||||||
|
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
||||||
|
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Priority if you're short on time
|
## Priority if you're short on time
|
||||||
|
|
||||||
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
||||||
|
|||||||
+118
-76
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -241,14 +244,20 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) (EXTREME COMPLEXITY, LOW PRIORITY)
|
### [~] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) — CLIENT-SIDE BUILT, awaiting live verification
|
||||||
|
|
||||||
**Spec:** MSC3861, merged Matrix spec v1.15. Uses Matrix Authentication Service (MAS).
|
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
|
||||||
**Context:** ~80% of homeserver users have LLDAP/Authelia/SSO accounts. SSO is currently enabled on `matrix.lotusguild.org` but accounts are not yet linked. This would allow users to log in via their SSO credentials.
|
**Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
|
||||||
**What:** OAuth 2.0 / OIDC login flow, token refresh, account management page linking Matrix identity to SSO identity.
|
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
|
||||||
**EXTREME COMPLEXITY** — requires: MAS deployment/configuration on the homeserver, significant auth flow changes in the client, token refresh handling, session management overhaul.
|
|
||||||
**[SERVER CHECK]** — Before any client work, audit whether MAS is already deployed on `compute-storage-01`. Check: `pct exec 151 -- systemctl status matrix-authentication-service` or similar.
|
- Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
|
||||||
**Complexity:** Extreme. Multi-sprint project. Plan separately.
|
- Login: `pages/auth/oidc/{oidcConfig,oidcLoginUtil,oidcState}.ts` (dynamic registration + cache, PKCE authorize), `login/OidcLogin.tsx`, issuer-gated `Login.tsx`.
|
||||||
|
- Callback: `oidc/OidcCallback.tsx` + `App.tsx` short-circuit (non-hash redirect path).
|
||||||
|
- Session/refresh: `state/sessions.ts` OIDC fields, `client/{oidcTokenRefresher,oidcLogout}.ts`, `initMatrix.ts` wiring.
|
||||||
|
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
||||||
|
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
|
||||||
|
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
|
||||||
|
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -260,11 +269,17 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-15 · In-Call Soundboard
|
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
|
||||||
|
|
||||||
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
|
**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.
|
||||||
**Complexity:** High.
|
**Shipped (cinny):**
|
||||||
|
|
||||||
|
- Clips stored in `io.lotus.soundboard` account data → **synced across devices like emoji/sticker packs** (`useSoundboard` hook; `AccountDataEvent.LotusSoundboard`).
|
||||||
|
- Upload audio (≤1 MB, ≤40 clips) → `mx.uploadContent` → mxc; play resolves mxc → authed download → `blob:` object URL (the widget can't fetch authenticated media itself) → `control.injectAudio(url, volume)` + local playback.
|
||||||
|
- `CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
|
||||||
|
**Remaining:** a dedicated Settings management page (optional — upload/delete already live in the popout); a small default clip set; live verification (D2-7). Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`, `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||||
|
**Complexity:** Medium — done.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -280,38 +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.
|
||||||
**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.
|
**🔱 [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.
|
||||||
**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".
|
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
|
||||||
|
|
||||||
**Model Roadmap (priority order):**
|
**Models — all in-source in the fork:**
|
||||||
|
|
||||||
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
|
- [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 ~3–4 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.
|
||||||
@@ -320,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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -531,6 +571,8 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
- Pass the destination's `.stream` to the call bridge.
|
- Pass the destination's `.stream` to the call bridge.
|
||||||
|
|
||||||
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
||||||
|
>
|
||||||
|
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -601,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`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
||||||
|
|
||||||
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1
|
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ A Matrix chat client built for Lotus Guild — fast, private, and packed with th
|
|||||||
|
|
||||||
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
||||||
|
|
||||||
The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,6 +52,9 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
|
|||||||
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 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,6 +161,26 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
|
|||||||
|
|
||||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
|
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||||
|
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
|
||||||
|
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||||
|
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||||
|
behavior is editable source instead of fragile DOM/widget hacks.
|
||||||
|
|
||||||
|
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
|
||||||
|
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
|
||||||
|
avatar decorations on EC video tiles, and a native transparent background.
|
||||||
|
**Built but dormant (need cinny UI):** real call-audio injection
|
||||||
|
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
||||||
|
(`io.lotus.set_quality`).
|
||||||
|
|
||||||
|
The full plan and integration map is in
|
||||||
|
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting +
|
||||||
|
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs
|
||||||
|
for the **`[EC-FORK]`** tag to find every related note.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Local OIDC / next-gen-auth (MSC3861) test loop
|
||||||
|
|
||||||
|
The Lotus client gained MSC3861/MSC2965 OIDC login (P4-6). lotusguild's own
|
||||||
|
homeserver is **not** MSC3861, so to exercise the flow without a mozilla.org
|
||||||
|
tester you need a local homeserver that delegates auth to a **Matrix
|
||||||
|
Authentication Service (MAS)**. This is the dev loop.
|
||||||
|
|
||||||
|
> Status: the Lotus-client side is unit-tested + gate-green; this server loop is
|
||||||
|
> the manual end-to-end check. It hasn't been run in CI (no container runtime
|
||||||
|
> there), so treat version pins as a starting point and bump as needed.
|
||||||
|
|
||||||
|
## 1. Stand up MAS + Synapse
|
||||||
|
|
||||||
|
The simplest path is the **upstream MAS docker-compose quickstart** — it's
|
||||||
|
maintained and handles key generation + the database:
|
||||||
|
<https://element-hq.github.io/matrix-authentication-service/setup/installation.html>
|
||||||
|
(`docker compose` section). Use it to get MAS + Synapse + Postgres running, then
|
||||||
|
apply the two Lotus-specific deltas below.
|
||||||
|
|
||||||
|
A minimal `compose.yaml` skeleton (generate MAS keys first — do **not** hand-write them):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment: { POSTGRES_USER: synapse, POSTGRES_PASSWORD: pw, POSTGRES_DB: synapse }
|
||||||
|
mas:
|
||||||
|
image: ghcr.io/element-hq/matrix-authentication-service:latest
|
||||||
|
command: server
|
||||||
|
ports: ['8090:8080'] # MAS issuer on http://localhost:8090
|
||||||
|
volumes: ['./mas:/data']
|
||||||
|
# First run once: `docker compose run --rm mas config generate -o /data/config.yaml`
|
||||||
|
# then edit /data/mas/config.yaml (see §1a) before `up`.
|
||||||
|
synapse:
|
||||||
|
image: ghcr.io/element-hq/synapse:latest
|
||||||
|
ports: ['8008:8008'] # client/federation API
|
||||||
|
volumes: ['./synapse:/data']
|
||||||
|
depends_on: [postgres, mas]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1a. MAS `config.yaml` — the parts that matter
|
||||||
|
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
public_base: http://localhost:8090/
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
database:
|
||||||
|
uri: postgresql://synapse:pw@postgres/synapse
|
||||||
|
matrix:
|
||||||
|
homeserver: localhost # the server_name
|
||||||
|
endpoint: http://synapse:8008/
|
||||||
|
secret: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
||||||
|
clients:
|
||||||
|
- client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
||||||
|
passwords: # so you can create a local test account in the MAS UI
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
|
||||||
|
See `synapse-msc3861.yaml` in this folder; the key block is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
experimental_features:
|
||||||
|
msc3861:
|
||||||
|
enabled: true
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" # == MAS clients[].client_secret
|
||||||
|
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" # == MAS matrix.secret
|
||||||
|
account_management_url: "http://localhost:8090/account"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a test user via the MAS UI (`http://localhost:8090/`) or
|
||||||
|
`docker compose exec mas mas-cli manage register-user`.
|
||||||
|
|
||||||
|
Sanity check discovery (the client relies on this):
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
|
||||||
|
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Point the Lotus dev client at it
|
||||||
|
|
||||||
|
Run the client: `npm start` (vite dev). Override `public/config.json` so the
|
||||||
|
local server is selectable and custom servers are allowed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"defaultHomeserver": 0,
|
||||||
|
"homeserverList": ["localhost:8008"],
|
||||||
|
"allowCustomHomeservers": true,
|
||||||
|
"hashRouter": { "enabled": false, "basename": "/" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dynamic client registration handles the redirect URI automatically — it's
|
||||||
|
`<vite-origin>/auth/oidc/callback` (e.g. `http://localhost:5173/auth/oidc/callback`),
|
||||||
|
and MAS allows `http://localhost` redirects in dev.
|
||||||
|
|
||||||
|
## 3. Run the checklist
|
||||||
|
|
||||||
|
See **section N** of `../../LOTUS_TESTING.md` for the actual pass/fail steps
|
||||||
|
(login redirect, callback, session-persist-on-reload, token refresh, logout
|
||||||
|
revocation, account-management link, and the non-OIDC-regression check).
|
||||||
|
|
||||||
|
## Files here
|
||||||
|
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
|
||||||
|
- `config.local.json` — the Lotus `public/config.json` override.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"defaultHomeserver": 0,
|
||||||
|
"homeserverList": ["localhost:8008"],
|
||||||
|
"allowCustomHomeservers": true,
|
||||||
|
"featuredCommunities": { "openAsDefault": false, "spaces": [], "rooms": [], "servers": [] },
|
||||||
|
"hashRouter": { "enabled": false, "basename": "/" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Synapse experimental-features delta to delegate auth to a local MAS (MSC3861).
|
||||||
|
# Merge this into your test homeserver.yaml. The client_secret + admin_token MUST
|
||||||
|
# match the MAS config (clients[].client_secret and matrix.secret respectively).
|
||||||
|
experimental_features:
|
||||||
|
msc3861:
|
||||||
|
enabled: true
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
||||||
|
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
||||||
|
account_management_url: "http://localhost:8090/account"
|
||||||
|
|
||||||
|
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
||||||
|
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
||||||
|
# Lotus client's getOidcIssuer() reads to switch into the OIDC flow.
|
||||||
Generated
+27
-503
@@ -21,7 +21,6 @@
|
|||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@sentry/react": "10.53.1",
|
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
@@ -78,10 +77,9 @@
|
|||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@element-hq/element-call-embedded": "0.20.1",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
@@ -110,6 +108,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
@@ -1790,12 +1789,6 @@
|
|||||||
"node": ">=v18"
|
"node": ">=v18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@element-hq/element-call-embedded": {
|
|
||||||
"version": "0.20.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz",
|
|
||||||
"integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -2695,6 +2688,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
|
"version": "0.20.1-lotus.1",
|
||||||
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
|
||||||
|
"integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "18.3.0",
|
"version": "18.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
||||||
@@ -3782,403 +3781,6 @@
|
|||||||
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/feedback": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay-canvas": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/browser": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry-internal/feedback": "10.53.1",
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry-internal/replay-canvas": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/core": "^7.18.5",
|
|
||||||
"@sentry/babel-plugin-component-annotate": "5.3.0",
|
|
||||||
"@sentry/cli": "^2.58.5",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"find-up": "^5.0.0",
|
|
||||||
"glob": "^13.0.6",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
|
|
||||||
"version": "13.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
|
||||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"minimatch": "^10.2.2",
|
|
||||||
"minipass": "^7.1.3",
|
|
||||||
"path-scurry": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
|
|
||||||
"version": "10.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^5.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minipass": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"https-proxy-agent": "^5.0.0",
|
|
||||||
"node-fetch": "^2.6.7",
|
|
||||||
"progress": "^2.0.3",
|
|
||||||
"proxy-from-env": "^1.1.0",
|
|
||||||
"which": "^2.0.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sentry-cli": "bin/sentry-cli"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@sentry/cli-darwin": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-linux-i686": "2.58.6",
|
|
||||||
"@sentry/cli-linux-x64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-i686": "2.58.6",
|
|
||||||
"@sentry/cli-win32-x64": "2.58.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-darwin": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/core": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/react": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/browser": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/rollup-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rollup": ">=3.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"rollup": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/vite-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"@sentry/rollup-plugin": "5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@simple-libs/stream-utils": {
|
"node_modules/@simple-libs/stream-utils": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
|
||||||
@@ -4893,18 +4495,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -6634,19 +6224,6 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "16.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -8473,19 +8050,6 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "6",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/husky": {
|
"node_modules/husky": {
|
||||||
"version": "9.1.7",
|
"version": "9.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
@@ -10599,26 +10163,6 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "4.x || >=6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"encoding": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"encoding": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -11178,16 +10722,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/progress": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -11198,13 +10732,6 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -12783,12 +12310,6 @@
|
|||||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -12837,6 +12358,25 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -13336,22 +12876,6 @@
|
|||||||
"defaults": "^1.0.3"
|
"defaults": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
+3
-3
@@ -16,6 +16,7 @@
|
|||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"commit": "git-cz",
|
"commit": "git-cz",
|
||||||
"postinstall": "node scripts/patch-folds.mjs",
|
"postinstall": "node scripts/patch-folds.mjs",
|
||||||
@@ -45,7 +46,6 @@
|
|||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@sentry/react": "10.53.1",
|
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
@@ -102,10 +102,9 @@
|
|||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@element-hq/element-call-embedded": "0.20.1",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
@@ -134,6 +133,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
|
|||||||
@@ -54,6 +54,18 @@
|
|||||||
"src": "./res/android/android-chrome-512x512.png",
|
"src": "./res/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["social", "communication", "productivity"],
|
"categories": ["social", "communication", "productivity"],
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -9,6 +9,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
Dialog,
|
Dialog,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
useCallStart,
|
useCallStart,
|
||||||
} from '../hooks/useCallEmbed';
|
} from '../hooks/useCallEmbed';
|
||||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
@@ -43,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';
|
||||||
@@ -51,6 +54,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
|
|||||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||||
import { getChatBg } from '../features/lotus/chatBackground';
|
import { getChatBg } from '../features/lotus/chatBackground';
|
||||||
|
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -62,6 +66,7 @@ import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
|||||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
|
import { zIndices } from '../styles/zIndex';
|
||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
const PIP_MIN_H = 112;
|
||||||
@@ -321,7 +326,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: config.space.S400,
|
top: config.space.S400,
|
||||||
right: config.space.S400,
|
right: config.space.S400,
|
||||||
zIndex: 9990,
|
zIndex: zIndices.inCallBanner,
|
||||||
width: toRem(300),
|
width: toRem(300),
|
||||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
@@ -402,6 +407,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const directs = useAtomValue(mDirectAtom);
|
const directs = useAtomValue(mDirectAtom);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||||
@@ -421,6 +427,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
await event.getDecryptionPromise();
|
await event.getDecryptionPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caller-side: a participant declined a call we're hosting in this room.
|
||||||
|
// Without this the caller's UI keeps "ringing" until the notification
|
||||||
|
// lifetime expires, with no indication the callee said no.
|
||||||
|
if (event.getType() === EventType.RTCDecline) {
|
||||||
|
const decliner = event.getSender();
|
||||||
|
if (
|
||||||
|
data.liveEvent &&
|
||||||
|
room &&
|
||||||
|
decliner &&
|
||||||
|
decliner !== mx.getSafeUserId() &&
|
||||||
|
callEmbed?.roomId === room.roomId
|
||||||
|
) {
|
||||||
|
const declinerName =
|
||||||
|
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
|
||||||
|
setToast({
|
||||||
|
id: `rtc-decline-${event.getId() ?? decliner}`,
|
||||||
|
displayName: declinerName,
|
||||||
|
body: 'Declined your call',
|
||||||
|
roomName: room.name,
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!room ||
|
!room ||
|
||||||
event.getType() !== EventType.RTCNotification ||
|
event.getType() !== EventType.RTCNotification ||
|
||||||
@@ -483,7 +514,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
setCallInfo(info);
|
setCallInfo(info);
|
||||||
},
|
},
|
||||||
[mx, directs],
|
[mx, directs, callEmbed, setToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -554,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(() => {
|
||||||
@@ -1095,10 +1127,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||||
{document.fullscreenEnabled && (
|
{document.fullscreenEnabled && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||||
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePipFullscreen();
|
handlePipFullscreen();
|
||||||
@@ -1107,19 +1142,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
// Dark scrim is intentional for legibility over arbitrary video.
|
// Dark scrim is intentional for legibility over arbitrary video.
|
||||||
background: 'rgba(0,0,0,0.65)',
|
background: 'rgba(0,0,0,0.65)',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
border: 'none',
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
padding: `${config.space.S100} ${config.space.S200}`,
|
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: '13px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pipIsFullscreen ? '⊡' : '⛶'}
|
{pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
import { color, Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
||||||
|
|
||||||
type MemberVerificationBadgeProps = {
|
type MemberVerificationBadgeProps = {
|
||||||
@@ -9,8 +9,7 @@ type MemberVerificationBadgeProps = {
|
|||||||
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
||||||
const vs = useUserVerifiedStatus(userId);
|
const vs = useUserVerifiedStatus(userId);
|
||||||
if (vs === 'unknown') return null;
|
if (vs === 'unknown') return null;
|
||||||
const color =
|
const iconColor = vs === 'verified' ? color.Success.Main : color.Warning.Main;
|
||||||
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
|
|
||||||
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -27,7 +26,7 @@ export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps
|
|||||||
title={label}
|
title={label}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Icon size="100" src={Icons.ShieldUser} style={{ color }} />
|
<Icon size="100" src={Icons.ShieldUser} style={{ color: iconColor }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -529,7 +529,7 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
style={{
|
style={{
|
||||||
width: '280px',
|
width: '280px',
|
||||||
height: '160px',
|
height: '160px',
|
||||||
border: '1px solid var(--bg-surface-border)',
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, color, config, Text, toRem } from 'folds';
|
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import { RoomEvent } from 'matrix-js-sdk';
|
import { RoomEvent } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
@@ -339,11 +339,7 @@ export function PollContent({
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected && isMultiple ? (
|
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
|
||||||
<Text as="span" size="T200" style={{ lineHeight: 1 }}>
|
|
||||||
✓
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
@@ -1,719 +1,25 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import { zIndices } from '../../styles/zIndex';
|
||||||
animSeasonFall,
|
import { SeasonTheme } from './types';
|
||||||
animLeafFall,
|
import { getActiveSeason } from './seasonSchedule';
|
||||||
animFloatUp,
|
import { HalloweenOverlay } from './themes/Halloween';
|
||||||
animBob,
|
import { ChristmasOverlay } from './themes/Christmas';
|
||||||
animTasselSway,
|
import { NewYearOverlay } from './themes/NewYear';
|
||||||
animGoldShimmer,
|
import { AutumnOverlay } from './themes/Autumn';
|
||||||
animCloverDrift,
|
import { AprilFoolsOverlay } from './themes/AprilFools';
|
||||||
animEarthLeafDrift,
|
import { LunarNewYearOverlay } from './themes/LunarNewYear';
|
||||||
animWarp,
|
import { ValentinesOverlay } from './themes/Valentines';
|
||||||
animScanline,
|
import { StPatricksOverlay } from './themes/StPatricks';
|
||||||
animPixelBlink,
|
import { EarthDayOverlay } from './themes/EarthDay';
|
||||||
} from './Seasonal.css';
|
import { DeepSpaceOverlay } from './themes/DeepSpace';
|
||||||
|
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 10–15)
|
|
||||||
if (m === 2 && d >= 10 && d <= 15) return 'valentines';
|
|
||||||
// St. Patrick's Day (March 15–18)
|
|
||||||
if (m === 3 && d >= 15 && d <= 18) return 'stpatricks';
|
|
||||||
// April Fool's (April 1)
|
|
||||||
if (m === 4 && d === 1) return 'aprilfools';
|
|
||||||
// Earth Day (April 20–23)
|
|
||||||
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 4–10)
|
|
||||||
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 10–30)
|
|
||||||
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) ──
|
||||||
|
|
||||||
@@ -758,7 +64,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
||||||
// by it, and below modals (9999) so dialogs are never obscured.
|
// by it, and below modals (9999) so dialogs are never obscured.
|
||||||
zIndex: 9997,
|
zIndex: zIndices.seasonalEffect,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
||||||
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
|
// Date(year, monthIndex0, day)
|
||||||
|
const on = (monthIndex0: number, day: number): Date => new Date(2026, monthIndex0, day);
|
||||||
|
|
||||||
|
test('each theme activates on a representative day in its window', () => {
|
||||||
|
const cases: Array<[Date, SeasonTheme]> = [
|
||||||
|
[on(11, 31), 'newyear'], // Dec 31
|
||||||
|
[on(0, 1), 'newyear'], // Jan 1
|
||||||
|
[on(0, 25), 'lunar'], // Jan 25
|
||||||
|
[on(1, 3), 'lunar'], // Feb 3
|
||||||
|
[on(1, 12), 'valentines'], // Feb 12
|
||||||
|
[on(2, 16), 'stpatricks'], // Mar 16
|
||||||
|
[on(3, 1), 'aprilfools'], // Apr 1
|
||||||
|
[on(3, 21), 'earthday'], // Apr 21
|
||||||
|
[on(8, 12), 'arcade'], // Sep 12
|
||||||
|
[on(8, 25), 'autumn'], // Sep 25
|
||||||
|
[on(9, 20), 'halloween'], // Oct 20
|
||||||
|
[on(10, 1), 'halloween'], // Nov 1
|
||||||
|
[on(11, 15), 'christmas'], // Dec 15
|
||||||
|
];
|
||||||
|
for (const [date, expected] of cases) {
|
||||||
|
assert.equal(getActiveSeason(date), expected, `${date.toDateString()} -> ${expected}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('priority order resolves overlapping windows (Deep Space outranks Autumn)', () => {
|
||||||
|
// Oct 4-10 is inside Autumn's Oct<=14 window too; Deep Space comes first.
|
||||||
|
assert.equal(getActiveSeason(on(9, 5)), 'deepspace'); // Oct 5
|
||||||
|
// Oct 12 is past Deep Space -> falls through to Autumn.
|
||||||
|
assert.equal(getActiveSeason(on(9, 12)), 'autumn');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('New Year outranks Lunar New Year on Jan 1-2', () => {
|
||||||
|
assert.equal(getActiveSeason(on(0, 1)), 'newyear');
|
||||||
|
// Jan 22+ is past New Year -> Lunar.
|
||||||
|
assert.equal(getActiveSeason(on(0, 22)), 'lunar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null on an off-season day', () => {
|
||||||
|
assert.equal(getActiveSeason(on(5, 15)), null); // Jun 15
|
||||||
|
assert.equal(getActiveSeason(on(6, 4)), null); // Jul 4
|
||||||
|
});
|
||||||
|
|
||||||
|
test('window boundaries are inclusive at both ends', () => {
|
||||||
|
assert.equal(getActiveSeason(on(1, 10)), 'valentines'); // Feb 10 start
|
||||||
|
assert.equal(getActiveSeason(on(1, 15)), 'valentines'); // Feb 15 end
|
||||||
|
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||||
|
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||||
|
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||||
|
assert.equal(new Set(themes).size, 11); // unique
|
||||||
|
for (const t of themes) {
|
||||||
|
assert.ok(
|
||||||
|
typeof SEASON_DATE_RANGES[t] === 'string' && SEASON_DATE_RANGES[t].length > 0,
|
||||||
|
`missing date range for ${t}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for when each seasonal theme auto-activates.
|
||||||
|
*
|
||||||
|
* Both `getActiveSeason` (the runtime "Auto" selector) and the settings UI read
|
||||||
|
* this list, so the date windows shown to the user can never drift from the
|
||||||
|
* dates actually used. Order matters: it is the activation PRIORITY — the first
|
||||||
|
* entry whose window matches wins (e.g. Deep Space outranks Autumn in their
|
||||||
|
* early-October overlap).
|
||||||
|
*/
|
||||||
|
export type SeasonScheduleEntry = {
|
||||||
|
theme: SeasonTheme;
|
||||||
|
/** Human-readable activation window for display in settings. */
|
||||||
|
dateRange: string;
|
||||||
|
/** Whether this theme is active on the given month (1-12) and day (1-31). */
|
||||||
|
matches: (month: number, day: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEASON_SCHEDULE: SeasonScheduleEntry[] = [
|
||||||
|
{
|
||||||
|
theme: 'newyear',
|
||||||
|
dateRange: 'Dec 31 – Jan 2',
|
||||||
|
matches: (m, d) => (m === 12 && d === 31) || (m === 1 && d <= 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'valentines',
|
||||||
|
dateRange: 'Feb 10 – 15',
|
||||||
|
matches: (m, d) => m === 2 && d >= 10 && d <= 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'stpatricks',
|
||||||
|
dateRange: 'Mar 15 – 18',
|
||||||
|
matches: (m, d) => m === 3 && d >= 15 && d <= 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'aprilfools',
|
||||||
|
dateRange: 'Apr 1',
|
||||||
|
matches: (m, d) => m === 4 && d === 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'earthday',
|
||||||
|
dateRange: 'Apr 20 – 23',
|
||||||
|
matches: (m, d) => m === 4 && d >= 20 && d <= 23,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'lunar',
|
||||||
|
dateRange: 'Jan 22 – Feb 5',
|
||||||
|
matches: (m, d) => (m === 1 && d >= 22) || (m === 2 && d <= 5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'arcade',
|
||||||
|
dateRange: 'Sep 12',
|
||||||
|
matches: (m, d) => m === 9 && d === 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'deepspace',
|
||||||
|
dateRange: 'Oct 4 – 10',
|
||||||
|
matches: (m, d) => m === 10 && d >= 4 && d <= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'halloween',
|
||||||
|
dateRange: 'Oct 15 – Nov 1',
|
||||||
|
matches: (m, d) => (m === 10 && d >= 15) || (m === 11 && d === 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'christmas',
|
||||||
|
dateRange: 'Dec 10 – 30',
|
||||||
|
matches: (m, d) => m === 12 && d >= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'autumn',
|
||||||
|
dateRange: 'Sep 21 – Oct 14',
|
||||||
|
matches: (m, d) => (m === 9 && d >= 21) || (m === 10 && d <= 14),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Map of theme → human-readable activation window (for settings captions). */
|
||||||
|
export const SEASON_DATE_RANGES: Record<SeasonTheme, string> = SEASON_SCHEDULE.reduce(
|
||||||
|
(acc, entry) => {
|
||||||
|
acc[entry.theme] = entry.dateRange;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<SeasonTheme, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seasonal theme that should be active on `now`, or null if none. First
|
||||||
|
* matching entry in SEASON_SCHEDULE priority order wins.
|
||||||
|
*/
|
||||||
|
export function getActiveSeason(now: Date): SeasonTheme | null {
|
||||||
|
const month = now.getMonth() + 1; // 1-12
|
||||||
|
const day = now.getDate();
|
||||||
|
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doodle float-up — a hand-drawn glyph drifts gently upward while bobbing
|
||||||
|
* side to side and lazily rotating, like a thought balloon escaping the page.
|
||||||
|
* GPU-only: transform + opacity exclusively. A tall translateY lets one set of
|
||||||
|
* keyframes serve every doodle; per-element duration/delay/scale add variety.
|
||||||
|
*/
|
||||||
|
export const animDoodleFloat = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 8vh, 0) rotate(-8deg) scale(0.85)', opacity: '0' },
|
||||||
|
'10%': { opacity: '1' },
|
||||||
|
'35%': { transform: 'translate3d(16px, -28vh, 0) rotate(6deg) scale(1)' },
|
||||||
|
'65%': { transform: 'translate3d(-14px, -64vh, 0) rotate(-5deg) scale(1.04)' },
|
||||||
|
'90%': { opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(10px, -112vh, 0) rotate(7deg) scale(1.1)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confetti tumble — a small chip falls while flipping. Reuses a single tall
|
||||||
|
* translateY; the flip (rotate + scaleX) sells the paper tumble cheaply.
|
||||||
|
*/
|
||||||
|
export const animConfettiTumble = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg) scaleX(1)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(18px, 50vh, 0) rotate(220deg) scaleX(-1)' },
|
||||||
|
'92%': { opacity: '0.9' },
|
||||||
|
'100%': { transform: 'translate3d(-12px, 112vh, 0) rotate(440deg) scaleX(1)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playful wobble — an almost-imperceptible skew/rotate of a faux tint layer so
|
||||||
|
* the whole scene feels gently "tickled". Tiny amplitude keeps it from being
|
||||||
|
* disorienting. Transform only, stays on the compositor.
|
||||||
|
*/
|
||||||
|
export const animWobble = keyframes({
|
||||||
|
'0%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||||
|
'50%': { transform: 'rotate(0.5deg) skewX(0.4deg) scale(1.01)' },
|
||||||
|
'100%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pastel aurora drift — a soft rainbow wash high in the scene slides and
|
||||||
|
* breathes. translateX + opacity (never background-position) to stay on GPU.
|
||||||
|
*/
|
||||||
|
export const animRainbowDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'translate3d(5%, 0, 0) scaleY(1.06)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Googly-eye look-around — the pupil layer nudges around its socket, giving
|
||||||
|
* each eye a cheeky wandering gaze. Small translate only.
|
||||||
|
*/
|
||||||
|
export const animGoogly = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||||
|
'20%': { transform: 'translate3d(-1.5px, 1.5px, 0)' },
|
||||||
|
'45%': { transform: 'translate3d(1px, -1.5px, 0)' },
|
||||||
|
'70%': { transform: 'translate3d(-1px, -0.5px, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sly wink/sparkle — a four-point glint that twinkles open and shut, scaling
|
||||||
|
* and fading like a sly little wink. Transform + opacity only.
|
||||||
|
*/
|
||||||
|
export const animSparkle = keyframes({
|
||||||
|
'0%, 100%': { transform: 'scale(0.2) rotate(0deg)', opacity: '0' },
|
||||||
|
'40%': { transform: 'scale(1) rotate(35deg)', opacity: '0.9' },
|
||||||
|
'60%': { transform: 'scale(0.95) rotate(45deg)', opacity: '0.7' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animDoodleFloat,
|
||||||
|
animConfettiTumble,
|
||||||
|
animWobble,
|
||||||
|
animRainbowDrift,
|
||||||
|
animGoogly,
|
||||||
|
animSparkle,
|
||||||
|
} from './AprilFools.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical on every mount and the
|
||||||
|
// reduced-motion preview thumbnail is stable. Large primes spread the values.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bright-but-soft pastel rainbow in oklch. Kept luminous and gentle so the
|
||||||
|
// doodles read as crayon pastel over chat without ever fighting the text.
|
||||||
|
const PASTELS = [
|
||||||
|
'oklch(0.85 0.12 20)', // pink
|
||||||
|
'oklch(0.88 0.12 90)', // butter yellow
|
||||||
|
'oklch(0.82 0.12 160)', // mint
|
||||||
|
'oklch(0.8 0.12 260)', // periwinkle
|
||||||
|
'oklch(0.84 0.12 320)', // lilac
|
||||||
|
'oklch(0.86 0.11 50)', // peach
|
||||||
|
];
|
||||||
|
|
||||||
|
// Inline-SVG data-URI doodle glyphs, drawn hand-sketch style (round caps,
|
||||||
|
// open paths). `enc()` keeps them CSP-safe — no external assets, no base64.
|
||||||
|
const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
|
||||||
|
// A single rough stroke wrapper helper for the glyph SVGs.
|
||||||
|
const stroke = (color: string, body: string) =>
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' ` +
|
||||||
|
`stroke='${color}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`;
|
||||||
|
|
||||||
|
// Question mark — the playful "huh?" centerpiece doodle.
|
||||||
|
const glyphQuestion = (c: string) =>
|
||||||
|
stroke(
|
||||||
|
c,
|
||||||
|
`<path d='M11 11 q0 -6 6 -6 q6 0 6 5 q0 4 -5 6 q-2 1 -2 4'/>` +
|
||||||
|
`<circle cx='16' cy='27' r='0.6' fill='${c}'/>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exclamation / "bang" — a surprised little doodle.
|
||||||
|
const glyphBang = (c: string) =>
|
||||||
|
stroke(c, `<path d='M16 5 L16 20'/><circle cx='16' cy='27' r='0.6' fill='${c}'/>`);
|
||||||
|
|
||||||
|
// Squiggle — a loopy scribble that adds whimsy.
|
||||||
|
const glyphSquiggle = (c: string) => stroke(c, `<path d='M5 18 q4 -10 8 0 t8 0 t8 0'/>`);
|
||||||
|
|
||||||
|
// Five-point doodle star (open-stroke, hand-drawn look).
|
||||||
|
const glyphStar = (c: string) =>
|
||||||
|
stroke(
|
||||||
|
c,
|
||||||
|
`<path d='M16 5 L19.4 13 L28 13.6 L21.4 19.2 L23.5 27.6 L16 22.8 L8.5 27.6 ` +
|
||||||
|
`L10.6 19.2 L4 13.6 L12.6 13 Z'/>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A tiny heart doodle for extra grin.
|
||||||
|
const glyphHeart = (c: string) =>
|
||||||
|
stroke(c, `<path d='M16 26 C6 18 7 8 16 12 C25 8 26 18 16 26 Z'/>`);
|
||||||
|
|
||||||
|
const GLYPHS = [glyphQuestion, glyphBang, glyphSquiggle, glyphStar, glyphHeart, glyphQuestion];
|
||||||
|
|
||||||
|
type Doodle = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
glyph: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
startTop: number; // used for the static (reduced) scatter
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Confetti = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
startTop: number;
|
||||||
|
ratio: number; // chip aspect
|
||||||
|
round: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Eye = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Spark = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AprilFoolsOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// ~16 drifting doodles. Built once; per-element timing creates the variety.
|
||||||
|
const doodles = useMemo<Doodle[]>(() => {
|
||||||
|
const count = 16;
|
||||||
|
const out: Doodle[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const color = PASTELS[i % PASTELS.length];
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 0.1) * 96 + 2,
|
||||||
|
size: 18 + rand(i + 0.3) * 22,
|
||||||
|
glyph: enc(GLYPHS[i % GLYPHS.length](color)),
|
||||||
|
duration: 16 + rand(i + 0.5) * 12,
|
||||||
|
delay: -rand(i + 0.7) * 26,
|
||||||
|
startTop: rand(i + 0.9) * 92 + 4,
|
||||||
|
opacity: 0.5 + rand(i + 0.2) * 0.32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ~14 confetti chips in a couple of falling bands.
|
||||||
|
const confetti = useMemo<Confetti[]>(() => {
|
||||||
|
const count = 14;
|
||||||
|
const out: Confetti[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 3.1) * 98 + 1,
|
||||||
|
size: 5 + rand(i + 3.3) * 6,
|
||||||
|
color: PASTELS[(i + 2) % PASTELS.length],
|
||||||
|
duration: 10 + rand(i + 3.5) * 9,
|
||||||
|
delay: -rand(i + 3.7) * 18,
|
||||||
|
startTop: rand(i + 3.9) * 96 + 2,
|
||||||
|
ratio: 0.45 + rand(i + 3.2) * 0.8,
|
||||||
|
round: rand(i + 3.6) > 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A few googly eyes peeking from corners/edges — the cheeky surprise.
|
||||||
|
const eyes = useMemo<Eye[]>(() => {
|
||||||
|
const anchors = [
|
||||||
|
{ left: 6, top: 12 },
|
||||||
|
{ left: 90, top: 20 },
|
||||||
|
{ left: 80, top: 82 },
|
||||||
|
{ left: 14, top: 74 },
|
||||||
|
];
|
||||||
|
return anchors.map((a, i) => ({
|
||||||
|
left: a.left,
|
||||||
|
top: a.top,
|
||||||
|
size: 22 + rand(i + 5.1) * 12,
|
||||||
|
duration: 3 + rand(i + 5.3) * 2.5,
|
||||||
|
delay: -rand(i + 5.5) * 3,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sly winking sparkles scattered sparsely.
|
||||||
|
const sparks = useMemo<Spark[]>(() => {
|
||||||
|
const count = 5;
|
||||||
|
const out: Spark[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 7.1) * 90 + 5,
|
||||||
|
top: rand(i + 7.3) * 84 + 8,
|
||||||
|
size: 12 + rand(i + 7.5) * 12,
|
||||||
|
color: PASTELS[(i + 1) % PASTELS.length],
|
||||||
|
duration: 4 + rand(i + 7.7) * 3,
|
||||||
|
delay: -rand(i + 7.9) * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Four-point glint used for the winking sparkles.
|
||||||
|
const sparkGlint = (c: string) =>
|
||||||
|
enc(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
|
||||||
|
`<path d='M12 0 C13 8 16 11 24 12 C16 13 13 16 12 24 C11 16 8 13 0 12 C8 11 11 8 12 0 Z' fill='${c}'/></svg>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Soft pastel ambient wash — layered oklch radials for depth. Very low
|
||||||
|
opacity so chat text keeps WCAG-AA contrast. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(110% 70% at 18% -8%, oklch(0.85 0.12 20 / 0.1) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(95% 65% at 86% 0%, oklch(0.82 0.12 160 / 0.09) 0%, transparent 58%)',
|
||||||
|
'radial-gradient(120% 80% at 50% 112%, oklch(0.8 0.12 260 / 0.1) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.88 0.12 90 / 0.05) 0%, transparent 30%, transparent 78%, oklch(0.84 0.12 320 / 0.06) 100%)',
|
||||||
|
].join(','),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Faux wobble layer — a near-invisible pastel haze that gently skews so
|
||||||
|
the whole scene feels playfully "tickled". Tiny amplitude = not
|
||||||
|
nauseating. backdrop-filter is one cheap layer for a candy bloom. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '-2%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||||
|
WebkitBackdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(130% 120% at 50% 45%, transparent 60%, oklch(0.86 0.11 50 / 0.05) 80%, oklch(0.8 0.12 260 / 0.08) 100%)',
|
||||||
|
transformOrigin: '50% 50%',
|
||||||
|
animation: reduced ? 'none' : `${animWobble} 14s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pastel rainbow aurora high up — soft band of the full palette. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8%',
|
||||||
|
left: '-10%',
|
||||||
|
right: '-10%',
|
||||||
|
height: '42%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
filter: 'blur(30px)',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(50% 100% at 18% 0%, oklch(0.85 0.12 20 / 0.16) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 40% 0%, oklch(0.88 0.12 90 / 0.14) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 62% 0%, oklch(0.82 0.12 160 / 0.14) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 84% 0%, oklch(0.8 0.12 260 / 0.16) 0%, transparent 72%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animRainbowDrift} 20s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drifting doodles. Motion: rise from below. Reduced: static scatter. */}
|
||||||
|
{doodles.map((d, i) => {
|
||||||
|
const common: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${d.left}%`,
|
||||||
|
width: `${d.size}px`,
|
||||||
|
height: `${d.size}px`,
|
||||||
|
backgroundImage: d.glyph,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
opacity: d.opacity,
|
||||||
|
filter: 'drop-shadow(0 1px 1px oklch(0.4 0.05 300 / 0.18))',
|
||||||
|
};
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`doodle-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: `${d.startTop}%`,
|
||||||
|
transform: `rotate(${(rand(i + 11) - 0.5) * 24}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`doodle-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: 0,
|
||||||
|
animation: `${animDoodleFloat} ${d.duration}s ease-in-out ${d.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Light confetti — tumbling pastel chips. */}
|
||||||
|
{confetti.map((c, i) => {
|
||||||
|
const common: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${c.left}%`,
|
||||||
|
width: `${c.size}px`,
|
||||||
|
height: `${c.size * c.ratio}px`,
|
||||||
|
background: c.color,
|
||||||
|
borderRadius: c.round ? '50%' : '1px',
|
||||||
|
opacity: 0.75,
|
||||||
|
};
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`confetti-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: `${c.startTop}%`,
|
||||||
|
transform: `rotate(${rand(i + 13) * 360}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`confetti-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: 0,
|
||||||
|
animation: `${animConfettiTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
|
||||||
|
{eyes.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`eye-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${e.left}%`,
|
||||||
|
top: `${e.top}%`,
|
||||||
|
width: `${e.size}px`,
|
||||||
|
height: `${e.size}px`,
|
||||||
|
marginLeft: `${-e.size / 2}px`,
|
||||||
|
marginTop: `${-e.size / 2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 38% 32%, oklch(0.99 0.005 90 / 0.85) 0%, oklch(0.95 0.01 90 / 0.7) 62%, oklch(0.75 0.02 90 / 0.6) 100%)',
|
||||||
|
boxShadow: 'inset 0 0 0 1.5px oklch(0.45 0.03 300 / 0.35)',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Pupil */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
width: `${e.size * 0.4}px`,
|
||||||
|
height: `${e.size * 0.4}px`,
|
||||||
|
marginLeft: `${-e.size * 0.2}px`,
|
||||||
|
marginTop: `${-e.size * 0.2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 36% 30%, oklch(0.5 0.04 300 / 0.95) 0%, oklch(0.28 0.04 300 / 0.95) 70%)',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animGoogly} ${e.duration}s ease-in-out ${e.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Catchlight */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '22%',
|
||||||
|
top: '20%',
|
||||||
|
width: '28%',
|
||||||
|
height: '28%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'oklch(0.99 0.005 90 / 0.85)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
|
||||||
|
{sparks.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`spark-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
top: `${s.top}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
backgroundImage: sparkGlint(s.color),
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: `drop-shadow(0 0 3px ${s.color.replace(')', ' / 0.5)')})`,
|
||||||
|
opacity: reduced ? 0.8 : undefined,
|
||||||
|
transform: reduced ? 'scale(0.95) rotate(40deg)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arcade overlay keyframes — retro synthwave CRT.
|
||||||
|
*
|
||||||
|
* Every animation touches ONLY `transform` and `opacity` so the compositor can
|
||||||
|
* run them on the GPU without triggering layout or paint. keyframes() returns
|
||||||
|
* the generated animation-name string, which is applied inline in Arcade.tsx.
|
||||||
|
*
|
||||||
|
* Motion philosophy: a neon perspective grid scrolls toward the viewer, a soft
|
||||||
|
* CRT scanline field breathes, the whole screen glows and flickers ever so
|
||||||
|
* faintly, sparse pixel sparkles drift up, and an "INSERT COIN" blip pulses.
|
||||||
|
* The grid scroll is done with a translateY on a tiled, perspective-projected
|
||||||
|
* plane — never background-position — so it rides the compositor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The neon grid plane is laid out twice its visible height and tiled with the
|
||||||
|
* horizontal rule lines. Translating it up by exactly one tile makes the lines
|
||||||
|
* appear to flow continuously toward the viewer (the horizon). Because the
|
||||||
|
* plane sits under a `perspective` transform, the lines also accelerate as they
|
||||||
|
* approach, giving a true receding-grid illusion. Pure transform.
|
||||||
|
*/
|
||||||
|
export const animGridScroll = keyframes({
|
||||||
|
'0%': { transform: 'translateZ(0) translateY(0)' },
|
||||||
|
'100%': { transform: 'translateZ(0) translateY(50%)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slow vertical drift of the fine scanline field — a couple of pixels so the
|
||||||
|
* raster looks like it's gently rolling, the way a real CRT does. Transform
|
||||||
|
* only; the line texture itself never moves on the GPU's paint layer.
|
||||||
|
*/
|
||||||
|
export const animScanRoll = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 4px, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overall CRT screen-glow breathes: a barely-there opacity swell that keeps
|
||||||
|
* the static neon tint feeling alive and powered-on. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animScreenGlow = keyframes({
|
||||||
|
'0%': { opacity: '0.72' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.72' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A faint, irregular CRT brightness flicker laid over the glow — the classic
|
||||||
|
* unstable-tube shimmer. Kept extremely shallow so it never distracts or harms
|
||||||
|
* readability. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animCrtFlicker = keyframes({
|
||||||
|
'0%': { opacity: '0.94' },
|
||||||
|
'12%': { opacity: '1' },
|
||||||
|
'20%': { opacity: '0.9' },
|
||||||
|
'34%': { opacity: '0.98' },
|
||||||
|
'52%': { opacity: '0.92' },
|
||||||
|
'70%': { opacity: '1' },
|
||||||
|
'83%': { opacity: '0.95' },
|
||||||
|
'100%': { opacity: '0.94' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromatic-aberration twin: the magenta/cyan fringe layers nudge a sub-pixel
|
||||||
|
* apart and back so the edges shimmer with RGB split, like a misconverged tube.
|
||||||
|
* transform + opacity only.
|
||||||
|
*/
|
||||||
|
export const animChromaShift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
|
||||||
|
'50%': { transform: 'translate3d(1.5px, 0, 0)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel sparkle drift: a tiny neon speck rises and twinkles like a coin-burst
|
||||||
|
* particle floating up off the grid. transform + opacity, single tall path.
|
||||||
|
*/
|
||||||
|
export const animSparkleDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
|
||||||
|
'12%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(8px, -42vh, 0) scale(1)', opacity: '0.85' },
|
||||||
|
'78%': { transform: 'translate3d(-6px, -70vh, 0) scale(0.8)', opacity: '0.5' },
|
||||||
|
'92%': { opacity: '0.18' },
|
||||||
|
'100%': { transform: 'translate3d(6px, -92vh, 0) scale(0.55)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independent pixel twinkle layered on the drift so specks blink on/off like a
|
||||||
|
* low-res sprite. Stepped opacity for a crisp 8-bit feel.
|
||||||
|
*/
|
||||||
|
export const animSparkleTwinkle = keyframes({
|
||||||
|
'0%, 44%': { opacity: '1' },
|
||||||
|
'50%, 94%': { opacity: '0.35' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "INSERT COIN" blink: the classic attract-mode pulse. Stepped so it reads as a
|
||||||
|
* hard retro blink rather than a soft fade, but with a brief bright swell.
|
||||||
|
* Opacity + a hair of scale for a CRT bloom feel.
|
||||||
|
*/
|
||||||
|
export const animCoinBlink = keyframes({
|
||||||
|
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
|
||||||
|
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score-blip pulse for the corner HUD glyph: a quick pop then settle, like a
|
||||||
|
* counter ticking up. transform + opacity.
|
||||||
|
*/
|
||||||
|
export const animScoreBlip = keyframes({
|
||||||
|
'0%': { opacity: '0.4', transform: 'scale(1)' },
|
||||||
|
'50%': { opacity: '0.85', transform: 'scale(1.12)' },
|
||||||
|
'100%': { opacity: '0.4', transform: 'scale(1)' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animGridScroll,
|
||||||
|
animScanRoll,
|
||||||
|
animScreenGlow,
|
||||||
|
animCrtFlicker,
|
||||||
|
animChromaShift,
|
||||||
|
animSparkleDrift,
|
||||||
|
animSparkleTwinkle,
|
||||||
|
animCoinBlink,
|
||||||
|
animScoreBlip,
|
||||||
|
} from './Arcade.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArcadeOverlay — retro synthwave CRT.
|
||||||
|
*
|
||||||
|
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||||||
|
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||||||
|
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||||||
|
* children and never sets position:fixed / z-index / pointer-events.
|
||||||
|
*
|
||||||
|
* Composition (back to front):
|
||||||
|
* 1. near-black synthwave ambient wash (magenta sky-glow up top, cyan/purple
|
||||||
|
* pool toward the floor) — layered oklch gradients for depth
|
||||||
|
* 2. a neon perspective grid receding to a vanishing point on the horizon,
|
||||||
|
* scrolling toward the viewer via transform translateY (never bg-position)
|
||||||
|
* 3. a soft horizon sun-glow + thin neon horizon line where the grid meets sky
|
||||||
|
* 4. drifting pixel sparkles / neon coin-burst specks rising off the grid
|
||||||
|
* 5. fine CRT scanlines, gently rolling
|
||||||
|
* 6. a faint chromatic-aberration fringe at the screen edges
|
||||||
|
* 7. a glowing "INSERT COIN" blip + a corner SCORE HUD glyph
|
||||||
|
* 8. a CRT vignette + screen-glow that frames and protects central text
|
||||||
|
*
|
||||||
|
* All motion is transform/opacity only (compositor-friendly). When `reduced` is
|
||||||
|
* true we render a static-but-gorgeous scene: a still neon grid, steady
|
||||||
|
* scanlines + vignette, and a steady "INSERT COIN" — no `animation` anywhere,
|
||||||
|
* no flicker. The settings preview always passes reduced=true, so the still
|
||||||
|
* form stands on its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Synthwave neon palette in oklch. Saturated where it glows, but every layer is
|
||||||
|
// held at low opacity so it tints rather than takes over the chat beneath.
|
||||||
|
const NEON_MAGENTA = 'oklch(0.65 0.25 350)';
|
||||||
|
const NEON_CYAN = 'oklch(0.80 0.15 200)';
|
||||||
|
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
|
||||||
|
|
||||||
|
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
|
||||||
|
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
|
||||||
|
// lines fanning toward a top-center vanishing point. The plane is then placed
|
||||||
|
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
|
||||||
|
// tile up by one tile-height (animGridScroll → translateY 50%) loops seamlessly.
|
||||||
|
function gridDataUri(): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
// Horizontal rules — denser toward the top (the horizon) for a perspective
|
||||||
|
// feel even before the CSS rotateX is applied.
|
||||||
|
const rows = [0, 16, 34, 54, 76, 100, 126, 156, 190, 228, 270, 316, 366, 420, 478, 540];
|
||||||
|
rows.forEach((y) => {
|
||||||
|
lines.push(
|
||||||
|
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
||||||
|
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Vertical lines fanning out from the top-center vanishing point.
|
||||||
|
for (let i = -7; i <= 7; i += 1) {
|
||||||
|
const topX = 300 + i * 6; // tight near the horizon
|
||||||
|
const botX = 300 + i * 95; // wide at the foreground
|
||||||
|
lines.push(
|
||||||
|
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
|
||||||
|
`stroke-width='1.4' stroke-opacity='0.8'/>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600' ` +
|
||||||
|
`preserveAspectRatio='none'>${lines.join('')}</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sparkle = {
|
||||||
|
left: number; // vw
|
||||||
|
bottom: number; // % up from floor where it spawns
|
||||||
|
size: number; // px
|
||||||
|
duration: number; // s
|
||||||
|
delay: number; // s
|
||||||
|
twinkle: number; // s
|
||||||
|
hue: 'magenta' | 'cyan';
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hand-placed still sparkles for the reduced/static scene — a few neon specks
|
||||||
|
// resting low over the grid, away from the busy chat center.
|
||||||
|
const RESTING_SPARKLES: ReadonlyArray<{
|
||||||
|
left: number;
|
||||||
|
bottom: number;
|
||||||
|
size: number;
|
||||||
|
hue: 'magenta' | 'cyan';
|
||||||
|
opacity: number;
|
||||||
|
}> = [
|
||||||
|
{ left: 12, bottom: 18, size: 4, hue: 'cyan', opacity: 0.5 },
|
||||||
|
{ left: 26, bottom: 30, size: 3, hue: 'magenta', opacity: 0.42 },
|
||||||
|
{ left: 78, bottom: 22, size: 4, hue: 'magenta', opacity: 0.5 },
|
||||||
|
{ left: 88, bottom: 34, size: 3, hue: 'cyan', opacity: 0.4 },
|
||||||
|
{ left: 50, bottom: 14, size: 3, hue: 'cyan', opacity: 0.38 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GRID_URI = gridDataUri();
|
||||||
|
|
||||||
|
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Deterministic sparkle field, computed ONCE. No per-frame state.
|
||||||
|
const sparkles = useMemo<Sparkle[]>(() => {
|
||||||
|
const COUNT = 16;
|
||||||
|
return Array.from({ length: COUNT }, (_, i) => ({
|
||||||
|
left: (i * 6.27 + 4) % 100,
|
||||||
|
bottom: (i * 3.7) % 28, // spawn in the lower third (over the grid)
|
||||||
|
size: 2 + (i % 3), // 2..4 px pixels
|
||||||
|
duration: 14 + (i % 6) * 2.2,
|
||||||
|
delay: -((i * 1.83) % 16),
|
||||||
|
twinkle: 1.4 + (i % 4) * 0.5,
|
||||||
|
hue: i % 2 === 0 ? 'cyan' : 'magenta',
|
||||||
|
opacity: 0.45 + (i % 3) * 0.12,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sparkleColor = (hue: 'magenta' | 'cyan') => (hue === 'cyan' ? NEON_CYAN : NEON_MAGENTA);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 1. Near-black synthwave ambient wash. Magenta sky-glow up top, a
|
||||||
|
cyan/purple pool toward the floor, and an overall dark vertical
|
||||||
|
grade. Layered oklch gradients give depth at very low opacity. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
|
||||||
|
].join(','),
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. The neon perspective grid. A wide, tall plane is tilted away from
|
||||||
|
the viewer with `perspective` + rotateX so its rule lines recede to
|
||||||
|
a vanishing point at the top (the horizon). It lives in the lower
|
||||||
|
half of the screen — the "floor". The inner plane scrolls upward by
|
||||||
|
one tile via transform translateY, which reads as the grid flowing
|
||||||
|
toward the viewer. Pure transform; never background-position. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-25%',
|
||||||
|
right: '-25%',
|
||||||
|
bottom: 0,
|
||||||
|
height: '62%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
perspective: '280px',
|
||||||
|
perspectiveOrigin: '50% 0%',
|
||||||
|
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||||
|
opacity: reduced ? 0.5 : 0.62,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: '200%',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
transform: 'rotateX(74deg)',
|
||||||
|
backgroundImage: GRID_URI,
|
||||||
|
backgroundRepeat: 'repeat-y',
|
||||||
|
backgroundSize: '100% 50%',
|
||||||
|
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
|
||||||
|
where the grid meets the sky, with a thin bright rule on top of it
|
||||||
|
to seal the vanishing point. Static (no motion) either way. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '38%',
|
||||||
|
width: '70%',
|
||||||
|
height: '34%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '12%',
|
||||||
|
right: '12%',
|
||||||
|
top: '38%',
|
||||||
|
height: '1.5px',
|
||||||
|
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
|
||||||
|
opacity: 0.55,
|
||||||
|
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 4. Drifting pixel sparkles / neon coin-burst specks. Tiny square
|
||||||
|
neon pixels rising off the grid and twinkling. The static scene uses
|
||||||
|
a small resting set instead. */}
|
||||||
|
{reduced
|
||||||
|
? RESTING_SPARKLES.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`rest-spark-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
bottom: `${s.bottom}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
background: sparkleColor(s.hue),
|
||||||
|
opacity: s.opacity,
|
||||||
|
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: sparkles.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`spark-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
bottom: `${s.bottom}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animSparkleDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: sparkleColor(s.hue),
|
||||||
|
opacity: s.opacity,
|
||||||
|
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||||
|
animation: `${animSparkleTwinkle} ${s.twinkle}s step-end infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 5. Fine CRT scanlines. A repeating 1px dark rule field over the whole
|
||||||
|
screen, gently rolling downward on the compositor (transform only).
|
||||||
|
Held faint so text stays crisp. The pattern is in a child taller
|
||||||
|
than the frame so the roll never reveals an edge. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
mixBlendMode: 'multiply',
|
||||||
|
opacity: 0.5,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: '-8px',
|
||||||
|
bottom: '-8px',
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Chromatic-aberration fringe. Two thin edge-glows — magenta and cyan —
|
||||||
|
offset a sub-pixel apart at the screen border so the frame shimmers
|
||||||
|
with an RGB split, like a misconverged tube. Animated only; in the
|
||||||
|
static scene it sits as a steady fringe. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
boxShadow: `inset 2px 0 14px oklch(0.65 0.25 350 / 0.16), inset -2px 0 14px oklch(0.80 0.15 200 / 0.16)`,
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
animation: reduced ? 'none' : `${animChromaShift} 4.5s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
||||||
|
Static scene shows it steady (no blink). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '5%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.32em',
|
||||||
|
color: NEON_CYAN,
|
||||||
|
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
INSERT COIN
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
|
||||||
|
very low opacity so it reads as ambient chrome, not UI. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '2.5%',
|
||||||
|
left: '2%',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.18em',
|
||||||
|
color: NEON_MAGENTA,
|
||||||
|
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: reduced ? 0.5 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
|
||||||
|
with a faint magenta tube-glow swell. The vignette protects central
|
||||||
|
chat-text contrast. Static scene holds it steady; live scene adds a
|
||||||
|
shallow breathing glow + irregular flicker, both opacity-only. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(125% 95% at 50% 46%, oklch(0.72 0.18 340 / 0.05) 0%, transparent 40%)',
|
||||||
|
'radial-gradient(120% 115% at 50% 50%, transparent 50%, oklch(0.10 0.05 310 / 0.20) 84%, oklch(0.06 0.04 305 / 0.34) 100%)',
|
||||||
|
].join(','),
|
||||||
|
contain: 'layout paint style',
|
||||||
|
opacity: reduced ? 1 : undefined,
|
||||||
|
willChange: reduced ? undefined : 'opacity',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animScreenGlow} 8s ease-in-out infinite, ${animCrtFlicker} 5.5s steps(1, end) infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
|
||||||
|
* `opacity` so the compositor can run them on the GPU without triggering
|
||||||
|
* layout or paint. keyframes() returns the generated animation-name string,
|
||||||
|
* which is applied inline in Autumn.tsx.
|
||||||
|
*
|
||||||
|
* Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
|
||||||
|
* with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
|
||||||
|
* drift up through the light; the whole frame has a barely-there warm pulse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A leaf falls from above to below the viewport while continuously rotating.
|
||||||
|
* A single tall translateY serves every leaf — per-leaf duration/delay/scale
|
||||||
|
* create the parallax variety. Horizontal travel is intentionally small here
|
||||||
|
* because the real lateral motion comes from the sway wrapper below.
|
||||||
|
*/
|
||||||
|
export const animLeafFall = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
|
||||||
|
'92%': { opacity: '0.85' },
|
||||||
|
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lateral sway applied to a leaf's wrapper so the descent reads as wind
|
||||||
|
* catching the blade. Decoupled from the fall so the two compose into an
|
||||||
|
* organic, non-repeating-looking path.
|
||||||
|
*/
|
||||||
|
export const animLeafSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(34px, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
|
||||||
|
* mimics the blade catching air as it spins. Cheap, transform-only.
|
||||||
|
*/
|
||||||
|
export const animLeafFlutter = keyframes({
|
||||||
|
'0%': { transform: 'rotate(-8deg) scaleX(1)' },
|
||||||
|
'50%': { transform: 'rotate(8deg) scaleX(0.82)' },
|
||||||
|
'100%': { transform: 'rotate(-8deg) scaleX(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
|
||||||
|
* translateX + opacity (never background-position) so it stays on the
|
||||||
|
* compositor. Scale on Y makes the beam subtly elongate as it brightens.
|
||||||
|
*/
|
||||||
|
export const animSunShaft = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
|
||||||
|
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
|
||||||
|
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
|
||||||
|
* pulsing softly in brightness as it catches the sun. transform + opacity.
|
||||||
|
*/
|
||||||
|
export const animMoteDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
|
||||||
|
'15%': { opacity: '0.85' },
|
||||||
|
'40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
|
||||||
|
'70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
|
||||||
|
'90%': { opacity: '0.2' },
|
||||||
|
'100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independent twinkle for motes — a brightness flicker layered on the drift so
|
||||||
|
* specks shimmer as if turning in the light. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animMoteTwinkle = keyframes({
|
||||||
|
'0%': { opacity: '0.5' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barely-there breathing of the warm vignette frame so the static tint feels
|
||||||
|
* alive without any distracting motion. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animEmberPulse = keyframes({
|
||||||
|
'0%': { opacity: '0.82' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.82' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animLeafFall,
|
||||||
|
animLeafSway,
|
||||||
|
animLeafFlutter,
|
||||||
|
animSunShaft,
|
||||||
|
animMoteDrift,
|
||||||
|
animMoteTwinkle,
|
||||||
|
animEmberPulse,
|
||||||
|
} from './Autumn.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutumnOverlay — warm falling leaves.
|
||||||
|
*
|
||||||
|
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||||||
|
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||||||
|
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||||||
|
* children and never sets position:fixed / z-index / pointer-events.
|
||||||
|
*
|
||||||
|
* Composition (back to front):
|
||||||
|
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
|
||||||
|
* 2. soft angled sun shafts breathing high across the scene
|
||||||
|
* 3. drifting pollen / dust motes catching the light
|
||||||
|
* 4. maple & oak leaf silhouettes tumbling and rotating as they fall
|
||||||
|
* 5. a warm low-saturation vignette that frames + protects text contrast
|
||||||
|
*
|
||||||
|
* All motion is transform/opacity only (compositor-friendly). When `reduced`
|
||||||
|
* is true we render a static-but-gorgeous scene: a handful of leaves at rest,
|
||||||
|
* still sun shafts, and the warm vignette — no `animation` anywhere. The
|
||||||
|
* settings preview always passes reduced=true, so the still form stands alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
|
||||||
|
// chat text underneath. Each leaf picks a tone for variety.
|
||||||
|
const LEAF_TONES = [
|
||||||
|
'oklch(0.75 0.15 70)', // amber
|
||||||
|
'oklch(0.55 0.16 40)', // rust
|
||||||
|
'oklch(0.82 0.13 85)', // warm gold
|
||||||
|
'oklch(0.62 0.16 55)', // burnt orange
|
||||||
|
'oklch(0.5 0.14 35)', // deep ember
|
||||||
|
];
|
||||||
|
|
||||||
|
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
|
||||||
|
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
|
||||||
|
const MAPLE_PATH =
|
||||||
|
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
|
||||||
|
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
|
||||||
|
const OAK_PATH =
|
||||||
|
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
|
||||||
|
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
|
||||||
|
'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
|
||||||
|
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
|
||||||
|
|
||||||
|
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
|
||||||
|
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
|
||||||
|
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
|
||||||
|
// A faint vein line gives the blade depth without extra DOM nodes.
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
|
||||||
|
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
|
||||||
|
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
|
||||||
|
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
|
||||||
|
`</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leaf = {
|
||||||
|
kind: 'maple' | 'oak';
|
||||||
|
uri: string;
|
||||||
|
left: number; // vw column
|
||||||
|
size: number; // px
|
||||||
|
duration: number; // s — fall time
|
||||||
|
delay: number; // s
|
||||||
|
swayDuration: number; // s — wrapper sway
|
||||||
|
flutterDuration: number; // s — inner flutter
|
||||||
|
tilt: number; // deg — resting rotation (used for static scene)
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Mote = {
|
||||||
|
left: number;
|
||||||
|
bottom: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
twinkle: number;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A few hand-placed leaves at rest for the reduced/static scene — arranged so
|
||||||
|
// they read as "settled" near edges and corners, never over the busy center.
|
||||||
|
const RESTING_LEAVES: ReadonlyArray<{
|
||||||
|
kind: 'maple' | 'oak';
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
tilt: number;
|
||||||
|
tone: number;
|
||||||
|
opacity: number;
|
||||||
|
}> = [
|
||||||
|
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
|
||||||
|
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
|
||||||
|
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
|
||||||
|
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
|
||||||
|
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
|
||||||
|
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
|
||||||
|
const { leaves, motes } = useMemo(() => {
|
||||||
|
const LEAF_COUNT = 16;
|
||||||
|
const MOTE_COUNT = 12;
|
||||||
|
|
||||||
|
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
|
||||||
|
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
|
||||||
|
const tone = LEAF_TONES[i % LEAF_TONES.length];
|
||||||
|
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
|
||||||
|
const size = 22 + sizeBucket * 9; // 22..49 px
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
uri: leafDataUri(kind, tone),
|
||||||
|
left: (i * 6.13 + 4) % 100,
|
||||||
|
size,
|
||||||
|
// Larger (nearer) leaves fall a touch faster; all slow + cozy.
|
||||||
|
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
|
||||||
|
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
|
||||||
|
swayDuration: 5 + (i % 5) * 0.8,
|
||||||
|
flutterDuration: 1.6 + (i % 4) * 0.45,
|
||||||
|
tilt: ((i * 53) % 70) - 35,
|
||||||
|
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
|
||||||
|
left: (i * 8.7 + 5) % 100,
|
||||||
|
bottom: (i * 4.3) % 30, // start in lower third, drift up
|
||||||
|
size: 2 + (i % 3),
|
||||||
|
duration: 16 + (i % 6) * 2.4,
|
||||||
|
delay: -((i * 2.1) % 16),
|
||||||
|
twinkle: 2.2 + (i % 4) * 0.6,
|
||||||
|
opacity: 0.4 + (i % 3) * 0.12,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { leaves: builtLeaves, motes: builtMotes };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
|
||||||
|
depth: a warm low-sun glow from the upper-left, a rust pool at the
|
||||||
|
base, and a faint gold core. Kept very low opacity. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
|
||||||
|
].join(','),
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
|
||||||
|
late-afternoon light raking across the room. */}
|
||||||
|
{[
|
||||||
|
{ left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
|
||||||
|
{ left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
|
||||||
|
{ left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
|
||||||
|
].map((shaft, i) => (
|
||||||
|
<div
|
||||||
|
key={`shaft-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-30%',
|
||||||
|
left: `${shaft.left}%`,
|
||||||
|
width: `${shaft.w}vw`,
|
||||||
|
height: '160%',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
transform: `rotate(${shaft.rotate}deg)`,
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
|
||||||
|
filter: 'blur(14px)',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
opacity: reduced ? shaft.opacity * 0.85 : undefined,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
|
||||||
|
them — stillness reads cleaner at rest. */}
|
||||||
|
{!reduced &&
|
||||||
|
motes.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={`mote-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${m.left}%`,
|
||||||
|
bottom: `${m.bottom}%`,
|
||||||
|
width: `${m.size}px`,
|
||||||
|
height: `${m.size}px`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
|
||||||
|
opacity: m.opacity,
|
||||||
|
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 4. Falling / resting maple & oak leaves. */}
|
||||||
|
{reduced
|
||||||
|
? RESTING_LEAVES.map((leaf, i) => (
|
||||||
|
<div
|
||||||
|
key={`rest-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${leaf.left}%`,
|
||||||
|
top: `${leaf.top}%`,
|
||||||
|
width: `${leaf.size}px`,
|
||||||
|
height: `${leaf.size}px`,
|
||||||
|
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
|
||||||
|
opacity: leaf.opacity,
|
||||||
|
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: leaves.map((leaf, i) => (
|
||||||
|
// Sway wrapper: horizontal wind motion, decoupled from the fall.
|
||||||
|
<div
|
||||||
|
key={`leaf-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: `${leaf.left}%`,
|
||||||
|
width: `${leaf.size}px`,
|
||||||
|
height: `${leaf.size}px`,
|
||||||
|
willChange: 'transform',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Fall wrapper: vertical descent + tumble rotation. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Inner blade: the actual silhouette + flutter wobble. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundImage: leaf.uri,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
opacity: leaf.opacity,
|
||||||
|
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
|
||||||
|
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
|
||||||
|
edges — protecting central chat text contrast. Breathes faintly. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
opacity: reduced ? 1 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snowfall — a flake drifts downward while swaying horizontally and slowly
|
||||||
|
* rotating. GPU-only: animates transform + opacity exclusively. The vertical
|
||||||
|
* travel uses a tall translateY so a single keyframe set serves all flakes;
|
||||||
|
* per-flake duration/delay/scale create the parallax variety.
|
||||||
|
*/
|
||||||
|
export const animSnowFall = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(14px, 50vh, 0) rotate(180deg)' },
|
||||||
|
'92%': { opacity: '0.85' },
|
||||||
|
'100%': { transform: 'translate3d(-10px, 112vh, 0) rotate(360deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gentle lateral sway applied to a flake's wrapper so the drift reads as wind,
|
||||||
|
* decoupled from the fall so the two combine into an organic path.
|
||||||
|
*/
|
||||||
|
export const animSnowSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(18px, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String-light breathing — bokeh orbs softly pulse in brightness and scale,
|
||||||
|
* like incandescent bulbs warming and cooling. Opacity + transform only.
|
||||||
|
*/
|
||||||
|
export const animBulbBreathe = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.92)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'scale(1.08)', opacity: '0.95' },
|
||||||
|
'100%': { transform: 'scale(0.92)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aurora shimmer — a wide soft band high in the scene slowly slides and
|
||||||
|
* breathes. Uses translateX + opacity (never background-position) so it stays
|
||||||
|
* on the compositor.
|
||||||
|
*/
|
||||||
|
export const animAurora = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
|
||||||
|
'50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vignette frost — a barely-there breathing of the cold frame so the static
|
||||||
|
* tint feels alive without distracting motion.
|
||||||
|
*/
|
||||||
|
export const animFrostPulse = keyframes({
|
||||||
|
'0%': { opacity: '0.85' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.85' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animSnowFall,
|
||||||
|
animSnowSway,
|
||||||
|
animBulbBreathe,
|
||||||
|
animAurora,
|
||||||
|
animFrostPulse,
|
||||||
|
} from './Christmas.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical every mount (no React
|
||||||
|
// state per frame). Large primes keep the distribution well spread.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
|
||||||
|
// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
|
||||||
|
const BULB_COLORS = [
|
||||||
|
'oklch(0.85 0.12 85)', // warm gold
|
||||||
|
'oklch(0.72 0.15 28)', // soft red
|
||||||
|
'oklch(0.95 0.03 230)', // icy white
|
||||||
|
'oklch(0.78 0.13 150)', // pine green
|
||||||
|
'oklch(0.8 0.1 235)', // cool blue
|
||||||
|
];
|
||||||
|
|
||||||
|
type Flake = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
swayDuration: number;
|
||||||
|
opacity: number;
|
||||||
|
blur: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Bulb = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
|
||||||
|
const flakes = useMemo<Flake[]>(() => {
|
||||||
|
const bands = [
|
||||||
|
{ count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
|
||||||
|
{ count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
|
||||||
|
{ count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
|
||||||
|
];
|
||||||
|
const out: Flake[] = [];
|
||||||
|
let s = 1;
|
||||||
|
bands.forEach((b) => {
|
||||||
|
for (let i = 0; i < b.count; i += 1) {
|
||||||
|
const r1 = rand(s);
|
||||||
|
const r2 = rand(s + 0.37);
|
||||||
|
const r3 = rand(s + 0.71);
|
||||||
|
const r4 = rand(s + 0.91);
|
||||||
|
out.push({
|
||||||
|
left: r1 * 100,
|
||||||
|
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
|
||||||
|
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
|
||||||
|
delay: -r4 * (b.dur[1] + 4),
|
||||||
|
swayDuration: 4 + r2 * 5,
|
||||||
|
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
||||||
|
blur: b.blur,
|
||||||
|
});
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Bokeh string lights strung along the very top edge, gently sagging.
|
||||||
|
const bulbs = useMemo<Bulb[]>(() => {
|
||||||
|
const count = 9;
|
||||||
|
const out: Bulb[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const t = i / (count - 1);
|
||||||
|
// Two-segment garland sag so the lights drape rather than sit in a line.
|
||||||
|
const sag = Math.sin(t * Math.PI * 2) * 3.2;
|
||||||
|
out.push({
|
||||||
|
left: 4 + t * 92,
|
||||||
|
top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
|
||||||
|
size: 12 + rand(i + 5) * 8,
|
||||||
|
color: BULB_COLORS[i % BULB_COLORS.length],
|
||||||
|
duration: 3.4 + rand(i + 2) * 2.6,
|
||||||
|
delay: -rand(i + 9) * 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Deep night-blue ambient wash — layered radial + linear oklch gradients
|
||||||
|
for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
|
||||||
|
].join(','),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
|
||||||
|
single cheap layer for a faint icy haze around the rim. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||||
|
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
|
||||||
|
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-6%',
|
||||||
|
left: '-10%',
|
||||||
|
right: '-10%',
|
||||||
|
height: '40%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
filter: 'blur(26px)',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
|
||||||
|
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* String-light wire — a faint catenary line the bulbs hang from. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '14%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bokeh string lights — soft blurred orbs that breathe. */}
|
||||||
|
{bulbs.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={`bulb-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${b.left}%`,
|
||||||
|
top: `${b.top}%`,
|
||||||
|
width: `${b.size}px`,
|
||||||
|
height: `${b.size}px`,
|
||||||
|
marginLeft: `${-b.size / 2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
|
||||||
|
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
|
||||||
|
filter: 'blur(0.5px)',
|
||||||
|
opacity: reduced ? 0.9 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
|
||||||
|
{!reduced &&
|
||||||
|
flakes.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={`snow-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: `${f.left}%`,
|
||||||
|
width: `${f.size}px`,
|
||||||
|
height: `${f.size}px`,
|
||||||
|
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
|
||||||
|
opacity: f.opacity,
|
||||||
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||||
|
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Static dusting of snow for the reduced-motion / preview scene — a
|
||||||
|
sparse scatter so the thumbnail still reads as snowfall. */}
|
||||||
|
{reduced &&
|
||||||
|
flakes.map((f, i) => {
|
||||||
|
const fy = rand(i + 0.5) * 96 + 2;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`snow-static-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${f.left}%`,
|
||||||
|
top: `${fy}%`,
|
||||||
|
width: `${f.size}px`,
|
||||||
|
height: `${f.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
|
||||||
|
opacity: f.opacity,
|
||||||
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep Space overlay keyframes. Everything here animates ONLY transform/opacity
|
||||||
|
* so the compositor can run it cheaply. The `keyframes()` helper returns the
|
||||||
|
* generated class name string, which the component splices into inline
|
||||||
|
* `animation` shorthands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cosmos breathe: the whole nebula backdrop drifts and dims almost imperceptibly. */
|
||||||
|
export const animCosmosDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
|
||||||
|
'50%': { transform: 'translate3d(-1.5%, 1%, 0) scale(1.04)', opacity: '1' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Nebula cloud drift: a single blurred cloud floats slowly across its layer. */
|
||||||
|
export const animNebulaA = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||||
|
'50%': { transform: 'translate3d(4%, -3%, 0) scale(1.08)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const animNebulaB = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
|
||||||
|
'50%': { transform: 'translate3d(-5%, 2.5%, 0) scale(1)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Galaxy spiral: an exceptionally slow rotation of a distant pinwheel. */
|
||||||
|
export const animGalaxySpin = keyframes({
|
||||||
|
'0%': { transform: 'rotate(0deg) scale(1)' },
|
||||||
|
'50%': { transform: 'rotate(180deg) scale(1.03)' },
|
||||||
|
'100%': { transform: 'rotate(360deg) scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tiny star twinkle: gentle opacity + micro-scale pulse. */
|
||||||
|
export const animTwinkle = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.85)', opacity: '0.35' },
|
||||||
|
'50%': { transform: 'scale(1)', opacity: '1' },
|
||||||
|
'100%': { transform: 'scale(0.85)', opacity: '0.35' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bright star pulse: a slower, fuller bloom for the few hero stars. */
|
||||||
|
export const animStarPulse = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'scale(1.15) rotate(45deg)', opacity: '1' },
|
||||||
|
'100%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Parallax depth: a star layer drifts as if the viewer is gliding through space. */
|
||||||
|
export const animParallaxNear = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(-3%, 1.5%, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const animParallaxFar = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(-1.2%, 0.6%, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comet streak: a thin meteor crosses the field on a diagonal, fading in then
|
||||||
|
* out. The element is rotated by the component; this only translates along its
|
||||||
|
* own local X axis (its length direction) and fades.
|
||||||
|
*/
|
||||||
|
export const animComet = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0' },
|
||||||
|
'6%': { opacity: '1' },
|
||||||
|
'40%': { opacity: '0.9' },
|
||||||
|
'60%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animCosmosDrift,
|
||||||
|
animNebulaA,
|
||||||
|
animNebulaB,
|
||||||
|
animGalaxySpin,
|
||||||
|
animTwinkle,
|
||||||
|
animStarPulse,
|
||||||
|
animParallaxNear,
|
||||||
|
animParallaxFar,
|
||||||
|
animComet,
|
||||||
|
} from './DeepSpace.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep Space overlay — a cosmic, awe-inspiring ambient mode. Layered oklch
|
||||||
|
* radial gradients build a deep violet void seeded with drifting magenta/cyan
|
||||||
|
* nebula clouds and a faint distant galaxy spiral. A parallax starfield sits at
|
||||||
|
* two depths (a dense field of tiny twinkling stars plus a handful of brighter
|
||||||
|
* hero stars), and slow comet streaks cross the sky occasionally.
|
||||||
|
*
|
||||||
|
* Palette (oklch): deep cosmic violet oklch(0.2 0.12 300), nebula magenta
|
||||||
|
* oklch(0.55 0.2 330), cyan oklch(0.75 0.13 200), starlight white
|
||||||
|
* oklch(0.98 0.02 280).
|
||||||
|
*
|
||||||
|
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
|
||||||
|
* pointer-events:none container at the right z-index. We only return
|
||||||
|
* absolutely-positioned aria-hidden children at low opacity — no z-index,
|
||||||
|
* position:fixed, or pointer-events here — kept well below opaque so chat text
|
||||||
|
* stays WCAG-AA legible.
|
||||||
|
*
|
||||||
|
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a still
|
||||||
|
* nebula, a static starfield, a frozen galaxy and one frozen comet streak) with
|
||||||
|
* no `animation` at all. The settings preview always passes reduced=true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STAR_TINTS = [
|
||||||
|
'oklch(0.98 0.02 280)', // starlight white
|
||||||
|
'oklch(0.9 0.07 230)', // cool cyan-white
|
||||||
|
'oklch(0.88 0.08 330)', // faint magenta-white
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const HERO_TINTS = [
|
||||||
|
'oklch(0.92 0.06 200)', // cyan starlight
|
||||||
|
'oklch(0.9 0.09 330)', // magenta starlight
|
||||||
|
'oklch(0.98 0.02 280)', // pure starlight
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Star = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
staticOpacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeroStar = Star;
|
||||||
|
|
||||||
|
type Comet = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
length: number;
|
||||||
|
angle: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the memoized scene is stable across renders.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A four-point gleam (sparkle) as an inline SVG data-URI — CSP-safe, no assets.
|
||||||
|
const gleamUri = (color: string) =>
|
||||||
|
`url("data:image/svg+xml,${encodeURIComponent(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 C12.6 7.4 16.6 11.4 24 12 C16.6 12.6 12.6 16.6 12 24 C11.4 16.6 7.4 12.6 0 12 C7.4 11.4 11.4 7.4 12 0 Z' fill='${color}'/></svg>`,
|
||||||
|
)}")`;
|
||||||
|
|
||||||
|
function makeStars(count: number, seedBase: number): Star[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const s = seedBase + i;
|
||||||
|
return {
|
||||||
|
top: rand(s + 1) * 100,
|
||||||
|
left: rand(s + 101) * 100,
|
||||||
|
size: 1 + Math.floor(rand(s + 201) * 2), // 1–2px 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), // 9–17px 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Shared seasonal types. Kept in a leaf module so the schedule, the overlay
|
||||||
|
// components (one per theme under ./themes/), and the settings UI can all import
|
||||||
|
// them without circular dependencies.
|
||||||
|
|
||||||
|
export type SeasonTheme =
|
||||||
|
| 'halloween'
|
||||||
|
| 'christmas'
|
||||||
|
| 'newyear'
|
||||||
|
| 'autumn'
|
||||||
|
| 'aprilfools'
|
||||||
|
| 'lunar'
|
||||||
|
| 'valentines'
|
||||||
|
| 'stpatricks'
|
||||||
|
| 'earthday'
|
||||||
|
| 'deepspace'
|
||||||
|
| 'arcade';
|
||||||
|
|
||||||
|
// Props every per-theme overlay component receives. `reduced` mirrors
|
||||||
|
// `prefers-reduced-motion`: when true the overlay must render a static (no
|
||||||
|
// animation) but still beautiful ambient version. The settings preview always
|
||||||
|
// passes reduced=true, so the static form has to stand on its own.
|
||||||
|
export type SeasonalOverlayProps = {
|
||||||
|
reduced: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export type SettingsSelectOption<T extends string> = {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
|
||||||
|
* pattern — used instead of a raw `<select>`, which renders OS-styled and
|
||||||
|
* breaks under non-default themes.
|
||||||
|
*/
|
||||||
|
export function SettingsSelect<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: SettingsSelectOption<T>[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (v: T) => {
|
||||||
|
onChange(v);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={!!menuCords}
|
||||||
|
>
|
||||||
|
<Text size="T300">{selectedLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt.value}
|
||||||
|
size="300"
|
||||||
|
variant={opt.value === value ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
disabled={opt.disabled}
|
||||||
|
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{opt.label}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,10 @@ export const Sidebar = style([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const SidebarGlass = style({
|
export const SidebarGlass = style({
|
||||||
backgroundColor: 'rgba(3, 5, 8, 0.55)',
|
backgroundColor: `color-mix(in srgb, ${color.Surface.Container} 55%, transparent)`,
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
borderRight: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SidebarStack = style([
|
export const SidebarStack = style([
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
|||||||
{(status) => {
|
{(status) => {
|
||||||
const deviceColor =
|
const deviceColor =
|
||||||
status === VerificationStatus.Verified
|
status === VerificationStatus.Verified
|
||||||
? 'var(--tc-positive-normal, #5effc4)'
|
? color.Success.Main
|
||||||
: status === VerificationStatus.Unverified
|
: status === VerificationStatus.Unverified
|
||||||
? 'var(--tc-warning-normal, #ffcc55)'
|
? color.Warning.Main
|
||||||
: 'var(--tc-surface-low-contrast)';
|
: color.SurfaceVariant.OnContainer;
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
|
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
|
||||||
@@ -106,7 +106,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
|||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
truncate
|
truncate
|
||||||
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }}
|
style={{ color: color.SurfaceVariant.OnContainer, fontFamily: 'monospace' }}
|
||||||
>
|
>
|
||||||
{device.deviceId}
|
{device.deviceId}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -160,7 +160,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
}}
|
}}
|
||||||
@@ -171,7 +171,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
|||||||
<Text size="T300">
|
<Text size="T300">
|
||||||
<b>Sessions</b>
|
<b>Sessions</b>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
|
<Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
|
||||||
{devices.length}
|
{devices.length}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getOidcIssuer, AutoDiscoveryInfo } from './cs-api';
|
||||||
|
|
||||||
|
const info = (extra: Record<string, unknown>): AutoDiscoveryInfo =>
|
||||||
|
({ 'm.homeserver': { base_url: 'https://hs' }, ...extra }) as AutoDiscoveryInfo;
|
||||||
|
|
||||||
|
test('getOidcIssuer reads the stable m.authentication key', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getOidcIssuer(info({ 'm.authentication': { issuer: 'https://i', account: 'https://a' } })),
|
||||||
|
{ issuer: 'https://i', account: 'https://a' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOidcIssuer falls back to the unstable msc2965 key', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getOidcIssuer(info({ 'org.matrix.msc2965.authentication': { issuer: 'https://u' } })),
|
||||||
|
{ issuer: 'https://u', account: undefined },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOidcIssuer prefers stable over unstable when both present', () => {
|
||||||
|
assert.equal(
|
||||||
|
getOidcIssuer(
|
||||||
|
info({
|
||||||
|
'm.authentication': { issuer: 'https://stable' },
|
||||||
|
'org.matrix.msc2965.authentication': { issuer: 'https://unstable' },
|
||||||
|
}),
|
||||||
|
).issuer,
|
||||||
|
'https://stable',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOidcIssuer returns {} for non-OIDC servers', () => {
|
||||||
|
assert.deepEqual(getOidcIssuer(info({})), {});
|
||||||
|
assert.deepEqual(getOidcIssuer(info({ 'm.authentication': {} })), {}); // present but no issuer
|
||||||
|
});
|
||||||
@@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
|||||||
'm.identity_server'?: {
|
'm.identity_server'?: {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
};
|
};
|
||||||
|
// v1.15 stable next-gen-auth (MSC2965) discovery key — emitted by servers that
|
||||||
|
// delegate to a Matrix Authentication Service (e.g. mozilla.org). The
|
||||||
|
// `org.matrix.msc2965.authentication` key below is the unstable predecessor.
|
||||||
|
'm.authentication'?: {
|
||||||
|
issuer?: string;
|
||||||
|
account?: string;
|
||||||
|
};
|
||||||
'org.matrix.msc2965.authentication'?: {
|
'org.matrix.msc2965.authentication'?: {
|
||||||
account?: string;
|
account?: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
@@ -32,6 +39,24 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OIDC issuer (and account-management URL) advertised by a homeserver
|
||||||
|
* in its `.well-known/matrix/client`, preferring the v1.15 stable
|
||||||
|
* `m.authentication` key over the unstable `org.matrix.msc2965.authentication`.
|
||||||
|
* Returns `{}` when the server is not OIDC-native (e.g. matrix.lotusguild.org).
|
||||||
|
*/
|
||||||
|
export const getOidcIssuer = (info: AutoDiscoveryInfo): { issuer?: string; account?: string } => {
|
||||||
|
const stable = info['m.authentication'];
|
||||||
|
if (stable && typeof stable.issuer === 'string') {
|
||||||
|
return { issuer: stable.issuer, account: stable.account };
|
||||||
|
}
|
||||||
|
const unstable = info['org.matrix.msc2965.authentication'];
|
||||||
|
if (unstable && typeof unstable.issuer === 'string') {
|
||||||
|
return { issuer: unstable.issuer, account: unstable.account };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
export const autoDiscovery = async (
|
export const autoDiscovery = async (
|
||||||
request: typeof fetch,
|
request: typeof fetch,
|
||||||
server: string,
|
server: string,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -36,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;
|
||||||
@@ -87,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)
|
||||||
@@ -276,8 +294,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
bottom: '110%',
|
bottom: '110%',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
background: 'var(--bg-surface)',
|
background: color.Surface.Container,
|
||||||
border: '1px solid var(--bg-surface-border)',
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: '0.75rem',
|
borderRadius: '0.75rem',
|
||||||
padding: '1rem 1.25rem',
|
padding: '1rem 1.25rem',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
@@ -338,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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||||
import { CallMemberRenderer } from './CallMemberCard';
|
import { CallMemberRenderer } from './CallMemberCard';
|
||||||
@@ -199,6 +200,8 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
|||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Box grow="Yes" ref={containerRef} />
|
<Box grow="Yes" ref={containerRef} />
|
||||||
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
|
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
|
||||||
|
{/* [lotus #6] push avatar decorations to EC's in-call tiles (post-join) */}
|
||||||
|
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,13 +166,13 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FullscreenIcon = () => (
|
export const FullscreenIcon = () => (
|
||||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
|
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ExitFullscreenIcon = () => (
|
export const ExitFullscreenIcon = () => (
|
||||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
|
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
import { Box, Button, color, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
||||||
@@ -78,10 +78,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" direction="Column" gap="200">
|
<Box grow="Yes" direction="Column" gap="200">
|
||||||
{micDenied && (
|
{micDenied && (
|
||||||
<Text
|
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
|
||||||
size="T200"
|
|
||||||
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
Microphone access is blocked. Enable it in your browser settings to join.
|
Microphone access is blocked. Enable it in your browser settings to join.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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 (`data-tauri-drag-region`) 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();
|
||||||
|
|
||||||
|
const handleDoubleClick = (evt: MouseEvent<HTMLDivElement>): void => {
|
||||||
|
// Only the drag surface itself toggles maximize, not the brand/children.
|
||||||
|
if (evt.target !== evt.currentTarget) return;
|
||||||
|
invokeTauri('window_toggle_maximize');
|
||||||
|
};
|
||||||
|
|
||||||
|
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} data-tauri-drag-region onDoubleClick={handleDoubleClick}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react';
|
||||||
|
import { type CallEmbed } from '../../plugins/call';
|
||||||
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||||
|
import { decorationUrl } from './avatarDecorations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lotus #6] Pushes each call participant's avatar-decoration image URL to the
|
||||||
|
* forked Element Call (`io.lotus.decorations`), which renders it on the in-call
|
||||||
|
* video-tile avatars. Mounted only while joined, so the EC-side handler exists.
|
||||||
|
*
|
||||||
|
* The decoration roster is per-user slugs resolved via `useAvatarDecoration`;
|
||||||
|
* we render one invisible probe per member to reuse that hook + its cache, then
|
||||||
|
* debounce-send the aggregated `{ userId: url }` map whenever it changes.
|
||||||
|
*/
|
||||||
|
function DecorationProbe({
|
||||||
|
userId,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
onResolve: (userId: string, url: string | null) => void;
|
||||||
|
}): null {
|
||||||
|
const slug = useAvatarDecoration(userId);
|
||||||
|
useEffect(() => {
|
||||||
|
onResolve(userId, slug ? decorationUrl(slug) : null);
|
||||||
|
}, [userId, slug, onResolve]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }): ReactElement {
|
||||||
|
const session = useCallSession(callEmbed.room);
|
||||||
|
const members = useCallMembers(session);
|
||||||
|
const map = useRef<Map<string, string>>(new Map());
|
||||||
|
const pushTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const userIds = useMemo(
|
||||||
|
() => Array.from(new Set(members.map((m) => m.userId).filter((u): u is string => !!u))),
|
||||||
|
[members],
|
||||||
|
);
|
||||||
|
|
||||||
|
const push = useCallback(() => {
|
||||||
|
const decorations: Record<string, string> = {};
|
||||||
|
map.current.forEach((url, userId) => {
|
||||||
|
decorations[userId] = url;
|
||||||
|
});
|
||||||
|
callEmbed.call.transport.send('io.lotus.decorations', { decorations }).catch(() => undefined);
|
||||||
|
}, [callEmbed]);
|
||||||
|
|
||||||
|
const schedulePush = useCallback(() => {
|
||||||
|
if (pushTimer.current) clearTimeout(pushTimer.current);
|
||||||
|
pushTimer.current = setTimeout(push, 300);
|
||||||
|
}, [push]);
|
||||||
|
|
||||||
|
const onResolve = useCallback(
|
||||||
|
(userId: string, url: string | null) => {
|
||||||
|
const prev = map.current.get(userId);
|
||||||
|
if (url) {
|
||||||
|
if (prev !== url) {
|
||||||
|
map.current.set(userId, url);
|
||||||
|
schedulePush();
|
||||||
|
}
|
||||||
|
} else if (prev !== undefined) {
|
||||||
|
map.current.delete(userId);
|
||||||
|
schedulePush();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[schedulePush],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop decorations for participants who left the call.
|
||||||
|
useEffect(() => {
|
||||||
|
const present = new Set(userIds);
|
||||||
|
let changed = false;
|
||||||
|
map.current.forEach((_url, userId) => {
|
||||||
|
if (!present.has(userId)) {
|
||||||
|
map.current.delete(userId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) schedulePush();
|
||||||
|
}, [userIds, schedulePush]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (pushTimer.current) clearTimeout(pushTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{userIds.map((userId) => (
|
||||||
|
<DecorationProbe key={userId} userId={userId} onResolve={onResolve} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
DECORATION_CDN,
|
||||||
|
DECORATION_CATEGORIES,
|
||||||
|
ALL_DECORATIONS,
|
||||||
|
decorationUrl,
|
||||||
|
} from './avatarDecorations';
|
||||||
|
|
||||||
|
test('decorationUrl builds a CDN png url from the slug', () => {
|
||||||
|
assert.equal(decorationUrl('joystick'), `${DECORATION_CDN}/joystick.png`);
|
||||||
|
assert.equal(decorationUrl('lotus_flower'), `${DECORATION_CDN}/lotus_flower.png`);
|
||||||
|
// slug is used verbatim, no encoding/normalisation
|
||||||
|
assert.equal(decorationUrl(''), `${DECORATION_CDN}/.png`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DECORATION_CDN is an https url with no trailing slash', () => {
|
||||||
|
assert.match(DECORATION_CDN, /^https:\/\//);
|
||||||
|
assert.equal(DECORATION_CDN.endsWith('/'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ALL_DECORATIONS is the flattened set of every category decoration', () => {
|
||||||
|
const expectedCount = DECORATION_CATEGORIES.reduce((n, c) => n + c.decorations.length, 0);
|
||||||
|
assert.equal(ALL_DECORATIONS.length, expectedCount);
|
||||||
|
|
||||||
|
// every decoration in a category is present (by reference) in ALL_DECORATIONS
|
||||||
|
DECORATION_CATEGORIES.forEach((category) => {
|
||||||
|
category.decorations.forEach((decoration) => {
|
||||||
|
assert.ok(ALL_DECORATIONS.includes(decoration));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every category has a non-empty id, label and decorations list', () => {
|
||||||
|
DECORATION_CATEGORIES.forEach((category) => {
|
||||||
|
assert.equal(typeof category.id, 'string');
|
||||||
|
assert.ok(category.id.length > 0);
|
||||||
|
assert.equal(typeof category.label, 'string');
|
||||||
|
assert.ok(category.label.length > 0);
|
||||||
|
assert.ok(Array.isArray(category.decorations));
|
||||||
|
assert.ok(category.decorations.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('category ids are unique', () => {
|
||||||
|
const ids = DECORATION_CATEGORIES.map((c) => c.id);
|
||||||
|
assert.equal(new Set(ids).size, ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every decoration slug is unique across all categories', () => {
|
||||||
|
const slugs = ALL_DECORATIONS.map((d) => d.slug);
|
||||||
|
assert.equal(new Set(slugs).size, slugs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every decoration has a non-empty slug and name', () => {
|
||||||
|
ALL_DECORATIONS.forEach((decoration) => {
|
||||||
|
assert.equal(typeof decoration.slug, 'string');
|
||||||
|
assert.ok(decoration.slug.length > 0);
|
||||||
|
assert.equal(typeof decoration.name, 'string');
|
||||||
|
assert.ok(decoration.name.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
|
||||||
|
ALL_DECORATIONS.forEach((decoration) => {
|
||||||
|
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.05–0.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.03–0.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.03–0.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.05–0.5 on the accents, traces ~0.1–0.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.02–0.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.14–0.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 };
|
||||||
@@ -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(','),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.03–0.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.03–0.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 };
|
||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem, Button } from 'folds';
|
import {
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
Spinner,
|
||||||
|
IconButton,
|
||||||
|
Line,
|
||||||
|
toRem,
|
||||||
|
Button,
|
||||||
|
} from 'folds';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
@@ -112,7 +124,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
|
|||||||
gap="200"
|
gap="200"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -121,7 +133,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
|
|||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
{room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" style={{ opacity: 0.55 }}>
|
<Text size="T200" priority="300">
|
||||||
{msgEvents.length > 0
|
{msgEvents.length > 0
|
||||||
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
|
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
|
||||||
: 'No messages cached yet'}
|
: 'No messages cached yet'}
|
||||||
@@ -141,7 +153,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!canLoadMore && events.length > 0 && (
|
{!canLoadMore && events.length > 0 && (
|
||||||
<Text size="T200" style={{ opacity: 0.5, flexShrink: 0 }}>
|
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
Fully cached
|
Fully cached
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -644,7 +656,7 @@ export function MessageSearch({
|
|||||||
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
|
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
|
||||||
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
|
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
|
||||||
{!senderOnlyMode && (
|
{!senderOnlyMode && (
|
||||||
<Text size="T200" style={{ opacity: 0.55 }}>
|
<Text size="T200" priority="300">
|
||||||
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
|
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -280,7 +280,8 @@ export function SearchInput({
|
|||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
truncate
|
truncate
|
||||||
style={{ opacity: 0.6, fontFamily: 'monospace', fontSize: '0.75em' }}
|
priority="300"
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.75em' }}
|
||||||
>
|
>
|
||||||
{user.userId}
|
{user.userId}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
|
||||||
|
|
||||||
|
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
|
||||||
|
// (event.content.msgtype, event.event_id, group.roomId).
|
||||||
|
const item = (msgtype: string | undefined, eventId: string) => ({
|
||||||
|
rank: 1,
|
||||||
|
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
const mkGroups = (
|
||||||
|
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
|
||||||
|
): ResultGroup[] => groups as unknown as ResultGroup[];
|
||||||
|
|
||||||
|
test('filterGroupsByMsgType: empty filter returns groups unchanged', () => {
|
||||||
|
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1'), item('m.image', '$2')] });
|
||||||
|
assert.equal(filterGroupsByMsgType(groups, []), groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterGroupsByMsgType: keeps only matching msgtypes (union)', () => {
|
||||||
|
const groups = mkGroups({
|
||||||
|
roomId: '!r1',
|
||||||
|
items: [item('m.text', '$1'), item('m.image', '$2'), item('m.file', '$3')],
|
||||||
|
});
|
||||||
|
const out = filterGroupsByMsgType(groups, ['m.image', 'm.file']);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
out[0].items.map((i) => i.event.event_id),
|
||||||
|
['$2', '$3'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterGroupsByMsgType: drops groups left empty', () => {
|
||||||
|
const groups = mkGroups(
|
||||||
|
{ roomId: '!r1', items: [item('m.text', '$1')] },
|
||||||
|
{ roomId: '!r2', items: [item('m.image', '$2')] },
|
||||||
|
);
|
||||||
|
const out = filterGroupsByMsgType(groups, ['m.image']);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].roomId, '!r2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
|
||||||
|
const groups = mkGroups({ roomId: '!r1', items: [item(undefined, '$1'), item('m.video', '$2')] });
|
||||||
|
const out = filterGroupsByMsgType(groups, ['m.video']);
|
||||||
|
assert.equal(out[0].items.length, 1);
|
||||||
|
assert.equal(out[0].items[0].event.event_id, '$2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
|
||||||
|
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
|
||||||
|
assert.equal(
|
||||||
|
filterGroupsByPinned(groups, false, () => false),
|
||||||
|
groups,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
||||||
|
const groups = mkGroups(
|
||||||
|
{ roomId: '!r1', items: [item('m.text', '$1'), item('m.text', '$2')] },
|
||||||
|
{ roomId: '!r2', items: [item('m.text', '$3')] },
|
||||||
|
);
|
||||||
|
const pinned = new Set(['!r1/$2']);
|
||||||
|
const out = filterGroupsByPinned(groups, true, (roomId, eventId) =>
|
||||||
|
pinned.has(`${roomId}/${eventId}`),
|
||||||
|
);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].roomId, '!r1');
|
||||||
|
assert.deepEqual(
|
||||||
|
out[0].items.map((i) => i.event.event_id),
|
||||||
|
['$2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RoomLocalAddresses,
|
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`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
Scroll,
|
Scroll,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useNearViewport } from '../../hooks/useNearViewport';
|
import { useNearViewport } from '../../hooks/useNearViewport';
|
||||||
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
||||||
@@ -250,102 +253,112 @@ function Lightbox({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<div
|
<FocusTrap
|
||||||
role="dialog"
|
focusTrapOptions={{
|
||||||
aria-modal
|
initialFocus: false,
|
||||||
aria-label="Media viewer"
|
clickOutsideDeactivates: false,
|
||||||
onKeyDown={handleKeyDown}
|
escapeDeactivates: false,
|
||||||
tabIndex={-1}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
background: 'rgba(0,0,0,0.92)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header bar */}
|
|
||||||
<Box
|
|
||||||
alignItems="Center"
|
|
||||||
gap="200"
|
|
||||||
style={{
|
|
||||||
padding: `${config.space.S200} ${config.space.S300}`,
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
|
<div
|
||||||
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
|
role="dialog"
|
||||||
</Text>
|
aria-modal
|
||||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
|
aria-label="Media viewer"
|
||||||
{item.sender} · {dateStr}
|
onKeyDown={handleKeyDown}
|
||||||
</Text>
|
tabIndex={-1}
|
||||||
</Box>
|
style={{
|
||||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
position: 'fixed',
|
||||||
{index + 1} / {items.length}
|
inset: 0,
|
||||||
</Text>
|
zIndex: 1000,
|
||||||
<TooltipProvider
|
background: 'rgba(0,0,0,0.92)',
|
||||||
position="Bottom"
|
display: 'flex',
|
||||||
align="End"
|
flexDirection: 'column',
|
||||||
offset={4}
|
}}
|
||||||
tooltip={
|
|
||||||
<Tooltip>
|
|
||||||
<Text>Close</Text>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{(ref) => (
|
{/* Header bar */}
|
||||||
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
|
<Box
|
||||||
<Icon src={Icons.Cross} />
|
alignItems="Center"
|
||||||
</IconButton>
|
gap="200"
|
||||||
)}
|
style={{
|
||||||
</TooltipProvider>
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
</Box>
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
|
||||||
|
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
|
||||||
|
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
|
||||||
|
{item.sender} · {dateStr}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||||
|
{index + 1} / {items.length}
|
||||||
|
</Text>
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Close</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ref) => (
|
||||||
|
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Media area with nav arrows */}
|
{/* Media area with nav arrows */}
|
||||||
<Box
|
<Box
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
style={{ overflow: 'hidden', padding: config.space.S400 }}
|
style={{ overflow: 'hidden', padding: config.space.S400 }}
|
||||||
>
|
|
||||||
{index > 0 && (
|
|
||||||
<IconButton
|
|
||||||
variant="Surface"
|
|
||||||
aria-label="Previous"
|
|
||||||
onClick={prev}
|
|
||||||
style={{ flexShrink: 0, marginRight: config.space.S200 }}
|
|
||||||
>
|
>
|
||||||
<Icon src={Icons.ArrowLeft} />
|
{index > 0 && (
|
||||||
</IconButton>
|
<IconButton
|
||||||
)}
|
variant="Surface"
|
||||||
<Box
|
aria-label="Previous"
|
||||||
grow="Yes"
|
onClick={prev}
|
||||||
alignItems="Center"
|
style={{ flexShrink: 0, marginRight: config.space.S200 }}
|
||||||
justifyContent="Center"
|
>
|
||||||
style={{ overflow: 'hidden', height: '100%' }}
|
<Icon src={Icons.ArrowLeft} />
|
||||||
>
|
</IconButton>
|
||||||
<LightboxMedia
|
)}
|
||||||
key={`${item.mxcUrl}-${item.ts}`}
|
<Box
|
||||||
item={item}
|
grow="Yes"
|
||||||
useAuthentication={useAuthentication}
|
alignItems="Center"
|
||||||
/>
|
justifyContent="Center"
|
||||||
</Box>
|
style={{ overflow: 'hidden', height: '100%' }}
|
||||||
{index < items.length - 1 && (
|
>
|
||||||
<IconButton
|
<LightboxMedia
|
||||||
variant="Surface"
|
key={`${item.mxcUrl}-${item.ts}`}
|
||||||
aria-label="Next"
|
item={item}
|
||||||
onClick={next}
|
useAuthentication={useAuthentication}
|
||||||
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
|
/>
|
||||||
>
|
</Box>
|
||||||
<Icon src={Icons.ArrowRight} />
|
{index < items.length - 1 && (
|
||||||
</IconButton>
|
<IconButton
|
||||||
)}
|
variant="Surface"
|
||||||
</Box>
|
aria-label="Next"
|
||||||
</div>
|
onClick={next}
|
||||||
|
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ArrowRight} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+260
-181
@@ -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: 'var(--tc-danger-normal)',
|
|
||||||
padding: '2px 6px',
|
|
||||||
alignSelf: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{gifError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{locationError && (
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
style={{
|
|
||||||
color: 'var(--tc-danger-normal)',
|
|
||||||
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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user