Compare commits
120 Commits
203568c967
...
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 | |||
| 5204766276 | |||
| 6218012d3f | |||
| ccb0c1d18e | |||
| 65e24bd446 | |||
| de6cecaffc | |||
| da545ba9b9 | |||
| 3c4842df1e | |||
| 1ee0f0b57a | |||
| 4fbbd9680b | |||
| 259a5a2b3e | |||
| 8d62be9eff | |||
| 63139350e4 | |||
| 33b33e685a | |||
| a8038bb534 | |||
| 4d0e34c4cf | |||
| 70ffd252bd | |||
| 51d468fbcc | |||
| 1c84556600 | |||
| 34997bcbd1 | |||
| 78cb2acd6c | |||
| ce8a03ab16 | |||
| 19feca4964 | |||
| adbda094e7 | |||
| 7013da70bc | |||
| 49d9410e3a | |||
| 84a2e7a93e | |||
| 950b8a8128 | |||
| af58f7a32c | |||
| 91c6f2f091 | |||
| 31cf353463 | |||
| 8912423aeb | |||
| bc85cd4984 | |||
| fc8eb70617 | |||
| 1a5896ef84 | |||
| 7b94eeaa60 | |||
| 50076962f6 | |||
| d39aef0aac | |||
| 9f533b1077 | |||
| fdaba40ba9 | |||
| caf6318a5d | |||
| 23649d85b0 | |||
| c67aed01dc | |||
| 66cc51d6d0 | |||
| 4a87588435 | |||
| c0fd372529 |
@@ -1,2 +1 @@
|
||||
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
|
||||
VITE_APP_VERSION=lotus
|
||||
|
||||
+24
-2
@@ -21,16 +21,38 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||
# 3 times with backoff before failing the build.
|
||||
run: |
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
npm config set fetch-timeout 600000
|
||||
for attempt in 1 2 3; do
|
||||
echo "npm ci attempt $attempt…"
|
||||
npm ci && break
|
||||
if [ "$attempt" = "3" ]; then
|
||||
echo "npm ci failed after 3 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||
# A failure blocks the deploy.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
legacy-peer-deps=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.
|
||||
+143
-471
@@ -1,487 +1,159 @@
|
||||
# Lotus Chat — Bug Report & Technical Audit
|
||||
# Lotus Chat — Open Bugs & Technical Debt
|
||||
|
||||
**Date:** June 2026
|
||||
**Only OPEN and awaiting-verification items live here.** Resolved findings
|
||||
(fixed-and-verified, false-positives, won't-fix) have been removed to keep this
|
||||
actionable — the full history is in git. Items fixed in code but not yet
|
||||
verified in a real environment are in **Needs Verification** below and have
|
||||
step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
|
||||
|
||||
This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item.
|
||||
> Design rules for any fix here: follow the **Native-Cinny Law** and **TDS
|
||||
> Design Law** in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
||||
|
||||
---
|
||||
|
||||
## 🚩 Critical & UI Bugs
|
||||
## ⚠️ Needs Verification — fixed in code, awaiting live testing
|
||||
|
||||
### 12. PiP Mute Icon Misidentifies Whose Mic Is Muted
|
||||
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||
|
||||
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with at least one other participant who mutes/unmutes
|
||||
- **Issue:** The muted-mic badge in the Picture-in-Picture window used `useRemoteAllMuted` (fires when ANY remote participant is muted) and rendered in the bottom-left corner — the conventional position for "YOUR" mic status. Users read it as their own mic being muted.
|
||||
- **Root Cause:** `PipMuteOverlay` was triggering on remote-mute events while displaying in a position that implies local-user status.
|
||||
- **Fix Applied:**
|
||||
- **Bottom-left badge** now shows only when the LOCAL user's mic is muted (checked via `!controlState.microphone` from `useCallControlState`). Includes "You" label to make it unambiguous. Uses `color.Critical.Main`.
|
||||
- **Top-right badge** (new) shows "All muted" in `color.Warning.Main` when all remote participants are muted — positioned and labeled so it's clearly about other people, not the local user.
|
||||
- Both badges use `aria-label` / `title` for accessibility.
|
||||
| ID | Item | File / area | Test |
|
||||
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
||||
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
||||
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
||||
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
||||
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
||||
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
||||
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
||||
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||
|
||||
**Verified working in live testing (2026-06):** A2, 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.
|
||||
|
||||
---
|
||||
|
||||
### 1. No Camera Focus During Screenshare
|
||||
## 🧩 Element Call source-level items — now actionable via the fork
|
||||
|
||||
- **File:** `cinny/src/app/features/call/CallControls.tsx`
|
||||
- **Status:** **OPEN**
|
||||
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
|
||||
- **Root Cause:** Current spotlighting logic prioritizes active screenshare streams over manual participant selections, effectively ignoring or overriding user-initiated focus states.
|
||||
- **Proposed Fix:** Introduce a manual 'Focus' state that takes precedence over automatic screenshare spotlighting, implemented via a toggle/click UI on participant tiles. Update the video renderer to respect this manual override.
|
||||
> 🔱 **[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.
|
||||
|
||||
### 2. Chat Background Animation Flickering
|
||||
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:
|
||||
|
||||
- **File:** `cinny/src/app/features/lotus/chatBackground.ts`
|
||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real device with an animated background active
|
||||
- **Issue:** Animated background properties cause visible flickering on message text and the composer area, particularly on browsers/GPUs susceptible to repaint-induced artifacts.
|
||||
- **Root Cause:** Animation triggers excessive repaints or layout recalculations on descendant elements, likely due to animating non-GPU accelerated properties on parent containers without proper rendering context isolation.
|
||||
- **Fix Applied:** `getChatBg()` now injects `willChange: 'background-position'` and `contain: 'paint'` for any animated variant. This promotes the element to its own compositor layer and isolates repaints from descendants. Background-position animation is already GPU-hinted on modern browsers; `contain: paint` prevents descendant elements from being invalidated during each frame.
|
||||
|
||||
### 3. Avatar Decorations in Element Call
|
||||
|
||||
- **File:** `cinny/src/app/components/avatar-decoration/AvatarDecoration.tsx`
|
||||
- **Status:** **OPEN**
|
||||
- **Issue:** Avatar decorations are failing to render within the call/room interface member lists.
|
||||
- **Root Cause:** Likely a mismatch between the expected `member` object structure required by the `AvatarDecoration` component and the data actually provided by the call/room UI components. Matrix event data for decorations might not be propagating correctly to these UI member objects.
|
||||
- **Proposed Fix:** Analyze the data propagation chain from Matrix events to the member object in `cinny/src/app/components/call` and `room`, ensuring that decoration-related properties are correctly mapped and passed to the `AvatarDecoration` component.
|
||||
|
||||
### 4. DM and Group Message Calls
|
||||
|
||||
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
||||
- **Status:** **PARTIALLY FIXED ⚠️ UNTESTED** — Volume control added. Remaining: ringtone selection, suppression during active calls.
|
||||
- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call.
|
||||
- **Root Cause:** Ringing logic is tightly coupled to `RTCNotification` events in `CallEmbedProvider.tsx`, using a hardcoded audio file path. It lacks an abstraction for sound management or user-configurable settings for ringtones/volumes.
|
||||
- **Fix Applied:** Added `ringtoneVolume` setting (0–100, default 70). `IncomingCall` reads this setting and applies `audioElement.volume = ringtoneVolume / 100` before `play()`. Slider added to Settings → General → Calls section.
|
||||
- **Remaining:** (a) Ringtone selection (still hardcoded to `call.ogg`); (b) Suppression during active calls — not investigated.
|
||||
|
||||
### 5. Seasonal Themes and Chat Backgrounds Design
|
||||
|
||||
- **File:** `cinny/src/app/hooks/useTheme.ts`, `cinny/src/app/features/lotus/chatBackground.ts`
|
||||
- **Status:** **OPEN**
|
||||
- **Issue:** Basic CSS or random moving lines are insufficient for high-fidelity wallpaper/theming. They lack professional design theory, coherence, and aesthetic depth.
|
||||
- **Root Cause:** Current implementation relies on basic CSS, lacks advanced design theory, and does not leverage modern, performant CSS wallpaper techniques.
|
||||
- **Proposed Fix (Extreme Depth Redesign):**
|
||||
- **Research-Backed Implementation:** Implement advanced design techniques (layered `oklch` gradients, `backdrop-filter` for refractive "liquid glass" effects, GPU-accelerated `transform` animations) to create living, breathing backgrounds.
|
||||
- **Performance Optimization:** Ensure all animations strictly use compositor-thread properties (`transform`, `opacity`) and apply `contain: paint` / `will-change: transform` to prevent layout thrashing/flickering.
|
||||
- **Design Resources (Examples/Inspiration):**
|
||||
- [Uiverse.io Patterns](https://uiverse.io/patterns)
|
||||
- [MagicPattern CSS Backgrounds](https://www.magicpattern.design/tools/css-backgrounds)
|
||||
- [Prismic Blog: CSS Background Effects](https://prismic.io/blog/css-background-effects)
|
||||
- [CSS-Pattern.com](https://css-pattern.com) (Pure CSS pattern library)
|
||||
- [BGJar](https://bgjar.com) (Performance-focused generators)
|
||||
- **Goal:** Treat each theme/background as a week-long development sprint to ensure professional polish, WCAG AA contrast compliance for overlaying UI, and seamless integration with the Lotus TDS.
|
||||
|
||||
### 6. Exclusive Background vs. Seasonal Choice
|
||||
|
||||
- **File:** `cinny/src/app/state/settings.ts`
|
||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: (a) pick a background, confirm seasonal theme auto-clears; (b) pick a seasonal theme, confirm background auto-clears; (c) set both via old localStorage data and reload, confirm SeasonalEffect guard suppresses the overlay
|
||||
- **Issue:** Concurrent application of both Chat Backgrounds and Seasonal Themes causes visual clutter and high GPU usage.
|
||||
- **Root Cause:** These are currently handled as independent settings in the `settingsAtom` and applied simultaneously without mutual exclusion.
|
||||
- **Fix Applied:** Mutual exclusion enforced at two layers: (1) `General.tsx` — ChatBgGrid clears seasonalThemeOverride→'off' when any non-'none' background is picked; SeasonalBgGrid clears chatBackground→'none' when any real seasonal theme is selected. (2) `SeasonalEffect.tsx` — runtime guard returns null if `chatBackground !== 'none'`, protecting against legacy persisted state.
|
||||
|
||||
### 7. Tiny Touch Targets in Composer Toolbar
|
||||
|
||||
- **File:** `cinny/src/app/features/room/RoomInput.tsx`
|
||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real mobile device: open composer, confirm all toolbar buttons are tappable without mis-taps
|
||||
- **Issue:** Toolbar buttons have hit areas smaller than the WCAG-recommended 44x44px for touch, hindering mobile accessibility.
|
||||
- **Fix Applied:** Added `touchTarget = { minWidth: '44px', minHeight: '44px' }` computed from `mobileOrTablet()` and applied as `style={touchTarget}` to all 8 composer toolbar `IconButton` elements (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
|
||||
|
||||
### 8. Horizontal Overflow in Room Settings
|
||||
|
||||
- **File:** `cinny/src/app/components/page/style.css.ts`
|
||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: open Room Settings on a narrow mobile screen, confirm nav panel fills full width and no horizontal scrollbar appears
|
||||
- **Issue:** Wide tables and input elements in room settings cause horizontal overflow on mobile viewports.
|
||||
- **Fix Applied:** Added `@media (max-width: 750px) { width: '100%' }` to both `'400'` and `'300'` size variants of the `PageNav` vanilla-extract recipe in `style.css.ts`.
|
||||
|
||||
### 9. Modal Float-Style Responsiveness
|
||||
|
||||
- **File:** Multiple modal files
|
||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification by opening each modal on a real mobile device
|
||||
- **Issue:** Modals appear as floating boxes on mobile, creating navigation and readability challenges.
|
||||
- **Fix Applied:** Created `useModalStyle(desktopMaxWidth)` hook (`src/app/hooks/useModalStyle.ts`) that returns fullscreen styles on mobile (no border-radius, no max-width, `height: 100%`) and desktop box styles otherwise. Applied to all 22+ modal files: `LeaveRoomPrompt`, `LeaveSpacePrompt`, `ReportRoomModal`, `ReportUserModal`, `DeviceVerification`, `InviteUserPrompt`, `LogoutDialog`, `DeviceVerificationSetup`, `DeviceVerificationReset`, `JoinAddressPrompt`, `JumpToTime`, `EditHistoryModal`, `ForwardMessageDialog`, `RemindMeDialog`, `CreateRoomModal`, `CreateSpaceModal`, `ScheduleMessageModal`, `PollCreator`, `AddExistingModal`, `RoomEncryption`, `RoomUpgrade`, `Modal500`, `ReadReceiptAvatars`, `RoomTopicViewer`.
|
||||
- **Note:** `UIAFlowOverlay` already fullscreen via `<Overlay>` — no change needed. `JoinRulesSwitcher`/`RoomNotificationSwitcher` are dropdowns, not modals.
|
||||
|
||||
### 10. Composer Keyboard Obscurity
|
||||
|
||||
- **File:** `src/index.css`
|
||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on iOS Safari specifically (the worst offender); on Android Chrome `100dvh` has been standard since Chrome 108
|
||||
- **Issue:** The chat composer is often partially or fully obscured by the virtual keyboard on mobile.
|
||||
- **Fix Applied:** Added `height: 100dvh` (dynamic viewport height) to `html` alongside the existing `height: 100%` fallback. `dvh` updates when the software keyboard appears, ensuring the layout shrinks correctly and the composer stays visible.
|
||||
|
||||
### 11. Inline Jotai atom creation
|
||||
|
||||
- **File:** `cinny/src/app/hooks/useSpaceHierarchy.ts`
|
||||
- **Status:** **FALSE POSITIVE — CLOSED**
|
||||
- **Issue:** Inline Jotai atom creation in a hook risks re-rendering components unnecessarily.
|
||||
- **Resolution:** `useState(() => atom(...))` IS the correct Jotai pattern for local stable atom references. The factory function form of `useState` ensures the atom is created only once per component mount. No change warranted.
|
||||
- **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.**
|
||||
|
||||
---
|
||||
|
||||
## 📦 Barrel File Audit
|
||||
|
||||
| File Path | Note | Status |
|
||||
| :------------------------------------------ | :------------------------- | :----- |
|
||||
| `cinny/src/app/plugins/call/index.ts` | Extensive `export *` usage | OPEN |
|
||||
| `cinny/src/app/plugins/text-area/index.ts` | Extensive `export *` usage | OPEN |
|
||||
| `cinny/src/app/components/message/index.ts` | Extensive `export *` usage | OPEN |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Technical & Performance Refinements
|
||||
|
||||
| Category | Issue Description | File Path | Status |
|
||||
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| State Sync | Fire-and-forget network call to set offline presence during `pagehide` event may not complete reliably, potentially causing UI drift in presence status. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
|
||||
| State Sync | Fire-and-forget network call `setPresence().catch(...)` suppresses errors, meaning the app may falsely assume presence update success. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
|
||||
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | PARTIALLY FIXED ⚠️ UNTESTED — Blob revocation was already correct; added `enabled` param to `useDecryptedMediaUrl` and `useNearViewport(300px)` to each `GalleryTile` to gate decryption until near-viewport, reducing burst on pagination. True virtualization (windowing) deferred — requires significant refactor. |
|
||||
| Data Persistence | Scheduled Messages are ephemeral (lost on refresh) due to fragile `localStorage` parsing. | `cinny/src/app/state/scheduledMessages.ts` | FIXED — now uses `atomWithStorage` + `createJSONStorage` (Jotai's built-in persistence with error-safe JSON parsing) |
|
||||
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | FALSE POSITIVE — `usePan` already uses `attachedRef` to track listeners and cleans them up in an unmount `useEffect`. No change needed. |
|
||||
| Asset Optimization | Large unoptimized media asset (213KB) found in `public/res`. | `public/res/Lotus.png` | OPEN |
|
||||
| Data Persistence | Non-atomic `localStorage` updates in session management can lead to inconsistent state. | `cinny/src/app/state/sessions.ts` | OPEN |
|
||||
| Data Persistence | Lack of cross-tab synchronization for `localStorage` updates in session management risks race conditions. | `cinny/src/app/state/sessions.ts` | OPEN |
|
||||
| Network Resilience | `uploadContent` lacks retry logic, failing immediately upon network error. | `cinny/src/app/utils/matrix.ts` | OPEN |
|
||||
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | FIXED — fallback delay now uses capped exponential backoff (`min(1000 * 2^retryCount, 30_000)ms`) when server doesn't send `Retry-After`; server header still takes precedence via `getRetryAfterMs()`. |
|
||||
| Matrix Event Robustness | `useMatrixEventRenderer` handles unknown events gracefully by returning `null`, which may hide potentially important unrendered data. | `cinny/src/app/hooks/useMatrixEventRenderer.ts` | OPEN |
|
||||
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | OPEN |
|
||||
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | OPEN |
|
||||
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | FALSE POSITIVE — `MatrixError.httpStatus` is defined as `readonly httpStatus?: number` in `matrix-js-sdk/lib/http-api/errors.d.ts`. It is optional (not on all instances) but the `?.` optional chain already guards against undefined. No change needed. |
|
||||
| Type Contract | Custom types in `cinny/src/types/matrix` mirror SDK types instead of using them, risking drift and contract mismatches. | `cinny/src/types/matrix/` | OPEN |
|
||||
|
||||
## 🏗️ Architectural & Hygiene Audit
|
||||
|
||||
| Category | Issue Description | File Path | Status |
|
||||
| :------- | :--------------------------------------------------------------- | :-------- | :----- |
|
||||
| Hygiene | No stale development notes or TypeScript strictness issues found | N/A | OPEN |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ TDS Compliance & Styling Issues
|
||||
|
||||
| Issue Description | File Path |
|
||||
| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Hardcoded inline style `cursor: 'pointer'` | `cinny/src/app/plugins/react-custom-html-parser.tsx` |
|
||||
| Hardcoded color `#00D4FF`, `#FFB300` ✅ **VERIFIED COMPLIANT** | `cinny/src/app/components/event-readers/EventReaders.tsx` |
|
||||
| Hardcoded color `#EE1D52`, `#9146ff`, `#ff4500`, `#cb3837`, `#f48024` ⚠️ **BRAND EXCEPTION** | `cinny/src/app/components/url-preview/UrlPreviewCard.tsx` + `UrlPreview.css.tsx` — official third-party brand colors in SVG logos and site badge backgrounds; cannot convert to CSS variables without inventing new tokens (violates TDS rule 3) |
|
||||
| Massive number of hardcoded `backgroundColor` values ⚠️ **PATTERN CONTENT EXCEPTION** | `cinny/src/app/features/lotus/chatBackground.ts` — each background's base color is aesthetic content that defines the pattern identity; converting requires inventing 40+ CSS variables (violates TDS rule 3) or using CSS4 `relative-color-syntax` in inline styles (insufficient browser support); these are visual content, not UI chrome |
|
||||
| Hardcoded colors `#00FF88`, `#FF6B00` ✅ **VERIFIED COMPLIANT** | `cinny/src/app/features/call/CallControls.tsx` |
|
||||
| Hardcoded fallback hexes in toast colors ✅ **FIXED** | `cinny/src/app/features/toast/LotusToastContainer.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Localization, Accessibility & Performance
|
||||
|
||||
| Category | Issue Description | File Path | Status |
|
||||
| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
||||
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | OPEN |
|
||||
| Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN |
|
||||
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | OPEN |
|
||||
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | OPEN |
|
||||
| Performance | Numerous event handlers (e.g., handleUserClick, handleReplyClick) lack `useCallback`, leading to unnecessary re-renders of message components. | `cinny/src/app/features/room/RoomTimeline.tsx` | OPEN |
|
||||
| Performance | The `submit` function and file handling callbacks (e.g., handleSendUpload) are re-created on every render, causing re-renders of the editor and toolbar components. | `cinny/src/app/features/room/RoomInput.tsx` | OPEN |
|
||||
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
|
||||
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | **FIXED ⚠️ UNTESTED** — `Reaction` component now computes `aria-label="{shortcode} reaction, N people"` internally using `getShortcodeFor`; custom (mxc://) emoji falls back to "custom emoji reaction". |
|
||||
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
|
||||
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Infrastructure, DevEx & Type Safety
|
||||
|
||||
| Category | Issue Description | File Path | Status |
|
||||
| :------------- | :----------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | :----- |
|
||||
| Dependencies | `lodash` pinned to non-existent version `4.18.1` | `cinny/package.json` | OPEN |
|
||||
| Dependencies | Various pinned versions of `@atlaskit`, `matrix-js-sdk` | `cinny/package.json` | OPEN |
|
||||
| Dependencies | `matrix-js-sdk` pinned to Release Candidate (`41.6.0-rc.0`) | `cinny/package.json` | OPEN |
|
||||
| Dependencies | Unstable/experimental versions for build tools (`vite` 8.0.14, `typescript` 6.0.3, `eslint` 9.39.4) | `cinny/package.json` | OPEN |
|
||||
| CI/CD | `package-manager-cache` set to `false` | `cinny/.github/workflows/build-pull-request.yml` | OPEN |
|
||||
| CI/CD | Inefficient sequential execution in deployment | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
||||
| CI/CD | Aggressive 1-minute timeout for Netlify deploy | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
||||
| DevEx | Stale upstream bug tracker link/donations/CLA | `cinny/CONTRIBUTING.md` | OPEN |
|
||||
| DevEx | Alignment issue between README and CONTRIBUTING | `cinny/README.md` | OPEN |
|
||||
| Testing | No evident automated testing configuration/files | `cinny/src/` | OPEN |
|
||||
| Type Safety | Extensive use of `as any` type assertions | `cinny/src/` | OPEN |
|
||||
| Security | Hardcoded public CDN URL; consider moving to environment variable | /root/code/cinny/scripts/syncDecorations.mjs | OPEN |
|
||||
| Architecture | Modifying node_modules directly is brittle; use patch-package instead | /root/code/cinny/scripts/patch-folds.mjs | OPEN |
|
||||
| Robustness | Missing security headers (HSTS, CSP, etc.) and inefficient asset serving using rewrites instead of try_files | /root/code/cinny/contrib/nginx/cinny.domain.tld.conf | OPEN |
|
||||
| Robustness | Incomplete documentation/placeholder path in Caddyfile | /root/code/cinny/contrib/caddy/caddyfile | OPEN |
|
||||
| Matrix SDK | Inefficient listener management (`setMaxListeners: 150`) and incomplete SDK state transition handling. | `src/client/initMatrix.ts` | OPEN |
|
||||
| PWA Robustness | Service worker lacks caching strategy for application assets, resulting in no offline capability. | `cinny/src/sw.ts` | OPEN |
|
||||
| PWA Integrity | `manifest: false` in `vite.config.js` might prevent correct PWA installation if not handled externally. | `cinny/vite.config.js` | OPEN |
|
||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/plugins/call/CallEmbed.ts` | OPEN |
|
||||
| PII Leakage | Potential PII exposure via console.warn (parameter imgError/videoError/thumbError object). | `cinny/src/app/features/room/msgContent.ts` | OPEN |
|
||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/features/room/RoomInput.tsx` | OPEN |
|
||||
|
||||
## 🏗️ Architectural & Resilience Audit
|
||||
|
||||
| Category | Issue Description | File Path | Status |
|
||||
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :----- |
|
||||
| Element Call Integration | Lacks robust iframe failure monitoring beyond initial 'preparing' event; can result in a permanently hung 'Loading...' state with no user-visible error or recovery path. | `src/app/plugins/call/CallEmbed.ts` | OPEN |
|
||||
| Component Resilience | `RoomTimeline` has no `ErrorBoundary` wrapper — a single malformed event crashing the renderer takes down the entire timeline with no fallback UI. | `src/app/features/room/RoomTimeline.tsx` | OPEN |
|
||||
| Component Resilience | `RoomInput` has no `ErrorBoundary` wrapper — a crash in the composer leaves users unable to send messages. | `src/app/features/room/RoomInput.tsx` | OPEN |
|
||||
| Fallback Logic | No explicit empty/error fallback for Matrix SDK data calls in `RoomTimeline`; relies purely on SDK internal error propagation, meaning silent failures show a blank timeline. | `src/app/features/room/RoomTimeline.tsx` | OPEN |
|
||||
| Dependency | Potential for complex dependency chains due to deep nesting in `src/app/features/` and `src/app/hooks/`. | `src/app/` | OPEN |
|
||||
| Hydration/Race Condition | The SyncState listener registered by useSyncState may miss the initial 'PREPARED' event if the client initializes synchronously from IndexedDB before the effect runs, leading to an infinite loading state. | `cinny/src/app/pages/client/ClientRoot.tsx` | OPEN |
|
||||
| Structure | High number of small, highly coupled utility hooks (`src/app/hooks/`) may obscure dependency graphs. | `src/app/hooks/` | OPEN |
|
||||
| Dead Code | Potential for unused CSS modules or UI components in `src/app/features/`. | `src/app/` | OPEN |
|
||||
| Security | Sensitive session data (access tokens, device ID) stored in `localStorage` is vulnerable to XSS. | `src/app/state/sessions.ts` | OPEN |
|
||||
| Privacy | Sensitive user status messages and expiry timestamps are persisted in `localStorage`. | `src/app/features/settings/account/Profile.tsx` | OPEN |
|
||||
| Privacy | Unsent composer drafts stored in `localStorage` without encryption could leak info on shared devices. | `src/app/features/room/RoomInput.tsx` | OPEN |
|
||||
| Persistence | Scheduled messages relying on fragile `localStorage` parsing are prone to data loss on session expiry or error. | `src/app/state/scheduledMessages.ts` | OPEN |
|
||||
| Bundle Bloat | Inefficient `lodash` import; risks including entire library instead of necessary utilities. | `cinny/package.json` | OPEN |
|
||||
| Bundle Bloat | Large `matrix-js-sdk` (RC version) dependency; high potential for tree-shaking overhead. | `cinny/package.json` | OPEN |
|
||||
| Build-Time Overhead | `lotusDenoise` plugin performs heavy, sequential `fs` operations during `closeBundle`, significantly slowing build times. | `cinny/vite.config.js` | OPEN |
|
||||
| Build-Time Overhead | Complex manual `viteStaticCopy` configuration requiring multiple renames and path manipulations; risks redundant processing. | `cinny/vite.config.js` | OPEN |
|
||||
| Architectural Debt | Redundant style variant logic in `SpacingVariant` could be simplified. | `cinny/src/app/components/message/layout/layout.css.ts` | OPEN |
|
||||
| Overhead Analysis | Potential CSS bloat from `DropTarget` composition across multiple recipes (`SidebarItem`, `SidebarFolder`). | `cinny/src/app/components/sidebar/Sidebar.css.ts` | OPEN |
|
||||
|
||||
## 🏗️ Git Workflow & History Audit
|
||||
|
||||
| Category | Issue Description | File Path | Status |
|
||||
| :------- | :------------------------------------------------------------------------------------------------------ | :---------- | :----- |
|
||||
| Workflow | Monolithic "Fix all bugs" commits (e.g., `10f6544e`, `aa48c9ef`) make `git bisect` difficult. | Git History | OPEN |
|
||||
| Workflow | Inconsistent commit message prefixes (e.g., `fix`, `feat`, `docs`, `assets`). | Git History | OPEN |
|
||||
| Workflow | Use of `fix` or `feat` for large-scale changes affecting multiple disparate systems (e.g., `938ead79`). | Git History | OPEN |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Native UI/UX Consistency — Lotus vs. Cinny Baseline
|
||||
|
||||
> Audit of every Lotus-custom UI feature against Cinny's native folds design-system conventions. "Native pattern" means the `folds` component library, vanilla-extract tokens (`color.*`, `config.radii.*`, `config.space.*`), and established Cinny component patterns. 52 findings, organized by severity.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Major — Broken Styling / Functional Regressions
|
||||
|
||||
#### N1. `ProfileDecoration` Save Button — Undefined `--accent-cyan` Variable (border invisible on all non-TDS themes)
|
||||
|
||||
- **File:** `src/app/features/settings/account/ProfileDecoration.tsx`, lines 191–213
|
||||
- **Status:** **FIXED** — replaced raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`, removed undefined `--accent-cyan` reference
|
||||
- **Issue:** The save button is a raw `<button>` with `border: '1px solid var(--accent-cyan)'` and `color: 'var(--accent-cyan)'`. The variable `--accent-cyan` (without the `--lt-` prefix) is never defined in any theme file — the correct prefixed form is `--lt-accent-cyan`. On all non-TDS themes the border is **invisible** and the text has no color.
|
||||
- **Root Cause:** Missing `--lt-` prefix. Additionally, the raw `<button>` should be a folds `<Button>` to match every other save button in the same `Profile.tsx` settings panel (e.g., `ProfileDisplayName` save at `Profile.tsx:303`).
|
||||
- **Fix:** Replace raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`. Remove the `--accent-cyan` reference.
|
||||
|
||||
#### N2. `UserPrivateNotes` Textarea — Undefined `--border-interactive` Variable (border invisible on all themes)
|
||||
|
||||
- **File:** `src/app/components/user-profile/UserRoomProfile.tsx`, lines 246–265
|
||||
- **Status:** **FIXED** — replaced undefined CSS vars with `color.SurfaceVariant.ContainerLine`, `config.radii.R300`, `config.space.S200/S300`
|
||||
- **Issue:** The notes textarea sets `border: '1px solid var(--border-interactive)'`. This variable is never defined anywhere in the codebase — the correct equivalents are `--bg-surface-border` (`src/index.css`) or `color.SurfaceVariant.ContainerLine` (folds token). The border is **invisible on all themes**.
|
||||
- **Root Cause:** Invented CSS variable name. Also uses raw pixel sizing (`borderRadius: '6px'`, `padding: '8px 10px'`, `fontSize: '14px'`) instead of folds tokens.
|
||||
- **Fix:** Replace inline style with `border: \`1px solid ${color.SurfaceVariant.ContainerLine}\``, `borderRadius: config.radii.R300`, `padding: config.space.S200`.
|
||||
|
||||
#### N3. `LotusToastContainer` — Z-Index Places Toasts Below Night Light Overlay and All Modals
|
||||
|
||||
- **File:** `src/app/features/toast/LotusToastContainer.tsx`, lines 184–211; `src/app/pages/App.tsx`
|
||||
- **Status:** **FIXED** — raised toast `zIndex` from `9997` to `10001` (above Night Light at 9998 and modals at 9999)
|
||||
- **Issue:** The toast container uses hardcoded `zIndex: 9997`. The Night Light overlay is at `z-index: 9998`. The folds `Overlay`/`Dialog` components used for all modals resolve to `z-index: 9999`. Result: (a) toasts render **under** the Night Light tint and take on the warm orange filter; (b) any open modal covers toasts entirely, making notifications invisible.
|
||||
- **Root Cause:** The toast container does not use the `folds` `OverlayContainerProvider` portal that manages z-index correctly — it is a plain `position: fixed` div injected directly in `App.tsx`.
|
||||
- **Fix:** Either route the toast portal through `OverlayContainerProvider` (matching how all other floating UI works), or raise `zIndex` above all overlay layers (10001+). Also audit Night Light's z-index (9998) relative to toasts.
|
||||
|
||||
#### N4. `PollContent` Vote Buttons — Entirely Outside the Folds Design System
|
||||
|
||||
- **File:** `src/app/components/message/content/PollContent.tsx`, lines 250–358
|
||||
- **Status:** **OPEN**
|
||||
- **Issue:** Each poll answer is a native `<button>` with ~15 hardcoded inline style properties using undefined CSS variables (`--accent-cyan`, `--accent-cyan-dim`, `--accent-cyan-border`). Checkbox/radio indicators, percentage spans, and the poll label use raw pixel font sizes (`0.68rem`, `0.78rem`, `0.88rem`) and hardcoded `borderRadius: '8px'`. None of these variables exist in any theme — the entire component will render unstyled on non-TDS themes. All other interactive message content (audio, file, image) uses folds `Chip` or `Button` variants.
|
||||
- **Root Cause:** Custom implementation that bypasses folds primitives entirely.
|
||||
- **Fix:** Rewrite using folds `Button` or `Chip` for answers; replace `--accent-cyan*` with `color.Secondary.*` folds tokens; use `Text size="T300"` for labels.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 Moderate — Interaction Pattern or Visual Deviations
|
||||
|
||||
| # | Area | File | Lines | Issue | Native Pattern |
|
||||
| :-- | :------------------------- | :---------------------------------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| N5 | Read Receipts | `ReadReceiptAvatars.tsx` | 62–137 | Trigger button is raw `<button>` with `onMouseEnter`/`onMouseLeave` JS style mutation for hover state — **FIXED**: hover/focus emphasis moved to co-located `ReadReceiptAvatars.css.ts` (`:hover`/`:focus-visible`), no JS `.style` mutation | All interactive elements use `useHover` from `react-aria` and folds variant system for hover; direct `.style` mutation used nowhere else on buttons |
|
||||
| N6 | Read Receipts | `ReadReceiptAvatars.tsx` & `Message.tsx` | 32–56 / 268–283 | Two code paths open `EventReaders`: avatar-pill path uses `useModalStyle(360)` for mobile fullscreen; context-menu path (`MessageReadReceiptItem`) does not — on mobile the context menu path opens a fixed-size non-fullscreen modal for the same content | All modals that share a layout variant use `useModalStyle` consistently; `MessageReadReceiptItem` was not updated when `useModalStyle` was added |
|
||||
| N7 | Delivery Status | `Message.tsx` | 89–148 | `DeliveryStatus` renders Unicode glyphs (`⟳ ✓ ✕`) in a `<span>` with `fontSize: '10px'` instead of folds `<Icon>` components — **FIXED**: replaced with `Icons.Check/Cross/Send` via `<Icon size="100">` | `Icons.Check`, `Icons.Cross`, etc. are used for all other status glyphs; folds `Text` size tokens for all supplementary text |
|
||||
| N8 | GIF Picker | `GifPicker.tsx` | 83–124 | GIF picker container uses fully bespoke inline styles (`borderRadius: '12px'`, `boxShadow: '0 8px 32px rgba(0,0,0,0.4)'`, raw `rgba` border) — two separate style sets for TDS and non-TDS paths — **FIXED**: non-TDS path now uses folds tokens (`color.Surface.Container`, `config.radii.R400`, `color.Surface.ContainerLine`, `color.Other.Shadow`), dropping the undefined `var(--bg-surface)`; the TDS branch keeps its `--lt-*` glow chrome (valid TDS styling) | `EmojiBoard` has no caller-applied container styling; folds components handle their own surface internally via design tokens |
|
||||
| N9 | GIF Button | `RoomInput.tsx` | 1076–1087 | GIF toolbar button renders `<Text size="T200">` with hand-rolled `fontWeight`/`fontSize`/`letterSpacing` instead of `<Icon>` — **WON'T FIX (deliberate)**: folds has no GIF icon, and "GIF" is a widely-recognized text affordance (Slack/Discord/Element all use a text label). Converting to an arbitrary icon would be less clear, not more. | All 8 other toolbar buttons (`Smile`, `Sticker`, `Location`, `Poll`, etc.) use `<Icon src={...} />` exclusively |
|
||||
| N10 | Send Animation | `Message.tsx` + `Animations.css.ts` | 979–998 / 60–71 | `MsgAppearClass` and `MentionHighlightPulse` both animate `transform: scale` on the same `MessageBase` DOM node — on self-sent mention messages both classes apply simultaneously and fight over the `transform` property — **FIXED**: `mentionPulseKeyframes` now animates only `box-shadow` (dropped the imperceptible `scale(1.003)`), so the appear-scale and the mention glow no longer contend for `transform` | Pre-existing `highlightAnime` only animates `backgroundColor`; no prior `transform` animation on `MessageBase` |
|
||||
| N11 | AvatarDecoration | `AvatarDecoration.tsx` | 5 / 38–41 | Fixed 8px inset on all sides regardless of avatar size — at folds size `"200"` (~32px) the decoration bleeds 50% of the avatar diameter, clipping against `overflow: hidden` parent containers in member lists. **Inset issue still OPEN.** _Related regression fixed in `useAvatarDecoration.ts`_: the decoration fetch cached **all** failures (including transient 429/5xx) as "no decoration" permanently for the session, so a single rate-limited burst (member list / timeline mount many avatars at once) would make decorations vanish until a full reload. Now only a genuine 404 is cached; transient errors retry on the next mount. | Folds `Avatar` and `PresenceRingAvatar` do not emit overflow outside their bounding box |
|
||||
| N12 | MediaGallery Drawer | `MediaGallery.tsx` | 651–661 | Drawer uses `position: 'fixed'` with hardcoded `width: '320px'` as inline styles on a `<Box>` — **FIXED**: moved positioning/width into co-located `MediaGallery.css.ts` using `toRem(320)` + a `max-width: 750px` full-screen media query (mirrors `MembersDrawer`); border/header now use `config.borderWidth`/`config.space` tokens. Added Escape-to-close on the panel (previously only the lightbox handled Escape). **Full chrome redesign (round 2)** to match native conventions: panel + header switched from `Surface` to `Background` variant (matching `MembersDrawer`/Saved Messages); header now `Text size="H4"` + plain close `IconButton` (dropped the bespoke tooltip-wrapped button); tabs moved to a bordered toolbar strip with the `variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'}` pattern from `PolicyListViewer` and now show per-tab counts; the centered "lines + label" month divider replaced with a left-aligned group label (Cinny group-label pattern); thumbnail tiles moved hover/focus styling to CSS `:hover`/`:focus-visible` (no JS hover state) and into `MediaGallery.css.ts`; file rows + grid tokenized. **Docking fix (round 3)** — the core of the finding: the gallery was a `position: fixed` overlay floating over the timeline, mounted from `RoomViewHeader`. It is now a **docked flex sibling** in the room layout row, exactly like `MembersDrawer`: open state lifted to a `mediaGalleryAtom` (mirrors `bookmarksPanelAtom`), rendered in `Room.tsx` with a vertical `Line` separator on desktop and `key={room.roomId}` to reset per room; the CSS is static-width on desktop and only `position: fixed; inset: 0` full-screen on mobile (identical strategy to `MembersDrawer.css`). It now shares the row with the timeline instead of overlapping it. | `MembersDrawer` uses a vanilla-extract class with `width: toRem(266)` and is placed by the layout system, not `position: fixed`. 54px width discrepancy also breaks visual rhythm if both panels could be open |
|
||||
| N13 | ScheduledMessagesTray | `ScheduledMessagesTray.tsx` | 108–126 | Collapsible tray header is `<Box as="button">` with `cursor: 'pointer'` inline style and no folds variant — no hover state, no focus ring — **FIXED**: replaced with folds `<Button variant="Secondary" fill="None" radii="0">` using `before`/`after` icon props (gains design-system hover/focus) | All clickable header/toggle elements in the room view use folds `<Button>` or `<IconButton>` with explicit variants for hover/focus; `<Box as="button">` with no variant is used nowhere else |
|
||||
| N14 | ForwardMessageDialog | `ForwardMessageDialog.tsx` | 137–154 | Dialog uses `<Modal>` but has no `<Header>` component and no close `<IconButton>` — only way to close is clicking outside — **FIXED**: added a folds `<Header variant="Surface" size="500">` with the title + close `<IconButton radii="300">`, matching every other modal | Every other modal using `<Modal>` or `<Box role="dialog">` includes a `<Header>` with a close `<IconButton>` in the top-right (EditHistoryModal, LeaveRoomPrompt, ScheduleMessageModal, RemindMeDialog, etc.) |
|
||||
| N15 | ScheduleMessageModal | `ScheduleMessageModal.tsx` | 180–193 | Modal root is `<Box as="form" role="dialog">` with manually assembled `borderRadius: config.radii.R400`/`boxShadow` — **FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `ForwardMessageDialog` uses folds `<Modal size="400">` with `R500` radius; the R400 vs R500 mismatch is visible when both dialogs appear in the same session |
|
||||
| N16 | Presence Picker | `SettingsTab.tsx` | 118–144 | Presence trigger dot is raw `<button>` with `position: absolute; bottom: 2; right: 2` inline and no folds focus ring; no tooltip — **FIXED**: wrapped the trigger in a folds `TooltipProvider` (shows "Status: …"); replaced the undefined `var(--bg-surface)` with `color.Background.Container`. Kept the absolute-positioned `<button>` (it overlays the avatar corner; a full `IconButton` would be too large for the dot). | Every other sidebar icon button uses folds `IconButton` with `SidebarItemTooltip` and `TooltipProvider` |
|
||||
| N17 | Presence Picker | `SettingsTab.tsx` | 80–86 | `PresencePicker` `FocusTrap` missing `escapeDeactivates: stopPropagation` and `isKeyForward`/`isKeyBackward` — **FIXED**: added all three options, matching the theme selector / sort menus | Every other `PopOut`+`FocusTrap`+`Menu` combo supplies both (theme selector `General.tsx:143–160`, `SettingsSelect`, sort menus) — without it Escape bubbles past the trap and arrow-key navigation is absent |
|
||||
| N18 | Profile Selects | `Profile.tsx` | 547–575 / 816–848 | `ProfileStatus` auto-clear and `ProfileTimezone` selectors are native `<select>` elements with hardcoded `colorScheme: 'dark'` — will render in dark mode on light themes | General.tsx uses folds `SettingsSelect<T>` (`Button`+`PopOut`+`Menu`) for all dropdowns; `colorScheme: 'dark'` breaks light/custom theme appearance |
|
||||
| N19 | Presence Labels | `useUserPresence.ts` vs `SettingsTab.tsx` | 55–62 / 36–42 | `PresenceBadge` tooltip shows "Active / Busy / Away"; `PresencePicker` options read "Online / Idle / Do Not Disturb / Invisible" — a DND user shows tooltip "Busy", not "Do Not Disturb" — **FIXED**: aligned `usePresenceLabel` reader vocabulary to the setter (online→"Online", unavailable→"Idle", offline→"Offline") | Within the same Lotus feature set the user-facing vocabulary is inconsistent between the setter UI and the reader tooltip |
|
||||
| N20 | Notification Presets | `Notifications.tsx` | 57–107 | Gaming/Work/Sleep preset buttons are bare `<button>` elements with Lotus-specific CSS vars (`--border-interactive-normal`, `--bg-surface-low`) not defined in all themes — **FIXED**: converted to folds `<Button variant="Secondary" fill="Soft" radii="300">` (auto height) wrapping the emoji/label/description column; undefined vars removed | Grouped preset/action buttons elsewhere use folds `Chip variant="Primary/Secondary" outlined radii="Pill"` (e.g., Composer Toolbar toggles in `General.tsx:1100–1113`) |
|
||||
| N21 | Notification Sound Selects | `SystemNotification.tsx` | 111–305 | Message sound, invite sound, and quiet-hours time pickers are bare `<select>`/`<input type="time">` with `colorScheme: 'dark'` workaround | All other dropdowns in settings use the `Button`+`PopOut`+`Menu`+`MenuItem` folds pattern; the native select renders OS-styled on all platforms |
|
||||
| N22 | DM Preview Virtualizer | `RoomNavItem.tsx` / `Direct.tsx` | 608–627 / 232 | DM preview adds a second text row to each DM item, making it taller than 38px, but `useVirtualizer` in `Direct.tsx` still uses `estimateSize: () => 38` — causes layout jump/overlap on initial render — **FIXED**: bumped `estimateSize` to 52 (the two-line DM-row height) so the initial estimate matches the common case; `measureElement` still corrects each row exactly | Non-DM rooms in Home.tsx also estimate 38px; DM items with a preview are now a different height, creating two visual densities in the same nav column |
|
||||
| N23 | RoomServerACL | `RoomServerACL.tsx` | 100–115 / 298–309 | Server-name text input is a raw `<input type="text">` with inline style object; "Allow IP literal addresses" is a raw `<input type="checkbox">` with `style={{ width: 16, height: 16 }}` — **FIXED**: text input → folds `<Input variant={error?'Critical':'Secondary'}>`; checkbox → folds `<Checkbox variant="Primary">` | All other text/boolean controls in room settings use folds `Input` and `Checkbox` components (`RoomAddress.tsx:163`, `RoomAddress.tsx:330`) |
|
||||
| N24 | PolicyListViewer | `PolicyListViewer.tsx` | 245–264 | Room-ID add input is a raw `<input type="text">` with manually replicated folds token values — **FIXED**: replaced with folds `<Input variant={error?'Critical':'Secondary'} size="400" radii="300">` | Native pattern: folds `<Input variant="Secondary" size="300" radii="300">` — no inline style needed |
|
||||
| N25 | ExportRoomHistory Inputs | `ExportRoomHistory.tsx` | 258–292 | Both date range pickers are raw `<input type="date">` with inline styles — **FIXED**: replaced with folds `<Input type="date" variant="Secondary" size="400" radii="300">` | Native pattern: folds `Input` component; `<input type="date">` renders OS-native date picker, unstyled relative to the rest of the settings panel |
|
||||
| N26 | RoomShareInvite QR | `RoomShareInvite.tsx` | 66–73 | QR code `<img>` has no `onError` handler and no loading state — broken-image placeholder shown when the external API is unreachable — **FIXED**: added `loading="lazy"` + `onError` that swaps to a folds "QR code unavailable" placeholder card | Cinny avatar components and MediaGallery use `onError` handlers; this is the only settings element making a request to a third-party server with no graceful degradation |
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Minor — Cosmetic / Token Discipline
|
||||
|
||||
| # | Area | File | Lines | Issue | Native Pattern |
|
||||
| :------ | :--------------------------------- | :------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| N27 | GIF Picker | `GifPicker.tsx` | 103–110 | `FocusTrap` omits `returnFocusOnDeactivate: false` — focus returns to GIF button on dismiss instead of staying in the editor — **FIXED**: added `returnFocusOnDeactivate: false` (matches EmojiBoard) | `EmojiBoard` in `RoomInput.tsx:978` explicitly sets `returnFocusOnDeactivate={false}`; GIF picker dismiss behaviour is inconsistent with emoji picker |
|
||||
| N28 | Character Counter | `RoomInput.tsx` | 1159–1174 | Composer character counter rendered with `color: 'var(--tc-surface-low)'` and raw pixel padding — a CSS variable not used anywhere else in the codebase — **FIXED**: removed undefined var and raw opacity; now `<Text priority="300">` with `config.space.S100` padding | Use `color.*` folds tokens or `priority="300"` on a `Text` component |
|
||||
| N29 | PollCreator Modal | `PollCreator.tsx` | 103–116 | Modal root is `<Box as="form" role="dialog" aria-modal="true">` with manually assembled surface styles instead of folds `<Dialog variant="Surface">` — **FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `MessageDeleteItem` and `MessageReportItem` in `Message.tsx:506,635` use `<Dialog variant="Surface">` inside `OverlayCenter > FocusTrap` |
|
||||
| N30 | Playback Speed Chip | `AudioContent.tsx` | 163–189 | Speed chip uses `variant="SurfaceVariant" radii="Pill"` while adjacent Play/Pause chip uses `variant="Secondary" radii="300"` — mismatched shape and variant within the same `leftControl` row — **FIXED**: changed speed chip to `variant="Secondary" radii="300"` | Controls grouped in the same row should share variant and radii |
|
||||
| N31 | Collapsible Message Toggle | `MsgTypeRenderers.tsx` | 97–105 | "Read more ↓" / "Show less ↑" uses `<Button size="300" variant="Secondary" fill="None">` — visually a padded form button — **FIXED**: replaced with the native flush inline-button pattern (`background:none;border:none;padding:0`) + `<Text size="T200">` tinted `color.Primary.Main`, matching `(edited)` in FallbackContent | Inline text toggles in message content (e.g. `(edited)` in `FallbackContent.tsx:74`) use bare `<button>` with `background: none; border: none; padding: 0` to stay flush with text |
|
||||
| N32 | ReadReceiptAvatars Pill | `ReadReceiptAvatars.tsx` | 95–103 | Pill border is `'1px solid rgba(0,212,255,0.30)'` hardcoded raw rgba string; `borderRadius: '999px'` not a folds radii token; padding in raw pixels — **FIXED**: replaced with `config.borderWidth.B300`, `config.radii.Pill`, `config.space.S100/S200` | Use `color.*` folds tokens and `config.radii.Pill` / `config.space.S*` |
|
||||
| ~~N33~~ | ~~ReadReceiptAvatars Class~~ | ~~`ReadReceiptAvatars.tsx`~~ | ~~67~~ | ~~`className="receipt-pill-btn"` references a class never defined~~ — **FIXED**: removed dead className | All custom CSS goes through co-located vanilla-extract `*.css.ts` files |
|
||||
| N34 | EventReaders Header Size | `EventReaders.tsx` | 70 | `Header size="600"` (56px tall) while all peer message-action modals use `size="500"` (48px) — **FIXED**: changed to `size="500"` | `EditHistoryModal`, `LeaveRoomPrompt`, `MessageDeleteItem`, `MessageReportItem` all use `size="500"`; `size="600"` is reserved for full-page panel headers |
|
||||
| N35 | EventReaders Close Button | `EventReaders.tsx` | 96 | Close `IconButton` missing explicit `radii="300"` prop — **FIXED**: added `radii="300"` | Every peer modal close button explicitly sets `radii="300"` (EditHistoryModal:184, LeaveRoomPrompt:75, MessageDeleteItem:517) |
|
||||
| N36 | EventReaders Header Border | `EventReaders.tsx` | 72–77 | Lotus-mode header sets `borderBottom: '1px solid var(--lt-border-color)'` as a CSS shorthand string — **FIXED**: changed to `borderBottomWidth: config.borderWidth.B300` | Native modals use `borderBottomWidth: config.borderWidth.B300` to avoid overriding the border-color set by the folds variant system |
|
||||
| N37 | EventReaders Timestamp | `EventReaders.tsx` | 143–151 | Lotus path sets `fontSize: '0.72rem'` inline — a raw relative unit between folds `T200` and `T100` scale steps — **FIXED**: removed raw `fontSize`, added `priority="300"` | Use folds `Text size="T200" priority="300"` for subdued secondary text |
|
||||
| N38 | BookmarksPanel Header | `BookmarksPanel.tsx` | 155–196 | Header uses `variant="Surface"` and close button uses `size="300" radii="300"`; also has a SurfaceVariant search bar strip with no equivalent in any native drawer — **FIXED (full redesign)**: rebuilt the whole "Saved Messages" panel to match the canonical `MembersDrawer` — co-located `BookmarksPanel.css.ts` (`toRem(266)` + `max-width:750px` full-screen media query, replacing the old `position:absolute; zIndex:100` mobile "modal" that had no backdrop/escape), `variant="Background"` header, room **avatars** on each item (was a generic hash icon), `priority` tokens replacing all raw `opacity` hacks, the `borderLeft:3px` accent removed, and Escape-to-close added. | `MembersDrawer` header uses `variant="Background"` and default-size close button; the extra search+count strip creates a structurally different component family |
|
||||
| N39 | Forward Menu Icon | `Message.tsx` | 1150 | Forward context menu item's `after` icon has no `size="100"` prop — **FIXED**: added `size="100"` to the `ArrowRight` icon | Every other after-icon in the same menu block explicitly uses `size="100"` (Reply, Reaction, Edit, Remind Me, Bookmark); missing size causes the Forward icon to render larger |
|
||||
| N40 | ProfileDecoration Remove Button | `ProfileDecoration.tsx` | 185 | "Remove" link is a raw `<button>` with `background: 'none'; color: 'var(--tc-surface-low-contrast)'` — an undefined CSS variable — **FIXED**: replaced with `<Button variant="Critical" fill="None" size="300" radii="300">` | Use folds `<Button variant="Critical" fill="None">` or a `Text`-styled inline link |
|
||||
| N41 | PresenceBadge / UserNotes Saving | `UserRoomProfile.tsx` | 240–244 | "Saving…" indicator is `<Text opacity={0.5}>` without a spinner — **FIXED**: now shows a folds `<Spinner variant="Success" fill="Solid" size="100">` beside the "Saving…" text | Every other save operation in `Profile.tsx` shows a folds `<Spinner variant="Success" fill="Solid" size="300">` alongside the save button |
|
||||
| N42 | Character Counter Convention | `UserRoomProfile.tsx` vs `Profile.tsx` | 243 / 479–490 | `UserPrivateNotes` shows remaining count `"N left"`, appears only under 100; `ProfileStatus` shows `"current / 64"` always with color progression | Two Lotus features in the same settings flow use different counter conventions; neither matches a pre-existing Cinny pattern |
|
||||
| N43 | Night Light Slider | `General.tsx` | 554–565 | Night Light intensity slider is a raw `<input type="range">` with no `accentColor` token — renders in browser-default blue on all themes — **FIXED**: added `accentColor: color.Primary.Main`; the intensity label `opacity` hack also replaced with `priority="300"` | The Gate Threshold slider at `General.tsx:1456` at minimum sets `accentColor: 'var(--accent-orange)'`; the Night Light slider does neither |
|
||||
| N44 | Mention Highlight & Boot Button | `General.tsx` | 597–677 | `<input type="color">` for mention highlight uses raw pixel dimensions (`width: '36px'`, `height: '28px'`, `borderRadius: '4px'`); Reset and Boot buttons are bare `<button>` with Lotus CSS vars — **PARTIALLY FIXED**: the mention-highlight Reset button (renders on all themes) is now a folds `<Button variant="Secondary" fill="Soft">`, removing the undefined `--border-interactive-normal` var. The Boot button is **deliberately kept** as-is: it only renders when `lotusTerminal` is active, i.e. exactly when the `--accent-orange*` TDS vars are defined. The `<input type="color">` itself is tracked separately as N69. | Adjacent settings controls use folds `IconButton`/`Button`; there is no other `<input type="color">` in the Cinny settings UI |
|
||||
| N45 | SettingsSelect vs SelectTheme | `General.tsx` | 126 vs 197 | `SettingsSelect` trigger uses `variant="Secondary"` while `SelectTheme` uses `variant="Primary" outlined fill="Soft"` for the same `Button`+`PopOut` dropdown pattern — adjacent rows in the same Appearance section have different visual weight — **FIXED**: `SelectTheme` trigger changed to `variant="Secondary"` to match `SettingsSelect` | Dropdown triggers should share the same variant within the same settings section |
|
||||
| N46 | RoomInsights SectionHeader | `RoomInsights.tsx` | 24–37 | `SectionHeader` adds `textTransform: 'uppercase'`, `letterSpacing: '0.06em'`, `opacity: 0.6` to `Text size="L400"` — **FIXED**: simplified to `<Text size="L400" priority="300">` | Every other settings panel uses bare `<Text size="L400">Label</Text>` with no transforms (`General.tsx:52–72`, `ExportRoomHistory.tsx:220,246`) |
|
||||
| N47 | RoomInsights Chart Radii | `RoomInsights.tsx` | 350–356 / 415–436 | Bar chart uses `borderRadius: 3` and histogram bars use `borderRadius: '2px 2px 0 0'` as raw pixel integers — **FIXED**: replaced with `config.radii.R300` | All other rounded corners use `config.radii.*` tokens |
|
||||
| N48 | RoomInsights Font Size | `RoomInsights.tsx` | 448 | Hour-axis labels set `style={{ fontSize: 9 }}` as a raw pixel integer — overrides the folds `Text size="T200"` applied on the same element — **FIXED**: removed raw `style={{ fontSize: 9 }}` | Use only folds `Text` size props; never override with raw `fontSize` |
|
||||
| N49 | RoomInsights Emoji Icons | `RoomInsights.tsx` | 41–65 / 292–295 | `StatTile` uses literal Unicode emoji (`🖼️ 🎬 🎵 📎`) in `<Text size="H4">` as icons — **FIXED**: `StatTile` now takes an `icon: IconSrc` and renders `<Icon>` using `Icons.Photo/VideoCamera/Headphone/File` | All other iconographic elements use `<Icon src={Icons.*} />` from folds — emoji rendering varies between Windows/macOS/Linux and cannot be tinted by the theme |
|
||||
| N50 | RoomInsights Warning Banner | `RoomInsights.tsx` | 168–192 | Disclaimer banner uses raw `<Box style={{ border: color.Warning.Main, background: color.Warning.Container }}>` — **FIXED**: replaced with `<SequenceCard variant="SurfaceVariant">` with `<Icon>` colored via `color.Warning.Main` | Settings panel informational cards use `<SequenceCard variant="SurfaceVariant">` throughout RoomServerACL, ExportRoomHistory, PolicyListViewer |
|
||||
| N51 | ExportRoomHistory Progress | `ExportRoomHistory.tsx` | 311–314 | Export progress shows as a plain `Text` string ("Exporting… N messages") — **WON'T FIX (deliberate)**: unlike `BackupRestore` (which has a known total to drive a determinate `ProgressBar`), export has no known total — it counts messages as they stream. The operation already shows a folds `Spinner` in the button plus a live count, which is the correct affordance for an indeterminate task. | `BackupRestore.tsx:72,90` uses a folds `<ProgressBar variant="Secondary" size="300">` for the same kind of long async operation |
|
||||
| N52 | MessageQuickReactions Empty Return | `Message.tsx` | 160 | `if (recentEmojis.length === 0) return <span />;` — injects an invisible DOM node into the hover action bar flex container — **FIXED**: changed to `return null` | Universal convention for empty renders in Cinny is `return null`; 144+ instances across the codebase; the empty `<span>` can affect flex spacing |
|
||||
|
||||
---
|
||||
|
||||
### Round 2 — Additional Feature Areas
|
||||
|
||||
#### 🔴 Additional Major Findings
|
||||
|
||||
**N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`**
|
||||
|
||||
- **File:** `src/app/features/call/CallControls.tsx`, lines 242–282
|
||||
- **Status:** **OPEN**
|
||||
- **Issue:** When `lotusTerminal` is true the PTT badge renders as a bare `<Box>` with inline styles referencing `--lt-accent-green-dim`, `--lt-accent-green-border`, `--lt-accent-green` — variables absent outside TDS mode — hardcoded rem padding, `borderRadius: '99px'` (non-token), a raw monospace `fontFamily` string, non-token `letterSpacing`, and a raw `animation:` CSS string for the live-pulse dot. The live `●` dot is a raw `<span>` with inline style.
|
||||
- **Root Cause:** Two entirely separate component trees for the same badge depending on a theme boolean. The non-terminal path (lines 284–301) uses the correct `<Chip variant="Success"|"Warning" fill="Soft" radii="400" outlined>`.
|
||||
- **Fix:** Remove the terminal branch. The standard `<Chip>` path already exists and TDS theming can be applied via the CSS variable layer without a separate component tree.
|
||||
|
||||
**N54 — PiP Mute Overlay Badges: Raw `<div>` instead of folds `<Badge>`/`<Chip>`**
|
||||
|
||||
- **File:** `src/app/components/CallEmbedProvider.tsx`, lines 438–477
|
||||
- **Status:** **FIXED** — replaced hardcoded `borderRadius`/`padding`/`fontSize` with `config.radii.R300`, `config.space.S100/S200` tokens; replaced raw `<span>` text with folds `<Text size="T200">`; color now applied to the `Icon`/`Text` via `color.Critical/Warning.Main`. The dark translucent scrim (`rgba(0,0,0,0.65)`) is **deliberately retained**: these badges overlay arbitrary video, where a theme `Chip`/`Badge` surface token would not guarantee legibility. They are also non-interactive (`pointerEvents: 'none'`), so an interactive `Chip` (a `<button>`) is semantically wrong.
|
||||
- **Issue:** Both the "You muted" (bottom-left) and "All muted" (top-right) PiP badges are raw `<div>` elements with hardcoded `background: 'rgba(0,0,0,0.65)'`, `backdropFilter: 'blur(4px)'`, `borderRadius: '6px'`, `padding: '3px 7px'`, `fontSize: '12px'`. Color is set as `color: color.Critical.Main` directly on the wrapper `<div>`, not via a folds `variant` prop. Text is `<span style={{ fontSize: '11px', fontWeight: 600 }}>`.
|
||||
- **Root Cause:** `CallView.tsx` line 127 uses `<Badge variant="Critical" fill="Solid" size="400">` in the same file for the "N Live" indicator — the native pattern exists and is unused here.
|
||||
|
||||
**N55 — Chat Background / Seasonal Theme Selected State Uses `color.Critical.Main` (Error Red)**
|
||||
|
||||
- **File:** `src/app/features/settings/general/General.tsx`, lines 1660–1661 and 1726–1728
|
||||
- **Status:** **FIXED** — replaced all 4 instances of `color.Critical.Main` with `color.Primary.Main` in `General.tsx`
|
||||
- **Issue:** The selected-state border for both `ChatBgGrid` and `SeasonalBgGrid` is `border: \`2px solid ${color.Critical.Main}\``and the label color is also`color.Critical.Main`. `color.Critical.Main` is the semantic token for **destructive/error states** — it is used for "Leave Room", "Delete Message", "Report Room" in the same file. A normal selection indicator rendered in error red is semantically wrong and visually alarming.
|
||||
- **Root Cause:** Wrong semantic token for an active/selected state.
|
||||
- **Fix:** Replace `color.Critical.Main` with `color.Primary.Main` (or `color.Success.Main` to match how other settings selections are styled) for both the border and label color.
|
||||
|
||||
**N56 — Report Modal Category Dropdown: Native `<select>` Instead of folds `Chip`+`PopOut`+`Menu`**
|
||||
|
||||
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138–163; `src/app/features/room/ReportUserModal.tsx` lines 144–169
|
||||
- **Status:** **FIXED** — extracted a shared `ReportCategorySelect` component (`src/app/features/room/ReportCategorySelect.tsx`) using the folds `Button` trigger + `PopOut` + `FocusTrap` + `Menu` + `MenuItem` pattern (with `escapeDeactivates`/arrow-key nav, matching `OrderButton`); both modals now use it instead of the native `<select>`.
|
||||
- **Issue:** Both report modals render the "Category" field as `<Box as="select">` with hand-rolled inline styles (padding, border, background, color, fontSize, fontFamily). No other selector in the message-action modal context uses `<select>` — the established pattern for all dropdowns in both message modals and search filters is `Chip onClick → setMenuAnchor → PopOut → FocusTrap → Menu → MenuItem` (reference: `OrderButton` in `SearchFilters.tsx` lines 63–114).
|
||||
|
||||
---
|
||||
|
||||
#### 🟠 Additional Moderate Findings
|
||||
|
||||
| # | Area | File | Lines | Issue | Native Pattern |
|
||||
| :-- | :--------------------------------------------------------------------------- | :-------------------------------------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| N57 | PiP Fullscreen Button | `CallEmbedProvider.tsx` | 929–951 | PiP fullscreen toggle is a raw `<button>` with `background: 'rgba(0,0,0,0.65)'`, `color: '#fff'`, `fontSize: '13px'`, Unicode ⛶/⊡ glyph — no focus ring, no tooltip — **FIXED (token discipline)**: `borderRadius`/`padding`/gap replaced with `config.radii.R300` + `config.space.*` tokens (also on the "Return to call" label). The dark scrim and `#fff` text are **deliberately kept** for legibility over arbitrary video; the glyph stays because folds has no fullscreen icon. `aria-label`/`title` tooltip already present. | `Controls.tsx` fullscreen button uses `<IconButton variant="Surface" fill="Soft" radii="400" size="400" outlined>` with `<TooltipProvider>`; hardcoded `#fff` fails on light themes |
|
||||
| N58 | Screenshare Confirm Popup | `CallControls.tsx` | 303–360 | "Share your screen?" popup is a raw `<Box>` with `--bg-surface`/`--bg-surface-border` vars (undefined outside TDS), `borderRadius: '0.75rem'`, `boxShadow: '0 8px 32px rgba(...)'`, no `FocusTrap` | Cinny's confirmation dialogs use folds `<Menu>` + `<FocusTrap>` + `<PopOut>`; the non-FocusTrap popup is not keyboard-accessible |
|
||||
| N59 | ML Noise Suppression Panel | `General.tsx` | 1303–1487 | Sub-panel uses `var(--border-color)`, `var(--bg-card)`, `var(--bg-input)` (undefined in folds default theme), raw `<details>`/`<summary>` (UA-styled), `accentColor: 'var(--accent-orange)'` (TDS-only) | All other settings sub-sections use `<SettingTile>` rows inside `<SequenceCard>`; no other settings component uses `<details>` |
|
||||
| N60 | Knock Badge on Members Button | `RoomViewHeader.tsx` | 744–782 | Knock count badge wrapped in extra `<div style={{ position: 'relative' }}>` with hardcoded `fontSize: '9px'`, `minWidth: '14px'`, `height: '14px'`, `padding: '0 3px'` overriding folds `size="200"` — **FIXED**: removed wrapper div, put `position: 'relative'` directly on the `IconButton`, `<Badge size="400">` with `toRem(3)` insets and `<Text size="L400">` — now matches the Pinned Messages badge pattern exactly | Pinned Messages badge (same header, lines 651–677) uses `position: 'relative'` directly on `<IconButton>` + `toRem()` for inset; no extra wrapper div |
|
||||
| N61 | Knock Member Rows | `MembersDrawer.tsx` | 441–487 | Knock requester rows use raw `<Box>` with manually duplicated padding; no `<MenuItem>` wrapper → no hover/focus/active states — **WON'T FIX (deliberate)**: unlike a `MemberItem` (a clickable navigation row), a knock row contains two action buttons (Approve / Deny) and is **not itself clickable**. Wrapping it in `<MenuItem>` (a `<button>`) would nest interactive controls inside a button — invalid HTML/ARIA. The row has no interactive state to express. | Every joined/invited member uses `<MemberItem>` which wraps `<MenuItem variant="Background" radii="400">` with baked-in spacing and all interactive states |
|
||||
| N62 | Unverified Device Banner | `RoomInput.tsx` | 860–883 | Warning callout above composer uses inline `background: color.Warning.Container`, `borderLeft: '3px solid color.Warning.Main'` — a custom left-border accent pattern not present anywhere else in the folds system — **FIXED**: replaced the `borderLeft: '3px'` accent with a standard full `border` using `color.Warning.ContainerLine` + `config.borderWidth.B300`; removed the `opacity` hacks (folds `OnContainer` already meets contrast) | Warning indicators in the same codebase use `<Chip variant="Warning">` or `<Badge variant="Warning">`; the 3px left-border card pattern has no folds equivalent |
|
||||
| N63 | Report Modals — Box Instead of Dialog | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 97–110 / 103–116 | Both modals render as `<Box as="form" role="dialog">` with inline `background`/`borderRadius`/`boxShadow`; use `config.radii.R400` (rounder) vs native `Dialog` which uses `R300` — **FIXED**: both shells are now `<Dialog as="form" variant="Surface">`; removed inline surface styles (Dialog provides background/radius/shadow) | Native `MessageReportItem` at `Message.tsx:634` and all other Cinny message-action modals use `<Dialog variant="Surface">` |
|
||||
| N64 | EditHistoryModal — `<Modal>` vs `<Dialog>` | `EditHistoryModal.tsx` | 166 | Uses `<Modal variant="Surface" size="500">` while sibling message-action modals (`DeleteMessageItem:505`, `MessageReportItem:634`) all use `<Dialog variant="Surface">` — different widths and internal padding | `<Dialog variant="Surface">` is the established modal shell for all message-triggered dialogs |
|
||||
| N65 | EditHistoryModal — No "Load More" | `EditHistoryModal.tsx` | 253–259 | When `hasMore` is true the modal shows passive `<Text>"Showing the 50 most recent edits"</Text>` with no action; older edits are inaccessible — **FIXED**: implemented real pagination — edits accumulate across `next_batch` fetches (de-duped by event id, re-sorted by ts), with a folds `<Button>Load more</Button>` (spinner while loading) replacing the passive text | `RoomActivityLog.tsx:425` and `MessageSearch.tsx:129` both render a folds `<Button size="300" variant="Secondary">Load more</Button>` to fetch the next page |
|
||||
| N66 | DateRangeButton — Native `<input type="date">` | `SearchFilters.tsx` | 558–589 | "From" and "To" date fields are raw `<input type="date">` with inline style overrides including `fontSize: '0.82rem'` — **FIXED**: replaced both with folds `<Input type="date" variant="SurfaceVariant" size="300" radii="300">`; removed now-unused `color` import | `SelectRoomButton` (same file, line 224) and `SelectSenderButton` (line 424) both use folds `<Input size="300" radii="300">`; the date inputs are the only native browser inputs in the search filter row |
|
||||
| N67 | SeasonalEffect / NightLight Z-Index Order | `SeasonalEffect.tsx` / `App.tsx` | 759 / 62–77 | `SeasonalEffect` mounts at `zIndex: 9999`; `NightLightOverlay` at `zIndex: 9998`. Seasonal particles render **above** Night Light so they are never tinted. `SeasonalEffect` also shares `z-index: 9999` with the skip-to-content link in `ClientLayout.tsx` — **FIXED**: lowered `SeasonalEffect` overlay to `zIndex: 9997` (below Night Light at 9998 and modals at 9999), so Night Light now tints the particles and dialogs are never obscured | Expected UX: Night Light tints all visible content including effects; requires either a higher Night Light z-index or a lower SeasonalEffect z-index |
|
||||
| N68 | Syntax Highlighting — `--lt-accent-*` Vars in Non-TDS Themes | `syntaxHighlight.ts` | 313–323 | `tokenStyle()` returns `var(--lt-accent-cyan/green/orange/purple, hardcoded-fallback)` — `--lt-*` vars only exist in TDS mode; fallbacks are Monokai dark colors that have poor contrast on light themes and no relationship to the existing `--prism-*` variables in `ReactPrism.css` — **FIXED**: `tokenStyle()` now maps to the `--prism-*` family (keyword/selector/boolean/atrule/comment) which has proper light/dark/TDS palettes; comment uses `--prism-comment` instead of an opacity hack | `ReactPrism.css` uses `--prism-keyword`, `--prism-selector` etc. which switch correctly between light and dark palettes; syntax highlighting should use the same variable family |
|
||||
| N69 | Mention Highlight — `<input type="color">` Instead of `HexColorPickerPopOut` | `General.tsx` | 644–675 | Raw `<input type="color">` with hardcoded pixel dimensions; OS-native color picker chrome renders completely differently from the rest of settings UI — **FIXED**: replaced with `<HexColorPickerPopOut>` + `<HexColorPicker>` (react-colorful) behind a folds `<Button>` trigger showing a color swatch; the picker's built-in `onRemove` replaces the separate Reset button | `PowersEditor.tsx:125–143` establishes `<HexColorPickerPopOut picker={<HexColorPicker ...>}>` as the codebase's color-picking pattern; Reset button should be `<Button size="300" variant="Secondary" radii="300">` |
|
||||
| N70 | ChatBgGrid / SeasonalBgGrid — Raw `<button>` Elements | `General.tsx` | 1648–1689 / 1711–1742 | Both pickers use raw HTML `<button>` elements with hardcoded `width: toRem(76)`, `height: toRem(50/56)`, `borderRadius: toRem(8)`, `border: 2px solid rgba(...)` — no focus ring via folds, no `variant` prop, no hover state from the design system — **FIXED**: chrome (radius, border, hover, **keyboard `:focus-visible` ring**, selected state via `data-selected`) moved to a shared `BgSwatch.css.ts` using `config`/`color` tokens; only the per-swatch size + live preview background remain inline (these are inherently custom preview tiles, not folds `MenuItem`/`Chip` candidates) | Native Cinny theme pickers use folds `<MenuItem>` or `<Chip>` which respond to theme and provide focus/hover states automatically |
|
||||
|
||||
---
|
||||
|
||||
#### 🟡 Additional Minor Findings
|
||||
|
||||
| # | Area | File | Lines | Issue | Native Pattern |
|
||||
| :-- | :-------------------------------------------- | :-------------------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| N71 | Call Prescreen Text | `CallView.tsx` | 63–85 | `ChannelFullMessage` and `AlreadyInCallMessage` use `<Text style={{ color: color.Critical/Warning.Main }}>` inline instead of folds `<Badge variant="Critical/Warning">` — **WON'T FIX (deliberate)**: these are full, centered explanatory **sentences** ("Channel Full (N/M) — Wait for someone to leave…"), not short labels. A `Badge` is for compact chips like "N Live"; wrapping a sentence in one is visually wrong. They already use folds `color.*` tokens. The sibling `LivekitServerMissingMessage`/`NoPermissionMessage` use the same (un-flagged) pattern. | The "N Live" badge directly above (line 127) correctly uses `<Badge variant="Critical" fill="Solid" size="400">` |
|
||||
| N72 | Mute MenuItem Icon | `RoomNavItem.tsx` | 454–466 | "Mute" `<MenuItem>` places bell-mute icon as a raw child node instead of using the `before` prop — **FIXED**: moved `Icons.BellMute` to `before` prop | Every other `<MenuItem>` in both `RoomNavItemMenu` and `RoomMenu` places its leading icon in the `before` prop |
|
||||
| N73 | Pending Requests Header | `MembersDrawer.tsx` | 415–422 | "Pending Requests" section header is bare `<Text>` with inline padding instead of `className={css.MembersGroupLabel}` — **FIXED**: now uses `className={css.MembersGroupLabel}` like every other section header | Power-level group labels at lines 506–519 use `className={css.MembersGroupLabel}` for all other section headers in the same virtualizer list |
|
||||
| N74 | Emoji Prefix Span | `RoomNavItem.tsx` | 730–736 | Emoji prefix rendered as raw `<span style={{ fontSize: '1.15em', lineHeight: 1 }}>` inside a `<Text>` node — **FIXED**: removed the emoji-splitting span; the room name (including any leading emoji) now renders directly inside `<Text>` | All other nav item text uses folds `<Text size="Inherit">` or similar — no raw `<span>` with em-based font-size override exists elsewhere in the sidebar |
|
||||
| N75 | Room Name Override / Star Indicators | `RoomNavItem.tsx` | 741–757 | Pencil and star indicator icons are embedded inside the name `<Box as="span">`, giving them the same visual baseline as the room name text — **WON'T FIX (deliberate)**: an inline favorite-star / local-name marker adjacent to the name is a deliberate, common design (cf. Element/Slack pinned-name markers). Moving them to the far right would collide with the unread/notification indicators already there and risks layout regressions. Low value, real regression risk. | Native sidebar status indicators (unread count, notification mode icon) are placed to the far right of the item, never inside the name text span group |
|
||||
| N76 | Report Modals — Extra Cancel Button | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 189–191 / 195–197 | Both custom report modals include a "Cancel" `<Button>` in the footer row — **FIXED**: removed the Cancel button; dismissal is via the header `×` / click-outside, matching `MessageReportItem` | Native `MessageReportItem` (`Message.tsx:675–691`) has no Cancel button — dismissal is via `×` header button or click-outside only |
|
||||
| N77 | Search Filter Inline Lambdas | `SearchFilters.tsx` | 480, 625 | `SelectSenderButton` and `DateRangeButton` trigger chips use inline `onClick` arrow functions — **WON'T FIX (deliberate)**: purely a code-style nit with zero user-facing or behavioural impact. Inline arrow handlers are idiomatic React and used throughout this very file; extracting them yields no functional benefit. | `OrderButton` (line 58) and `SelectRoomButton` (line 195) both extract a named `const handleOpenMenu: MouseEventHandler<HTMLButtonElement>` handler — bypassing the type annotation in the inline form |
|
||||
| N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined` — **FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar |
|
||||
| N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">` — `Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only |
|
||||
| N80 | Server Support Contact Layout | `About.tsx` | 172–239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout — **WON'T FIX (deliberate)**: a contact is `role → {matrix_id?, email?, …}` (one-to-many links per role), which doesn't map onto `SettingTile`'s single `title`/`description`/`after` slots without contortion. The current layout already uses folds `Box`/`Text`/`SequenceCard` + tokens and `Text as="a"` (a valid folds pattern); no undefined vars or raw HTML chrome. | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit |
|
||||
| N81 | Background Picker Grid — No Responsive Layout | `General.tsx` | 1707–1742 | Fixed `width: toRem(76)` flex-wrap cells with no `minWidth` floor or CSS grid `auto-fill` — SeasonalBgGrid's 13 items produce a visually lopsided orphan last row at any viewport width | Cinny's native grids use `grid-template-columns: repeat(auto-fill, minmax(N, 1fr))` or equivalent for responsive fill |
|
||||
| N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 1592–1609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance (button label, description text, or "▶ Preview" button) communicates this to the user | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect |
|
||||
|
||||
---
|
||||
|
||||
### Round 3 — Rich Topic Editor, RemindMe Dialog, Composer Toolbar, Voice Recorder, Uploads, Location, Mention Highlight
|
||||
|
||||
#### 🔴 Additional Major Findings
|
||||
|
||||
**N83 — Rich Topic Formatting Toolbar: Raw `<button>` Elements with Fully Inline Styles**
|
||||
|
||||
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 335–358
|
||||
- **Status:** **FIXED** — replaced raw `<button>` elements with `<Button size="300" radii="300" variant="Secondary" fill="Soft">` with styled `<Text>` children for B/I/S/code labels
|
||||
- **Issue:** The four formatting buttons (B, I, S, `` ` ``) in the room topic editor are plain HTML `<button>` elements with entirely inline styles: manual `border`, `borderRadius`, `background`, `color`, `cursor`, `fontSize`, `fontWeight`, `fontStyle`, `fontFamily`, `lineHeight`. They bypass the folds design token system completely — no `variant`, `size`, or `radii` props, no theme-reactive hover/focus states.
|
||||
- **Root Cause:** Custom addition without referencing folds primitives.
|
||||
- **Fix:** Replace with `<IconButton type="button" size="300" radii="300" variant="Surface" fill="Soft">` matching the emoji-picker trigger immediately above them at line 285, which already uses the correct pattern.
|
||||
|
||||
**N84 — Topic Preview in Room Settings Renders Plain Text Instead of `formatted_body`**
|
||||
|
||||
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 457–461
|
||||
- **Status:** **FIXED** — read-mode topic now checks `topic.format === 'org.matrix.custom.html'` and renders `parse(sanitizeCustomHtml(topic.formatted_body))`, matching `RoomTopicViewer` and all other display sites
|
||||
- **Issue:** The read-mode topic display wraps `topic.topic` (the plain-text field) in `<Linkify>` and never reads `formatted_body`. However `buildTopicContent()` (lines 82–89) intentionally stores both `topic` and `formatted_body` under `org.matrix.custom.html`. After the user saves a formatted topic, the preview panel immediately shows the stripped plain-text version — the formatting appears to disappear within the same settings panel.
|
||||
- **Root Cause:** The existing `RoomTopicViewer` component (`src/app/components/room-topic-viewer/RoomTopicViewer.tsx:24–51`) already checks `topic.format === 'org.matrix.custom.html'` and pipes `formatted_body` through `sanitizeCustomHtml`. This component is used everywhere else (`RoomIntro`, `LobbyHero`, `RoomItem`, `Invites`, etc.) but not in Room Settings.
|
||||
- **Fix:** Replace the inline plain-text render with `<RoomTopicViewer topic={roomTopic}>` to match all other display sites.
|
||||
|
||||
---
|
||||
|
||||
#### 🟠 Additional Moderate Findings
|
||||
|
||||
| # | Area | File | Lines | Issue | Native Pattern |
|
||||
| :-- | :--------------------------------- | :------------------------- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| N85 | RemindMe Dialog Shell | `RemindMeDialog.tsx` | 69–81 | Dialog shell is `<Box role="dialog">` with `background`, `borderRadius`, `boxShadow`, `overflow` all set as inline styles using token lookups. Corner radius is `config.radii.R400` which differs from the `R300` embedded in `<Dialog variant="Surface">` — **FIXED**: shell replaced with `<Dialog variant="Surface" style={modalStyle}>`; removed the inline `background`/`borderRadius`/`boxShadow`/`overflow` and the now-unused `color` import | All small message-action dialogs (`LeaveRoomPrompt`, `LogoutDialog`, `JoinAddressPrompt`, `PowerChip`, `DeleteMessageItem`) use `<Dialog variant="Surface" style={modalStyle}>` as the shell |
|
||||
| N86 | RemindMe Preset Buttons | `RemindMeDialog.tsx` | 111–117 | The four preset time choices (20 min, 1 hr, 3 hr, tomorrow) use `<MenuItem size="300" radii="300">` — `MenuItem` is a navigation primitive tied to `menu`/`menubar` ARIA roles; placing it inside `role="dialog"` is an invalid ARIA combination — **FIXED**: each preset is now a folds `<Button variant="Secondary" fill="Soft" radii="300">`, resolving the invalid `menuitem`-in-`dialog` ARIA | Dialog action choices use `<Button>` (delete/leave/logout dialogs) or `<Chip>` (selection choices). No other dialog in the codebase uses `MenuItem` for action items |
|
||||
| N87 | Composer Toolbar Toggle Pattern | `General.tsx` | 1100–1114 | Per-button toolbar toggles (Format, Emoji, Sticker, GIF, Location, Poll, Voice, Schedule) use `<Chip variant="Primary"/"Secondary" radii="Pill">` in a wrap grid — a compact chip-toggle grid inside a `SettingTile`, different from every adjacent row | The three sibling tiles in the same `Editor()` function (ENTER for Newline, Markdown, Formatting Toolbar) all use `<SettingTile after={<Switch variant="Primary">}>`. 15+ other binary settings in the file use the Switch pattern |
|
||||
| N88 | Voice Recorder Recording State | `VoiceMessageRecorder.tsx` | 195, 206, 240, 276 | Recording container background is `var(--bg-surface-variant)`, the live pulse dot is `var(--tc-danger-normal)`, waveform bars are `var(--tc-primary-normal)` — custom Lotus CSS vars that may not exist in folds themes, falling back to transparent/black — **FIXED**: replaced with `color.SurfaceVariant.Container`, `color.Critical.Main`, `color.Primary.Main` | Native message components use JS-accessible `color.*` tokens that are always populated regardless of theme class |
|
||||
| N89 | Voice Recorder Preview Audio | `VoiceMessageRecorder.tsx` | 282–283 | Preview state renders bare `<audio src={previewUrl} controls>` — native browser element with inconsistent cross-browser chrome — **FIXED**: replaced with `<audio ref>` + folds `<IconButton>` play/pause toggle; `onEnded` resets playing state | Native audio messages use folds `Attachment`/`AttachmentContent` layout wrappers; pre-send preview should use `<IconButton>` play/pause controls |
|
||||
| N90 | Mention Highlight Contrast Formula | `App.tsx` | 36–40 | Auto-computed text color (black/white) uses simplified luma `(0.299r + 0.587g + 0.114b)/255 > 0.5` — not WCAG 2.1 relative luminance (which requires gamma linearization) — **FIXED**: replaced with WCAG 2.1 relative luminance formula using `((c+0.055)/1.055)^2.4` gamma linearization; threshold moved from 0.5 to 0.179 | Folds `color.*.OnContainer` tokens are manually curated to pass WCAG AA 4.5:1 contrast ratios; custom computation must match this guarantee |
|
||||
|
||||
---
|
||||
|
||||
#### 🟡 Additional Minor Findings
|
||||
|
||||
| # | Area | File | Lines | Issue | Native Pattern |
|
||||
| :-- | :--------------------------------- | :----------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| N91 | Upload Card Caption Input | `UploadCardRenderer.tsx` | 356–376 | Caption input is raw `<input type="text">` with hardcoded inline CSS using Lotus-specific vars not in folds — **FIXED**: replaced with folds `<Input variant="Secondary" size="300" radii="300">` | Other text inputs in the UI use folds `<Input size="300" radii="300">` with folds-token props for all sizing and color |
|
||||
| N92 | Location "Open Location" Button | `MsgTypeRenderers.tsx` | 534–547 | "Open Location" action link uses `<Chip as="a">` — compact badge-sized element — **FIXED**: replaced with `<Button as="a" variant="Secondary" fill="Solid" radii="300" size="400">` matching FileContent pattern | `FileContent.tsx` uses `<Button variant="Secondary" fill="Solid" radii="300" size="400">` for "Open File"/"Open PDF" |
|
||||
| N93 | Location Coordinates Text | `MsgTypeRenderers.tsx` | 532 | `<Text size="T300" style={{ opacity: 0.65 }}>` — hardcoded non-standard opacity — **FIXED**: replaced with `<Text size="T300" priority="300">` | Secondary text uses folds `priority` prop; `0.65` is outside the token scale |
|
||||
| N94 | Mention Highlight Border Invisible | `App.tsx` | 41 | `--mention-highlight-border` is set to the same value as `--mention-highlight-bg` — the border is invisible — **FIXED**: border is now `rgba(r,g,b,0.5)` — same hue as the background at 50% opacity, always visible | In folds, `color.*.ContainerLine` is always a lighter/muted sibling of `color.*.Container`, providing the 1px outline that gives mention chips visual definition |
|
||||
## 🔴 Open — Actionable
|
||||
|
||||
### Calls / Audio
|
||||
|
||||
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
|
||||
|
||||
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
|
||||
|
||||
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
|
||||
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
|
||||
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
|
||||
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
|
||||
> a dedicated cross-system planning session with the homeserver owner. Capture
|
||||
> full client console + a synapse-side trace for the same call before starting.
|
||||
> **None of these are caused by the EC fork work** (the issues reproduce on the
|
||||
> old build; the local mic/denoise path is unrelated to key distribution).
|
||||
|
||||
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
|
||||
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
|
||||
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}` —
|
||||
firing **continuously** (many/sec). The client repeatedly tries to publish an
|
||||
OTK at a key id the server already holds **with a different value**, i.e. the
|
||||
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
|
||||
the crypto outgoing-request loop and is the prime suspect for the downstream
|
||||
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
|
||||
to-device key events). _Investigate:_ device/key-store reset-or-restore
|
||||
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
|
||||
Synapse OTK bug. Repro signature: grep console for `already exists`.
|
||||
**Extreme — planning session.**
|
||||
|
||||
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
|
||||
`MissingKey: missing key at index N for participant @user`, `skipping decryption
|
||||
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
|
||||
rust-crypto `WARN … Received an unexpected encrypted to-device event …
|
||||
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
|
||||
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
|
||||
these aren't being received/decrypted in order, so remote LiveKit audio/video
|
||||
can't be decrypted — **this is the "friend's audio cuts out occasionally"
|
||||
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
|
||||
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
|
||||
session.**
|
||||
|
||||
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
|
||||
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
|
||||
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
|
||||
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
|
||||
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
|
||||
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
|
||||
|
||||
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
|
||||
`[MembershipManager] Network local timeout error while sending event, immediate
|
||||
retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
|
||||
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
|
||||
call membership and missed leave events. May be partly **homeserver
|
||||
responsiveness**; correlate with synapse latency/load. Include in the same
|
||||
planning session since it shares the call-reliability + HS-interaction surface.
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
|
||||
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
|
||||
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
|
||||
|
||||
### PWA / Offline / Notifications
|
||||
|
||||
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
|
||||
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
|
||||
- ~~**`manifest: false`** 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
|
||||
|
||||
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
|
||||
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
|
||||
|
||||
### Code Hygiene / DevEx
|
||||
|
||||
- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
|
||||
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
||||
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
||||
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
|
||||
- **`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`.
|
||||
- **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.
|
||||
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
|
||||
|
||||
### Big Projects
|
||||
|
||||
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
|
||||
|
||||
+191
-14
@@ -25,7 +25,8 @@ Last updated: June 2026.
|
||||
16. [Notifications](#notifications)
|
||||
17. [Server Integration](#server-integration)
|
||||
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
|
||||
|
||||
### Element Call Upgrade
|
||||
> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
|
||||
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
|
||||
> `LotusGuild/element-call`), served same-origin — no longer the upstream
|
||||
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
||||
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
||||
> work list are in
|
||||
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
|
||||
|
||||
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
|
||||
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
||||
|
||||
The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
|
||||
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
|
||||
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
|
||||
`@element-hq/element-call-embedded`. The iframe prints
|
||||
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
|
||||
upstream) — the quickest way to confirm a deploy landed the fork.
|
||||
|
||||
All custom behavior lives in the fork's `src/lotus/` modules and is **additive
|
||||
and dormant by default**, gated by URL flags / widget actions the host opts into,
|
||||
so a stock EC config is byte-for-byte upstream behavior.
|
||||
|
||||
**Active (cinny drives them today):**
|
||||
|
||||
| # | Feature | Mechanism | Replaces (old hack) |
|
||||
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||
|
||||
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
|
||||
|
||||
| # | Capability | Widget action | cinny surface |
|
||||
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||
| P5-15 | **Audio inject** | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below |
|
||||
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
||||
|
||||
> Both were dormant capabilities; cinny now drives them (armed via
|
||||
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
|
||||
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
||||
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
||||
> client cap is best-effort.
|
||||
|
||||
### In-Call Soundboard (P5-15)
|
||||
|
||||
A soundboard button (🔔) in the call controls bar opens a popout of the user's
|
||||
clips. Clicking one **injects it into the call as a real published LiveKit
|
||||
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
|
||||
plays it locally for the presser (LiveKit doesn't loop your own track back).
|
||||
|
||||
- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
|
||||
`io.lotus.soundboard` account data event, so they **sync across all your
|
||||
devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
|
||||
inline.
|
||||
- Authenticated media can't be fetched from the widget's realm, so the host
|
||||
resolves each mxc clip → an authenticated download → a same-session `blob:`
|
||||
object URL and hands that to the widget.
|
||||
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
|
||||
slider. The button is hidden when disabled.
|
||||
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
|
||||
`features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||
|
||||
### Call Quality Controls (P5-31)
|
||||
|
||||
Discord-style encoding controls applied to the local tracks via the fork's
|
||||
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
|
||||
encodings, re-applied on every re-publish/reconnect).
|
||||
|
||||
- **User settings** (Settings → General → Calls): Microphone Bitrate,
|
||||
Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
|
||||
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
|
||||
(`io.lotus.room_quality` state event); every Lotus client clamps its per-user
|
||||
quality to `min(user setting, room cap)`.
|
||||
- Applied by the `useCallQuality` hook on join and whenever settings/caps
|
||||
change; `utils/callQuality.ts` builds the payload (unit-tested).
|
||||
|
||||
**Server-enforced call permissions (hard, ALL clients).** The same
|
||||
`io.lotus.room_quality` event carries a **publish-source policy**
|
||||
(`allow_screenshare`, `allow_camera`) enforced server-side by
|
||||
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
|
||||
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
|
||||
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
|
||||
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
|
||||
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
|
||||
new joins, and a background reconcile loop revokes an **in-progress**
|
||||
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
|
||||
flipping the policy — so it kills active shares mid-call, not just future ones.
|
||||
|
||||
- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
|
||||
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
|
||||
grant, room config, server `limit:`, or admin API; stock Element Call ignores
|
||||
room metadata for publish quality. Numeric caps are therefore inherently
|
||||
**cooperative** — our fork honors them, which is the design above. The
|
||||
publish-source policy is the one genuine hard, cross-client lever, and it's
|
||||
implemented.
|
||||
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
|
||||
the fork).
|
||||
|
||||
### Camera Default Off
|
||||
|
||||
@@ -417,7 +513,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
||||
|
||||
**Advanced Features & Test Options:**
|
||||
|
||||
- **Multiple ML Models:** Toggle between **RNNoise** (standard hybrid) and **Speex** (legacy DSP-based) to compare artifact levels and suppression strength.
|
||||
- **Multiple ML Models:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
|
||||
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
||||
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
||||
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
||||
@@ -426,20 +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.
|
||||
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
|
||||
|
||||
**Open-Source Model Roadmap:**
|
||||
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **RNNoise** | Poor | Moderate | < 5% |
|
||||
| **DTLN** | Good | High | 10-20% |
|
||||
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ |
|
||||
**Open-Source Models (all now in-source in the EC fork):**
|
||||
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
|
||||
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
||||
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
|
||||
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
||||
|
||||
> **Note:** DeepFilterNet 3 is planned for future inclusion in the desktop build where larger binaries and higher CPU overhead are more acceptable.
|
||||
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
|
||||
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
|
||||
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
|
||||
> rather than ever going silent). The model picker selects between them.
|
||||
|
||||
> **Update (2026-07) — quality, reliability & AEC/AGC:**
|
||||
>
|
||||
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
|
||||
> a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
|
||||
> under the denoised signal so suppression can't fully collapse the noise
|
||||
> floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
|
||||
> would comb-filter, so they rely on their own level. The **noise gate now runs
|
||||
> after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
|
||||
> `lotusDenoiseFloor` param.
|
||||
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
|
||||
> **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
|
||||
> gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
|
||||
> playback stays on standard elements — no AEC-defeat vector.)
|
||||
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
|
||||
> `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
|
||||
> (transient failures recover), activation off the local participant (works
|
||||
> solo), and init/build-failure leak fixes.
|
||||
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
|
||||
> open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.
|
||||
|
||||
### Files
|
||||
|
||||
- `build/lotus-denoise.js` — multi-model getUserMedia shim
|
||||
- `vite.config.js` — `lotusDenoise()` plugin (copies assets for RNNoise, Speex, and NoiseGate)
|
||||
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → widget URL params
|
||||
- **EC fork** `src/lotus/lotusDenoise.ts` + `lotusDenoiseProcessor.ts` — in-source LiveKit `TrackProcessor` (RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated by `lotusDenoiseSource=1`. (The old build-time `getUserMedia` shim `build/lotus-denoise.js` is **removed**.)
|
||||
- `vite.config.js` — `lotusDenoise()` plugin (now only **copies model assets** for the fork to load; no longer injects a shim)
|
||||
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → `lotusDenoiseSource` widget URL param
|
||||
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
|
||||
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
|
||||
|
||||
@@ -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
|
||||
|
||||
| File | Purpose |
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
# Lotus Chat — Manual Testing Guide
|
||||
|
||||
**Generated:** June 2026
|
||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||
|
||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||
|
||||
## Environment notes
|
||||
|
||||
- You push from your own machine; these commits are local on `lotus` until you do.
|
||||
- Test the **web** build (LXC 106 / `code.lotusguild.org`) first; re-run the **call** + **poll** sections on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
|
||||
- Several call features need a **second participant** (second account on another device/browser, or a colleague). Items that need this are marked **👥 2 people**.
|
||||
- A couple of call items need a **third room/call** in parallel — marked **👥👥**.
|
||||
|
||||
---
|
||||
|
||||
## Commits covered
|
||||
|
||||
| Commit | Area |
|
||||
| :--------- | :--------------------------------------------------------------------------- |
|
||||
| `caf6318a` | Poll vote buttons → folds tokens (N4) |
|
||||
| `c67aed01` | In-call incoming-call banner (#4b) |
|
||||
| `4a875884` | Selectable ringtone (#4a) |
|
||||
| `0394fce9` | EC iframe load watchdog + recovery UI; avatar decorations on call tiles (#3) |
|
||||
| `d2946c00` | Upload retry/backoff, presence-on-unload, typed m.direct |
|
||||
| `b7e1f89c` | Timeline/composer/emoji perf memoization |
|
||||
| `c0f98672` | Upstream **Element Call 0.20.1** merge (regression sweep) |
|
||||
|
||||
---
|
||||
|
||||
## A. Calls — new ringtone + notification work (highest priority)
|
||||
|
||||
### A1. Ringtone selection — preview in Settings
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Open **Settings → General**, scroll to the **Calls** section.
|
||||
2. Find the new **Ringtone** dropdown (just above **Ringtone Volume**).
|
||||
3. Select each option in turn: **Classic, Chime, Soft, Retro, Silent**.
|
||||
|
||||
**Expected**
|
||||
|
||||
- Selecting **Classic** plays the existing `call.ogg` clip (cut off after a few seconds).
|
||||
- **Chime / Soft / Retro** each play a short, distinct synthesized preview.
|
||||
- **Silent** plays nothing.
|
||||
- Changing **Ringtone Volume** then re-selecting a ringtone previews at the new volume.
|
||||
- No console errors.
|
||||
|
||||
> ⚠️ **Known browser limitation:** the synthesized tones use WebAudio. If a preview is ever silent, click anywhere on the page once (a "user gesture") and retry — browsers suspend audio until the page has been interacted with. The Settings preview is _after_ a click so it should always sound; this note matters more for A3.
|
||||
|
||||
### A2. Ringtone selection persists
|
||||
|
||||
1. Set Ringtone to **Retro**, reload the app.
|
||||
2. **Expected:** the dropdown still shows **Retro** (setting persisted).
|
||||
3. Bonus: in devtools, set `localStorage.settings` to a bogus `ringtoneId` and reload → it should fall back to **Classic**, not break.
|
||||
|
||||
### A3. Incoming call uses the selected ringtone — 👥 2 people
|
||||
|
||||
**Setup:** Account A (you) and Account B in a **DM** or a **private (invite-only) group** room.
|
||||
|
||||
1. As A, pick a non-silent ringtone (e.g. **Chime**).
|
||||
2. From B, **start a call** in that DM/room. Do **not** answer on A.
|
||||
|
||||
**Expected on A**
|
||||
|
||||
- The full-screen **Incoming Call** dialog appears (caller name, room avatar, Answer / Reject).
|
||||
- The **selected ringtone loops** until you answer/reject/ignore (at the set volume).
|
||||
- Answer → joins the call. Reject (DM) / Ignore (group) → dialog dismisses and ring stops.
|
||||
- Set ringtone to **Silent** and repeat → dialog still appears, **no sound**.
|
||||
|
||||
### A4. In-call banner for a second incoming call — 👥👥 (the trickiest one)
|
||||
|
||||
**Setup:** You (A) already **in a call** in Room 1. Account B can call you in a **different** Room 2 (a DM or private group you share). Ideally a third account C, or B leaves Room 1's call first.
|
||||
|
||||
1. While A is **actively in Room 1's call**, trigger an incoming call to A from **Room 2**.
|
||||
|
||||
**Expected on A**
|
||||
|
||||
- **No** full-screen takeover. Instead a **compact banner appears in the top-right corner** with the caller's avatar, room name, "Incoming voice/video call", and **Answer / Reject (or Ignore)** buttons.
|
||||
- It plays a **single soft ping**, _not_ a looping ring (so it doesn't talk over your active call).
|
||||
- The banner does **not** cover your active call's controls/PiP in a way that blocks them.
|
||||
- **Answer** → switches you into Room 2's call. **Reject/Ignore** → banner disappears.
|
||||
- The banner auto-dismisses if the caller hangs up / the call times out.
|
||||
|
||||
**Also verify the no-op case:** while in Room 1's call, if a notification for **Room 1 itself** arrives, **nothing** should pop up (no banner, no dialog).
|
||||
|
||||
### A5. Camera focus during screenshare (#1) — 👥 2 people
|
||||
|
||||
**Setup:** You (A) and B in a call; B (or another participant) **sharing their screen**, and at least one person with **camera on**.
|
||||
|
||||
1. As A, open the **participant glance** (the stacked avatars / member list for the call) and click a participant who has their **camera on**.
|
||||
2. In the menu, click **"Focus camera"**.
|
||||
|
||||
**Expected**
|
||||
|
||||
- The view switches to **spotlight** and **pins that person's camera tile**, overriding the auto-spotlighted screenshare.
|
||||
- It **stays** on that camera (doesn't immediately snap back to the screenshare).
|
||||
- If you pick someone with their camera **off**, it should at worst just toggle spotlight (graceful fallback), not error.
|
||||
|
||||
### A6. Avatar decorations on call tiles (#3) — 👥 2 people
|
||||
|
||||
**Setup:** A participant in the call has an **avatar decoration** set (Settings → Profile decoration).
|
||||
|
||||
1. Join a call with that participant.
|
||||
2. Look at **our** participant roster / prescreen tiles (not the avatars rendered inside the Element Call video grid — those are EC's and out of scope).
|
||||
|
||||
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
|
||||
|
||||
### A7. EC iframe load watchdog + recovery UI (#EC, N96)
|
||||
|
||||
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
|
||||
|
||||
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
|
||||
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
|
||||
|
||||
**Expected**
|
||||
|
||||
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
|
||||
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
|
||||
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
|
||||
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
|
||||
|
||||
---
|
||||
|
||||
## B. Polls (N4) — render correctly on non-TDS themes
|
||||
|
||||
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
|
||||
|
||||
### B1. Poll renders on a default theme — ✅ PASS
|
||||
|
||||
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
|
||||
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
|
||||
|
||||
**Expected**
|
||||
|
||||
- Each option is a clearly **bordered** button with visible rounded corners.
|
||||
- A **radio circle** indicator is visible on the left of each option.
|
||||
- Text, and (after votes) the percentage, are legible.
|
||||
|
||||
### B2. Voting + selected/progress state
|
||||
|
||||
1. **Vote** on an option.
|
||||
**Expected**
|
||||
|
||||
- The selected option shows a **filled accent border + filled radio**, and an **accent progress-bar fill** grows behind it proportional to the vote %.
|
||||
- The percentage and total vote count update.
|
||||
- Click again / pick another option → selection moves correctly (single-choice replaces; the bar redraws).
|
||||
|
||||
### B3. Multiple-choice poll
|
||||
|
||||
1. Create a poll allowing **multiple selections**.
|
||||
**Expected**
|
||||
|
||||
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
|
||||
- You can select **several** options; each shows its own progress fill.
|
||||
|
||||
### B4. Lotus Terminal theme regression — ✅ PASS
|
||||
|
||||
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
|
||||
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
|
||||
|
||||
---
|
||||
|
||||
## C. Robustness / background behavior
|
||||
|
||||
### C1. Presence updates on tab close
|
||||
|
||||
1. Open the app, then **close the tab** (or quit the browser).
|
||||
2. From another session/device, check your **presence** shortly after.
|
||||
**Expected:** you go **offline/away** reliably (the unload now uses `fetch({keepalive})`). Previously this could be missed.
|
||||
|
||||
### C2. Upload retry on flaky network (best-effort)
|
||||
|
||||
1. In devtools → Network, set a throttle that drops/slows requests, or toggle Offline briefly **during** a file upload.
|
||||
**Expected**
|
||||
|
||||
- A transient failure **retries** (up to 3×, with backoff) and the upload can still succeed once the network recovers.
|
||||
- A genuine, permanent rejection (e.g. file too large / 4xx) still **fails fast** with the usual error — it should **not** spin retrying.
|
||||
|
||||
### C3. General timeline/composer perf (no functional regression)
|
||||
|
||||
The memoization changes are invisible if correct. Just confirm **nothing broke**:
|
||||
|
||||
- Open a busy room; scrolling, jump-to-latest, mark-as-read all still work.
|
||||
- Composer: send a message, upload a file, share a location, pick an emoji and a sticker — all still work.
|
||||
|
||||
---
|
||||
|
||||
## D. Element Call 0.20.1 merge — regression sweep (👥 2 people)
|
||||
|
||||
The upstream bump changed EC's internals and DOM selectors; our call controls drive that iframe, so sweep them. In a live call with 2 people, confirm **each** of our control-bar buttons works:
|
||||
|
||||
- [ ] **Mic** mute/unmute (icon + actual audio)
|
||||
- [ ] **Camera** on/off
|
||||
- [ ] **Deafen / Sound** toggle (your deafen key too)
|
||||
- [ ] **Screenshare** start/stop (and the "Share your screen?" confirm)
|
||||
- [ ] **Screenshare audio** mute toggle
|
||||
- [ ] **Fullscreen** toggle
|
||||
- [ ] **⋮ More** menu → **Spotlight/Grid**, **Reactions**, **Settings** each open the right EC panel
|
||||
- [ ] **End** call leaves cleanly
|
||||
- [ ] **PTT** (push-to-talk) if enabled: hold key = transmit, release = mute; releasing on blur works
|
||||
- [ ] **AFK auto-mute** if enabled: goes muted after the timeout
|
||||
- [ ] **PiP** (picture-in-picture) mini window: drag, resize, fullscreen button, return-to-call; the "You muted" / "All muted" badges show on the right person
|
||||
- [ ] **Denoise** (if ML noise suppression enabled): call audio still flows, no silence
|
||||
|
||||
If any control does nothing, that usually means an EC DOM selector changed — capture the console and tell me which button.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
> 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.
|
||||
|
||||
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
||||
|
||||
### E1. Composer toolbar touch targets (#7)
|
||||
|
||||
On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
|
||||
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.
|
||||
|
||||
### E2. Room Settings — no horizontal overflow (#8)
|
||||
|
||||
On a narrow phone screen, open **Room Settings**.
|
||||
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.
|
||||
|
||||
### E3. Modals go fullscreen on mobile (#9)
|
||||
|
||||
On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
|
||||
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.
|
||||
|
||||
### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially
|
||||
|
||||
On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
|
||||
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.
|
||||
|
||||
### E5. Mobile "Saved Messages" access (Mobile Bookmarks)
|
||||
|
||||
On a phone, **inside a room**, open the room header **··· More Options** menu.
|
||||
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)
|
||||
|
||||
---
|
||||
|
||||
## F. Visual / theming
|
||||
|
||||
### F1. Animated chat background — no flicker (#2)
|
||||
|
||||
Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
|
||||
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.
|
||||
|
||||
### F2. Background vs. Seasonal theme are mutually exclusive (#6)
|
||||
|
||||
In Settings → Appearance:
|
||||
|
||||
1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
|
||||
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
|
||||
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
|
||||
|
||||
### F3. Background / seasonal picker grid layout (N81)
|
||||
|
||||
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
|
||||
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
|
||||
|
||||
---
|
||||
|
||||
## G. Calls — additional unverified (👥 2 people)
|
||||
|
||||
### G1. PiP mute badges point at the right person (#12)
|
||||
|
||||
In a call with at least one other person, pop out the **Picture-in-Picture** mini window.
|
||||
|
||||
- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
|
||||
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
|
||||
**Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).
|
||||
|
||||
### G2. Full-screen camera broadcasts
|
||||
|
||||
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
|
||||
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
|
||||
|
||||
### G3. PTT badge renders on all themes (N53)
|
||||
|
||||
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
|
||||
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
|
||||
|
||||
---
|
||||
|
||||
## H. Media / performance (needs a room with many images)
|
||||
|
||||
### H1. Lazy image decryption (P5-5 / MediaGallery)
|
||||
|
||||
Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
|
||||
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.
|
||||
|
||||
### H2. Thumbnail framing (P5-6)
|
||||
|
||||
Look at **tall portrait** images in the timeline and in the media gallery.
|
||||
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).
|
||||
|
||||
---
|
||||
|
||||
## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)
|
||||
|
||||
With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):
|
||||
|
||||
- [ ] **Reaction** buttons announce the emoji + count (e.g. "thumbsup reaction, 3 people").
|
||||
- [ ] **Edit history** button announces "View edit history".
|
||||
- [ ] **Thread indicator** announces "View thread".
|
||||
- [ ] **Reply** (jump to original) announces "Jump to original message".
|
||||
|
||||
---
|
||||
|
||||
## J. Desktop / Tauri build only
|
||||
|
||||
### J1. Proactive update notifications (P5-40)
|
||||
|
||||
In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
|
||||
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)
|
||||
|
||||
### J2. DTLN noise suppression sanity
|
||||
|
||||
In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
|
||||
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.
|
||||
|
||||
---
|
||||
|
||||
## K. Features — end-to-end unverified
|
||||
|
||||
### K1. Remind Me Later
|
||||
|
||||
On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
|
||||
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).
|
||||
|
||||
### K2. Advanced search filters (P4-9)
|
||||
|
||||
In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
|
||||
**Expected:** each narrows results correctly and reflects in the search.
|
||||
|
||||
### K3. Notification content + click target (P5-20 partial)
|
||||
|
||||
Trigger a desktop/browser notification for a new message.
|
||||
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).
|
||||
|
||||
---
|
||||
|
||||
## L. Fixed — verify
|
||||
|
||||
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
|
||||
|
||||
**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 verify:**
|
||||
|
||||
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
||||
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
|
||||
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.
|
||||
|
||||
### L2. Maskable PWA icon (N108) — Android install
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## M. New features (this round)
|
||||
|
||||
### M1. Search: `has:image` / `has:file` / `has:video` filters
|
||||
|
||||
1. Open message search (in a room with shared images/files/videos in history).
|
||||
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
|
||||
|
||||
**Expected:**
|
||||
|
||||
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
|
||||
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
|
||||
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
|
||||
|
||||
### M2. Search: recent searches
|
||||
|
||||
1. Run a few different searches, then **clear the search box** and focus it.
|
||||
|
||||
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
|
||||
|
||||
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
|
||||
|
||||
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
|
||||
|
||||
**Expected:**
|
||||
|
||||
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
|
||||
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
|
||||
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
|
||||
- **Reset** clears it back to the theme default.
|
||||
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
|
||||
- Reload → the chosen accent **persists**.
|
||||
|
||||
---
|
||||
|
||||
### M4. Search: "Pinned only" filter
|
||||
|
||||
In message search, toggle the **Pinned** chip.
|
||||
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
|
||||
|
||||
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
|
||||
|
||||
Settings → Appearance → theme picker → try each of the 5 new themes.
|
||||
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
|
||||
|
||||
---
|
||||
|
||||
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
|
||||
|
||||
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
|
||||
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
|
||||
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
|
||||
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
|
||||
delta + a `config.json` override) OR against **mozilla.org** with a real account.
|
||||
|
||||
### N1. OIDC login flow (the core test) — needs a MAS homeserver
|
||||
|
||||
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
|
||||
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
|
||||
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
|
||||
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
|
||||
|
||||
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
|
||||
|
||||
### N2. Session persists across reload (token storage)
|
||||
|
||||
After N1, hard-refresh the page.
|
||||
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
|
||||
|
||||
### N3. Token refresh (long-lived session)
|
||||
|
||||
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
|
||||
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
|
||||
|
||||
### N4. Logout revokes at the issuer
|
||||
|
||||
Log out from Settings.
|
||||
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
|
||||
|
||||
### N5. Account-management deep-link
|
||||
|
||||
Settings → Account.
|
||||
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
|
||||
|
||||
### N6. Non-OIDC regression — password login unchanged
|
||||
|
||||
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
||||
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
|
||||
|
||||
---
|
||||
|
||||
## Priority if you're short on time
|
||||
|
||||
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
||||
2. **B1–B3** (polls on a default theme) — the confirmed visual bug.
|
||||
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
|
||||
4. **A7** false-positive check (normal joins don't show the error overlay).
|
||||
5. Everything else.
|
||||
+130
-146
@@ -5,28 +5,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Infrastructure & Maintenance
|
||||
|
||||
- [x] **Upgrade Synapse to v1.155.0** ✅ Done 2026-06-18
|
||||
- **Context:** 1.155.0 is the last version supporting Debian 12 Bookworm. LXC 151 is already on Debian 13 Trixie — OS migration was completed prior to this upgrade.
|
||||
- **What changed (1.154→1.155):** No breaking changes, no config changes, no DB migrations. Bugfixes: to-device EDU size limiting, restricted room joins, sliding sync subscription response timing. Rust port of more internal classes (perf only).
|
||||
- **MSC4452** (Preview URL capabilities) shipped in 1.154 — opt-in via `msc4452_enabled`, not enabled.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Quick Feature Additions
|
||||
|
||||
- [x] **Full-Screen Camera Broadcasts** ⚠️ UNTESTED — verify in a real call
|
||||
- **Context:** Element Call currently supports full-screening screenshares. We need to parity this functionality for camera broadcasts.
|
||||
- **Goal:** Users should be able to toggle any camera feed to full-screen mode, similar to the existing screenshare full-screen implementation.
|
||||
- **Implemented 2026-06-18:**
|
||||
1. **Fullscreen button always shows** — removed `screenshare &&` gate in `CallControls.tsx`. The fullscreen button is now available in camera-only calls, not just during screenshares.
|
||||
2. **Per-participant camera focus** — `CallControl.focusCameraParticipant(userId)` added. Finds the participant's video tile via `[data-testid="videoTile"]` / `[data-video-fit]` + `[aria-label="${userId}"]`, enables spotlight mode, then clicks the tile to focus them.
|
||||
3. **MemberGlance "Focus camera" action** — clicking a participant avatar in the call status bar now opens a mini popup with "Focus camera" (triggers focusCameraParticipant) and "View profile" options, rather than immediately opening the profile.
|
||||
4. **PiP fullscreen button** — a small fullscreen toggle button (⛶/⊡) is shown in the PiP overlay top-right, allowing users to go fullscreen directly from PiP mode without navigating back to the call room.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
|
||||
|
||||
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
|
||||
@@ -37,10 +15,45 @@
|
||||
|
||||
---
|
||||
|
||||
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
|
||||
|
||||
> **Every feature we implement must feel native to the upstream Cinny app — indistinguishable from something the Cinny team would have shipped.** Reference: <https://github.com/cinnyapp/cinny>.
|
||||
>
|
||||
> Concretely this means:
|
||||
>
|
||||
> - **Use the `folds` design system, not bespoke UI.** Build with folds primitives (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, etc.) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`, `config.borderWidth.*`). No hardcoded hex/`rgba()` for UI chrome, no invented/undefined CSS variables.
|
||||
> - **Match Cinny's existing patterns.** Before adding UI, find the closest existing Cinny component/flow and mirror it (e.g. a new dropdown uses `Button`+`PopOut`+`Menu`+`MenuItem` like the rest; a new modal has a `Header` with a close `IconButton`; a new setting is a `SettingTile` inside a `SequenceCard`). Consistency with stock Cinny beats personal style.
|
||||
> - **Lotus-custom additions should be unobtrusive** and fit Cinny's visual language, spacing, and interaction conventions — a stranger using Cinny should not be able to tell which features are ours.
|
||||
>
|
||||
> **The ONE exception:** explicit **Lotus Terminal Design System (TDS)** features, which intentionally have their own distinct look and follow the **TDS Design Law** above. TDS styling is opt-in (only active in Lotus Terminal mode); everything else must look and feel like native Cinny.
|
||||
|
||||
---
|
||||
|
||||
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Done — Awaiting Verification
|
||||
|
||||
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Bug-side fixes awaiting verification live in LOTUS_BUGS.md.)
|
||||
|
||||
| Feature | Test guide |
|
||||
| :-------------------------------------------------------------------------------- | :---------------- |
|
||||
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
|
||||
| Advanced search filters (sender/date/has-link/has:image·file·video/pinned/recent) | K2 / M1 / M2 / M4 |
|
||||
| Custom Accent Color Picker (non-TDS themes) | M3 |
|
||||
| 5 Color Theme Presets (Cyberpunk/Ocean/Blood Red/Matrix/Midnight) | M5 |
|
||||
| Intersection-based lazy media loading | H1 |
|
||||
| Context-aware thumbnail previews | H2 |
|
||||
| Desktop — proactive update notifications (Tauri) | J1 |
|
||||
| Remind Me Later | K1 |
|
||||
| Mobile Bookmarks access | E5 |
|
||||
| In-Call Soundboard (P5-15, uploadable clips → real call inject) | D2-7 |
|
||||
| Call Quality Controls (P5-31, user + room-admin caps) | D2-8 |
|
||||
| Call Permissions (P5-31, hard server-side screenshare/camera policy) | D2-9 |
|
||||
|
||||
---
|
||||
|
||||
Legend:
|
||||
|
||||
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing
|
||||
@@ -63,7 +76,7 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
||||
### Confirmed facts
|
||||
|
||||
| Finding | Impact |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
|
||||
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
|
||||
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
|
||||
@@ -83,7 +96,7 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
||||
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
|
||||
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
|
||||
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
|
||||
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
|
||||
| ~~Cindy CANNOT inject audio into EC call stream~~ **UNBLOCKED by EC fork** — `io.lotus.inject_audio` widget action publishes a clip as a real call track | In-call soundboard CAN now mix into the call (no longer local-only); needs cinny UI to drive the action |
|
||||
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
|
||||
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
|
||||
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
|
||||
@@ -193,24 +206,6 @@ Features:
|
||||
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
|
||||
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
||||
|
||||
### [x] P4-9 · Advanced Search Filter UI — PARTIALLY DONE (UNTESTED)
|
||||
|
||||
**What:** Improve search filter UX in `SearchFilters.tsx`.
|
||||
**Completed 2026-06-18:**
|
||||
|
||||
- ✅ `SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
|
||||
- ✅ `DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
|
||||
- ✅ `Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
|
||||
**UNTESTED** — needs verification at chat.lotusguild.org.
|
||||
|
||||
**Remaining for parity with Discord/Slack:**
|
||||
|
||||
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
|
||||
- [ ] Pinned messages filter
|
||||
- [ ] Saved searches / search history
|
||||
|
||||
---
|
||||
|
||||
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
|
||||
|
||||
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
|
||||
@@ -249,69 +244,42 @@ Features:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) (EXTREME COMPLEXITY, LOW PRIORITY)
|
||||
### [~] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) — CLIENT-SIDE BUILT, awaiting live verification
|
||||
|
||||
**Spec:** MSC3861, merged Matrix spec v1.15. Uses Matrix Authentication Service (MAS).
|
||||
**Context:** ~80% of homeserver users have LLDAP/Authelia/SSO accounts. SSO is currently enabled on `matrix.lotusguild.org` but accounts are not yet linked. This would allow users to log in via their SSO credentials.
|
||||
**What:** OAuth 2.0 / OIDC login flow, token refresh, account management page linking Matrix identity to SSO identity.
|
||||
**EXTREME COMPLEXITY** — requires: MAS deployment/configuration on the homeserver, significant auth flow changes in the client, token refresh handling, session management overhaul.
|
||||
**[SERVER CHECK]** — Before any client work, audit whether MAS is already deployed on `compute-storage-01`. Check: `pct exec 151 -- systemctl status matrix-authentication-service` or similar.
|
||||
**Complexity:** Extreme. Multi-sprint project. Plan separately.
|
||||
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
|
||||
**Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
|
||||
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
|
||||
|
||||
- Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
|
||||
- Login: `pages/auth/oidc/{oidcConfig,oidcLoginUtil,oidcState}.ts` (dynamic registration + cache, PKCE authorize), `login/OidcLogin.tsx`, issuer-gated `Login.tsx`.
|
||||
- Callback: `oidc/OidcCallback.tsx` + `App.tsx` short-circuit (non-hash redirect path).
|
||||
- Session/refresh: `state/sessions.ts` OIDC fields, `client/{oidcTokenRefresher,oidcLogout}.ts`, `initMatrix.ts` wiring.
|
||||
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
||||
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
|
||||
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
|
||||
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
|
||||
|
||||
---
|
||||
|
||||
## Priority 5 — Gamer / Aesthetic / Customization
|
||||
|
||||
### [ ] P5-1 · Custom Accent Color Picker (non-TDS mode only)
|
||||
|
||||
**What:** A hex/HSL color picker in Settings → Appearance. Chosen color replaces the primary accent throughout the UI: buttons, badges, active states, highlights, presence dot, links. Applied via a CSS custom property override injected into `<head>`.
|
||||
**IMPORTANT:** This feature is completely inactive when TDS is enabled — TDS has its own fixed palette. Add this setting under a "Non-TDS Themes" section that is hidden when TDS is active.
|
||||
**[AUDIT REQUIRED]** Identify all CSS custom properties that constitute the "accent color" in non-TDS mode. Map them to the folds/vanilla-extract token names. (Confirmed: folds uses vanilla-extract, NOT CSS custom properties — must create a new vanilla-extract theme variant dynamically.)
|
||||
**Complexity:** Medium.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-2 · Additional Color Theme Presets
|
||||
|
||||
**What:** 5 new one-click theme presets alongside TDS. Each must be a complete, polished system with proper contrast ratios (WCAG AA). All implemented as vanilla-extract themes matching the existing TDS pattern.
|
||||
|
||||
Themes:
|
||||
|
||||
1. **Cyberpunk** — deep navy bg (`#0a0015`), electric purple (`#bf5fff`) + hot pink (`#ff2d9b`) accents, neon glow
|
||||
2. **Ocean** — deep sea blue bg (`#020b18`), teal (`#00c9b1`) + aqua (`#0096d6`) accents, soft feel
|
||||
3. **Blood Red** — near-black bg (`#0d0203`), deep crimson (`#7a0010`) + bright red (`#ff2233`) accents
|
||||
4. **Classic Matrix** — pure black bg (`#000000`), phosphor green (`#00ff41`) text + accents
|
||||
5. **Midnight** — dark charcoal (`#111827`), cool blue-grey (`#6b7ca8`) accents, clean minimal
|
||||
|
||||
**[AUDIT REQUIRED]** Study `src/lotus-terminal.css.ts` for the full token list before designing themes. All tokens must be covered (~50 CSS custom properties each).
|
||||
**Complexity:** Medium (design effort is the main cost).
|
||||
|
||||
---
|
||||
|
||||
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
|
||||
|
||||
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
|
||||
|
||||
---
|
||||
|
||||
### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images
|
||||
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
|
||||
|
||||
**What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport.
|
||||
**Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible.
|
||||
**What:** Soundboard button in the call controls bar → popout grid of the user's clips; clicking one plays it **into the call** as a real published track (peers hear it) and locally (presser hears it). Clips are **user-uploadable, just like custom emojis/stickers**.
|
||||
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
|
||||
**Shipped (cinny):**
|
||||
|
||||
### [x] P5-6 · Context-Aware Thumbnail Previews ⚠️ UNTESTED
|
||||
|
||||
**What:** Enhance thumbnail rendering in the timeline for consistent, polished aesthetics.
|
||||
**Approach:** Use CSS `object-fit: cover` with improved focal-point centering within `ThumbnailContent` to prevent media stretching or awkward aspect-ratio cropping.
|
||||
**Fix Applied:** Added `objectPosition: 'center top'` to: (1) `media.css.ts` → `Image` component (timeline images), (2) video thumbnail inline style in `RenderMessageContent.tsx`, (3) `GalleryTile` `<img>` in `MediaGallery.tsx`. Full-size viewers retain `objectFit: 'contain'` — no change. `objectPosition: 'center top'` prevents face/subject cropping on tall portrait images capped at 600px by `AttachmentBox`.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-15 · In-Call Soundboard
|
||||
|
||||
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
|
||||
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature.
|
||||
**Complexity:** High.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -327,38 +295,55 @@ Themes:
|
||||
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
||||
|
||||
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
||||
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
|
||||
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
|
||||
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta".
|
||||
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
|
||||
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_BUGS.md` A7, `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
|
||||
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
|
||||
|
||||
**Model Roadmap (priority order):**
|
||||
**Models — all in-source in the fork:**
|
||||
|
||||
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
|
||||
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Effort: self-host `df_bg.wasm` + DFN3 ONNX model, wire a 48 kHz worklet.
|
||||
- [ ] **Desktop-only / HW-gated:** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in Tauri Rust backend + bridge a virtual mic into the webview. Must detect capability and only offer on supported hardware; web falls back to RNNoise.
|
||||
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
|
||||
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
|
||||
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams`→`ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
|
||||
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
|
||||
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
|
||||
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~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).
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
|
||||
### [~] P5-31 · Granular Voice & Screenshare Quality Controls — IMPLEMENTED (⚠️ awaiting live verification, D2-8)
|
||||
|
||||
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
|
||||
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
|
||||
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
|
||||
**Complexity:** Extreme.
|
||||
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
|
||||
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
|
||||
|
||||
**Shipped (cinny):**
|
||||
|
||||
1. **User settings** (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (`callAudioBitrate` / `screenshareBitrate` / `screenshareFramerate`).
|
||||
2. **Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
|
||||
3. **Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
|
||||
|
||||
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
|
||||
|
||||
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()` → `set_quality`, already shipped).
|
||||
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
|
||||
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
|
||||
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
|
||||
|
||||
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
|
||||
|
||||
---
|
||||
|
||||
### [ ] 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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
@@ -367,100 +352,92 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) ⚠️ UNTESTED (requires Tauri build)
|
||||
|
||||
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings.
|
||||
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
|
||||
**Note:** Ensure the check is throttled (e.g., once every 12 hours) to avoid redundant Tauri commands.
|
||||
**Complexity:** Low-Medium.
|
||||
|
||||
---
|
||||
|
||||
### [ ] 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.
|
||||
**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.
|
||||
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
|
||||
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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."
|
||||
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
|
||||
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
|
||||
**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).
|
||||
|
||||
### [~] 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.
|
||||
**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.
|
||||
**What:** Granular per-room sync tuning (frequency, event-type filtering).
|
||||
**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.
|
||||
**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.
|
||||
**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.
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Features to Add
|
||||
|
||||
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
|
||||
- [x] **Remind Me Later:** Slack-style reminders for messages — fully implemented ⚠️ UNTESTED end-to-end
|
||||
- **Storage:** `useReminders.ts` — persists to `io.lotus.reminders` account data with `addReminder` / `removeReminder` / `getReminders`
|
||||
- **UI:** `RemindMeDialog.tsx` — 4 presets (20 min, 1 hr, 3 hr, tomorrow 9am); wired into `Message.tsx` context menu via `remindOpen` state; `useModalStyle(320)` for mobile fullscreen
|
||||
- **Monitor:** `ReminderMonitor` in `ClientNonUIFeatures.tsx` — polls every 30s + on tab visibility; fires Lotus toast when due and calls `removeReminder`
|
||||
- [x] **Mobile Bookmarks:** Fixed ⚠️ UNTESTED — bookmarks now accessible from within any room on mobile
|
||||
- **Root Cause:** `BookmarksPanel` renders correctly on mobile but `BookmarksTab` lives in `SidebarNav`, which is hidden when inside a room on mobile (`MobileFriendlyClientNav` returns `null`). No trigger existed.
|
||||
- **Fix:** Added "Saved Messages" `MenuItem` to the `RoomMenu` (···More Options) in `RoomViewHeader.tsx`. Toggles `bookmarksPanelAtom` and closes the menu. Works on all screen sizes — desktop users see it as a duplicate of the sidebar star, mobile users now have their only in-room access point.
|
||||
|
||||
---
|
||||
|
||||
@@ -543,6 +520,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
||||
**Mechanism:** KaTeX injection into the HTML parser.
|
||||
|
||||
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
|
||||
> [Gemini_Found] `sanitize.ts` uses **`sanitize-html`** (not DOMPurify) with an explicit allowlist (`allowedTags`) and `disallowedTagsMode: 'discard'`. All MathML tags are currently absent from the allowlist and are silently stripped. Update `permittedHtmlTags` to include: `<math>`, `<mi>`, `<mo>`, `<mn>`, `<ms>`, `<mtext>`, `<mspace>`, `<mrow>`, `<mfrac>`, `<msqrt>`, `<mroot>`, `<mstyle>`, `<merror>`, `<mpadded>`, `<mphantom>`, `<mfenced>`, `<menclose>`, `<msub>`, `<msup>`, `<msubsup>`, `<munder>`, `<mover>`, `<munderover>`, `<mmultiscripts>`, `<mtable>`, `<mtr>`, `<mtd>`, `<maligngroup>`, `<malignmark>`, and `annotation`. Also add the required MathML attributes (e.g. `xmlns`, `display`, `mathvariant`) to `permittedTagToAttributes`.
|
||||
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
|
||||
```tsx
|
||||
if (node.type === 'text') {
|
||||
@@ -592,12 +570,18 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
||||
- Route the mic `MediaStream` and the clip source to the destination node.
|
||||
- Pass the destination's `.stream` to the call bridge.
|
||||
|
||||
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
||||
>
|
||||
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
|
||||
|
||||
---
|
||||
|
||||
### P5-20 · Quick Reply from Browser Notification
|
||||
|
||||
**Mechanism:** Service Worker `notificationclick` Action.
|
||||
|
||||
> [Gemini_Found] Implementation detail: `serviceWorkerRegistration.showNotification()` should be used instead of `new Notification()` so that the service worker can listen to the `notificationclick` event. `new Notification()` creates notifications that are bound to the client page, not the SW.
|
||||
|
||||
```typescript
|
||||
// src/sw.ts
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
@@ -659,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`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 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
|
||||
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
||||
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||
- Soundboard: upload your own short audio clips (like custom emojis — they sync across your devices) and play them into a call so everyone hears them
|
||||
- Call quality settings: cap your microphone bitrate, screenshare bitrate, and screenshare framerate — handy on a slow connection (Settings → Calls)
|
||||
- Room call permissions: admins can turn off screen sharing or make a room audio-only (no cameras) — enforced server-side for every Matrix client, and it stops an in-progress share within seconds of being switched off
|
||||
|
||||
### Customization & Appearance
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
@@ -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.
|
||||
|
||||
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||
|
||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
|
||||
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||
behavior is editable source instead of fragile DOM/widget hacks.
|
||||
|
||||
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
|
||||
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
|
||||
avatar decorations on EC video tiles, and a native transparent background.
|
||||
**Built but dormant (need cinny UI):** real call-audio injection
|
||||
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
||||
(`io.lotus.set_quality`).
|
||||
|
||||
The full plan and integration map is in
|
||||
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting +
|
||||
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs
|
||||
for the **`[EC-FORK]`** tag to find every related note.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
|
||||
+19
-2
@@ -30,6 +30,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
||||
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
||||
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
||||
var targetOrigin;
|
||||
try {
|
||||
var parentUrl = params.get('parentUrl');
|
||||
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
||||
} catch (e) {
|
||||
targetOrigin = window.location.origin;
|
||||
}
|
||||
|
||||
var md = navigator.mediaDevices;
|
||||
if (!md || typeof md.getUserMedia !== 'function') return;
|
||||
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||
@@ -274,6 +285,9 @@
|
||||
source.disconnect();
|
||||
mlNode.disconnect();
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (gateNode) gateNode.disconnect();
|
||||
} catch (e) {}
|
||||
try {
|
||||
origTrack.stop();
|
||||
} catch (e) {}
|
||||
@@ -301,7 +315,7 @@
|
||||
nativeNS: USE_NATIVE_NS,
|
||||
gate: USE_GATE,
|
||||
},
|
||||
'*',
|
||||
targetOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +330,10 @@
|
||||
.catch(function (e) {
|
||||
var msg = e instanceof Error ? e.message : String(e);
|
||||
console.error('[lotus-denoise] Setup failed:', msg);
|
||||
window.parent.postMessage({ type: 'lotus-denoise-status', active: false, error: msg }, '*');
|
||||
window.parent.postMessage(
|
||||
{ type: 'lotus-denoise-status', active: false, error: msg },
|
||||
targetOrigin,
|
||||
);
|
||||
return stream;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
-504
@@ -21,7 +21,6 @@
|
||||
"@giphy/js-util": "5.2.0",
|
||||
"@giphy/react-components": "10.1.2",
|
||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||
"@sentry/react": "10.53.1",
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
@@ -54,7 +53,6 @@
|
||||
"jotai": "2.20.0",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"lodash": "4.18.1",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
@@ -79,10 +77,9 @@
|
||||
"ua-parser-js": "2.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.20.1",
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
@@ -111,6 +108,7 @@
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"prettier": "3.8.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
@@ -1791,12 +1789,6 @@
|
||||
"node": ">=v18"
|
||||
}
|
||||
},
|
||||
"node_modules/@element-hq/element-call-embedded": {
|
||||
"version": "0.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz",
|
||||
"integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -2696,6 +2688,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.20.1-lotus.1",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
|
||||
"integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "18.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
||||
@@ -3783,403 +3781,6 @@
|
||||
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
|
||||
@@ -4894,18 +4495,6 @@
|
||||
"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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -6635,19 +6224,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -8474,19 +8050,6 @@
|
||||
"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": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
@@ -10600,26 +10163,6 @@
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"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": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
@@ -11179,16 +10722,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": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -11199,13 +10732,6 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -12784,12 +12310,6 @@
|
||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||
"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": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -12838,6 +12358,25 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -13337,22 +12876,6 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
+3
-4
@@ -16,6 +16,7 @@
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||
"prepare": "husky",
|
||||
"commit": "git-cz",
|
||||
"postinstall": "node scripts/patch-folds.mjs",
|
||||
@@ -45,7 +46,6 @@
|
||||
"@giphy/js-util": "5.2.0",
|
||||
"@giphy/react-components": "10.1.2",
|
||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||
"@sentry/react": "10.53.1",
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
@@ -78,7 +78,6 @@
|
||||
"jotai": "2.20.0",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"lodash": "4.18.1",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
@@ -103,10 +102,9 @@
|
||||
"ua-parser-js": "2.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.20.1",
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
@@ -135,6 +133,7 @@
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"prettier": "3.8.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
"Organisms": {
|
||||
"RoomCommon": {
|
||||
"changed_room_name": " changed room name"
|
||||
},
|
||||
"CreateRoom": {
|
||||
"chat_room": "Chat Room",
|
||||
"chat_room_desc": "Messages, photos, and videos.",
|
||||
"voice_room": "Voice Room",
|
||||
"voice_room_desc": "Live audio and video conversations."
|
||||
},
|
||||
"ImageViewer": {
|
||||
"download": "Download"
|
||||
},
|
||||
"Message": {
|
||||
"open_location": "Open Location",
|
||||
"thread": "Thread"
|
||||
},
|
||||
"ImageContent": {
|
||||
"view": "View",
|
||||
"spoiler": "Spoiler",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"DeviceVerification": {
|
||||
"close": "Close",
|
||||
"accept": "Accept",
|
||||
"they_match": "They Match",
|
||||
"okay": "Okay",
|
||||
"do_not_match": "Do not Match",
|
||||
"please_accept": "Please accept the request from other device.",
|
||||
"waiting_accept": "Waiting for request to be accepted...",
|
||||
"click_accept": "Click accept to start the verification process.",
|
||||
"request_accepted": "Verification request has been accepted.",
|
||||
"waiting_response": "Waiting for the response from other device...",
|
||||
"starting_emoji": "Starting verification using emoji comparison...",
|
||||
"confirm_emoji": "Confirm the emoji below are displayed on both devices, in the same order:",
|
||||
"device_verified": "Your device is verified.",
|
||||
"verification_canceled": "Verification has been canceled."
|
||||
},
|
||||
"UrlPreview": {
|
||||
"join_server": "Join Server"
|
||||
},
|
||||
"InviteUser": {
|
||||
"invite": "Invite"
|
||||
},
|
||||
"UploadBoard": {
|
||||
"files": "Files",
|
||||
"send": "Send",
|
||||
"upload_failed": "Upload Failed"
|
||||
},
|
||||
"PasswordStage": {
|
||||
"account_password": "Account Password",
|
||||
"password": "Password",
|
||||
"invalid_password": "Invalid Password!",
|
||||
"authenticate_prompt": "To perform this action you need to authenticate yourself by entering you account password."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@
|
||||
"src": "./res/android/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"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"],
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
+11
-2
@@ -19,8 +19,17 @@ try {
|
||||
writeFileSync(foldsPath, content, 'utf8');
|
||||
console.log('Applied defensive Icon src guard to folds.');
|
||||
} else {
|
||||
console.warn('Warning: folds Icon patch target not found - may need updating.');
|
||||
// Genuine "patch could not be applied" case: the target string is gone
|
||||
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
|
||||
// so the postinstall hook / CI breaks loudly instead of silently shipping
|
||||
// an unpatched folds (which crashes at render with "src is not a function").
|
||||
console.error(
|
||||
'ERROR: folds Icon patch target not found - folds may have updated. ' +
|
||||
'Update the patch target string in scripts/patch-folds.mjs before building.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Warning: Could not patch folds:', e.message);
|
||||
console.error('ERROR: Could not patch folds:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
||||
|
||||
const CDN = 'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
// Single source of truth: the CDN base URL lives in avatarDecorations.ts as
|
||||
// `export const DECORATION_CDN`. We extract it from there at runtime rather than
|
||||
// re-declaring it here, so the build script and the app can never drift. This
|
||||
// .mjs script can't cleanly import the browser-side .ts module (it's outside the
|
||||
// Vite/TS app graph), so we parse the constant out of the file text instead.
|
||||
// If you migrate the CDN, change it ONLY in avatarDecorations.ts.
|
||||
const catalog = readFileSync(catalogPath, 'utf8');
|
||||
|
||||
const cdnMatch = catalog.match(/export const DECORATION_CDN\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (!cdnMatch) {
|
||||
console.error(
|
||||
'Could not find `export const DECORATION_CDN` in avatarDecorations.ts — ' +
|
||||
'the constant may have been renamed. Update scripts/syncDecorations.mjs.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const CDN = cdnMatch[1];
|
||||
|
||||
// Extract all slugs from the catalog file
|
||||
const catalog = readFileSync(catalogPath, 'utf8');
|
||||
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
||||
|
||||
if (slugMatches.length === 0) {
|
||||
@@ -41,7 +56,8 @@ async function headCheck(slug) {
|
||||
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
||||
return { slug, ok: res.ok, status: res.status };
|
||||
} catch {
|
||||
return { slug, ok: false, status: 0 };
|
||||
// Network/DNS/TLS failure — NOT a confirmation the file is gone.
|
||||
return { slug, ok: false, status: 0, networkError: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +69,27 @@ for (let i = 0; i < slugMatches.length; i += BATCH) {
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const missing = results.filter((r) => !r.ok);
|
||||
// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
|
||||
// remove. A network error or any other non-ok status (5xx, 403, timeout) is
|
||||
// ambiguous — the CDN may be unreachable — so refuse to remove anything and
|
||||
// abort, otherwise a transient outage would wipe the whole catalog from source
|
||||
// control (N119).
|
||||
const transient = results.filter((r) => !r.ok && r.status !== 404);
|
||||
if (transient.length > 0) {
|
||||
console.error(
|
||||
`Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
|
||||
`(network error / server error). The CDN may be unreachable — refusing to ` +
|
||||
`remove entries to avoid wiping the catalog.`,
|
||||
);
|
||||
transient
|
||||
.slice(0, 8)
|
||||
.forEach((r) =>
|
||||
console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const missing = results.filter((r) => r.status === 404);
|
||||
const found = results.filter((r) => r.ok);
|
||||
|
||||
if (missing.length === 0) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
config,
|
||||
Dialog,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
@@ -36,13 +37,15 @@ import {
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import CallSound from '../../../public/sound/call.ogg';
|
||||
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
@@ -51,6 +54,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
import { getChatBg } from '../features/lotus/chatBackground';
|
||||
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
@@ -62,6 +66,7 @@ import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112;
|
||||
@@ -103,8 +108,8 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
const canAnswer = livekitSupported && rtcSupported;
|
||||
const { room } = info;
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room, dm);
|
||||
@@ -125,25 +130,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
),
|
||||
);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audioElement = audioRef.current;
|
||||
if (!audioElement) return;
|
||||
audioElement.volume = Math.max(0, Math.min(1, ringtoneVolume / 100));
|
||||
audioElement.play().catch(() => undefined);
|
||||
}, [ringtoneVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (info.notificationType === 'ring') {
|
||||
playSound();
|
||||
}
|
||||
return () => {
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
audioEl.currentTime = 0;
|
||||
}
|
||||
};
|
||||
}, [playSound, info.notificationType]);
|
||||
if (info.notificationType !== 'ring') return undefined;
|
||||
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||
return stop;
|
||||
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||
@@ -156,7 +147,6 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
@@ -258,10 +248,154 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
||||
<source src={CallSound} type="audio/ogg" />
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type IncomingCallBannerProps = {
|
||||
dm: boolean;
|
||||
info: IncomingCallInfo;
|
||||
onIgnore: () => void;
|
||||
onAnswer: (room: Room, video: boolean) => void;
|
||||
onReject: (room: Room, eventId: string) => void;
|
||||
};
|
||||
/**
|
||||
* Compact, non-intrusive incoming-call notification shown when the user is
|
||||
* ALREADY in a call. Unlike the full-screen `IncomingCall` overlay this is a
|
||||
* corner banner that does not take over the screen, and it plays a single
|
||||
* soft ping (via the one-shot ringtone preview) rather than the looping ring,
|
||||
* so it doesn't talk over the active call.
|
||||
*/
|
||||
function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallBannerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { room } = info;
|
||||
const isVideo = info.intent === 'video';
|
||||
|
||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room, dm);
|
||||
const avatarUrl = roomAvatar
|
||||
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const session = useCallSession(room);
|
||||
useCallMembersChange(
|
||||
session,
|
||||
useCallback(
|
||||
(members) => {
|
||||
if (members.length === 0) {
|
||||
onIgnore();
|
||||
}
|
||||
},
|
||||
[onIgnore],
|
||||
),
|
||||
);
|
||||
|
||||
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
|
||||
// + volume. We intentionally do NOT loop here — the user is mid-call — and we
|
||||
// ping exactly once per incoming call, not again if the user happens to tweak
|
||||
// ringtone settings while the banner is showing.
|
||||
const pingedRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (info.notificationType !== 'ring') return;
|
||||
if (pingedRef.current === info.refEventId) return;
|
||||
pingedRef.current = info.refEventId;
|
||||
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||
if (remaining <= 0) {
|
||||
onIgnore();
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(onIgnore, remaining);
|
||||
return () => clearTimeout(id);
|
||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||
|
||||
const callerName =
|
||||
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: config.space.S400,
|
||||
right: config.space.S400,
|
||||
zIndex: zIndices.inCallBanner,
|
||||
width: toRem(300),
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
padding: config.space.S300,
|
||||
background: color.Surface.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: `0 8px 32px ${color.Other.Shadow}`,
|
||||
}}
|
||||
role="alert"
|
||||
aria-label={`Incoming ${isVideo ? 'video' : 'voice'} call from ${roomName}`}
|
||||
>
|
||||
<Box gap="300" alignItems="Center">
|
||||
<Box shrink="No">
|
||||
<Avatar size="300" className={CallAvatarAnimation}>
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
roomType={room.getType()}
|
||||
size="200"
|
||||
joinRule={room.getJoinRule()}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
|
||||
<Text size="T300" truncate>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{isVideo ? 'Incoming video call' : 'Incoming voice call'}
|
||||
{dm ? '' : ` · ${callerName}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
fill="Solid"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => onAnswer(room, isVideo)}
|
||||
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
Answer
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant={dm ? 'Critical' : 'Secondary'}
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{dm ? 'Reject' : 'Ignore'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,6 +407,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
const mx = useMatrixClient();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
|
||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||
@@ -292,6 +427,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
await event.getDecryptionPromise();
|
||||
}
|
||||
|
||||
// Caller-side: a participant declined a call we're hosting in this room.
|
||||
// Without this the caller's UI keeps "ringing" until the notification
|
||||
// lifetime expires, with no indication the callee said no.
|
||||
if (event.getType() === EventType.RTCDecline) {
|
||||
const decliner = event.getSender();
|
||||
if (
|
||||
data.liveEvent &&
|
||||
room &&
|
||||
decliner &&
|
||||
decliner !== mx.getSafeUserId() &&
|
||||
callEmbed?.roomId === room.roomId
|
||||
) {
|
||||
const declinerName =
|
||||
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
|
||||
setToast({
|
||||
id: `rtc-decline-${event.getId() ?? decliner}`,
|
||||
displayName: declinerName,
|
||||
body: 'Declined your call',
|
||||
roomName: room.name,
|
||||
roomId: room.roomId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!room ||
|
||||
event.getType() !== EventType.RTCNotification ||
|
||||
@@ -354,7 +514,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
setCallInfo(info);
|
||||
},
|
||||
[mx, directs],
|
||||
[mx, directs, callEmbed, setToast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -390,10 +550,25 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
[startCall, navigateRoom],
|
||||
);
|
||||
|
||||
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
||||
if (!callInfo) return null;
|
||||
// Already in this room's own call — no notification at all.
|
||||
if (callEmbed?.roomId === callInfo.room.roomId) {
|
||||
return null;
|
||||
}
|
||||
return !joined && callInfo ? (
|
||||
// In a different call already: show the compact, non-intrusive banner
|
||||
// instead of the full-screen takeover overlay.
|
||||
if (joined) {
|
||||
return (
|
||||
<IncomingCallBanner
|
||||
dm={dm}
|
||||
info={callInfo}
|
||||
onIgnore={handleIgnore}
|
||||
onAnswer={handleAnswer}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IncomingCall
|
||||
dm={dm}
|
||||
info={callInfo}
|
||||
@@ -401,7 +576,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
onAnswer={handleAnswer}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
@@ -410,6 +585,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallThemeSync(embed);
|
||||
useCallQuality(embed);
|
||||
useCallHangupEvent(
|
||||
embed,
|
||||
useCallback(() => {
|
||||
@@ -576,7 +752,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
if (pipMode) {
|
||||
if (!wasInPip) {
|
||||
const saved = localStorage.getItem('pip-position');
|
||||
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
|
||||
let savedPos: { left: number; top: number } | null = null;
|
||||
if (saved) {
|
||||
try {
|
||||
const raw = JSON.parse(saved) as { left?: unknown; top?: unknown };
|
||||
// Validate shape + finiteness: a corrupt value would otherwise feed
|
||||
// NaN into Math.min and produce an invalid `NaNpx` CSS value.
|
||||
if (
|
||||
raw &&
|
||||
typeof raw.left === 'number' &&
|
||||
Number.isFinite(raw.left) &&
|
||||
typeof raw.top === 'number' &&
|
||||
Number.isFinite(raw.top)
|
||||
) {
|
||||
savedPos = { left: raw.left, top: raw.top };
|
||||
}
|
||||
} catch {
|
||||
savedPos = null;
|
||||
}
|
||||
}
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
if (savedPos) {
|
||||
@@ -933,10 +1127,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
>
|
||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||
{document.fullscreenEnabled && (
|
||||
<button
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePipFullscreen();
|
||||
@@ -945,19 +1142,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
// Dark scrim is intentional for legibility over arbitrary video.
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: 'none',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{pipIsFullscreen ? '⊡' : '⛶'}
|
||||
</button>
|
||||
{pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Verifier,
|
||||
} from 'matrix-js-sdk/lib/crypto-api';
|
||||
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
Box,
|
||||
@@ -51,21 +52,23 @@ function WaitingMessage({ message }: WaitingMessageProps) {
|
||||
|
||||
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
||||
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text>{message}</Text>
|
||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||
<Text size="B400">Close</Text>
|
||||
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationWaitAccept() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text>Please accept the request from other device.</Text>
|
||||
<WaitingMessage message="Waiting for request to be accepted..." />
|
||||
<Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
|
||||
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -74,12 +77,13 @@ type VerificationAcceptProps = {
|
||||
onAccept: () => Promise<void>;
|
||||
};
|
||||
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
||||
const { t } = useTranslation();
|
||||
const [acceptState, accept] = useAsyncCallback(onAccept);
|
||||
|
||||
const accepting = acceptState.status === AsyncStatus.Loading;
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text>Click accept to start the verification process.</Text>
|
||||
<Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
|
||||
<Button
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
@@ -87,17 +91,18 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
||||
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
||||
disabled={accepting}
|
||||
>
|
||||
<Text size="B400">Accept</Text>
|
||||
<Text size="B400">{t('Organisms.DeviceVerification.accept')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationWaitStart() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text>Verification request has been accepted.</Text>
|
||||
<WaitingMessage message="Waiting for the response from other device..." />
|
||||
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
|
||||
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -106,18 +111,20 @@ type VerificationStartProps = {
|
||||
onStart: () => Promise<void>;
|
||||
};
|
||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
onStart();
|
||||
}, [onStart]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
||||
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
||||
|
||||
const confirming =
|
||||
@@ -125,7 +132,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
||||
<Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||
style={{
|
||||
@@ -157,7 +164,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||
disabled={confirming}
|
||||
before={confirming && <Spinner size="100" variant="Primary" />}
|
||||
>
|
||||
<Text size="B400">They Match</Text>
|
||||
<Text size="B400">{t('Organisms.DeviceVerification.they_match')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Primary"
|
||||
@@ -165,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||
onClick={() => sasData.mismatch()}
|
||||
disabled={confirming}
|
||||
>
|
||||
<Text size="B400">Do not Match</Text>
|
||||
<Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -177,6 +184,7 @@ type SasVerificationProps = {
|
||||
onCancel: () => void;
|
||||
};
|
||||
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
||||
|
||||
useVerifierShowSas(verifier, setSasData);
|
||||
@@ -192,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
||||
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -201,13 +209,14 @@ type VerificationDoneProps = {
|
||||
onExit: () => void;
|
||||
};
|
||||
function VerificationDone({ onExit }: VerificationDoneProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<div>
|
||||
<Text>Your device is verified.</Text>
|
||||
<Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
|
||||
</div>
|
||||
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
||||
<Text size="B400">Okay</Text>
|
||||
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
@@ -217,11 +226,12 @@ type VerificationCanceledProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text>Verification has been canceled.</Text>
|
||||
<Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
|
||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||
<Text size="B400">Close</Text>
|
||||
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
type MemberVerificationBadgeProps = {
|
||||
@@ -9,8 +9,7 @@ type MemberVerificationBadgeProps = {
|
||||
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
||||
const vs = useUserVerifiedStatus(userId);
|
||||
if (vs === 'unknown') return null;
|
||||
const color =
|
||||
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
|
||||
const iconColor = vs === 'verified' ? color.Success.Main : color.Warning.Main;
|
||||
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
||||
return (
|
||||
<TooltipProvider
|
||||
@@ -27,7 +26,7 @@ export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps
|
||||
title={label}
|
||||
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>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
||||
import { SequenceCard } from '../sequence-card';
|
||||
import { SettingTile } from '../setting-tile';
|
||||
@@ -17,6 +18,7 @@ export function CreateRoomTypeSelector({
|
||||
disabled,
|
||||
getIcon,
|
||||
}: CreateRoomTypeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
<SequenceCard
|
||||
@@ -36,10 +38,10 @@ export function CreateRoomTypeSelector({
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Chat Room
|
||||
{t('Organisms.CreateRoom.chat_room')}
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Messages, photos, and videos.
|
||||
- {t('Organisms.CreateRoom.chat_room_desc')}
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
@@ -61,10 +63,10 @@ export function CreateRoomTypeSelector({
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Voice Room
|
||||
{t('Organisms.CreateRoom.voice_room')}
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Live audio and video conversations.
|
||||
- {t('Organisms.CreateRoom.voice_room_desc')}
|
||||
</Text>
|
||||
<BetaNoticeBadge />
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileSaver from 'file-saver';
|
||||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
@@ -15,6 +16,7 @@ export type ImageViewerProps = {
|
||||
|
||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||
|
||||
@@ -69,7 +71,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
radii="300"
|
||||
before={<Icon size="50" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">Download</Text>
|
||||
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
@@ -66,6 +67,7 @@ type InviteUserProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(560);
|
||||
const alive = useAlive();
|
||||
@@ -194,7 +196,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
Invite
|
||||
{t('Organisms.InviteUser.invite')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" gap="100" alignItems="Center">
|
||||
@@ -351,7 +353,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
disabled={!validUserId || inviting}
|
||||
before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
|
||||
>
|
||||
<Text size="B400">Invite</Text>
|
||||
<Text size="B400">{t('Organisms.InviteUser.invite')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||
@@ -507,6 +508,7 @@ type MLocationProps = {
|
||||
content: IContent;
|
||||
};
|
||||
export function MLocation({ content }: MLocationProps) {
|
||||
const { t } = useTranslation();
|
||||
const geoUri = content.geo_uri;
|
||||
if (typeof geoUri !== 'string') return <BrokenContent />;
|
||||
const location = parseGeoUri(geoUri);
|
||||
@@ -527,7 +529,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
style={{
|
||||
width: '280px',
|
||||
height: '160px',
|
||||
border: '1px solid var(--bg-surface-border)',
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: '8px',
|
||||
display: 'block',
|
||||
}}
|
||||
@@ -549,7 +551,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
radii="300"
|
||||
before={<Icon src={Icons.External} size="50" />}
|
||||
>
|
||||
<Text size="B300">Open Location</Text>
|
||||
<Text size="B300">{t('Organisms.Message.open_location')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
||||
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
||||
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
@@ -37,7 +38,9 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||
),
|
||||
);
|
||||
|
||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
shrink="No"
|
||||
className={css.ThreadIndicator}
|
||||
@@ -47,9 +50,10 @@ export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||
ref={ref}
|
||||
>
|
||||
<Icon size="50" src={Icons.Thread} />
|
||||
<Text size="L400">Thread</Text>
|
||||
<Text size="L400">{t('Organisms.Message.thread')}</Text>
|
||||
</Box>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
type ReplyProps = {
|
||||
room: Room;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -81,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [viewer, setViewer] = useState(false);
|
||||
@@ -168,7 +170,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
onClick={loadSrc}
|
||||
before={<Icon size="Inherit" src={Icons.Photo} filled />}
|
||||
>
|
||||
<Text size="B300">View</Text>
|
||||
<Text size="B300">{t('Organisms.ImageContent.view')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -212,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="B300">Spoiler</Text>
|
||||
<Text size="B300">{t('Organisms.ImageContent.spoiler')}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -247,7 +249,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
onClick={handleRetry}
|
||||
before={<Icon size="Inherit" src={Icons.Warning} filled />}
|
||||
>
|
||||
<Text size="B300">Retry</Text>
|
||||
<Text size="B300">{t('Organisms.ImageContent.retry')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { RoomEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
@@ -175,7 +175,7 @@ export function PollContent({
|
||||
|
||||
if (!poll) {
|
||||
return (
|
||||
<Text style={{ opacity: 0.6 }}>
|
||||
<Text priority="300">
|
||||
<i>Poll (unreadable format)</i>
|
||||
</Text>
|
||||
);
|
||||
@@ -244,21 +244,20 @@ export function PollContent({
|
||||
gap="200"
|
||||
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
||||
>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
<Text
|
||||
as="div"
|
||||
size="T200"
|
||||
priority="300"
|
||||
data-poll-content-label
|
||||
style={{
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.55,
|
||||
marginBottom: '2px',
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||
</Box>
|
||||
</Text>
|
||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||
{questionText}
|
||||
</Text>
|
||||
@@ -280,18 +279,19 @@ export function PollContent({
|
||||
data-selected={selected}
|
||||
onClick={canVote ? () => handleVote(id) : undefined}
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: '8px',
|
||||
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.04)',
|
||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
|
||||
fontSize: '0.88rem',
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
|
||||
}`,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
cursor: canVote ? 'pointer' : 'default',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
gap: config.space.S100,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
@@ -306,58 +306,55 @@ export function PollContent({
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.03)',
|
||||
background: selected
|
||||
? color.Primary.ContainerActive
|
||||
: color.SurfaceVariant.ContainerActive,
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{isMultiple && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
|
||||
borderRadius: '3px',
|
||||
background: selected ? 'var(--accent-cyan)' : 'none',
|
||||
width: toRem(14),
|
||||
height: toRem(14),
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||
}`,
|
||||
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||
background: selected ? color.Primary.Main : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: '#fff',
|
||||
color: color.Primary.OnMain,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{selected ? '✓' : ''}
|
||||
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
|
||||
</span>
|
||||
)}
|
||||
{!isMultiple && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
|
||||
borderRadius: '50%',
|
||||
background: selected ? 'var(--accent-cyan)' : 'none',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||
{text}
|
||||
</Text>
|
||||
{total > 0 && (
|
||||
<span style={{ opacity: 0.55, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
||||
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
||||
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
|
||||
<i>
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||
{canVote
|
||||
|
||||
@@ -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 { useAtomValue } from 'jotai';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
animSeasonFall,
|
||||
animLeafFall,
|
||||
animFloatUp,
|
||||
animBob,
|
||||
animTasselSway,
|
||||
animGoldShimmer,
|
||||
animCloverDrift,
|
||||
animEarthLeafDrift,
|
||||
animWarp,
|
||||
animScanline,
|
||||
animPixelBlink,
|
||||
} from './Seasonal.css';
|
||||
import { zIndices } from '../../styles/zIndex';
|
||||
import { SeasonTheme } from './types';
|
||||
import { getActiveSeason } from './seasonSchedule';
|
||||
import { HalloweenOverlay } from './themes/Halloween';
|
||||
import { ChristmasOverlay } from './themes/Christmas';
|
||||
import { NewYearOverlay } from './themes/NewYear';
|
||||
import { AutumnOverlay } from './themes/Autumn';
|
||||
import { AprilFoolsOverlay } from './themes/AprilFools';
|
||||
import { LunarNewYearOverlay } from './themes/LunarNewYear';
|
||||
import { ValentinesOverlay } from './themes/Valentines';
|
||||
import { StPatricksOverlay } from './themes/StPatricks';
|
||||
import { EarthDayOverlay } from './themes/EarthDay';
|
||||
import { DeepSpaceOverlay } from './themes/DeepSpace';
|
||||
import { ArcadeOverlay } from './themes/Arcade';
|
||||
|
||||
export type SeasonTheme =
|
||||
| 'halloween'
|
||||
| 'christmas'
|
||||
| 'newyear'
|
||||
| 'autumn'
|
||||
| 'aprilfools'
|
||||
| 'lunar'
|
||||
| 'valentines'
|
||||
| 'stpatricks'
|
||||
| 'earthday'
|
||||
| 'deepspace'
|
||||
| 'arcade';
|
||||
|
||||
function getActiveSeason(now: Date): SeasonTheme | null {
|
||||
const m = now.getMonth() + 1; // 1-12
|
||||
const d = now.getDate();
|
||||
|
||||
// New Year takes highest priority (Dec 31 – Jan 2)
|
||||
if ((m === 12 && d === 31) || (m === 1 && d <= 2)) return 'newyear';
|
||||
// Valentine's Day (Feb 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%)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// SeasonTheme + the date-window logic now live in leaf modules (single source
|
||||
// of truth, shared with the settings UI). Re-exported here for existing
|
||||
// importers that still reach for it from this file.
|
||||
export type { SeasonTheme };
|
||||
|
||||
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
|
||||
|
||||
@@ -758,7 +64,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
|
||||
pointerEvents: 'none',
|
||||
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
||||
// by it, and below modals (9999) so dialogs are never obscured.
|
||||
zIndex: 9997,
|
||||
zIndex: zIndices.seasonalEffect,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
||||
import { SeasonTheme } from './types';
|
||||
|
||||
// Date(year, monthIndex0, day)
|
||||
const on = (monthIndex0: number, day: number): Date => new Date(2026, monthIndex0, day);
|
||||
|
||||
test('each theme activates on a representative day in its window', () => {
|
||||
const cases: Array<[Date, SeasonTheme]> = [
|
||||
[on(11, 31), 'newyear'], // Dec 31
|
||||
[on(0, 1), 'newyear'], // Jan 1
|
||||
[on(0, 25), 'lunar'], // Jan 25
|
||||
[on(1, 3), 'lunar'], // Feb 3
|
||||
[on(1, 12), 'valentines'], // Feb 12
|
||||
[on(2, 16), 'stpatricks'], // Mar 16
|
||||
[on(3, 1), 'aprilfools'], // Apr 1
|
||||
[on(3, 21), 'earthday'], // Apr 21
|
||||
[on(8, 12), 'arcade'], // Sep 12
|
||||
[on(8, 25), 'autumn'], // Sep 25
|
||||
[on(9, 20), 'halloween'], // Oct 20
|
||||
[on(10, 1), 'halloween'], // Nov 1
|
||||
[on(11, 15), 'christmas'], // Dec 15
|
||||
];
|
||||
for (const [date, expected] of cases) {
|
||||
assert.equal(getActiveSeason(date), expected, `${date.toDateString()} -> ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('priority order resolves overlapping windows (Deep Space outranks Autumn)', () => {
|
||||
// Oct 4-10 is inside Autumn's Oct<=14 window too; Deep Space comes first.
|
||||
assert.equal(getActiveSeason(on(9, 5)), 'deepspace'); // Oct 5
|
||||
// Oct 12 is past Deep Space -> falls through to Autumn.
|
||||
assert.equal(getActiveSeason(on(9, 12)), 'autumn');
|
||||
});
|
||||
|
||||
test('New Year outranks Lunar New Year on Jan 1-2', () => {
|
||||
assert.equal(getActiveSeason(on(0, 1)), 'newyear');
|
||||
// Jan 22+ is past New Year -> Lunar.
|
||||
assert.equal(getActiveSeason(on(0, 22)), 'lunar');
|
||||
});
|
||||
|
||||
test('returns null on an off-season day', () => {
|
||||
assert.equal(getActiveSeason(on(5, 15)), null); // Jun 15
|
||||
assert.equal(getActiveSeason(on(6, 4)), null); // Jul 4
|
||||
});
|
||||
|
||||
test('window boundaries are inclusive at both ends', () => {
|
||||
assert.equal(getActiveSeason(on(1, 10)), 'valentines'); // Feb 10 start
|
||||
assert.equal(getActiveSeason(on(1, 15)), 'valentines'); // Feb 15 end
|
||||
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||
});
|
||||
|
||||
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||
assert.equal(new Set(themes).size, 11); // unique
|
||||
for (const t of themes) {
|
||||
assert.ok(
|
||||
typeof SEASON_DATE_RANGES[t] === 'string' && SEASON_DATE_RANGES[t].length > 0,
|
||||
`missing date range for ${t}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SeasonTheme } from './types';
|
||||
|
||||
/**
|
||||
* Single source of truth for when each seasonal theme auto-activates.
|
||||
*
|
||||
* Both `getActiveSeason` (the runtime "Auto" selector) and the settings UI read
|
||||
* this list, so the date windows shown to the user can never drift from the
|
||||
* dates actually used. Order matters: it is the activation PRIORITY — the first
|
||||
* entry whose window matches wins (e.g. Deep Space outranks Autumn in their
|
||||
* early-October overlap).
|
||||
*/
|
||||
export type SeasonScheduleEntry = {
|
||||
theme: SeasonTheme;
|
||||
/** Human-readable activation window for display in settings. */
|
||||
dateRange: string;
|
||||
/** Whether this theme is active on the given month (1-12) and day (1-31). */
|
||||
matches: (month: number, day: number) => boolean;
|
||||
};
|
||||
|
||||
export const SEASON_SCHEDULE: SeasonScheduleEntry[] = [
|
||||
{
|
||||
theme: 'newyear',
|
||||
dateRange: 'Dec 31 – Jan 2',
|
||||
matches: (m, d) => (m === 12 && d === 31) || (m === 1 && d <= 2),
|
||||
},
|
||||
{
|
||||
theme: 'valentines',
|
||||
dateRange: 'Feb 10 – 15',
|
||||
matches: (m, d) => m === 2 && d >= 10 && d <= 15,
|
||||
},
|
||||
{
|
||||
theme: 'stpatricks',
|
||||
dateRange: 'Mar 15 – 18',
|
||||
matches: (m, d) => m === 3 && d >= 15 && d <= 18,
|
||||
},
|
||||
{
|
||||
theme: 'aprilfools',
|
||||
dateRange: 'Apr 1',
|
||||
matches: (m, d) => m === 4 && d === 1,
|
||||
},
|
||||
{
|
||||
theme: 'earthday',
|
||||
dateRange: 'Apr 20 – 23',
|
||||
matches: (m, d) => m === 4 && d >= 20 && d <= 23,
|
||||
},
|
||||
{
|
||||
theme: 'lunar',
|
||||
dateRange: 'Jan 22 – Feb 5',
|
||||
matches: (m, d) => (m === 1 && d >= 22) || (m === 2 && d <= 5),
|
||||
},
|
||||
{
|
||||
theme: 'arcade',
|
||||
dateRange: 'Sep 12',
|
||||
matches: (m, d) => m === 9 && d === 12,
|
||||
},
|
||||
{
|
||||
theme: 'deepspace',
|
||||
dateRange: 'Oct 4 – 10',
|
||||
matches: (m, d) => m === 10 && d >= 4 && d <= 10,
|
||||
},
|
||||
{
|
||||
theme: 'halloween',
|
||||
dateRange: 'Oct 15 – Nov 1',
|
||||
matches: (m, d) => (m === 10 && d >= 15) || (m === 11 && d === 1),
|
||||
},
|
||||
{
|
||||
theme: 'christmas',
|
||||
dateRange: 'Dec 10 – 30',
|
||||
matches: (m, d) => m === 12 && d >= 10,
|
||||
},
|
||||
{
|
||||
theme: 'autumn',
|
||||
dateRange: 'Sep 21 – Oct 14',
|
||||
matches: (m, d) => (m === 9 && d >= 21) || (m === 10 && d <= 14),
|
||||
},
|
||||
];
|
||||
|
||||
/** Map of theme → human-readable activation window (for settings captions). */
|
||||
export const SEASON_DATE_RANGES: Record<SeasonTheme, string> = SEASON_SCHEDULE.reduce(
|
||||
(acc, entry) => {
|
||||
acc[entry.theme] = entry.dateRange;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SeasonTheme, string>,
|
||||
);
|
||||
|
||||
/**
|
||||
* The seasonal theme that should be active on `now`, or null if none. First
|
||||
* matching entry in SEASON_SCHEDULE priority order wins.
|
||||
*/
|
||||
export function getActiveSeason(now: Date): SeasonTheme | null {
|
||||
const month = now.getMonth() + 1; // 1-12
|
||||
const day = now.getDate();
|
||||
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { keyframes } from '@vanilla-extract/css';
|
||||
|
||||
/**
|
||||
* Doodle float-up — a hand-drawn glyph drifts gently upward while bobbing
|
||||
* side to side and lazily rotating, like a thought balloon escaping the page.
|
||||
* GPU-only: transform + opacity exclusively. A tall translateY lets one set of
|
||||
* keyframes serve every doodle; per-element duration/delay/scale add variety.
|
||||
*/
|
||||
export const animDoodleFloat = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 8vh, 0) rotate(-8deg) scale(0.85)', opacity: '0' },
|
||||
'10%': { opacity: '1' },
|
||||
'35%': { transform: 'translate3d(16px, -28vh, 0) rotate(6deg) scale(1)' },
|
||||
'65%': { transform: 'translate3d(-14px, -64vh, 0) rotate(-5deg) scale(1.04)' },
|
||||
'90%': { opacity: '0.8' },
|
||||
'100%': { transform: 'translate3d(10px, -112vh, 0) rotate(7deg) scale(1.1)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Confetti tumble — a small chip falls while flipping. Reuses a single tall
|
||||
* translateY; the flip (rotate + scaleX) sells the paper tumble cheaply.
|
||||
*/
|
||||
export const animConfettiTumble = keyframes({
|
||||
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg) scaleX(1)', opacity: '0' },
|
||||
'8%': { opacity: '1' },
|
||||
'50%': { transform: 'translate3d(18px, 50vh, 0) rotate(220deg) scaleX(-1)' },
|
||||
'92%': { opacity: '0.9' },
|
||||
'100%': { transform: 'translate3d(-12px, 112vh, 0) rotate(440deg) scaleX(1)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Playful wobble — an almost-imperceptible skew/rotate of a faux tint layer so
|
||||
* the whole scene feels gently "tickled". Tiny amplitude keeps it from being
|
||||
* disorienting. Transform only, stays on the compositor.
|
||||
*/
|
||||
export const animWobble = keyframes({
|
||||
'0%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||
'50%': { transform: 'rotate(0.5deg) skewX(0.4deg) scale(1.01)' },
|
||||
'100%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Pastel aurora drift — a soft rainbow wash high in the scene slides and
|
||||
* breathes. translateX + opacity (never background-position) to stay on GPU.
|
||||
*/
|
||||
export const animRainbowDrift = keyframes({
|
||||
'0%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||
'50%': { transform: 'translate3d(5%, 0, 0) scaleY(1.06)', opacity: '0.8' },
|
||||
'100%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Googly-eye look-around — the pupil layer nudges around its socket, giving
|
||||
* each eye a cheeky wandering gaze. Small translate only.
|
||||
*/
|
||||
export const animGoogly = keyframes({
|
||||
'0%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||
'20%': { transform: 'translate3d(-1.5px, 1.5px, 0)' },
|
||||
'45%': { transform: 'translate3d(1px, -1.5px, 0)' },
|
||||
'70%': { transform: 'translate3d(-1px, -0.5px, 0)' },
|
||||
'100%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Sly wink/sparkle — a four-point glint that twinkles open and shut, scaling
|
||||
* and fading like a sly little wink. Transform + opacity only.
|
||||
*/
|
||||
export const animSparkle = keyframes({
|
||||
'0%, 100%': { transform: 'scale(0.2) rotate(0deg)', opacity: '0' },
|
||||
'40%': { transform: 'scale(1) rotate(35deg)', opacity: '0.9' },
|
||||
'60%': { transform: 'scale(0.95) rotate(45deg)', opacity: '0.7' },
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animDoodleFloat,
|
||||
animConfettiTumble,
|
||||
animWobble,
|
||||
animRainbowDrift,
|
||||
animGoogly,
|
||||
animSparkle,
|
||||
} from './AprilFools.css';
|
||||
|
||||
// Deterministic pseudo-random so the scene is identical on every mount and the
|
||||
// reduced-motion preview thumbnail is stable. Large primes spread the values.
|
||||
const rand = (seed: number) => {
|
||||
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
// Bright-but-soft pastel rainbow in oklch. Kept luminous and gentle so the
|
||||
// doodles read as crayon pastel over chat without ever fighting the text.
|
||||
const PASTELS = [
|
||||
'oklch(0.85 0.12 20)', // pink
|
||||
'oklch(0.88 0.12 90)', // butter yellow
|
||||
'oklch(0.82 0.12 160)', // mint
|
||||
'oklch(0.8 0.12 260)', // periwinkle
|
||||
'oklch(0.84 0.12 320)', // lilac
|
||||
'oklch(0.86 0.11 50)', // peach
|
||||
];
|
||||
|
||||
// Inline-SVG data-URI doodle glyphs, drawn hand-sketch style (round caps,
|
||||
// open paths). `enc()` keeps them CSP-safe — no external assets, no base64.
|
||||
const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
|
||||
// A single rough stroke wrapper helper for the glyph SVGs.
|
||||
const stroke = (color: string, body: string) =>
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' ` +
|
||||
`stroke='${color}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`;
|
||||
|
||||
// Question mark — the playful "huh?" centerpiece doodle.
|
||||
const glyphQuestion = (c: string) =>
|
||||
stroke(
|
||||
c,
|
||||
`<path d='M11 11 q0 -6 6 -6 q6 0 6 5 q0 4 -5 6 q-2 1 -2 4'/>` +
|
||||
`<circle cx='16' cy='27' r='0.6' fill='${c}'/>`,
|
||||
);
|
||||
|
||||
// Exclamation / "bang" — a surprised little doodle.
|
||||
const glyphBang = (c: string) =>
|
||||
stroke(c, `<path d='M16 5 L16 20'/><circle cx='16' cy='27' r='0.6' fill='${c}'/>`);
|
||||
|
||||
// Squiggle — a loopy scribble that adds whimsy.
|
||||
const glyphSquiggle = (c: string) => stroke(c, `<path d='M5 18 q4 -10 8 0 t8 0 t8 0'/>`);
|
||||
|
||||
// Five-point doodle star (open-stroke, hand-drawn look).
|
||||
const glyphStar = (c: string) =>
|
||||
stroke(
|
||||
c,
|
||||
`<path d='M16 5 L19.4 13 L28 13.6 L21.4 19.2 L23.5 27.6 L16 22.8 L8.5 27.6 ` +
|
||||
`L10.6 19.2 L4 13.6 L12.6 13 Z'/>`,
|
||||
);
|
||||
|
||||
// A tiny heart doodle for extra grin.
|
||||
const glyphHeart = (c: string) =>
|
||||
stroke(c, `<path d='M16 26 C6 18 7 8 16 12 C25 8 26 18 16 26 Z'/>`);
|
||||
|
||||
const GLYPHS = [glyphQuestion, glyphBang, glyphSquiggle, glyphStar, glyphHeart, glyphQuestion];
|
||||
|
||||
type Doodle = {
|
||||
left: number;
|
||||
size: number;
|
||||
glyph: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
startTop: number; // used for the static (reduced) scatter
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type Confetti = {
|
||||
left: number;
|
||||
size: number;
|
||||
color: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
startTop: number;
|
||||
ratio: number; // chip aspect
|
||||
round: boolean;
|
||||
};
|
||||
|
||||
type Eye = {
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
type Spark = {
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
color: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
export function AprilFoolsOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// ~16 drifting doodles. Built once; per-element timing creates the variety.
|
||||
const doodles = useMemo<Doodle[]>(() => {
|
||||
const count = 16;
|
||||
const out: Doodle[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const color = PASTELS[i % PASTELS.length];
|
||||
out.push({
|
||||
left: rand(i + 0.1) * 96 + 2,
|
||||
size: 18 + rand(i + 0.3) * 22,
|
||||
glyph: enc(GLYPHS[i % GLYPHS.length](color)),
|
||||
duration: 16 + rand(i + 0.5) * 12,
|
||||
delay: -rand(i + 0.7) * 26,
|
||||
startTop: rand(i + 0.9) * 92 + 4,
|
||||
opacity: 0.5 + rand(i + 0.2) * 0.32,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// ~14 confetti chips in a couple of falling bands.
|
||||
const confetti = useMemo<Confetti[]>(() => {
|
||||
const count = 14;
|
||||
const out: Confetti[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
out.push({
|
||||
left: rand(i + 3.1) * 98 + 1,
|
||||
size: 5 + rand(i + 3.3) * 6,
|
||||
color: PASTELS[(i + 2) % PASTELS.length],
|
||||
duration: 10 + rand(i + 3.5) * 9,
|
||||
delay: -rand(i + 3.7) * 18,
|
||||
startTop: rand(i + 3.9) * 96 + 2,
|
||||
ratio: 0.45 + rand(i + 3.2) * 0.8,
|
||||
round: rand(i + 3.6) > 0.6,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// A few googly eyes peeking from corners/edges — the cheeky surprise.
|
||||
const eyes = useMemo<Eye[]>(() => {
|
||||
const anchors = [
|
||||
{ left: 6, top: 12 },
|
||||
{ left: 90, top: 20 },
|
||||
{ left: 80, top: 82 },
|
||||
{ left: 14, top: 74 },
|
||||
];
|
||||
return anchors.map((a, i) => ({
|
||||
left: a.left,
|
||||
top: a.top,
|
||||
size: 22 + rand(i + 5.1) * 12,
|
||||
duration: 3 + rand(i + 5.3) * 2.5,
|
||||
delay: -rand(i + 5.5) * 3,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Sly winking sparkles scattered sparsely.
|
||||
const sparks = useMemo<Spark[]>(() => {
|
||||
const count = 5;
|
||||
const out: Spark[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
out.push({
|
||||
left: rand(i + 7.1) * 90 + 5,
|
||||
top: rand(i + 7.3) * 84 + 8,
|
||||
size: 12 + rand(i + 7.5) * 12,
|
||||
color: PASTELS[(i + 1) % PASTELS.length],
|
||||
duration: 4 + rand(i + 7.7) * 3,
|
||||
delay: -rand(i + 7.9) * 5,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// Four-point glint used for the winking sparkles.
|
||||
const sparkGlint = (c: string) =>
|
||||
enc(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
|
||||
`<path d='M12 0 C13 8 16 11 24 12 C16 13 13 16 12 24 C11 16 8 13 0 12 C8 11 11 8 12 0 Z' fill='${c}'/></svg>`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Soft pastel ambient wash — layered oklch radials for depth. Very low
|
||||
opacity so chat text keeps WCAG-AA contrast. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
'radial-gradient(110% 70% at 18% -8%, oklch(0.85 0.12 20 / 0.1) 0%, transparent 55%)',
|
||||
'radial-gradient(95% 65% at 86% 0%, oklch(0.82 0.12 160 / 0.09) 0%, transparent 58%)',
|
||||
'radial-gradient(120% 80% at 50% 112%, oklch(0.8 0.12 260 / 0.1) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.88 0.12 90 / 0.05) 0%, transparent 30%, transparent 78%, oklch(0.84 0.12 320 / 0.06) 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Faux wobble layer — a near-invisible pastel haze that gently skews so
|
||||
the whole scene feels playfully "tickled". Tiny amplitude = not
|
||||
nauseating. backdrop-filter is one cheap layer for a candy bloom. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-2%',
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||
WebkitBackdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||
backgroundImage:
|
||||
'radial-gradient(130% 120% at 50% 45%, transparent 60%, oklch(0.86 0.11 50 / 0.05) 80%, oklch(0.8 0.12 260 / 0.08) 100%)',
|
||||
transformOrigin: '50% 50%',
|
||||
animation: reduced ? 'none' : `${animWobble} 14s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pastel rainbow aurora high up — soft band of the full palette. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8%',
|
||||
left: '-10%',
|
||||
right: '-10%',
|
||||
height: '42%',
|
||||
contain: 'layout paint style',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(30px)',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
backgroundImage: [
|
||||
'radial-gradient(50% 100% at 18% 0%, oklch(0.85 0.12 20 / 0.16) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 40% 0%, oklch(0.88 0.12 90 / 0.14) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 62% 0%, oklch(0.82 0.12 160 / 0.14) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 84% 0%, oklch(0.8 0.12 260 / 0.16) 0%, transparent 72%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animRainbowDrift} 20s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drifting doodles. Motion: rise from below. Reduced: static scatter. */}
|
||||
{doodles.map((d, i) => {
|
||||
const common: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: `${d.left}%`,
|
||||
width: `${d.size}px`,
|
||||
height: `${d.size}px`,
|
||||
backgroundImage: d.glyph,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
opacity: d.opacity,
|
||||
filter: 'drop-shadow(0 1px 1px oklch(0.4 0.05 300 / 0.18))',
|
||||
};
|
||||
if (reduced) {
|
||||
return (
|
||||
<div
|
||||
key={`doodle-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: `${d.startTop}%`,
|
||||
transform: `rotate(${(rand(i + 11) - 0.5) * 24}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`doodle-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: 0,
|
||||
animation: `${animDoodleFloat} ${d.duration}s ease-in-out ${d.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Light confetti — tumbling pastel chips. */}
|
||||
{confetti.map((c, i) => {
|
||||
const common: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: `${c.left}%`,
|
||||
width: `${c.size}px`,
|
||||
height: `${c.size * c.ratio}px`,
|
||||
background: c.color,
|
||||
borderRadius: c.round ? '50%' : '1px',
|
||||
opacity: 0.75,
|
||||
};
|
||||
if (reduced) {
|
||||
return (
|
||||
<div
|
||||
key={`confetti-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: `${c.startTop}%`,
|
||||
transform: `rotate(${rand(i + 13) * 360}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`confetti-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: 0,
|
||||
animation: `${animConfettiTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
|
||||
{eyes.map((e, i) => (
|
||||
<div
|
||||
key={`eye-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${e.left}%`,
|
||||
top: `${e.top}%`,
|
||||
width: `${e.size}px`,
|
||||
height: `${e.size}px`,
|
||||
marginLeft: `${-e.size / 2}px`,
|
||||
marginTop: `${-e.size / 2}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 38% 32%, oklch(0.99 0.005 90 / 0.85) 0%, oklch(0.95 0.01 90 / 0.7) 62%, oklch(0.75 0.02 90 / 0.6) 100%)',
|
||||
boxShadow: 'inset 0 0 0 1.5px oklch(0.45 0.03 300 / 0.35)',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
{/* Pupil */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: `${e.size * 0.4}px`,
|
||||
height: `${e.size * 0.4}px`,
|
||||
marginLeft: `${-e.size * 0.2}px`,
|
||||
marginTop: `${-e.size * 0.2}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 36% 30%, oklch(0.5 0.04 300 / 0.95) 0%, oklch(0.28 0.04 300 / 0.95) 70%)',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animGoogly} ${e.duration}s ease-in-out ${e.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
{/* Catchlight */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '22%',
|
||||
top: '20%',
|
||||
width: '28%',
|
||||
height: '28%',
|
||||
borderRadius: '50%',
|
||||
background: 'oklch(0.99 0.005 90 / 0.85)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
|
||||
{sparks.map((s, i) => (
|
||||
<div
|
||||
key={`spark-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
top: `${s.top}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
backgroundImage: sparkGlint(s.color),
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
filter: `drop-shadow(0 0 3px ${s.color.replace(')', ' / 0.5)')})`,
|
||||
opacity: reduced ? 0.8 : undefined,
|
||||
transform: reduced ? 'scale(0.95) rotate(40deg)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { keyframes } from '@vanilla-extract/css';
|
||||
|
||||
/**
|
||||
* Arcade overlay keyframes — retro synthwave CRT.
|
||||
*
|
||||
* Every animation touches ONLY `transform` and `opacity` so the compositor can
|
||||
* run them on the GPU without triggering layout or paint. keyframes() returns
|
||||
* the generated animation-name string, which is applied inline in Arcade.tsx.
|
||||
*
|
||||
* Motion philosophy: a neon perspective grid scrolls toward the viewer, a soft
|
||||
* CRT scanline field breathes, the whole screen glows and flickers ever so
|
||||
* faintly, sparse pixel sparkles drift up, and an "INSERT COIN" blip pulses.
|
||||
* The grid scroll is done with a translateY on a tiled, perspective-projected
|
||||
* plane — never background-position — so it rides the compositor.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The neon grid plane is laid out twice its visible height and tiled with the
|
||||
* horizontal rule lines. Translating it up by exactly one tile makes the lines
|
||||
* appear to flow continuously toward the viewer (the horizon). Because the
|
||||
* plane sits under a `perspective` transform, the lines also accelerate as they
|
||||
* approach, giving a true receding-grid illusion. Pure transform.
|
||||
*/
|
||||
export const animGridScroll = keyframes({
|
||||
'0%': { transform: 'translateZ(0) translateY(0)' },
|
||||
'100%': { transform: 'translateZ(0) translateY(50%)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Slow vertical drift of the fine scanline field — a couple of pixels so the
|
||||
* raster looks like it's gently rolling, the way a real CRT does. Transform
|
||||
* only; the line texture itself never moves on the GPU's paint layer.
|
||||
*/
|
||||
export const animScanRoll = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||
'100%': { transform: 'translate3d(0, 4px, 0)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* The overall CRT screen-glow breathes: a barely-there opacity swell that keeps
|
||||
* the static neon tint feeling alive and powered-on. Opacity only.
|
||||
*/
|
||||
export const animScreenGlow = keyframes({
|
||||
'0%': { opacity: '0.72' },
|
||||
'50%': { opacity: '1' },
|
||||
'100%': { opacity: '0.72' },
|
||||
});
|
||||
|
||||
/**
|
||||
* A faint, irregular CRT brightness flicker laid over the glow — the classic
|
||||
* unstable-tube shimmer. Kept extremely shallow so it never distracts or harms
|
||||
* readability. Opacity only.
|
||||
*/
|
||||
export const animCrtFlicker = keyframes({
|
||||
'0%': { opacity: '0.94' },
|
||||
'12%': { opacity: '1' },
|
||||
'20%': { opacity: '0.9' },
|
||||
'34%': { opacity: '0.98' },
|
||||
'52%': { opacity: '0.92' },
|
||||
'70%': { opacity: '1' },
|
||||
'83%': { opacity: '0.95' },
|
||||
'100%': { opacity: '0.94' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Chromatic-aberration twin: the magenta/cyan fringe layers nudge a sub-pixel
|
||||
* apart and back so the edges shimmer with RGB split, like a misconverged tube.
|
||||
* transform + opacity only.
|
||||
*/
|
||||
export const animChromaShift = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
|
||||
'50%': { transform: 'translate3d(1.5px, 0, 0)', opacity: '0.8' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Pixel sparkle drift: a tiny neon speck rises and twinkles like a coin-burst
|
||||
* particle floating up off the grid. transform + opacity, single tall path.
|
||||
*/
|
||||
export const animSparkleDrift = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
|
||||
'12%': { opacity: '1' },
|
||||
'50%': { transform: 'translate3d(8px, -42vh, 0) scale(1)', opacity: '0.85' },
|
||||
'78%': { transform: 'translate3d(-6px, -70vh, 0) scale(0.8)', opacity: '0.5' },
|
||||
'92%': { opacity: '0.18' },
|
||||
'100%': { transform: 'translate3d(6px, -92vh, 0) scale(0.55)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Independent pixel twinkle layered on the drift so specks blink on/off like a
|
||||
* low-res sprite. Stepped opacity for a crisp 8-bit feel.
|
||||
*/
|
||||
export const animSparkleTwinkle = keyframes({
|
||||
'0%, 44%': { opacity: '1' },
|
||||
'50%, 94%': { opacity: '0.35' },
|
||||
'100%': { opacity: '1' },
|
||||
});
|
||||
|
||||
/**
|
||||
* "INSERT COIN" blink: the classic attract-mode pulse. Stepped so it reads as a
|
||||
* hard retro blink rather than a soft fade, but with a brief bright swell.
|
||||
* Opacity + a hair of scale for a CRT bloom feel.
|
||||
*/
|
||||
export const animCoinBlink = keyframes({
|
||||
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
|
||||
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
|
||||
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Score-blip pulse for the corner HUD glyph: a quick pop then settle, like a
|
||||
* counter ticking up. transform + opacity.
|
||||
*/
|
||||
export const animScoreBlip = keyframes({
|
||||
'0%': { opacity: '0.4', transform: 'scale(1)' },
|
||||
'50%': { opacity: '0.85', transform: 'scale(1.12)' },
|
||||
'100%': { opacity: '0.4', transform: 'scale(1)' },
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animGridScroll,
|
||||
animScanRoll,
|
||||
animScreenGlow,
|
||||
animCrtFlicker,
|
||||
animChromaShift,
|
||||
animSparkleDrift,
|
||||
animSparkleTwinkle,
|
||||
animCoinBlink,
|
||||
animScoreBlip,
|
||||
} from './Arcade.css';
|
||||
|
||||
/**
|
||||
* ArcadeOverlay — retro synthwave CRT.
|
||||
*
|
||||
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||||
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||||
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||||
* children and never sets position:fixed / z-index / pointer-events.
|
||||
*
|
||||
* Composition (back to front):
|
||||
* 1. near-black synthwave ambient wash (magenta sky-glow up top, cyan/purple
|
||||
* pool toward the floor) — layered oklch gradients for depth
|
||||
* 2. a neon perspective grid receding to a vanishing point on the horizon,
|
||||
* scrolling toward the viewer via transform translateY (never bg-position)
|
||||
* 3. a soft horizon sun-glow + thin neon horizon line where the grid meets sky
|
||||
* 4. drifting pixel sparkles / neon coin-burst specks rising off the grid
|
||||
* 5. fine CRT scanlines, gently rolling
|
||||
* 6. a faint chromatic-aberration fringe at the screen edges
|
||||
* 7. a glowing "INSERT COIN" blip + a corner SCORE HUD glyph
|
||||
* 8. a CRT vignette + screen-glow that frames and protects central text
|
||||
*
|
||||
* All motion is transform/opacity only (compositor-friendly). When `reduced` is
|
||||
* true we render a static-but-gorgeous scene: a still neon grid, steady
|
||||
* scanlines + vignette, and a steady "INSERT COIN" — no `animation` anywhere,
|
||||
* no flicker. The settings preview always passes reduced=true, so the still
|
||||
* form stands on its own.
|
||||
*/
|
||||
|
||||
// Synthwave neon palette in oklch. Saturated where it glows, but every layer is
|
||||
// held at low opacity so it tints rather than takes over the chat beneath.
|
||||
const NEON_MAGENTA = 'oklch(0.65 0.25 350)';
|
||||
const NEON_CYAN = 'oklch(0.80 0.15 200)';
|
||||
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
|
||||
|
||||
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
|
||||
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
|
||||
// lines fanning toward a top-center vanishing point. The plane is then placed
|
||||
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
|
||||
// tile up by one tile-height (animGridScroll → translateY 50%) loops seamlessly.
|
||||
function gridDataUri(): string {
|
||||
const lines: string[] = [];
|
||||
// Horizontal rules — denser toward the top (the horizon) for a perspective
|
||||
// feel even before the CSS rotateX is applied.
|
||||
const rows = [0, 16, 34, 54, 76, 100, 126, 156, 190, 228, 270, 316, 366, 420, 478, 540];
|
||||
rows.forEach((y) => {
|
||||
lines.push(
|
||||
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
||||
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
||||
);
|
||||
});
|
||||
// Vertical lines fanning out from the top-center vanishing point.
|
||||
for (let i = -7; i <= 7; i += 1) {
|
||||
const topX = 300 + i * 6; // tight near the horizon
|
||||
const botX = 300 + i * 95; // wide at the foreground
|
||||
lines.push(
|
||||
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
|
||||
`stroke-width='1.4' stroke-opacity='0.8'/>`,
|
||||
);
|
||||
}
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600' ` +
|
||||
`preserveAspectRatio='none'>${lines.join('')}</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
}
|
||||
|
||||
type Sparkle = {
|
||||
left: number; // vw
|
||||
bottom: number; // % up from floor where it spawns
|
||||
size: number; // px
|
||||
duration: number; // s
|
||||
delay: number; // s
|
||||
twinkle: number; // s
|
||||
hue: 'magenta' | 'cyan';
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
// Hand-placed still sparkles for the reduced/static scene — a few neon specks
|
||||
// resting low over the grid, away from the busy chat center.
|
||||
const RESTING_SPARKLES: ReadonlyArray<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
size: number;
|
||||
hue: 'magenta' | 'cyan';
|
||||
opacity: number;
|
||||
}> = [
|
||||
{ left: 12, bottom: 18, size: 4, hue: 'cyan', opacity: 0.5 },
|
||||
{ left: 26, bottom: 30, size: 3, hue: 'magenta', opacity: 0.42 },
|
||||
{ left: 78, bottom: 22, size: 4, hue: 'magenta', opacity: 0.5 },
|
||||
{ left: 88, bottom: 34, size: 3, hue: 'cyan', opacity: 0.4 },
|
||||
{ left: 50, bottom: 14, size: 3, hue: 'cyan', opacity: 0.38 },
|
||||
];
|
||||
|
||||
const GRID_URI = gridDataUri();
|
||||
|
||||
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// Deterministic sparkle field, computed ONCE. No per-frame state.
|
||||
const sparkles = useMemo<Sparkle[]>(() => {
|
||||
const COUNT = 16;
|
||||
return Array.from({ length: COUNT }, (_, i) => ({
|
||||
left: (i * 6.27 + 4) % 100,
|
||||
bottom: (i * 3.7) % 28, // spawn in the lower third (over the grid)
|
||||
size: 2 + (i % 3), // 2..4 px pixels
|
||||
duration: 14 + (i % 6) * 2.2,
|
||||
delay: -((i * 1.83) % 16),
|
||||
twinkle: 1.4 + (i % 4) * 0.5,
|
||||
hue: i % 2 === 0 ? 'cyan' : 'magenta',
|
||||
opacity: 0.45 + (i % 3) * 0.12,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const sparkleColor = (hue: 'magenta' | 'cyan') => (hue === 'cyan' ? NEON_CYAN : NEON_MAGENTA);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1. Near-black synthwave ambient wash. Magenta sky-glow up top, a
|
||||
cyan/purple pool toward the floor, and an overall dark vertical
|
||||
grade. Layered oklch gradients give depth at very low opacity. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 2. The neon perspective grid. A wide, tall plane is tilted away from
|
||||
the viewer with `perspective` + rotateX so its rule lines recede to
|
||||
a vanishing point at the top (the horizon). It lives in the lower
|
||||
half of the screen — the "floor". The inner plane scrolls upward by
|
||||
one tile via transform translateY, which reads as the grid flowing
|
||||
toward the viewer. Pure transform; never background-position. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-25%',
|
||||
right: '-25%',
|
||||
bottom: 0,
|
||||
height: '62%',
|
||||
overflow: 'hidden',
|
||||
perspective: '280px',
|
||||
perspectiveOrigin: '50% 0%',
|
||||
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||
opacity: reduced ? 0.5 : 0.62,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '200%',
|
||||
transformOrigin: 'top center',
|
||||
transform: 'rotateX(74deg)',
|
||||
backgroundImage: GRID_URI,
|
||||
backgroundRepeat: 'repeat-y',
|
||||
backgroundSize: '100% 50%',
|
||||
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
|
||||
where the grid meets the sky, with a thin bright rule on top of it
|
||||
to seal the vanishing point. Static (no motion) either way. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '38%',
|
||||
width: '70%',
|
||||
height: '34%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundImage:
|
||||
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '12%',
|
||||
right: '12%',
|
||||
top: '38%',
|
||||
height: '1.5px',
|
||||
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
|
||||
opacity: 0.55,
|
||||
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 4. Drifting pixel sparkles / neon coin-burst specks. Tiny square
|
||||
neon pixels rising off the grid and twinkling. The static scene uses
|
||||
a small resting set instead. */}
|
||||
{reduced
|
||||
? RESTING_SPARKLES.map((s, i) => (
|
||||
<div
|
||||
key={`rest-spark-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
bottom: `${s.bottom}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
background: sparkleColor(s.hue),
|
||||
opacity: s.opacity,
|
||||
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: sparkles.map((s, i) => (
|
||||
<div
|
||||
key={`spark-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
bottom: `${s.bottom}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animSparkleDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: sparkleColor(s.hue),
|
||||
opacity: s.opacity,
|
||||
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||
animation: `${animSparkleTwinkle} ${s.twinkle}s step-end infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 5. Fine CRT scanlines. A repeating 1px dark rule field over the whole
|
||||
screen, gently rolling downward on the compositor (transform only).
|
||||
Held faint so text stays crisp. The pattern is in a child taller
|
||||
than the frame so the roll never reveals an edge. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
mixBlendMode: 'multiply',
|
||||
opacity: 0.5,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '-8px',
|
||||
bottom: '-8px',
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 6. Chromatic-aberration fringe. Two thin edge-glows — magenta and cyan —
|
||||
offset a sub-pixel apart at the screen border so the frame shimmers
|
||||
with an RGB split, like a misconverged tube. Animated only; in the
|
||||
static scene it sits as a steady fringe. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
boxShadow: `inset 2px 0 14px oklch(0.65 0.25 350 / 0.16), inset -2px 0 14px oklch(0.80 0.15 200 / 0.16)`,
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
contain: 'layout paint style',
|
||||
animation: reduced ? 'none' : `${animChromaShift} 4.5s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
||||
Static scene shows it steady (no blink). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.32em',
|
||||
color: NEON_CYAN,
|
||||
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||||
}}
|
||||
>
|
||||
INSERT COIN
|
||||
</div>
|
||||
|
||||
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
|
||||
very low opacity so it reads as ambient chrome, not UI. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '2.5%',
|
||||
left: '2%',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.18em',
|
||||
color: NEON_MAGENTA,
|
||||
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: reduced ? 0.5 : undefined,
|
||||
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
||||
}}
|
||||
>
|
||||
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
||||
</div>
|
||||
|
||||
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
|
||||
with a faint magenta tube-glow swell. The vignette protects central
|
||||
chat-text contrast. Static scene holds it steady; live scene adds a
|
||||
shallow breathing glow + irregular flicker, both opacity-only. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(125% 95% at 50% 46%, oklch(0.72 0.18 340 / 0.05) 0%, transparent 40%)',
|
||||
'radial-gradient(120% 115% at 50% 50%, transparent 50%, oklch(0.10 0.05 310 / 0.20) 84%, oklch(0.06 0.04 305 / 0.34) 100%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
opacity: reduced ? 1 : undefined,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animScreenGlow} 8s ease-in-out infinite, ${animCrtFlicker} 5.5s steps(1, end) infinite`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { keyframes } from '@vanilla-extract/css';
|
||||
|
||||
/**
|
||||
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
|
||||
* `opacity` so the compositor can run them on the GPU without triggering
|
||||
* layout or paint. keyframes() returns the generated animation-name string,
|
||||
* which is applied inline in Autumn.tsx.
|
||||
*
|
||||
* Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
|
||||
* with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
|
||||
* drift up through the light; the whole frame has a barely-there warm pulse.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A leaf falls from above to below the viewport while continuously rotating.
|
||||
* A single tall translateY serves every leaf — per-leaf duration/delay/scale
|
||||
* create the parallax variety. Horizontal travel is intentionally small here
|
||||
* because the real lateral motion comes from the sway wrapper below.
|
||||
*/
|
||||
export const animLeafFall = keyframes({
|
||||
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
|
||||
'8%': { opacity: '1' },
|
||||
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
|
||||
'92%': { opacity: '0.85' },
|
||||
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lateral sway applied to a leaf's wrapper so the descent reads as wind
|
||||
* catching the blade. Decoupled from the fall so the two compose into an
|
||||
* organic, non-repeating-looking path.
|
||||
*/
|
||||
export const animLeafSway = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||
'50%': { transform: 'translate3d(34px, 0, 0)' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
|
||||
* mimics the blade catching air as it spins. Cheap, transform-only.
|
||||
*/
|
||||
export const animLeafFlutter = keyframes({
|
||||
'0%': { transform: 'rotate(-8deg) scaleX(1)' },
|
||||
'50%': { transform: 'rotate(8deg) scaleX(0.82)' },
|
||||
'100%': { transform: 'rotate(-8deg) scaleX(1)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
|
||||
* translateX + opacity (never background-position) so it stays on the
|
||||
* compositor. Scale on Y makes the beam subtly elongate as it brightens.
|
||||
*/
|
||||
export const animSunShaft = keyframes({
|
||||
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
|
||||
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
|
||||
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
|
||||
* pulsing softly in brightness as it catches the sun. transform + opacity.
|
||||
*/
|
||||
export const animMoteDrift = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
|
||||
'15%': { opacity: '0.85' },
|
||||
'40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
|
||||
'70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
|
||||
'90%': { opacity: '0.2' },
|
||||
'100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Independent twinkle for motes — a brightness flicker layered on the drift so
|
||||
* specks shimmer as if turning in the light. Opacity only.
|
||||
*/
|
||||
export const animMoteTwinkle = keyframes({
|
||||
'0%': { opacity: '0.5' },
|
||||
'50%': { opacity: '1' },
|
||||
'100%': { opacity: '0.5' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Barely-there breathing of the warm vignette frame so the static tint feels
|
||||
* alive without any distracting motion. Opacity only.
|
||||
*/
|
||||
export const animEmberPulse = keyframes({
|
||||
'0%': { opacity: '0.82' },
|
||||
'50%': { opacity: '1' },
|
||||
'100%': { opacity: '0.82' },
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animLeafFall,
|
||||
animLeafSway,
|
||||
animLeafFlutter,
|
||||
animSunShaft,
|
||||
animMoteDrift,
|
||||
animMoteTwinkle,
|
||||
animEmberPulse,
|
||||
} from './Autumn.css';
|
||||
|
||||
/**
|
||||
* AutumnOverlay — warm falling leaves.
|
||||
*
|
||||
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||||
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||||
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||||
* children and never sets position:fixed / z-index / pointer-events.
|
||||
*
|
||||
* Composition (back to front):
|
||||
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
|
||||
* 2. soft angled sun shafts breathing high across the scene
|
||||
* 3. drifting pollen / dust motes catching the light
|
||||
* 4. maple & oak leaf silhouettes tumbling and rotating as they fall
|
||||
* 5. a warm low-saturation vignette that frames + protects text contrast
|
||||
*
|
||||
* All motion is transform/opacity only (compositor-friendly). When `reduced`
|
||||
* is true we render a static-but-gorgeous scene: a handful of leaves at rest,
|
||||
* still sun shafts, and the warm vignette — no `animation` anywhere. The
|
||||
* settings preview always passes reduced=true, so the still form stands alone.
|
||||
*/
|
||||
|
||||
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
|
||||
// chat text underneath. Each leaf picks a tone for variety.
|
||||
const LEAF_TONES = [
|
||||
'oklch(0.75 0.15 70)', // amber
|
||||
'oklch(0.55 0.16 40)', // rust
|
||||
'oklch(0.82 0.13 85)', // warm gold
|
||||
'oklch(0.62 0.16 55)', // burnt orange
|
||||
'oklch(0.5 0.14 35)', // deep ember
|
||||
];
|
||||
|
||||
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
|
||||
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
|
||||
const MAPLE_PATH =
|
||||
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
|
||||
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
|
||||
const OAK_PATH =
|
||||
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
|
||||
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
|
||||
'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
|
||||
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
|
||||
|
||||
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
|
||||
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
|
||||
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
|
||||
// A faint vein line gives the blade depth without extra DOM nodes.
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
|
||||
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
|
||||
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
|
||||
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
|
||||
`</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
}
|
||||
|
||||
type Leaf = {
|
||||
kind: 'maple' | 'oak';
|
||||
uri: string;
|
||||
left: number; // vw column
|
||||
size: number; // px
|
||||
duration: number; // s — fall time
|
||||
delay: number; // s
|
||||
swayDuration: number; // s — wrapper sway
|
||||
flutterDuration: number; // s — inner flutter
|
||||
tilt: number; // deg — resting rotation (used for static scene)
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type Mote = {
|
||||
left: number;
|
||||
bottom: number;
|
||||
size: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
twinkle: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
// A few hand-placed leaves at rest for the reduced/static scene — arranged so
|
||||
// they read as "settled" near edges and corners, never over the busy center.
|
||||
const RESTING_LEAVES: ReadonlyArray<{
|
||||
kind: 'maple' | 'oak';
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
tilt: number;
|
||||
tone: number;
|
||||
opacity: number;
|
||||
}> = [
|
||||
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
|
||||
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
|
||||
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
|
||||
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
|
||||
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
|
||||
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
|
||||
];
|
||||
|
||||
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
|
||||
const { leaves, motes } = useMemo(() => {
|
||||
const LEAF_COUNT = 16;
|
||||
const MOTE_COUNT = 12;
|
||||
|
||||
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
|
||||
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
|
||||
const tone = LEAF_TONES[i % LEAF_TONES.length];
|
||||
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
|
||||
const size = 22 + sizeBucket * 9; // 22..49 px
|
||||
return {
|
||||
kind,
|
||||
uri: leafDataUri(kind, tone),
|
||||
left: (i * 6.13 + 4) % 100,
|
||||
size,
|
||||
// Larger (nearer) leaves fall a touch faster; all slow + cozy.
|
||||
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
|
||||
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
|
||||
swayDuration: 5 + (i % 5) * 0.8,
|
||||
flutterDuration: 1.6 + (i % 4) * 0.45,
|
||||
tilt: ((i * 53) % 70) - 35,
|
||||
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
|
||||
};
|
||||
});
|
||||
|
||||
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
|
||||
left: (i * 8.7 + 5) % 100,
|
||||
bottom: (i * 4.3) % 30, // start in lower third, drift up
|
||||
size: 2 + (i % 3),
|
||||
duration: 16 + (i % 6) * 2.4,
|
||||
delay: -((i * 2.1) % 16),
|
||||
twinkle: 2.2 + (i % 4) * 0.6,
|
||||
opacity: 0.4 + (i % 3) * 0.12,
|
||||
}));
|
||||
|
||||
return { leaves: builtLeaves, motes: builtMotes };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
|
||||
depth: a warm low-sun glow from the upper-left, a rust pool at the
|
||||
base, and a faint gold core. Kept very low opacity. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
|
||||
late-afternoon light raking across the room. */}
|
||||
{[
|
||||
{ left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
|
||||
{ left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
|
||||
{ left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
|
||||
].map((shaft, i) => (
|
||||
<div
|
||||
key={`shaft-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-30%',
|
||||
left: `${shaft.left}%`,
|
||||
width: `${shaft.w}vw`,
|
||||
height: '160%',
|
||||
transformOrigin: 'top center',
|
||||
transform: `rotate(${shaft.rotate}deg)`,
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
|
||||
filter: 'blur(14px)',
|
||||
mixBlendMode: 'screen',
|
||||
opacity: reduced ? shaft.opacity * 0.85 : undefined,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
contain: 'layout paint style',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
|
||||
them — stillness reads cleaner at rest. */}
|
||||
{!reduced &&
|
||||
motes.map((m, i) => (
|
||||
<div
|
||||
key={`mote-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${m.left}%`,
|
||||
bottom: `${m.bottom}%`,
|
||||
width: `${m.size}px`,
|
||||
height: `${m.size}px`,
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
|
||||
opacity: m.opacity,
|
||||
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 4. Falling / resting maple & oak leaves. */}
|
||||
{reduced
|
||||
? RESTING_LEAVES.map((leaf, i) => (
|
||||
<div
|
||||
key={`rest-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leaf.left}%`,
|
||||
top: `${leaf.top}%`,
|
||||
width: `${leaf.size}px`,
|
||||
height: `${leaf.size}px`,
|
||||
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
|
||||
opacity: leaf.opacity,
|
||||
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: leaves.map((leaf, i) => (
|
||||
// Sway wrapper: horizontal wind motion, decoupled from the fall.
|
||||
<div
|
||||
key={`leaf-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${leaf.left}%`,
|
||||
width: `${leaf.size}px`,
|
||||
height: `${leaf.size}px`,
|
||||
willChange: 'transform',
|
||||
contain: 'layout paint style',
|
||||
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
{/* Fall wrapper: vertical descent + tumble rotation. */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
{/* Inner blade: the actual silhouette + flutter wobble. */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: leaf.uri,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
opacity: leaf.opacity,
|
||||
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
|
||||
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
|
||||
edges — protecting central chat text contrast. Breathes faintly. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
|
||||
contain: 'layout paint style',
|
||||
opacity: reduced ? 1 : undefined,
|
||||
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { keyframes } from '@vanilla-extract/css';
|
||||
|
||||
/**
|
||||
* Snowfall — a flake drifts downward while swaying horizontally and slowly
|
||||
* rotating. GPU-only: animates transform + opacity exclusively. The vertical
|
||||
* travel uses a tall translateY so a single keyframe set serves all flakes;
|
||||
* per-flake duration/delay/scale create the parallax variety.
|
||||
*/
|
||||
export const animSnowFall = keyframes({
|
||||
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
|
||||
'8%': { opacity: '1' },
|
||||
'50%': { transform: 'translate3d(14px, 50vh, 0) rotate(180deg)' },
|
||||
'92%': { opacity: '0.85' },
|
||||
'100%': { transform: 'translate3d(-10px, 112vh, 0) rotate(360deg)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Gentle lateral sway applied to a flake's wrapper so the drift reads as wind,
|
||||
* decoupled from the fall so the two combine into an organic path.
|
||||
*/
|
||||
export const animSnowSway = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||
'50%': { transform: 'translate3d(18px, 0, 0)' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* String-light breathing — bokeh orbs softly pulse in brightness and scale,
|
||||
* like incandescent bulbs warming and cooling. Opacity + transform only.
|
||||
*/
|
||||
export const animBulbBreathe = keyframes({
|
||||
'0%': { transform: 'scale(0.92)', opacity: '0.55' },
|
||||
'50%': { transform: 'scale(1.08)', opacity: '0.95' },
|
||||
'100%': { transform: 'scale(0.92)', opacity: '0.55' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Aurora shimmer — a wide soft band high in the scene slowly slides and
|
||||
* breathes. Uses translateX + opacity (never background-position) so it stays
|
||||
* on the compositor.
|
||||
*/
|
||||
export const animAurora = keyframes({
|
||||
'0%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
|
||||
'50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
|
||||
'100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Vignette frost — a barely-there breathing of the cold frame so the static
|
||||
* tint feels alive without distracting motion.
|
||||
*/
|
||||
export const animFrostPulse = keyframes({
|
||||
'0%': { opacity: '0.85' },
|
||||
'50%': { opacity: '1' },
|
||||
'100%': { opacity: '0.85' },
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animSnowFall,
|
||||
animSnowSway,
|
||||
animBulbBreathe,
|
||||
animAurora,
|
||||
animFrostPulse,
|
||||
} from './Christmas.css';
|
||||
|
||||
// Deterministic pseudo-random so the scene is identical every mount (no React
|
||||
// state per frame). Large primes keep the distribution well spread.
|
||||
const rand = (seed: number) => {
|
||||
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
|
||||
// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
|
||||
const BULB_COLORS = [
|
||||
'oklch(0.85 0.12 85)', // warm gold
|
||||
'oklch(0.72 0.15 28)', // soft red
|
||||
'oklch(0.95 0.03 230)', // icy white
|
||||
'oklch(0.78 0.13 150)', // pine green
|
||||
'oklch(0.8 0.1 235)', // cool blue
|
||||
];
|
||||
|
||||
type Flake = {
|
||||
left: number;
|
||||
size: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
swayDuration: number;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
};
|
||||
|
||||
type Bulb = {
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
color: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
|
||||
const flakes = useMemo<Flake[]>(() => {
|
||||
const bands = [
|
||||
{ count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
|
||||
{ count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
|
||||
{ count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
|
||||
];
|
||||
const out: Flake[] = [];
|
||||
let s = 1;
|
||||
bands.forEach((b) => {
|
||||
for (let i = 0; i < b.count; i += 1) {
|
||||
const r1 = rand(s);
|
||||
const r2 = rand(s + 0.37);
|
||||
const r3 = rand(s + 0.71);
|
||||
const r4 = rand(s + 0.91);
|
||||
out.push({
|
||||
left: r1 * 100,
|
||||
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
|
||||
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
|
||||
delay: -r4 * (b.dur[1] + 4),
|
||||
swayDuration: 4 + r2 * 5,
|
||||
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
||||
blur: b.blur,
|
||||
});
|
||||
s += 1;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// Bokeh string lights strung along the very top edge, gently sagging.
|
||||
const bulbs = useMemo<Bulb[]>(() => {
|
||||
const count = 9;
|
||||
const out: Bulb[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const t = i / (count - 1);
|
||||
// Two-segment garland sag so the lights drape rather than sit in a line.
|
||||
const sag = Math.sin(t * Math.PI * 2) * 3.2;
|
||||
out.push({
|
||||
left: 4 + t * 92,
|
||||
top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
|
||||
size: 12 + rand(i + 5) * 8,
|
||||
color: BULB_COLORS[i % BULB_COLORS.length],
|
||||
duration: 3.4 + rand(i + 2) * 2.6,
|
||||
delay: -rand(i + 9) * 3,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Deep night-blue ambient wash — layered radial + linear oklch gradients
|
||||
for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
|
||||
single cheap layer for a faint icy haze around the rim. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||
backgroundImage:
|
||||
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
|
||||
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6%',
|
||||
left: '-10%',
|
||||
right: '-10%',
|
||||
height: '40%',
|
||||
contain: 'layout paint style',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(26px)',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
backgroundImage: [
|
||||
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
|
||||
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* String-light wire — a faint catenary line the bulbs hang from. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '14%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage:
|
||||
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bokeh string lights — soft blurred orbs that breathe. */}
|
||||
{bulbs.map((b, i) => (
|
||||
<div
|
||||
key={`bulb-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${b.left}%`,
|
||||
top: `${b.top}%`,
|
||||
width: `${b.size}px`,
|
||||
height: `${b.size}px`,
|
||||
marginLeft: `${-b.size / 2}px`,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
|
||||
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
|
||||
filter: 'blur(0.5px)',
|
||||
opacity: reduced ? 0.9 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
|
||||
{!reduced &&
|
||||
flakes.map((f, i) => (
|
||||
<div
|
||||
key={`snow-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${f.left}%`,
|
||||
width: `${f.size}px`,
|
||||
height: `${f.size}px`,
|
||||
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
|
||||
opacity: f.opacity,
|
||||
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Static dusting of snow for the reduced-motion / preview scene — a
|
||||
sparse scatter so the thumbnail still reads as snowfall. */}
|
||||
{reduced &&
|
||||
flakes.map((f, i) => {
|
||||
const fy = rand(i + 0.5) * 96 + 2;
|
||||
return (
|
||||
<div
|
||||
key={`snow-static-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${f.left}%`,
|
||||
top: `${fy}%`,
|
||||
width: `${f.size}px`,
|
||||
height: `${f.size}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
|
||||
opacity: f.opacity,
|
||||
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { keyframes } from '@vanilla-extract/css';
|
||||
|
||||
/**
|
||||
* Deep Space overlay keyframes. Everything here animates ONLY transform/opacity
|
||||
* so the compositor can run it cheaply. The `keyframes()` helper returns the
|
||||
* generated class name string, which the component splices into inline
|
||||
* `animation` shorthands.
|
||||
*/
|
||||
|
||||
/** Cosmos breathe: the whole nebula backdrop drifts and dims almost imperceptibly. */
|
||||
export const animCosmosDrift = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
|
||||
'50%': { transform: 'translate3d(-1.5%, 1%, 0) scale(1.04)', opacity: '1' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
|
||||
});
|
||||
|
||||
/** Nebula cloud drift: a single blurred cloud floats slowly across its layer. */
|
||||
export const animNebulaA = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||
'50%': { transform: 'translate3d(4%, -3%, 0) scale(1.08)' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||
});
|
||||
|
||||
export const animNebulaB = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
|
||||
'50%': { transform: 'translate3d(-5%, 2.5%, 0) scale(1)' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
|
||||
});
|
||||
|
||||
/** Galaxy spiral: an exceptionally slow rotation of a distant pinwheel. */
|
||||
export const animGalaxySpin = keyframes({
|
||||
'0%': { transform: 'rotate(0deg) scale(1)' },
|
||||
'50%': { transform: 'rotate(180deg) scale(1.03)' },
|
||||
'100%': { transform: 'rotate(360deg) scale(1)' },
|
||||
});
|
||||
|
||||
/** Tiny star twinkle: gentle opacity + micro-scale pulse. */
|
||||
export const animTwinkle = keyframes({
|
||||
'0%': { transform: 'scale(0.85)', opacity: '0.35' },
|
||||
'50%': { transform: 'scale(1)', opacity: '1' },
|
||||
'100%': { transform: 'scale(0.85)', opacity: '0.35' },
|
||||
});
|
||||
|
||||
/** Bright star pulse: a slower, fuller bloom for the few hero stars. */
|
||||
export const animStarPulse = keyframes({
|
||||
'0%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
|
||||
'50%': { transform: 'scale(1.15) rotate(45deg)', opacity: '1' },
|
||||
'100%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
|
||||
});
|
||||
|
||||
/** Parallax depth: a star layer drifts as if the viewer is gliding through space. */
|
||||
export const animParallaxNear = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||
'100%': { transform: 'translate3d(-3%, 1.5%, 0)' },
|
||||
});
|
||||
|
||||
export const animParallaxFar = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||
'100%': { transform: 'translate3d(-1.2%, 0.6%, 0)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Comet streak: a thin meteor crosses the field on a diagonal, fading in then
|
||||
* out. The element is rotated by the component; this only translates along its
|
||||
* own local X axis (its length direction) and fades.
|
||||
*/
|
||||
export const animComet = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0' },
|
||||
'6%': { opacity: '1' },
|
||||
'40%': { opacity: '0.9' },
|
||||
'60%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
|
||||
'100%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animCosmosDrift,
|
||||
animNebulaA,
|
||||
animNebulaB,
|
||||
animGalaxySpin,
|
||||
animTwinkle,
|
||||
animStarPulse,
|
||||
animParallaxNear,
|
||||
animParallaxFar,
|
||||
animComet,
|
||||
} from './DeepSpace.css';
|
||||
|
||||
/**
|
||||
* Deep Space overlay — a cosmic, awe-inspiring ambient mode. Layered oklch
|
||||
* radial gradients build a deep violet void seeded with drifting magenta/cyan
|
||||
* nebula clouds and a faint distant galaxy spiral. A parallax starfield sits at
|
||||
* two depths (a dense field of tiny twinkling stars plus a handful of brighter
|
||||
* hero stars), and slow comet streaks cross the sky occasionally.
|
||||
*
|
||||
* Palette (oklch): deep cosmic violet oklch(0.2 0.12 300), nebula magenta
|
||||
* oklch(0.55 0.2 330), cyan oklch(0.75 0.13 200), starlight white
|
||||
* oklch(0.98 0.02 280).
|
||||
*
|
||||
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
|
||||
* pointer-events:none container at the right z-index. We only return
|
||||
* absolutely-positioned aria-hidden children at low opacity — no z-index,
|
||||
* position:fixed, or pointer-events here — kept well below opaque so chat text
|
||||
* stays WCAG-AA legible.
|
||||
*
|
||||
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a still
|
||||
* nebula, a static starfield, a frozen galaxy and one frozen comet streak) with
|
||||
* no `animation` at all. The settings preview always passes reduced=true.
|
||||
*/
|
||||
|
||||
const STAR_TINTS = [
|
||||
'oklch(0.98 0.02 280)', // starlight white
|
||||
'oklch(0.9 0.07 230)', // cool cyan-white
|
||||
'oklch(0.88 0.08 330)', // faint magenta-white
|
||||
] as const;
|
||||
|
||||
const HERO_TINTS = [
|
||||
'oklch(0.92 0.06 200)', // cyan starlight
|
||||
'oklch(0.9 0.09 330)', // magenta starlight
|
||||
'oklch(0.98 0.02 280)', // pure starlight
|
||||
] as const;
|
||||
|
||||
type Star = {
|
||||
top: number;
|
||||
left: number;
|
||||
size: number;
|
||||
color: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
staticOpacity: number;
|
||||
};
|
||||
|
||||
type HeroStar = Star;
|
||||
|
||||
type Comet = {
|
||||
top: number;
|
||||
left: number;
|
||||
length: number;
|
||||
angle: number;
|
||||
color: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
// Deterministic pseudo-random so the memoized scene is stable across renders.
|
||||
const rand = (seed: number) => {
|
||||
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
// A four-point gleam (sparkle) as an inline SVG data-URI — CSP-safe, no assets.
|
||||
const gleamUri = (color: string) =>
|
||||
`url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 C12.6 7.4 16.6 11.4 24 12 C16.6 12.6 12.6 16.6 12 24 C11.4 16.6 7.4 12.6 0 12 C7.4 11.4 11.4 7.4 12 0 Z' fill='${color}'/></svg>`,
|
||||
)}")`;
|
||||
|
||||
function makeStars(count: number, seedBase: number): Star[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const s = seedBase + i;
|
||||
return {
|
||||
top: rand(s + 1) * 100,
|
||||
left: rand(s + 101) * 100,
|
||||
size: 1 + Math.floor(rand(s + 201) * 2), // 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({
|
||||
backgroundColor: 'rgba(3, 5, 8, 0.55)',
|
||||
backgroundColor: `color-mix(in srgb, ${color.Surface.Container} 55%, transparent)`,
|
||||
backdropFilter: '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([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import React, { FormEventHandler } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthType } from 'matrix-js-sdk';
|
||||
import { StageComponentProps } from './types';
|
||||
import { ErrorCode } from '../../cs-errorcode';
|
||||
@@ -13,6 +14,7 @@ export function PasswordStage({
|
||||
}: StageComponentProps & {
|
||||
userId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { errorCode, error, session } = stageData;
|
||||
|
||||
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
@@ -44,7 +46,7 @@ export function PasswordStage({
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text as="h2" size="H4">
|
||||
Account Password
|
||||
{t('Organisms.PasswordStage.account_password')}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
|
||||
@@ -59,12 +61,9 @@ export function PasswordStage({
|
||||
gap="400"
|
||||
>
|
||||
<Box direction="Column" gap="400">
|
||||
<Text size="T200">
|
||||
To perform this action you need to authenticate yourself by entering you account
|
||||
password.
|
||||
</Text>
|
||||
<Text size="T200">{t('Organisms.PasswordStage.authenticate_prompt')}</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Password</Text>
|
||||
<Text size="L400">{t('Organisms.PasswordStage.password')}</Text>
|
||||
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
|
||||
{errorCode && (
|
||||
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
||||
@@ -72,7 +71,7 @@ export function PasswordStage({
|
||||
<Text size="T200">
|
||||
<b>
|
||||
{errorCode === ErrorCode.M_FORBIDDEN
|
||||
? 'Invalid Password!'
|
||||
? t('Organisms.PasswordStage.invalid_password')
|
||||
: `${errorCode}: ${error}`}
|
||||
</b>
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
@@ -43,6 +44,7 @@ export function UploadBoardHeader({
|
||||
onSend,
|
||||
imperativeHandlerRef,
|
||||
}: UploadBoardHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const sendingRef = useRef(false);
|
||||
const uploads = useAtomValue(uploadFamilyObserverAtom);
|
||||
|
||||
@@ -88,7 +90,7 @@ export function UploadBoardHeader({
|
||||
gap="100"
|
||||
>
|
||||
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
|
||||
<Text size="H6">Files</Text>
|
||||
<Text size="H6">{t('Organisms.UploadBoard.files')}</Text>
|
||||
</Box>
|
||||
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
|
||||
{isSuccess && (
|
||||
@@ -100,12 +102,12 @@ export function UploadBoardHeader({
|
||||
outlined
|
||||
after={<Icon src={Icons.Send} size="50" filled />}
|
||||
>
|
||||
<Text size="B300">Send</Text>
|
||||
<Text size="B300">{t('Organisms.UploadBoard.send')}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
{isError && !open && (
|
||||
<Badge variant="Critical" fill="Solid" radii="300">
|
||||
<Text size="L400">Upload Failed</Text>
|
||||
<Text size="L400">{t('Organisms.UploadBoard.upload_failed')}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{!isSuccess && !isError && !open && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||
import { ImageOverlay } from '../ImageOverlay';
|
||||
@@ -1343,6 +1344,7 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
|
||||
}
|
||||
|
||||
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const { t } = useTranslation();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
@@ -1383,7 +1385,9 @@ function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
||||
priority="300"
|
||||
>
|
||||
<SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
|
||||
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>Join Server</span>
|
||||
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>
|
||||
{t('Organisms.UrlPreview.join_server')}
|
||||
</span>
|
||||
</Text>
|
||||
{title && (
|
||||
<Text truncate priority="400">
|
||||
|
||||
@@ -91,10 +91,10 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
||||
{(status) => {
|
||||
const deviceColor =
|
||||
status === VerificationStatus.Verified
|
||||
? 'var(--tc-positive-normal, #5effc4)'
|
||||
? color.Success.Main
|
||||
: status === VerificationStatus.Unverified
|
||||
? 'var(--tc-warning-normal, #ffcc55)'
|
||||
: 'var(--tc-surface-low-contrast)';
|
||||
? color.Warning.Main
|
||||
: color.SurfaceVariant.OnContainer;
|
||||
return (
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
|
||||
@@ -106,7 +106,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }}
|
||||
style={{ color: color.SurfaceVariant.OnContainer, fontFamily: 'monospace' }}
|
||||
>
|
||||
{device.deviceId}
|
||||
</Text>
|
||||
@@ -160,7 +160,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
background: 'var(--bg-surface-variant)',
|
||||
background: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: config.space.S300,
|
||||
}}
|
||||
@@ -171,7 +171,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
||||
<Text size="T300">
|
||||
<b>Sessions</b>
|
||||
</Text>
|
||||
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
|
||||
<Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
|
||||
{devices.length}
|
||||
</Text>
|
||||
</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'?: {
|
||||
base_url: string;
|
||||
};
|
||||
// v1.15 stable next-gen-auth (MSC2965) discovery key — emitted by servers that
|
||||
// delegate to a Matrix Authentication Service (e.g. mozilla.org). The
|
||||
// `org.matrix.msc2965.authentication` key below is the unstable predecessor.
|
||||
'm.authentication'?: {
|
||||
issuer?: string;
|
||||
account?: string;
|
||||
};
|
||||
'org.matrix.msc2965.authentication'?: {
|
||||
account?: string;
|
||||
issuer?: string;
|
||||
@@ -32,6 +39,24 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the OIDC issuer (and account-management URL) advertised by a homeserver
|
||||
* in its `.well-known/matrix/client`, preferring the v1.15 stable
|
||||
* `m.authentication` key over the unstable `org.matrix.msc2965.authentication`.
|
||||
* Returns `{}` when the server is not OIDC-native (e.g. matrix.lotusguild.org).
|
||||
*/
|
||||
export const getOidcIssuer = (info: AutoDiscoveryInfo): { issuer?: string; account?: string } => {
|
||||
const stable = info['m.authentication'];
|
||||
if (stable && typeof stable.issuer === 'string') {
|
||||
return { issuer: stable.issuer, account: stable.account };
|
||||
}
|
||||
const unstable = info['org.matrix.msc2965.authentication'];
|
||||
if (unstable && typeof unstable.issuer === 'string') {
|
||||
return { issuer: unstable.issuer, account: unstable.account };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const autoDiscovery = async (
|
||||
request: typeof fetch,
|
||||
server: string,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -36,6 +37,10 @@ import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||
import { CallSoundboard } from './CallSoundboard';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { RoomQualityContent } from '../../utils/callQuality';
|
||||
|
||||
type CallControlsProps = {
|
||||
callEmbed: CallEmbed;
|
||||
@@ -87,7 +92,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||
|
||||
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||
// users don't click dead buttons. Absent/true = allowed.
|
||||
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
||||
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||
const cameraAllowed = roomQuality?.allow_camera !== false;
|
||||
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = cameraAllowed || video;
|
||||
const showScreenshare = screenshareAllowed || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const [pttActive, setPttActive] = useState(false);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
@@ -244,49 +261,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
{pttMode &&
|
||||
(lotusTerminal ? (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: pttActive ? 'var(--lt-accent-green-dim)' : 'var(--lt-accent-orange-dim)',
|
||||
border: `1px solid ${pttActive ? 'var(--lt-accent-green-border)' : 'var(--lt-accent-orange-border)'}`,
|
||||
borderRadius: '99px',
|
||||
padding: '0.2rem 0.9rem',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
color: pttActive ? 'var(--lt-accent-green)' : 'var(--lt-accent-orange)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
{pttActive ? (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
{' LIVE'}
|
||||
</>
|
||||
) : (
|
||||
`PTT — Hold ${pttKeyLabel}`
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
{pttMode && (
|
||||
<Chip
|
||||
variant={pttActive ? 'Success' : 'Warning'}
|
||||
fill="Soft"
|
||||
@@ -305,7 +280,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
)}
|
||||
{shareConfirm && (
|
||||
<>
|
||||
<div
|
||||
@@ -319,8 +294,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
bottom: '110%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--bg-surface-border)',
|
||||
background: color.Surface.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
@@ -381,24 +356,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
{!compact && showVideoGroup && <ControlDivider />}
|
||||
{showVideoGroup && (
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<VideoButton enabled={video} onToggle={handleVideoToggle} />
|
||||
{/* Show a forbidden control while its track is still live so the
|
||||
user can stop it; once stopped it hides and can't be restarted. */}
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<ChatButton />
|
||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
|
||||
@@ -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 { StateEvent } from '../../../types/matrix/room';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
@@ -164,7 +165,7 @@ function CallLoadErrorMessage() {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
// Disposing the embed tears down the hung iframe and returns the user to the
|
||||
// prescreen, from which they can join again ("Retry") or simply walk away.
|
||||
// prescreen, where they can choose to join again.
|
||||
const dismiss = () => setCallEmbed(undefined);
|
||||
|
||||
return (
|
||||
@@ -180,11 +181,8 @@ function CallLoadErrorMessage() {
|
||||
The call failed to load. Check your connection and try again.
|
||||
</Text>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Button variant="Primary" size="300" radii="400" onClick={dismiss}>
|
||||
<Text size="B400">Retry</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
||||
<Text size="B400">Leave</Text>
|
||||
<Text size="B400">Back</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -202,6 +200,8 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box grow="Yes" ref={containerRef} />
|
||||
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
|
||||
{/* [lotus #6] push avatar decorations to EC's in-call tiles (post-join) */}
|
||||
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExitFullscreenIcon = () => (
|
||||
export const ExitFullscreenIcon = () => (
|
||||
<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" />
|
||||
</svg>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 * as css from './styles.css';
|
||||
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
||||
@@ -78,10 +78,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
{micDenied && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
|
||||
>
|
||||
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
|
||||
Microphone access is blocked. Enable it in your browser settings to join.
|
||||
</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 './RoomProfile';
|
||||
export * from './RoomPublish';
|
||||
export * from './RoomQuality';
|
||||
export * from './RoomShareInvite';
|
||||
export * from './RoomUpgrade';
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
// Single source of truth for the avatar-decoration CDN base URL.
|
||||
// scripts/syncDecorations.mjs reads this exact `DECORATION_CDN` declaration out
|
||||
// of this file at runtime (by regex) instead of re-declaring it, so the two can
|
||||
// never drift. If you migrate the CDN, change it here ONLY — keep the
|
||||
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
|
||||
export const DECORATION_CDN =
|
||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
|
||||
// Runtime base. A deployment can repoint the decorations at a different host
|
||||
// without editing the catalog literal above (which scripts/syncDecorations.mjs
|
||||
// and the build read) by setting `VITE_DECORATION_CDN`; otherwise it falls back
|
||||
// to DECORATION_CDN. `import.meta.env` is undefined under the tsx test runner,
|
||||
// hence the guard. Trailing slashes are trimmed so `decorationUrl` stays clean.
|
||||
const envDecorationCdn = (import.meta as unknown as { env?: Record<string, string | undefined> })
|
||||
.env?.VITE_DECORATION_CDN;
|
||||
const RESOLVED_DECORATION_CDN = (envDecorationCdn || DECORATION_CDN).replace(/\/+$/, '');
|
||||
|
||||
export type AvatarDecoration = {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -175,5 +189,5 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
|
||||
);
|
||||
|
||||
export function decorationUrl(slug: string): string {
|
||||
return `${DECORATION_CDN}/${slug}.png`;
|
||||
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { keyframes } from '@vanilla-extract/css';
|
||||
|
||||
// Aurora Flow — a SLOW, gentle pan of layered soft aurora ribbons.
|
||||
//
|
||||
// The living-aurora illusion is a pure `background-position` drift: each
|
||||
// comma-separated gradient layer is authored larger than the viewport
|
||||
// (backgroundSize 200%–300%, see animAurora.ts) so there is slack to slide it
|
||||
// around. Panning several broad blurred bands by DIFFERENT
|
||||
// amounts and along DIFFERENT paths makes the ribbons appear to curl and cross
|
||||
// like real northern lights — no single layer ever moves in lockstep.
|
||||
//
|
||||
// LAYER ORDER (must match animAurora.ts exactly — one position value per layer):
|
||||
// 1. green ribbon (drifts a wide, lazy horizontal arc)
|
||||
// 2. teal ribbon (drifts on a slower, offset diagonal)
|
||||
// 3. violet ribbon (drifts vertically, the "curtain" fold)
|
||||
// 4. sky/aqua highlight (small counter-drift for shimmer)
|
||||
// 5. calm reading core (STATIC — kept at 50% 50% so the center never moves)
|
||||
// 6. vignette (STATIC — kept at 50% 50% so edges never move)
|
||||
//
|
||||
// SEAMLESS LOOP: every animated layer starts and ends on the SAME position
|
||||
// ('0%'/'100%' being identical sample points of the repeating gradient tile),
|
||||
// so one period returns each band to its origin with no visible jump. The two
|
||||
// static layers list their fixed position at every stop so they never pan.
|
||||
//
|
||||
// SLOW & GENTLE: paired with a long duration + ease-in-out in animAurora.ts, the
|
||||
// motion reads as a barely-perceptible breathing drift, keeping the reading
|
||||
// center calm and text crisp.
|
||||
//
|
||||
// getChatBg adds `willChange: 'background-position'` here and STRIPS the whole
|
||||
// `animation` for prefers-reduced-motion / pause-animations, at which point the
|
||||
// static `backgroundPosition` authored in animAurora.ts is what shows — already
|
||||
// a finished, gorgeous aurora.
|
||||
export const auroraFlow = keyframes({
|
||||
'0%': {
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
},
|
||||
'25%': {
|
||||
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||
},
|
||||
'50%': {
|
||||
backgroundPosition: '65% 60%, 40% 40%, 45% 70%, 70% 35%, 50% 50%, 50% 50%',
|
||||
},
|
||||
'75%': {
|
||||
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||
},
|
||||
'100%': {
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { ChatBgVariants } from './types';
|
||||
import { auroraFlow } from './animAurora.css';
|
||||
|
||||
// Aurora Flow — a premium ANIMATED aurora: soft ribbons of northern-lights color
|
||||
// slowly drifting and curling over a deep, calm base.
|
||||
//
|
||||
// CONCEPT
|
||||
// Broad, heavily-feathered gradient bands stacked over a deep midnight base, with
|
||||
// a gentle vignette that darkens the edges and keeps the reading center calm.
|
||||
// The distinct STATIC 'aurora' is a favorite still; this one earns its own slot
|
||||
// by MOVING — see animAurora.css.ts, which slowly pans each ribbon along its own
|
||||
// path via `background-position` so the curtains appear to fold and cross.
|
||||
//
|
||||
// LAYER ORDER (must stay in lockstep with auroraFlow's per-layer position list):
|
||||
// 1. green ribbon 2. teal ribbon 3. violet ribbon 4. sky highlight
|
||||
// 5. calm reading core (static) 6. vignette (static)
|
||||
//
|
||||
// READABILITY
|
||||
// Every ribbon is a wide ellipse fading fully to transparent well before its
|
||||
// edge, at low alpha (~0.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(','),
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user